diff --git a/src/wireviz/DataClasses.py b/src/wireviz/DataClasses.py index 7f01ffdf..48be7062 100644 --- a/src/wireviz/DataClasses.py +++ b/src/wireviz/DataClasses.py @@ -4,6 +4,7 @@ from typing import Optional, List, Tuple, Union from dataclasses import dataclass, field, InitVar from pathlib import Path + from wireviz.wv_helper import int2tuple, aspect_ratio from wireviz import wv_colors diff --git a/src/wireviz/Harness.py b/src/wireviz/Harness.py index 8973788d..352f93b2 100644 --- a/src/wireviz/Harness.py +++ b/src/wireviz/Harness.py @@ -1,20 +1,24 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from wireviz.DataClasses import Connector, Cable from graphviz import Graph -from wireviz import wv_colors, wv_helper, __version__, APP_NAME, APP_URL -from wireviz.wv_colors import get_color_hex -from wireviz.wv_helper import awg_equiv, mm2_equiv, tuplelist2tsv, \ - nested_html_table, flatten2d, index_if_list, html_line_breaks, \ - clean_whitespace, open_file_read, open_file_write, html_colorbar, \ - html_image, html_caption, manufacturer_info_field, component_table_entry, remove_links from collections import Counter from typing import List, Union from pathlib import Path from itertools import zip_longest import re +from wireviz import wv_colors, __version__, APP_NAME, APP_URL +from wireviz.DataClasses import Connector, Cable +from wireviz.wv_colors import get_color_hex +from wireviz.wv_gv_html import nested_html_table, html_colorbar, html_image, \ + html_caption, remove_links, html_line_breaks +from wireviz.wv_bom import manufacturer_info_field, component_table_entry, \ + get_additional_component_table, bom_list, generate_bom +from wireviz.wv_html import generate_html_output +from wireviz.wv_helper import awg_equiv, mm2_equiv, tuplelist2tsv, flatten2d, \ + open_file_read, open_file_write + class Harness: @@ -119,7 +123,7 @@ def create_graph(self) -> Graph: '' if connector.style != 'simple' else None, [html_image(connector.image)], [html_caption(connector.image)]] - rows.extend(self.get_additional_component_table(connector)) + rows.extend(get_additional_component_table(self, connector)) rows.append([html_line_breaks(connector.notes)]) html.extend(nested_html_table(rows)) @@ -205,7 +209,7 @@ def create_graph(self) -> Graph: [html_image(cable.image)], [html_caption(cable.image)]] - rows.extend(self.get_additional_component_table(cable)) + rows.extend(get_additional_component_table(self, cable)) rows.append([html_line_breaks(cable.notes)]) html.extend(nested_html_table(rows)) @@ -353,181 +357,13 @@ def output(self, filename: (str, Path), view: bool = False, cleanup: bool = True graph.render(filename=filename, view=view, cleanup=cleanup) graph.save(filename=f'{filename}.gv') # bom output - bom_list = self.bom_list() + bomlist = bom_list(self.bom()) with open_file_write(f'{filename}.bom.tsv') as file: - file.write(tuplelist2tsv(bom_list)) + file.write(tuplelist2tsv(bomlist)) # HTML output - with open_file_write(f'{filename}.html') as file: - file.write('\n') - file.write('\n') - file.write(' \n') - file.write(f' \n') - file.write(f' {APP_NAME} Diagram and BOM\n') - file.write('\n') - - file.write('

Diagram

') - with open_file_read(f'{filename}.svg') as svg: - file.write(re.sub( - '^<[?]xml [^?>]*[?]>[^<]*]*>', - '', - svg.read(1024), 1)) - for svgdata in svg: - file.write(svgdata) - - file.write('

Bill of Materials

