# -*- coding: utf-8 -*- # Copyright (c) 2020-2021 Salvador E. Tropea # Copyright (c) 2020-2021 Instituto Nacional de TecnologĂ­a Industrial # Copyright (c) 2016-2020 Oliver Henry Walters (@SchrodingersGat) # License: MIT # Project: KiBot (formerly KiPlot) # Adapted from: https://github.com/SchrodingersGat/KiBoM """ XLSX Writer: Generates an XLSX BoM file. """ import io import pprint import os.path as op import sys from textwrap import wrap from base64 import b64decode from .columnlist import ColumnList from .kibot_logo import KIBOT_LOGO from .. import log from ..misc import W_NOKICOST, W_UNKDIST, KICOST_ERROR, W_BADFIELD from ..error import trace_dump from ..__main__ import __version__ try: from xlsxwriter import Workbook XLSX_SUPPORT = True except ModuleNotFoundError: XLSX_SUPPORT = False class Workbook(): pass # KiCost support try: # Give priority to submodules rel_path = '../../submodules/KiCost/' if op.isfile(op.join(op.dirname(__file__), rel_path+'kicost/__init__.py')): rel_path = op.abspath(op.join(op.dirname(__file__), rel_path)) if rel_path not in sys.path: sys.path.insert(0, rel_path) # Init the logger first logger = log.get_logger(__name__) from kicost.global_vars import set_logger, KiCostError set_logger(logger) from kicost import PartGroup from kicost.kicost import query_part_info from kicost.spreadsheet import create_worksheet, Spreadsheet from kicost.distributors import (init_distributor_dict, set_distributors_logger, get_distributors_list, get_dist_name_from_label, set_distributors_progress, is_valid_api, configure_from_environment, configure_apis) from kicost.edas import set_edas_logger from kicost.config import load_config # Progress mechanism: use the one declared in __main__ (TQDM) from kicost.__main__ import ProgressConsole set_distributors_progress(ProgressConsole) KICOST_SUPPORT = True except ModuleNotFoundError: KICOST_SUPPORT = False BG_GEN = "#E6FFEE" # "#C6DFCE" BG_KICAD = "#FFE6B3" # "#DFC693" BG_USER = "#E6F9FF" # "#C6D9DF" BG_EMPTY = "#FF8080" BG_GEN_L = "#F0FFF4" BG_KICAD_L = "#FFF0BD" BG_USER_L = "#F0FFFF" BG_EMPTY_L = "#FF8A8A" BG_COLORS = [[BG_GEN, BG_GEN_L], [BG_KICAD, BG_KICAD_L], [BG_USER, BG_USER_L], [BG_EMPTY, BG_EMPTY_L]] GREY = "#dddddd" GREY_L = "#f3f3f3" HEAD_COLOR_R = "#982020" HEAD_COLOR_G = "#009879" HEAD_COLOR_B = "#0e4e8e" DEFAULT_FMT = {'text_wrap': True, 'align': 'center_across', 'valign': 'vcenter'} KICOST_COLUMNS = {'refs': ColumnList.COL_REFERENCE, 'desc': ColumnList.COL_DESCRIPTION, 'qty': ColumnList.COL_GRP_BUILD_QUANTITY} SPECS_GENERATED = set((ColumnList.COL_REFERENCE_L, ColumnList.COL_ROW_NUMBER_L, 'sep')) def bg_color(col): """ Return a background color for a given column title """ col = col.lower() # Auto-generated columns if col in ColumnList.COLUMNS_GEN_L: return 0 # KiCad protected columns elif col in ColumnList.COLUMNS_PROTECTED_L: return 1 # Additional user columns return 2 def add_info(worksheet, column_widths, row, col_offset, formats, text, value): worksheet.write_string(row, col_offset, text, formats[0]) if isinstance(value, (int, float)): worksheet.write_number(row, col_offset+1, value, formats[1]) value = str(value) else: worksheet.write_string(row, col_offset+1, value, formats[1]) column_widths[col_offset] = max(len(text)+1, column_widths[col_offset]) column_widths[col_offset+1] = max(len(value), column_widths[col_offset+1]) return row + 1 def compute_head_size(cfg): col_logo = 0 if cfg.xlsx.logo is None else 6 col_info = 1 if cfg.xlsx.title else 0 if not (cfg.xlsx.hide_pcb_info and cfg.xlsx.hide_stats_info): col_info += 5 if len(cfg.aggregate) > 1: col_info += 6*len(cfg.aggregate) head_size = max(col_logo, col_info) if head_size: # To separate head_size += 1 return head_size def get_bg_color(style_name): head_color = None if style_name.startswith('modern-'): if style_name.endswith('green'): head_color = HEAD_COLOR_G elif style_name.endswith('blue'): head_color = HEAD_COLOR_B else: head_color = HEAD_COLOR_R return head_color def create_fmt_head(workbook, style_name): fmt_head = workbook.add_format(DEFAULT_FMT) fmt_head.set_bold() if style_name.startswith('modern-'): fmt_head.set_bg_color(get_bg_color(style_name)) fmt_head.set_font_color("#ffffff") return fmt_head def get_logo_data(logo): if logo is not None: if logo: with open(logo, 'rb') as f: image_data = f.read() else: image_data = b64decode(KIBOT_LOGO) else: image_data = None return image_data def create_fmt_title(workbook, title): if not title: return None fmt_title = workbook.add_format(DEFAULT_FMT) fmt_title.set_font_size(24) fmt_title.set_bold() fmt_title.set_font_name('Arial') fmt_title.set_align('left') return fmt_title def create_fmt_subtitle(workbook): fmt_title = workbook.add_format(DEFAULT_FMT) fmt_title.set_font_size(18) fmt_title.set_bold() fmt_title.set_font_name('Arial') fmt_title.set_align('left') return fmt_title def create_fmt_cols(workbook, col_colors): """ Create the possible column formats """ fmt_cols = [] if col_colors: for c in BG_COLORS: fmts = [None, None] fmts[0] = workbook.add_format(DEFAULT_FMT) fmts[1] = workbook.add_format(DEFAULT_FMT) fmts[0].set_bg_color(c[0]) fmts[1].set_bg_color(c[1]) fmt_cols.append(fmts) else: fmts = [None, None] fmts[0] = workbook.add_format(DEFAULT_FMT) fmts[1] = workbook.add_format(DEFAULT_FMT) fmts[0].set_bg_color(GREY) fmts[1].set_bg_color(GREY_L) fmt_cols.append(fmts) return fmt_cols def create_col_fmt(col_fields, col_colors, fmt_cols): """ Assign a color to each column """ col_fmt = [] if col_colors: for c in col_fields: col_fmt.append(fmt_cols[bg_color(c)]) else: for c in col_fields: col_fmt.append(fmt_cols[0]) # Empty color col_fmt.append(fmt_cols[-1]) return col_fmt def create_fmt_info(workbook, cfg): """ Formats for the PCB and stats info """ if cfg.xlsx.hide_pcb_info and cfg.xlsx.hide_stats_info: return None # Data left justified fmt_data = workbook.add_format({'align': 'left'}) fmt_name = workbook.add_format(DEFAULT_FMT) fmt_name.set_bold() fmt_name.set_align('left') return [fmt_name, fmt_data] def insert_logo(worksheet, image_data, scale): """ Inserts the logo, returns how many columns we used """ if image_data: # Note: OpenOffice doesn't support using images in the header for XLSXs # worksheet.set_header('&L&[Picture]', {'image_left': 'logo.png', 'image_data_left': image_data}) options = {'image_data': io.BytesIO(image_data), 'x_scale': scale, 'y_scale': scale, 'object_position': 1, 'decorative': True} worksheet.insert_image('A1', 'logo.png', options) return 2 return 0 def create_color_ref(workbook, col_colors, hl_empty, fmt_cols, do_kicost, kicost_colors): if not (col_colors or do_kicost): return row = 0 worksheet = workbook.add_worksheet('Colors') worksheet.set_column(0, 0, 50) if col_colors: worksheet.write_string(0, 0, 'KiCad Fields (default)', fmt_cols[1][0]) worksheet.write_string(1, 0, 'Generated Fields', fmt_cols[0][0]) worksheet.write_string(2, 0, 'User Fields', fmt_cols[2][0]) if hl_empty: worksheet.write_string(3, 0, 'Empty Fields', fmt_cols[3][0]) row = 5 if do_kicost: worksheet.write_string(row, 0, 'Costs sheet') for label, format in kicost_colors.items(): row += 1 worksheet.write_string(row, 0, label, format) def get_spec(part, name): if name[0] != '_': return part.specs.get(name, ['', '']) name = name[1:] for k, v in part.dd.items(): val = v.extra_info.get(name, None) if val: return [name, val] return ['', ''] def create_meta(workbook, name, columns, parts, fmt_head, fmt_cols, max_w, rename, levels, comments, join): worksheet = workbook.add_worksheet(name) col_w = [] row_h = 1 for c, col in enumerate(columns): name = rename.get(col.lower(), col) if rename else col worksheet.write_string(0, c, name, fmt_head) text_l = max(len(col), 6) if text_l > max_w: h = len(wrap(col, max_w)) row_h = max(row_h, h) text_l = max_w col_w.append(text_l) if row_h > 1: worksheet.set_row(0, 15.0*row_h) for r, part in enumerate(parts): # Add the references as another spec part.specs[ColumnList.COL_REFERENCE_L] = (ColumnList.COL_REFERENCE, part.collapsed_refs) # Also add the row part.specs[ColumnList.COL_ROW_NUMBER_L] = (ColumnList.COL_ROW_NUMBER, str(r+1)) row_h = 1 for c, col in enumerate(columns): col_l = col.lower() if col_l == 'sep': col_w[c] = 0 continue v = get_spec(part, col_l) text = v[1] # Append text from other fields if join: for j in join: if j[0] == col_l: for c_join in j[1:]: v = part.specs.get(c_join, None) if v: text += ' ' + v[1] text_l = len(text) if not text_l: continue fmt_kind = 0 if col_l in SPECS_GENERATED else 2 worksheet.write_string(r+1, c, text, fmt_cols[fmt_kind][r % 2]) if text_l > col_w[c]: if text_l > max_w: h = len(wrap(text, max_w)) row_h = max(row_h, h) text_l = max_w col_w[c] = text_l if row_h > 1: worksheet.set_row(r+1, 15.0*row_h) for i, width in enumerate(col_w): ops = {'level': levels[i] if levels else 0} if not width: ops['hidden'] = 1 if comments and comments[i]: worksheet.write_comment(0, i, comments[i]) worksheet.set_column(i, i, width, None, ops) def adjust_widths(worksheet, column_widths, max_width, levels): c_levels = len(levels) for i, width in enumerate(column_widths): if width > max_width: width = max_width if i < c_levels: worksheet.set_column(i, i, width, None, {'level': levels[i]}) def adjust_heights(worksheet, rows, max_width, head_size): for rn, r in enumerate(rows): max_h = 1 for c in r: if len(c) > max_width: h = len(wrap(c, max_width)) max_h = max(h, max_h) if max_h > 1: worksheet.set_row(head_size+rn, 15.0*max_h) def write_info(cfg, r_info_start, worksheet, column_widths, col1, fmt_info, fmt_subtitle, compact=False): if len(cfg.aggregate) == 1: # Only one project rc = r_info_start if not cfg.xlsx.hide_pcb_info: prj = cfg.aggregate[0] rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Schematic:", prj.name) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Variant:", cfg.variant.name) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Revision:", prj.sch.revision) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Date:", prj.sch.date) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "KiCad Version:", cfg.kicad_version) col1 += 2 rc = r_info_start if not cfg.xlsx.hide_stats_info: rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Groups:", cfg.n_groups) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Count:", cfg.n_total) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Fitted Components:", cfg.n_fitted) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Number of PCBs:", cfg.number) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Total Components:", cfg.n_build) else: # Multiple projects # Global stats old_col1 = col1 rc = r_info_start if not cfg.xlsx.hide_pcb_info: rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Variant:", cfg.variant.name) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "KiCad Version:", cfg.kicad_version) col1 += 2 rc = r_info_start if not cfg.xlsx.hide_stats_info: rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Groups:", cfg.n_groups) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Count:", cfg.n_total) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Fitted Components:", cfg.n_fitted) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Number of PCBs:", cfg.number) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Total Components:", cfg.n_build) # Individual stats # No need to waste space for a column with no data r_info_start += 3 if cfg.xlsx.hide_stats_info and compact else 5 for prj in cfg.aggregate: col1 = old_col1 worksheet.set_row(r_info_start, 24) worksheet.merge_range(r_info_start, col1, r_info_start, len(column_widths)-1, prj.sch.title, fmt_subtitle) r_info_start += 1 rc = r_info_start if not cfg.xlsx.hide_pcb_info: rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Schematic:", prj.name) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Revision:", prj.sch.revision) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Date:", prj.sch.date) if prj.sch.company: rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Company:", prj.sch.company) if prj.ref_id: rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "ID:", prj.ref_id) col1 += 2 rc = r_info_start if not cfg.xlsx.hide_stats_info: rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Groups:", prj.comp_groups) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Component Count:", prj.comp_total) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Fitted Components:", prj.comp_fitted) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Number of PCBs:", prj.number) rc = add_info(worksheet, column_widths, rc, col1, fmt_info, "Total Components:", prj.comp_build) r_info_start += 5 def adapt_extra_cost_columns(cfg): if not cfg.columns_ce: return user_fields = [] for i, col in enumerate(cfg.columns_ce): data = {'field': col} comment = cfg.column_comments_ce[i] if comment: data['comment'] = comment level = cfg.column_levels_ce[i] if level: data['level'] = level col = col.lower() if col in cfg.column_rename_ce: data['label'] = cfg.column_rename_ce[col] user_fields.append(data) Spreadsheet.USER_FIELDS = user_fields def apply_join_requests(join, adapted, original): if not join: return for key, val in original.items(): append = '' for join_l in join: # Each list is "target, source..." so we need at least 2 elements elements = len(join_l) target = join_l[0] if elements > 1 and target == key: # Append data from the other fields for source in join_l[1:]: v = original.get(source) if v: append += ' ' + v if append: if val is None: val = '' adapted[key] = val + append def remove_unknown_distributors(distributors, available, silent): new_distributors = [] for d in distributors: d = d.lower() if d not in available: # Is the label of the column? new_d = get_dist_name_from_label(d) if new_d is not None: d = new_d if d not in available: if not silent: logger.warning(W_UNKDIST+'Unknown distributor `{}`'.format(d)) else: new_distributors.append(d) return new_distributors def solve_distributors(cfg, silent=True): # List of distributors to scrape available = get_distributors_list() include = remove_unknown_distributors(cfg.distributors, available, silent) exclude = remove_unknown_distributors(cfg.no_distributors, available, silent) # Default is to sort the entries Spreadsheet.SORT_DISTRIBUTORS = True if not include: # All by default dist_list = available else: # Requested to be included dist_list = include # Keep user sorting Spreadsheet.SORT_DISTRIBUTORS = False # Requested to be excluded for d in exclude: dist_list.remove(d) Spreadsheet.DISTRIBUTORS = dist_list return dist_list def compute_qtys(cfg, g): if len(cfg.aggregate) == 1: return str(g.get_count()) return [str(g.get_count(sch.name)) for sch in cfg.aggregate] def create_meta_sheets(workbook, used_parts, fmt_head, fmt_cols, cfg, ss): if cfg.xlsx.specs: meta_names = ['Specs', 'Specs (DNF)'] for ws in range(2): spec_cols = {} spec_cols_l = set() parts = used_parts[ws] for part in parts: for name_l, v in part.specs.items(): spec_cols_l.add(name_l) spec = v[0] if spec in spec_cols: spec_cols[spec] += 1 else: spec_cols[spec] = 1 if len(spec_cols): columns = cfg.xlsx.s_columns if columns is None: # Use all columns, sort them by relevance (most used) and alphabetically c = len(parts) columns = sorted(spec_cols, key=lambda k: (c - spec_cols[k], k)) columns.insert(0, ColumnList.COL_REFERENCE) else: # Inform about missing columns for c in columns: col = c.lower() if ((col[0] == '_' and col[1:] not in ss.extra_info_display) or (col[0] != '_' and col not in spec_cols_l and col not in SPECS_GENERATED)): logger.warning(W_BADFIELD+'Invalid Specs column name `{}` {}'.format(c, col[1:])) create_meta(workbook, meta_names[ws], columns, parts, fmt_head, fmt_cols, cfg.xlsx.max_col_width, cfg.xlsx.s_rename, cfg.xlsx.s_levels, cfg.xlsx.s_comments, cfg.xlsx.s_join) def _create_kicost_sheet(workbook, groups, image_data, fmt_title, fmt_info, fmt_subtitle, fmt_head, fmt_cols, cfg): if not KICOST_SUPPORT: logger.warning(W_NOKICOST, 'KiCost sheet requested but failed to load KiCost support') return if cfg.debug_level > 2: logger.debug("Groups exported to KiCost:") for g in groups: logger.debug(pprint.pformat(g.__dict__)) logger.debug("-- Components") for c in g.components: logger.debug(pprint.pformat(c.__dict__)) # Force KiCost to use our logger set_distributors_logger(logger) set_edas_logger(logger) # Load KiCost config (includes APIs config) api_options = load_config() # Environment with overwrite configure_from_environment(api_options, True) # Filter which APIs we want for api in cfg.xlsx.kicost_api_disable: if is_valid_api(api): api_options[api]['enable'] = False for api in cfg.xlsx.kicost_api_enable: if is_valid_api(api): api_options[api]['enable'] = True # Configure the APIs configure_apis(api_options) # Start with a clean list of available distributors init_distributor_dict() # Create the projects information structure prj_info = [{'title': p.name, 'company': p.sch.company, 'date': p.sch.date, 'qty': p.number} for p in cfg.aggregate] # Create the worksheets ws_names = ['Costs', 'Costs (DNF)'] Spreadsheet.PRJ_INFO_ROWS = 5 if len(cfg.aggregate) == 1 else 6 Spreadsheet.PRJ_INFO_START = 1 if len(cfg.aggregate) == 1 else 4 Spreadsheet.ADJUST_ROW_AND_COL_SIZE = True Spreadsheet.MAX_COL_WIDTH = cfg.xlsx.max_col_width Spreadsheet.PART_NSEQ_SEPRTR = cfg.ref_separator if hasattr(Spreadsheet, 'SUPPRESS_DIST_DESC'): Spreadsheet.SUPPRESS_DIST_DESC = not cfg.xlsx.kicost_dist_desc # Keep our sorting try: Spreadsheet.SORT_GROUPS = False except Exception: pass # Make the version less intrusive Spreadsheet.WRK_FORMATS['about_msg']['font_size'] = 8 # Don 't add project info, we add our own data Spreadsheet.INCLUDE_PRJ_INFO = False # Move the date to the bottom, and make it less relevant Spreadsheet.ADD_DATE_TOP = False Spreadsheet.ADD_DATE_BOTTOM = True Spreadsheet.WRK_FORMATS['proj_info_field']['font_size'] = 11 Spreadsheet.WRK_FORMATS['proj_info']['font_size'] = 11 Spreadsheet.DATE_FIELD_LABEL = 'Created:' # Set the color for the global section (using the selected theme) bg_color = get_bg_color(cfg.xlsx.style) if bg_color: Spreadsheet.WRK_FORMATS['global']['bg_color'] = bg_color Spreadsheet.WRK_FORMATS['header']['bg_color'] = bg_color Spreadsheet.WRK_FORMATS['header']['font_color'] = 'white' Spreadsheet.WRK_FORMATS['global']['font_size'] = 12 Spreadsheet.WRK_FORMATS['header']['font_size'] = 11 # Avoid the use of the same color twice Spreadsheet.WRK_FORMATS['order_too_much']['bg_color'] = '#FF4040' Spreadsheet.WRK_FORMATS['order_min_qty']['bg_color'] = '#FF6060' # Project quantity as the default quantity Spreadsheet.DEFAULT_BUILD_QTY = cfg.number # Add version information Spreadsheet.ABOUT_MSG += ' + KiBot v'+__version__ # References using ranges Spreadsheet.COLLAPSE_REFS = cfg.use_alt # Pass any extra column adapt_extra_cost_columns(cfg) # Adapt the column names for id, v in Spreadsheet.GLOBAL_COLUMNS.items(): if id in KICOST_COLUMNS: # We use another name new_id = KICOST_COLUMNS[id] v['label'] = new_id id = new_id.lower() if id in cfg.column_rename: v['label'] = cfg.column_rename[id] used_parts = [] for ws in range(2): # Second pass is DNF dnf = ws == 1 # Should we generate the DNF? if dnf and (not cfg.xlsx.generate_dnf or cfg.n_total == cfg.n_fitted): break # Create the parts structure from the groups parts = [] for g in groups: if (cfg.ignore_dnf and not g.is_fitted()) != dnf: continue part = PartGroup() part.refs = [c.ref for c in g.components] part.fields = g.fields part.fields['manf#_qty'] = compute_qtys(cfg, g) parts.append(part) # Process any "join" request apply_join_requests(cfg.join_ce, part.fields, g.fields) # Distributors dist_list = solve_distributors(cfg) # Get the prices query_part_info(parts, dist_list) # Distributors again. During `query_part_info` user defined distributors could be added solve_distributors(cfg, silent=False) # Create a class to hold the spreadsheet parameters ss = Spreadsheet(workbook, ws_names[ws], prj_info) wks = ss.wks # Page head # Logo col1 = insert_logo(wks, image_data, cfg.xlsx.logo_scale) if col1: col1 += 1 # PCB & Stats Info if not (cfg.xlsx.hide_pcb_info and cfg.xlsx.hide_stats_info): r_info_start = 1 if cfg.xlsx.title else 0 column_widths = [0]*5 # Column 1 to 5 old_stats = cfg.xlsx.hide_stats_info cfg.xlsx.hide_stats_info = True write_info(cfg, r_info_start, wks, column_widths, col1, fmt_info, fmt_subtitle, compact=True) cfg.xlsx.hide_stats_info = old_stats ss.col_widths[col1] = column_widths[col1] ss.col_widths[col1+1] = column_widths[col1+1] # Add a worksheet with costs to the spreadsheet create_worksheet(ss, logger, parts) # Title if cfg.xlsx.title: wks.set_row(0, 32) wks.merge_range(0, col1, 0, ss.globals_width, cfg.xlsx.title, fmt_title) used_parts.append(parts) # Specs sheets create_meta_sheets(workbook, used_parts, fmt_head, fmt_cols, cfg, ss) colors = {} colors['Best price'] = ss.wrk_formats['best_price'] colors['No manufacturer or distributor code'] = ss.wrk_formats['not_manf_codes'] colors['Not available'] = ss.wrk_formats['not_available'] colors['Purchase quantity is more than what is available'] = ss.wrk_formats['order_too_much'] colors['Minimum order quantity not respected'] = ss.wrk_formats['order_min_qty'] colors['Total available part quantity is less than needed'] = ss.wrk_formats['too_few_available'] colors['Total purchased part quantity is less than needed'] = ss.wrk_formats['too_few_purchased'] colors['This part is obsolete'] = ss.wrk_formats['part_format_obsolete'] colors['This part is listed but is not normally stocked'] = ss.wrk_formats['not_stocked'] return colors def create_kicost_sheet(workbook, groups, image_data, fmt_title, fmt_info, fmt_subtitle, fmt_head, fmt_cols, cfg): try: return _create_kicost_sheet(workbook, groups, image_data, fmt_title, fmt_info, fmt_subtitle, fmt_head, fmt_cols, cfg) except KiCostError as e: trace_dump() logger.error('KiCost error: `{}` ({})'.format(e.msg, e.id)) exit(KICOST_ERROR) def write_xlsx(filename, groups, col_fields, head_names, cfg): """ Write BoM out to a XLSX file filename = path to output file (must be a .csv, .txt or .tsv file) groups = [list of ComponentGroup groups] col_fields = [list of headings to search for data in the BoM file] head_names = [list of headings to display in the BoM file] cfg = BoMOptions object with all the configuration """ if not XLSX_SUPPORT: logger.error('Python xlsxwriter module not installed (Debian: python3-xlsxwriter)') return False link_datasheet = -1 if cfg.xlsx.datasheet_as_link and cfg.xlsx.datasheet_as_link in col_fields: link_datasheet = col_fields.index(cfg.xlsx.datasheet_as_link) link_digikey = cfg.xlsx.digikey_link hl_empty = cfg.xlsx.highlight_empty workbook = Workbook(filename) ws_names = ['BoM', 'DNF'] row_headings = head_names # Leave space for the logo, title and info head_size = compute_head_size(cfg) # First rowe for the information r_info_start = 1 if cfg.xlsx.title else 0 max_width = cfg.xlsx.max_col_width # ####################### # Create all the formats # ####################### # Headings # Column names format fmt_head = create_fmt_head(workbook, cfg.xlsx.style) # Column formats fmt_cols = create_fmt_cols(workbook, cfg.xlsx.col_colors) col_fmt = create_col_fmt(col_fields, cfg.xlsx.col_colors, fmt_cols) # Page head # Logo image_data = get_logo_data(cfg.xlsx.logo) # Title fmt_title = create_fmt_title(workbook, cfg.xlsx.title) fmt_subtitle = create_fmt_subtitle(workbook) # Info fmt_info = create_fmt_info(workbook, cfg) # ####################### # Fill the cells # ####################### # Normal BoM & DNF for ws in range(2): # Second pass is DNF dnf = ws == 1 # Should we generate the DNF? if dnf and (not cfg.xlsx.generate_dnf or cfg.n_total == cfg.n_fitted): break worksheet = workbook.add_worksheet(ws_names[ws]) row_count = head_size # Headings # Create the head titles column_widths = [0]*max(len(col_fields), 6) rows = [row_headings] for i in range(len(row_headings)): # Title for this column column_widths[i] = len(row_headings[i]) + 10 worksheet.write_string(row_count, i, row_headings[i], fmt_head) if cfg.column_comments[i]: worksheet.write_comment(row_count, i, cfg.column_comments[i]) # Body row_count += 1 for i, group in enumerate(groups): if (cfg.ignore_dnf and not group.is_fitted()) != dnf: continue # Get the data row row = group.get_row(col_fields) rows.append(row) if link_datasheet != -1: datasheet = group.get_field(ColumnList.COL_DATASHEET_L) # Fill the row for i in range(len(row)): cell = row[i] if hl_empty and (len(cell) == 0 or cell.strip() == "~"): fmt = col_fmt[-1][row_count % 2] else: fmt = col_fmt[i][row_count % 2] # Link this column to the datasheet? if link_datasheet == i and datasheet.startswith('http'): worksheet.write_url(row_count, i, datasheet, fmt, cell) # A link to Digi-Key? elif link_digikey and col_fields[i] in link_digikey: url = 'http://search.digikey.com/scripts/DkSearch/dksus.dll?Detail&name=' + cell worksheet.write_url(row_count, i, url, fmt, cell) else: worksheet.write_string(row_count, i, cell, fmt) if len(cell) > column_widths[i] - 5: column_widths[i] = len(cell) + 5 row_count += 1 # Page head # Logo col1 = insert_logo(worksheet, image_data, cfg.xlsx.logo_scale) # Title if cfg.xlsx.title: worksheet.set_row(0, 32) worksheet.merge_range(0, col1, 0, len(column_widths)-1, cfg.xlsx.title, fmt_title) # PCB & Stats Info if not (cfg.xlsx.hide_pcb_info and cfg.xlsx.hide_stats_info): write_info(cfg, r_info_start, worksheet, column_widths, col1, fmt_info, fmt_subtitle) # Adjust cols and rows adjust_widths(worksheet, column_widths, max_width, cfg.column_levels) adjust_heights(worksheet, rows, max_width, head_size) worksheet.freeze_panes(head_size+1, 0) worksheet.repeat_rows(head_size+1) worksheet.set_landscape() # Optionally add KiCost information kicost_colors = None if cfg.xlsx.kicost: kicost_colors = create_kicost_sheet(workbook, groups, image_data, fmt_title, fmt_info, fmt_subtitle, fmt_head, fmt_cols, cfg) # Add a sheet for the color references create_color_ref(workbook, cfg.xlsx.col_colors, hl_empty, fmt_cols, cfg.xlsx.kicost and KICOST_SUPPORT, kicost_colors) workbook.close() return True