') - listy = flatten2d(bom_list) - file.write('') - file.write('') - for item in listy[0]: - file.write(f'') - file.write('') - for row in listy[1:]: - file.write('') - for i, item in enumerate(row): - item_str = item.replace('\u00b2', '²') - align = 'text-align:right; ' if listy[0][i] == 'Qty' else '' - file.write(f'') - file.write('') - file.write('
{item}
{item_str}
') - - file.write('') - - def get_additional_component_table(self, component: Union[Connector, Cable]) -> List[str]: - rows = [] - if component.additional_components: - rows.append(["Additional components"]) - for extra in component.additional_components: - qty = extra.qty * component.get_qty_multiplier(extra.qty_multiplier) - if self.mini_bom_mode: - id = self.get_bom_index(extra.description, extra.unit, extra.manufacturer, extra.mpn, extra.pn) - rows.append(component_table_entry(f'#{id} ({extra.type.rstrip()})', qty, extra.unit)) - else: - rows.append(component_table_entry(extra.description, qty, extra.unit, extra.pn, extra.manufacturer, extra.mpn)) - return(rows) - - def get_additional_component_bom(self, component: Union[Connector, Cable]) -> List[dict]: - bom_entries = [] - for part in component.additional_components: - qty = part.qty * component.get_qty_multiplier(part.qty_multiplier) - bom_entries.append({ - 'item': part.description, - 'qty': qty, - 'unit': part.unit, - 'manufacturer': part.manufacturer, - 'mpn': part.mpn, - 'pn': part.pn, - 'designators': component.name if component.show_name else None - }) - return(bom_entries) + generate_html_output(filename, bomlist) def bom(self): - # if the bom has previously been generated then return the generated bom - if self._bom: - return self._bom - bom_entries = [] - - # connectors - for connector in self.connectors.values(): - if not connector.ignore_in_bom: - description = ('Connector' - + (f', {connector.type}' if connector.type else '') - + (f', {connector.subtype}' if connector.subtype else '') - + (f', {connector.pincount} pins' if connector.show_pincount else '') - + (f', {connector.color}' if connector.color else '')) - bom_entries.append({ - 'item': description, 'qty': 1, 'unit': None, 'designators': connector.name if connector.show_name else None, - 'manufacturer': connector.manufacturer, 'mpn': connector.mpn, 'pn': connector.pn - }) - - # add connectors aditional components to bom - bom_entries.extend(self.get_additional_component_bom(connector)) - - # cables - # TODO: If category can have other non-empty values than 'bundle', maybe it should be part of item name? - for cable in self.cables.values(): - if not cable.ignore_in_bom: - if cable.category != 'bundle': - # process cable as a single entity - description = ('Cable' - + (f', {cable.type}' if cable.type else '') - + (f', {cable.wirecount}') - + (f' x {cable.gauge} {cable.gauge_unit}' if cable.gauge else ' wires') - + (' shielded' if cable.shield else '')) - bom_entries.append({ - 'item': description, 'qty': cable.length, 'unit': 'm', 'designators': cable.name if cable.show_name else None, - 'manufacturer': cable.manufacturer, 'mpn': cable.mpn, 'pn': cable.pn - }) - else: - # add each wire from the bundle to the bom - for index, color in enumerate(cable.colors): - description = ('Wire' - + (f', {cable.type}' if cable.type else '') - + (f', {cable.gauge} {cable.gauge_unit}' if cable.gauge else '') - + (f', {color}' if color else '')) - bom_entries.append({ - 'item': description, 'qty': cable.length, 'unit': 'm', 'designators': cable.name if cable.show_name else None, - 'manufacturer': index_if_list(cable.manufacturer, index), - 'mpn': index_if_list(cable.mpn, index), 'pn': index_if_list(cable.pn, index) - }) - - # add cable/bundles aditional components to bom - bom_entries.extend(self.get_additional_component_bom(cable)) - - for item in self.additional_bom_items: - bom_entries.append({ - 'item': item.get('description', ''), 'qty': item.get('qty', 1), 'unit': item.get('unit'), 'designators': item.get('designators'), - 'manufacturer': item.get('manufacturer'), 'mpn': item.get('mpn'), 'pn': item.get('pn') - }) - - # remove line breaks if present and cleanup any resulting whitespace issues - bom_entries = [{k: clean_whitespace(v) for k, v in entry.items()} for entry in bom_entries] - - # deduplicate bom - bom_types_group = lambda bt: (bt['item'], bt['unit'], bt['manufacturer'], bt['mpn'], bt['pn']) - for group in Counter([bom_types_group(v) for v in bom_entries]): - group_entries = [v for v in bom_entries if bom_types_group(v) == group] - designators = [] - for group_entry in group_entries: - if group_entry.get('designators'): - if isinstance(group_entry['designators'], List): - designators.extend(group_entry['designators']) - else: - designators.append(group_entry['designators']) - designators = list(dict.fromkeys(designators)) # remove duplicates - designators.sort() - total_qty = sum(entry['qty'] for entry in group_entries) - self._bom.append({**group_entries[0], 'qty': round(total_qty, 3), 'designators': designators}) - - self._bom = sorted(self._bom, key=lambda k: k['item']) # sort list of dicts by their values (https://stackoverflow.com/a/73050) - - # add an incrementing id to each bom item - self._bom = [{**entry, 'id': index} for index, entry in enumerate(self._bom, 1)] + if not self._bom: + self._bom = generate_bom(self) return self._bom - - def get_bom_index(self, item, unit, manufacturer, mpn, pn): - # Remove linebreaks and clean whitespace of values in search - target = tuple(clean_whitespace(v) for v in (item, unit, manufacturer, mpn, pn)) - for entry in self.bom(): - if (entry['item'], entry['unit'], entry['manufacturer'], entry['mpn'], entry['pn']) == target: - return entry['id'] - return None - - def bom_list(self): - bom = self.bom() - keys = ['id', 'item', 'qty', 'unit', 'designators'] # these BOM columns will always be included - for fieldname in ['pn', 'manufacturer', 'mpn']: # these optional BOM columns will only be included if at least one BOM item actually uses them - if any(entry.get(fieldname) for entry in bom): - keys.append(fieldname) - bom_list = [] - # list of staic bom header names, headers not specified here are generated by capitilising the internal name - bom_headings = { - "pn": "P/N", - "mpn": "MPN" - } - bom_list.append([(bom_headings[k] if k in bom_headings else k.capitalize()) for k in keys]) # create header row with keys - for item in bom: - item_list = [item.get(key, '') for key in keys] # fill missing values with blanks - item_list = [', '.join(subitem) if isinstance(subitem, List) else subitem for subitem in item_list] # convert any lists into comma separated strings - item_list = ['' if subitem is None else subitem for subitem in item_list] # if a field is missing for some (but not all) BOM items - bom_list.append(item_list) - return bom_list diff --git a/src/wireviz/wv_bom.py b/src/wireviz/wv_bom.py new file mode 100644 index 00000000..a854ac86 --- /dev/null +++ b/src/wireviz/wv_bom.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from typing import List, Union +from collections import Counter + +from wireviz.DataClasses import Connector, Cable +from wireviz.wv_gv_html import html_line_breaks +from wireviz.wv_helper import clean_whitespace + +def get_additional_component_table(harness, component: Union[Connector, Cable]) -> List[str]: + rows = [] + if component.additional_components: + rows.append(["Additional components"]) + for extra in component.additional_components: + qty = extra.qty * component.get_qty_multiplier(extra.qty_multiplier) + if harness.mini_bom_mode: + id = get_bom_index(harness, extra.description, extra.unit, extra.manufacturer, extra.mpn, extra.pn) + rows.append(component_table_entry(f'#{id} ({extra.type.rstrip()})', qty, extra.unit)) + else: + rows.append(component_table_entry(extra.description, qty, extra.unit, extra.pn, extra.manufacturer, extra.mpn)) + return(rows) + +def get_additional_component_bom(component: Union[Connector, Cable]) -> List[dict]: + bom_entries = [] + for part in component.additional_components: + qty = part.qty * component.get_qty_multiplier(part.qty_multiplier) + bom_entries.append({ + 'item': part.description, + 'qty': qty, + 'unit': part.unit, + 'manufacturer': part.manufacturer, + 'mpn': part.mpn, + 'pn': part.pn, + 'designators': component.name if component.show_name else None + }) + return(bom_entries) + +def generate_bom(harness): + from wireviz.Harness import Harness # Local import to avoid circular imports + bom_entries = [] + # connectors + for connector in harness.connectors.values(): + if not connector.ignore_in_bom: + description = ('Connector' + + (f', {connector.type}' if connector.type else '') + + (f', {connector.subtype}' if connector.subtype else '') + + (f', {connector.pincount} pins' if connector.show_pincount else '') + + (f', {connector.color}' if connector.color else '')) + bom_entries.append({ + 'item': description, 'qty': 1, 'unit': None, 'designators': connector.name if connector.show_name else None, + 'manufacturer': connector.manufacturer, 'mpn': connector.mpn, 'pn': connector.pn + }) + + # add connectors aditional components to bom + bom_entries.extend(get_additional_component_bom(connector)) + + # cables + # TODO: If category can have other non-empty values than 'bundle', maybe it should be part of item name? + for cable in harness.cables.values(): + if not cable.ignore_in_bom: + if cable.category != 'bundle': + # process cable as a single entity + description = ('Cable' + + (f', {cable.type}' if cable.type else '') + + (f', {cable.wirecount}') + + (f' x {cable.gauge} {cable.gauge_unit}' if cable.gauge else ' wires') + + (' shielded' if cable.shield else '')) + bom_entries.append({ + 'item': description, 'qty': cable.length, 'unit': 'm', 'designators': cable.name if cable.show_name else None, + 'manufacturer': cable.manufacturer, 'mpn': cable.mpn, 'pn': cable.pn + }) + else: + # add each wire from the bundle to the bom + for index, color in enumerate(cable.colors): + description = ('Wire' + + (f', {cable.type}' if cable.type else '') + + (f', {cable.gauge} {cable.gauge_unit}' if cable.gauge else '') + + (f', {color}' if color else '')) + bom_entries.append({ + 'item': description, 'qty': cable.length, 'unit': 'm', 'designators': cable.name if cable.show_name else None, + 'manufacturer': index_if_list(cable.manufacturer, index), + 'mpn': index_if_list(cable.mpn, index), 'pn': index_if_list(cable.pn, index) + }) + + # add cable/bundles aditional components to bom + bom_entries.extend(get_additional_component_bom(cable)) + + for item in harness.additional_bom_items: + bom_entries.append({ + 'item': item.get('description', ''), 'qty': item.get('qty', 1), 'unit': item.get('unit'), 'designators': item.get('designators'), + 'manufacturer': item.get('manufacturer'), 'mpn': item.get('mpn'), 'pn': item.get('pn') + }) + + # remove line breaks if present and cleanup any resulting whitespace issues + bom_entries = [{k: clean_whitespace(v) for k, v in entry.items()} for entry in bom_entries] + + # deduplicate bom + bom = [] + bom_types_group = lambda bt: (bt['item'], bt['unit'], bt['manufacturer'], bt['mpn'], bt['pn']) + for group in Counter([bom_types_group(v) for v in bom_entries]): + group_entries = [v for v in bom_entries if bom_types_group(v) == group] + designators = [] + for group_entry in group_entries: + if group_entry.get('designators'): + if isinstance(group_entry['designators'], List): + designators.extend(group_entry['designators']) + else: + designators.append(group_entry['designators']) + designators = list(dict.fromkeys(designators)) # remove duplicates + designators.sort() + total_qty = sum(entry['qty'] for entry in group_entries) + bom.append({**group_entries[0], 'qty': round(total_qty, 3), 'designators': designators}) + + bom = sorted(bom, key=lambda k: k['item']) # sort list of dicts by their values (https://stackoverflow.com/a/73050) + + # add an incrementing id to each bom item + bom = [{**entry, 'id': index} for index, entry in enumerate(bom, 1)] + return bom + +def get_bom_index(harness, item, unit, manufacturer, mpn, pn): + # Remove linebreaks and clean whitespace of values in search + target = tuple(clean_whitespace(v) for v in (item, unit, manufacturer, mpn, pn)) + for entry in harness.bom(): + if (entry['item'], entry['unit'], entry['manufacturer'], entry['mpn'], entry['pn']) == target: + return entry['id'] + return None + +def bom_list(bom): + keys = ['id', 'item', 'qty', 'unit', 'designators'] # these BOM columns will always be included + for fieldname in ['pn', 'manufacturer', 'mpn']: # these optional BOM columns will only be included if at least one BOM item actually uses them + if any(entry.get(fieldname) for entry in bom): + keys.append(fieldname) + bom_list = [] + # list of staic bom header names, headers not specified here are generated by capitilising the internal name + bom_headings = { + "pn": "P/N", + "mpn": "MPN" + } + bom_list.append([(bom_headings[k] if k in bom_headings else k.capitalize()) for k in keys]) # create header row with keys + for item in bom: + item_list = [item.get(key, '') for key in keys] # fill missing values with blanks + item_list = [', '.join(subitem) if isinstance(subitem, List) else subitem for subitem in item_list] # convert any lists into comma separated strings + item_list = ['' if subitem is None else subitem for subitem in item_list] # if a field is missing for some (but not all) BOM items + bom_list.append(item_list) + return bom_list + +def component_table_entry(type, qty, unit=None, pn=None, manufacturer=None, mpn=None): + output = f'{qty}' + if unit: + output += f' {unit}' + output += f' x {type}' + # print an extra line with part and manufacturer information if provided + manufacturer_str = manufacturer_info_field(manufacturer, mpn) + if pn or manufacturer_str: + output += '
' + if pn: + output += f'P/N: {pn}' + if manufacturer_str: + output += ', ' + if manufacturer_str: + output += manufacturer_str + output = html_line_breaks(output) + # format the above output as left aligned text in a single visible cell + # indent is set to two to match the indent in the generated html table + return f''' + +
{output}
''' + +def manufacturer_info_field(manufacturer, mpn): + if manufacturer or mpn: + return f'{manufacturer if manufacturer else "MPN"}{": " + str(mpn) if mpn else ""}' + else: + return None + +# Return the value indexed if it is a list, or simply the value otherwise. +def index_if_list(value, index): + return value[index] if isinstance(value, list) else value diff --git a/src/wireviz/wv_gv_html.py b/src/wireviz/wv_gv_html.py new file mode 100644 index 00000000..ee5b2e28 --- /dev/null +++ b/src/wireviz/wv_gv_html.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from typing import List, Union +import re + +from wireviz.wv_colors import translate_color +from wireviz.wv_helper import remove_links + +def nested_html_table(rows): + # input: list, each item may be scalar or list + # output: a parent table with one child table per parent item that is list, and one cell per parent item that is scalar + # purpose: create the appearance of one table, where cell widths are independent between rows + # attributes in any leading inside a list are injected into to the preceeding tag + html = [] + html.append('') + for row in rows: + if isinstance(row, List): + if len(row) > 0 and any(row): + html.append(' ') + elif row is not None: + html.append(' ') + html.append('
') + html.append(' ') + for cell in row: + if cell is not None: + # Inject attributes to the preceeding '.replace('>
tag where needed + html.append(f' {cell}
') + html.append('
') + html.append(f' {row}') + html.append('
') + return html + +def html_colorbar(color): + return f'' if color else None + +def html_image(image): + from wireviz.DataClasses import Image + if not image: + return None + # The leading attributes belong to the preceeding tag. See where used below. + html = f'{html_size_attr(image)}>' + if image.fixedsize: + # Close the preceeding tag and enclose the image cell in a table without + # borders to avoid narrow borders when the fixed width < the node width. + html = f'''> + + +
+ ''' + return f'''{html_line_breaks(image.caption)}' if image and image.caption else None + +def html_size_attr(image): + from wireviz.DataClasses import Image + # Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object + return ((f' width="{image.width}"' if image.width else '') + + (f' height="{image.height}"' if image.height else '') + + ( ' fixedsize="true"' if image.fixedsize else '')) if image else '' + +def html_line_breaks(inp): + return remove_links(inp).replace('\n', '
') if isinstance(inp, str) else inp diff --git a/src/wireviz/wv_helper.py b/src/wireviz/wv_helper.py index 9d785229..ffc4bd84 100644 --- a/src/wireviz/wv_helper.py +++ b/src/wireviz/wv_helper.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -from wireviz import wv_colors from typing import List import re @@ -32,58 +31,6 @@ def awg_equiv(mm2): def mm2_equiv(awg): return mm2_equiv_table.get(str(awg), 'Unknown') -def nested_html_table(rows): - # input: list, each item may be scalar or list - # output: a parent table with one child table per parent item that is list, and one cell per parent item that is scalar - # purpose: create the appearance of one table, where cell widths are independent between rows - # attributes in any leading inside a list are injected into to the preceeding tag - html = [] - html.append('') - for row in rows: - if isinstance(row, List): - if len(row) > 0 and any(row): - html.append(' ') - elif row is not None: - html.append(' ') - html.append('
') - html.append(' ') - for cell in row: - if cell is not None: - # Inject attributes to the preceeding '.replace('>
tag where needed - html.append(f' {cell}
') - html.append('
') - html.append(f' {row}') - html.append('
') - return html - -def html_colorbar(color): - return f'' if color else None - -def html_image(image): - if not image: - return None - # The leading attributes belong to the preceeding tag. See where used below. - html = f'{html_size_attr(image)}>' - if image.fixedsize: - # Close the preceeding tag and enclose the image cell in a table without - # borders to avoid narrow borders when the fixed width < the node width. - html = f'''> - - -
- ''' - return f'''{html_line_breaks(image.caption)}' if image and image.caption else None - -def html_size_attr(image): - # Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object - return ((f' width="{image.width}"' if image.width else '') - + (f' height="{image.height}"' if image.height else '') - + ( ' fixedsize="true"' if image.fixedsize else '')) if image else '' - def expand(yaml_data): # yaml_data can be: @@ -140,19 +87,15 @@ def tuplelist2tsv(inp, header=None): output = output + '\t'.join(str(remove_links(item)) for item in row) + '\n' return output -# Return the value indexed if it is a list, or simply the value otherwise. -def index_if_list(value, index): - return value[index] if isinstance(value, list) else value def remove_links(inp): return re.sub(r'<[aA] [^>]*>([^<]*)', r'\1', inp) if isinstance(inp, str) else inp -def html_line_breaks(inp): - return remove_links(inp).replace('\n', '
') if isinstance(inp, str) else inp def clean_whitespace(inp): return ' '.join(inp.split()).replace(' ,', ',') if isinstance(inp, str) else inp + def open_file_read(filename): # TODO: Intelligently determine encoding return open(filename, 'r', encoding='UTF-8') @@ -163,7 +106,6 @@ def open_file_write(filename): def open_file_append(filename): return open(filename, 'a', encoding='UTF-8') - def aspect_ratio(image_src): try: from PIL import Image @@ -175,32 +117,3 @@ def aspect_ratio(image_src): except Exception as error: print(f'aspect_ratio(): {type(error).__name__}: {error}') return 1 # Assume 1:1 when unable to read actual image size - - -def manufacturer_info_field(manufacturer, mpn): - if manufacturer or mpn: - return f'{manufacturer if manufacturer else "MPN"}{": " + str(mpn) if mpn else ""}' - else: - return None - -def component_table_entry(type, qty, unit=None, pn=None, manufacturer=None, mpn=None): - output = f'{qty}' - if unit: - output += f' {unit}' - output += f' x {type}' - # print an extra line with part and manufacturer information if provided - manufacturer_str = manufacturer_info_field(manufacturer, mpn) - if pn or manufacturer_str: - output += '
' - if pn: - output += f'P/N: {pn}' - if manufacturer_str: - output += ', ' - if manufacturer_str: - output += manufacturer_str - output = html_line_breaks(output) - # format the above output as left aligned text in a single visible cell - # indent is set to two to match the indent in the generated html table - return f''' - -
{output}
''' diff --git a/src/wireviz/wv_html.py b/src/wireviz/wv_html.py new file mode 100644 index 00000000..b328ba3d --- /dev/null +++ b/src/wireviz/wv_html.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from pathlib import Path +import re + +from wireviz import __version__, APP_NAME, APP_URL +from wireviz.wv_helper import flatten2d, open_file_read, open_file_write + +def generate_html_output(filename: (str, Path), bom_list): + with open_file_write(f'{filename}.html') as file: + file.write('\n') + file.write('\n') + file.write(' \n') + file.write(f' \n') + file.write(f' {APP_NAME} Diagram and BOM\n') + file.write('\n') + + file.write('

Diagram

') + with open_file_read(f'{filename}.svg') as svg: + file.write(re.sub( + '^<[?]xml [^?>]*[?]>[^<]*]*>', + '', + svg.read(1024), 1)) + for svgdata in svg: + file.write(svgdata) + + file.write('

Bill of Materials

') + listy = flatten2d(bom_list) + file.write('') + file.write('') + for item in listy[0]: + file.write(f'') + file.write('') + for row in listy[1:]: + file.write('') + for i, item in enumerate(row): + item_str = item.replace('\u00b2', '²') + align = 'text-align:right; ' if listy[0][i] == 'Qty' else '' + file.write(f'') + file.write('') + file.write('
{item}
{item_str}
') + + file.write('')