├── .gitignore ├── .jsbeautifyrc ├── DATAFORMAT.md ├── InteractiveHtmlBom ├── .gitattributes ├── Run.bat ├── __init__.py ├── core │ ├── __init__.py │ ├── config.py │ ├── fontparser.py │ ├── ibom.py │ ├── lzstring.py │ ├── newstroke_font.py │ └── units.py ├── dialog │ ├── __init__.py │ ├── bitmaps │ │ ├── btn-arrow-down.png │ │ ├── btn-arrow-up.png │ │ ├── btn-minus.png │ │ ├── btn-plus.png │ │ └── btn-question.png │ ├── dialog_base.py │ └── settings_dialog.py ├── dialog_test.py ├── ecad │ ├── __init__.py │ ├── common.py │ ├── easyeda.py │ ├── genericjson.py │ ├── kicad.py │ ├── kicad_extra │ │ ├── __init__.py │ │ ├── netlistparser.py │ │ ├── parser_base.py │ │ ├── sexpressions.py │ │ └── xmlparser.py │ ├── schema │ │ └── genericjsonpcbdata_v1.schema │ └── svgpath.py ├── errors.py ├── generate_interactive_bom.py ├── i18n │ ├── language_en.bat │ └── language_zh.bat ├── icon.png ├── version.py └── web │ ├── ibom.css │ ├── ibom.html │ ├── ibom.js │ ├── lz-string.js │ ├── pep.js │ ├── render.js │ ├── split.js │ ├── table-util.js │ ├── user-file-examples │ ├── user.css │ ├── user.js │ ├── userfooter.html │ └── userheader.html │ └── util.js ├── LICENSE ├── README.md ├── __init__.py ├── icons ├── baseline-settings-20px.svg ├── bom-grouped-32px.svg ├── bom-left-right-32px.svg ├── bom-netlist-32px.svg ├── bom-only-32px.svg ├── bom-top-bot-32px.svg ├── bom-ungrouped-32px.svg ├── btn-arrow-down.svg ├── btn-arrow-up.svg ├── btn-minus.svg ├── btn-plus.svg ├── btn-question.svg ├── copy-48px.svg ├── io-36px.svg ├── plugin.svg ├── plugin_icon_big.png └── stats-36px.svg └── settings_dialog.fbp /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea 3 | .vscode 4 | *.iml 5 | *.bak 6 | test 7 | releases 8 | demo 9 | *config.ini 10 | InteractiveHtmlBom/web/user* 11 | -------------------------------------------------------------------------------- /.jsbeautifyrc: -------------------------------------------------------------------------------- 1 | { 2 | "indent_size": 2, 3 | "indent_char": " ", 4 | "indent_with_tabs": false, 5 | "editorconfig": false, 6 | "eol": "\n", 7 | "end_with_newline": true, 8 | "indent_level": 0, 9 | "preserve_newlines": true, 10 | "max_preserve_newlines": 10, 11 | "space_in_paren": false, 12 | "space_in_empty_paren": false, 13 | "jslint_happy": false, 14 | "space_after_anon_function": false, 15 | "space_after_named_function": false, 16 | "brace_style": "collapse", 17 | "unindent_chained_methods": false, 18 | "break_chained_methods": false, 19 | "keep_array_indentation": false, 20 | "unescape_strings": false, 21 | "wrap_line_length": 0, 22 | "e4x": false, 23 | "comma_first": false, 24 | "operator_position": "before-newline", 25 | "indent_empty_lines": false, 26 | "templating": ["auto"] 27 | } -------------------------------------------------------------------------------- /DATAFORMAT.md: -------------------------------------------------------------------------------- 1 | # pcbdata struct 2 | 3 | This document describes pcbdata json structure that plugin 4 | extracts from PCB file and injects into generated bom page. 5 | 6 | Notes on conventions: 7 | * Coordinate system has origin in top left corner i.e. Y grows downwards 8 | * All angles are in degrees measured clockwise from positive X axis vector 9 | * Units are arbitrary but some browsers will not handle too large numbers 10 | well so sticking to mm/mils is preferred. 11 | 12 | ```js 13 | pcbdata = { 14 | // Describes bounding box of all edge cut drawings. 15 | // Used for determining default zoom and pan values to fit 16 | // whole board on canvas. 17 | "edges_bbox": { 18 | "minx": 1, 19 | "miny": 2, 20 | "maxx": 100, 21 | "maxy": 200, 22 | }, 23 | // Describes all edge cut drawings including ones in footprints. 24 | // See drawing structure description below. 25 | "edges": [drawing1, drawing2, ...], 26 | "drawings": { 27 | // Contains all drawings + reference + value texts on silkscreen 28 | // layer grouped by front and back. 29 | "silkscreen": { 30 | "F": [drawing1, drawing2, ...], 31 | "B": [drawing1, drawing2, ...], 32 | }, 33 | // Same as above but for fabrication layer. 34 | "fabrication": { 35 | "F": [drawing1, drawing2, ...], 36 | "B": [drawing1, drawing2, ...], 37 | }, 38 | }, 39 | // Describes footprints. 40 | // See footprint structure description below. 41 | // index of entry corresponds to component's numeric ID 42 | "footprints": [ 43 | footprint1, 44 | footprint2, 45 | ... 46 | ], 47 | // Optional track data. Vias are 0 length tracks. 48 | "tracks": { 49 | "F": [ 50 | { 51 | // In case of line segment or via (via is 0 length segment) 52 | "start": [x, y], 53 | "end": [x, y], 54 | // In case of arc 55 | "center": [x, y], 56 | "startangle": 57 | "radius": radius, 58 | "startangle": angle1, 59 | "endangle": angle2, 60 | // Common fields 61 | "width": w, 62 | // Optional net name 63 | "net": netname 64 | }, 65 | ... 66 | ], 67 | "B": [...] 68 | }, 69 | // Optional zone data (should be present if tracks are present). 70 | "zones": { 71 | "F": [ 72 | { 73 | // SVG path of the polygon given as 'd' attribute of svg spec. 74 | // If "svgpath" is present "polygons" is ignored. 75 | "svgpath": svgpath, 76 | "polygons": [ 77 | // Set of polylines same as in polygon drawing. 78 | [[point1x, point1y], [point2x, point2y], ...], 79 | ], 80 | // Optional net name. 81 | "net": netname, 82 | }, 83 | ... 84 | ], 85 | "B": [...] 86 | }, 87 | // Optional net name list. 88 | "nets": [net1, net2, ...], 89 | // PCB metadata from the title block. 90 | "metadata": { 91 | "title": "title", 92 | "revision": "rev", 93 | "company": "Horns and Hoofs", 94 | "date": "2019-04-18", 95 | }, 96 | // Contains full bom table as well as filtered by front/back. 97 | // See bom row description below. 98 | "bom": { 99 | "both": [bomrow1, bomrow2, ...], 100 | "F": [bomrow1, bomrow2, ...], 101 | "B": [bomrow1, bomrow2, ...], 102 | // numeric IDs of DNP components that are not in BOM 103 | "skipped": [id1, id2, ...] 104 | // Fields map is keyed on component ID with values being field data. 105 | // It's order corresponds to order of fields data in config struct. 106 | "fields" { 107 | id1: [field1, field2, ...], 108 | id2: [field1, field2, ...], 109 | ... 110 | } 111 | }, 112 | // Contains parsed stroke data from newstroke font for 113 | // characters used on the pcb. 114 | "font_data": { 115 | "a": { 116 | "w": character_width, 117 | // Contains array of polylines that form the character shape. 118 | "l": [ 119 | [[point1x, point1y], [point2x, point2y],...], 120 | ... 121 | ] 122 | }, 123 | "%": { 124 | ... 125 | }, 126 | ... 127 | }, 128 | } 129 | ``` 130 | 131 | # drawing struct 132 | 133 | All drawings are either graphical items (arcs, lines, circles, curves) 134 | or text. 135 | 136 | Rendering method and properties are determined based on `type` 137 | attribute. 138 | 139 | 140 | ## graphical items 141 | 142 | ### segment 143 | 144 | ```js 145 | { 146 | "type": "segment", 147 | "start": [x, y], 148 | "end": [x, y], 149 | "width": width, 150 | } 151 | ``` 152 | 153 | ### rect 154 | 155 | ```js 156 | { 157 | "type": "rect", 158 | "start": [x, y], // coordinates of opposing corners 159 | "end": [x, y], 160 | "width": width, 161 | } 162 | ``` 163 | 164 | ### circle 165 | 166 | ```js 167 | { 168 | "type": "circle", 169 | "start": [x, y], 170 | "radius": radius, 171 | // Optional boolean, defaults to 0 172 | "filled": 0, 173 | // Line width (only has effect for non-filled shapes) 174 | "width": width, 175 | } 176 | ``` 177 | 178 | ### arc 179 | 180 | ```js 181 | { 182 | "type": "arc", 183 | "width": width, 184 | // SVG path of the arc given as 'd' attribute of svg spec. 185 | // If this parameter is specified everything below it is ignored. 186 | "svgpath": svgpath, 187 | "start": [x, y], // arc center 188 | "radius": radius, 189 | "startangle": angle1, 190 | "endangle": angle2, 191 | } 192 | ``` 193 | 194 | ### curve 195 | 196 | ```js 197 | { 198 | "type": "curve", // Bezier curve 199 | "start": [x, y], 200 | "end": [x, y], 201 | "cpa": [x, y], // control point A 202 | "cpb": [x, y], // control point B 203 | "width": width, 204 | } 205 | ``` 206 | 207 | ### polygon 208 | 209 | ```js 210 | { 211 | "type": "polygon", 212 | // Optional, defaults to 1 213 | "filled": 1, 214 | // Line width (only has effect for non-filled shapes) 215 | "width": width 216 | // SVG path of the polygon given as 'd' attribute of svg spec. 217 | // If this parameter is specified everything below it is ignored. 218 | "svgpath": svgpath, 219 | "pos": [x, y], 220 | "angle": angle, 221 | "polygons": [ 222 | // Polygons are described as set of outlines. 223 | [ 224 | [point1x, point1y], [point2x, point2y], ... 225 | ], 226 | ... 227 | ] 228 | } 229 | ``` 230 | 231 | ## text 232 | 233 | ```js 234 | { 235 | "pos": [x, y], 236 | "text": text, 237 | // SVG path of the text given as 'd' attribute of svg spec. 238 | // If this parameter is specified then height, width, angle, 239 | // text attributes and justification is ignored. Rendering engine 240 | // will not attempt to read character data from newstroke font and 241 | // will draw the path as is. "thickness" will be used as stroke width. 242 | "svgpath": svgpath, 243 | // If polygons are specified then remaining attributes are ignored 244 | "polygons": [ 245 | // Polygons are described as set of outlines. 246 | [ 247 | [point1x, point1y], [point2x, point2y], ... 248 | ], 249 | ... 250 | ], 251 | "height": height, 252 | "width": width, 253 | // -1: justify left/top 254 | // 0: justify center 255 | // 1: justify right/bot 256 | "justify": [horizontal, vertical], 257 | "thickness": thickness, 258 | "attr": [ 259 | // may include none, one or both 260 | "italic", "mirrored" 261 | ], 262 | "angle": angle, 263 | // Present only if text is reference designator 264 | "ref": 1, 265 | // Present only if text is component value 266 | "val": 1, 267 | } 268 | ``` 269 | 270 | # footprint struct 271 | 272 | Footprints are a collection of pads, drawings and some metadata. 273 | 274 | ```js 275 | { 276 | "ref": reference, 277 | "center": [x, y], 278 | "bbox": { 279 | // Position of the rotation center of the bounding box. 280 | "pos": [x, y], 281 | // Rotation angle in degrees. 282 | "angle": angle, 283 | // Left top corner position relative to center (after rotation) 284 | "relpos": [x, y], 285 | "size": [x, y], 286 | }, 287 | "pads": [ 288 | { 289 | "layers": [ 290 | // Contains one or both 291 | "F", "B", 292 | ], 293 | "pos": [x, y], 294 | "size": [x, y], 295 | "angle": angle, 296 | // Only present if pad is considered first pin. 297 | // Pins are considered first if it's name is one of 298 | // 1, A, A1, P1, PAD1 299 | // OR footprint has no pads named as one of above and 300 | // current pad's name is lexicographically smallest. 301 | "pin1": 1, 302 | // Shape is one of "rect", "oval", "circle", "roundrect", "chamfrect", custom". 303 | "shape": shape, 304 | // Only present if shape is "custom". 305 | // SVG path of the polygon given as 'd' attribute of svg spec. 306 | // If "svgpath" is present "polygons", "pos", "angle" are ignored. 307 | "svgpath": svgpath, 308 | "polygons": [ 309 | // Set of polylines same as in polygon drawing. 310 | [[point1x, point1y], [point2x, point2y], ...], 311 | ... 312 | ], 313 | // Only present if shape is "roundrect" or "chamfrect". 314 | "radius": radius, 315 | // Only present if shape is "chamfrect". 316 | // chamfpos is a bitmask, left = 1, right = 2, bottom left = 4, bottom right = 8 317 | "chamfpos": chamfpos, 318 | "chamfratio": ratio, 319 | // Pad type is "th" for standard and NPTH pads 320 | // "smd" otherwise. 321 | "type": type, 322 | // Present only if type is "th". 323 | // One of "circle", "oblong". 324 | "drillshape": drillshape, 325 | // Present only if type is "th". In case of circle shape x is diameter, y is ignored. 326 | "drillsize": [x, y], 327 | // Optional attribute. 328 | "offset": [x, y], 329 | // Optional net name 330 | "net": netname, 331 | }, 332 | ... 333 | ], 334 | "drawings": [ 335 | // Contains only copper F_Cu, B_Cu drawings of the footprint. 336 | { 337 | // One of "F", "B". 338 | "layer": layer, 339 | // See drawing struct description above. 340 | "drawing": drawing, 341 | }, 342 | ... 343 | ], 344 | // One of "F", "B". 345 | "layer": layer, 346 | } 347 | ``` 348 | 349 | # bom row struct 350 | 351 | Bom row is a list of reference sets 352 | 353 | Reference set is array of tuples of (ref, id) where id is just 354 | a unique numeric identifier for each footprint that helps avoid 355 | collisions when references are duplicated. 356 | 357 | ```js 358 | [ 359 | [reference_name, footprint_id], 360 | ... 361 | ] 362 | ``` 363 | 364 | # config struct 365 | 366 | ```js 367 | config = { 368 | "dark_mode": bool, 369 | "show_pads": bool, 370 | "show_fabrication": bool, 371 | "show_silkscreen": bool, 372 | "highlight_pin1": bool, 373 | "redraw_on_drag": bool, 374 | "board_rotation": int, 375 | "checkboxes": "checkbox1,checkbox2,...", 376 | // One of "bom-only", "left-right", "top-bottom". 377 | "bom_view": bom_view, 378 | // One of "F", "FB", "B". 379 | "layer_view": layer_view, 380 | "extra_fields": ["field1_name", "field2_name", ...], 381 | } 382 | ``` 383 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/.gitattributes: -------------------------------------------------------------------------------- 1 | *.bat eol=crlf 2 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/Run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | set pathofEDASourceFile=%1 3 | set FilePath=%~dp0 4 | 5 | ::delete --show-dialog after first start up and setting 6 | set option=--show-dialog 7 | 8 | ::detect current language of user. 9 | reg query "HKCU\Control Panel\Desktop" /v PreferredUILanguages>nul 2>nul&&goto _dosearch1_||goto _dosearch2_ 10 | 11 | :_dosearch1_ 12 | FOR /F "tokens=3" %%a IN ( 13 | 'reg query "HKCU\Control Panel\Desktop" /v PreferredUILanguages ^| find "PreferredUILanguages"' 14 | ) DO ( 15 | set language=%%a 16 | ) 17 | set language=%language:~,2% 18 | goto _setlanguage_ 19 | 20 | :_dosearch2_ 21 | FOR /F "tokens=3" %%a IN ( 22 | 'reg query "HKLM\SYSTEM\ControlSet001\Control\Nls\Language" /v InstallLanguage ^| find "InstallLanguage"' 23 | ) DO ( 24 | set language=%%a 25 | ) 26 | if %language%==0804 ( 27 | set language=zh 28 | ) 29 | goto _setlanguage_ 30 | 31 | :_setlanguage_ 32 | if %language%==zh ( 33 | call %FilePath%\i18n\language_zh.bat 34 | ) else ( 35 | call %FilePath%\i18n\language_en.bat 36 | ) 37 | 38 | cls 39 | 40 | echo ------------------------------------------------------------------------------------------------------------------- 41 | echo ------------------------------------------------------------------------------------------------------------------- 42 | echo. 43 | echo %i18n_thx4using% 44 | echo %i18n_gitAddr% 45 | echo %i18n_batScar% 46 | echo. 47 | echo ------------------------------------------------------------------------------------------------------------------- 48 | echo ------------------------------------------------------------------------------------------------------------------- 49 | 50 | set pyFilePath=%FilePath%generate_interactive_bom.py 51 | 52 | :_convert_ 53 | if not defined pathofEDASourceFile ( 54 | set /p pathofEDASourceFile=%i18n_draghere% 55 | ) 56 | echo. 57 | echo %i18n_converting% 58 | echo. 59 | python %pyFilePath% %pathofEDASourceFile% %option% 60 | set pathofEDASourceFile= 61 | 62 | echo ------------------------------------------------------------------------------------------------------------------- 63 | echo ------------------------------------------------------------------------------------------------------------------- 64 | echo. 65 | echo %i18n_converted% 66 | echo. 67 | echo ------------------------------------------------------------------------------------------------------------------- 68 | echo ------------------------------------------------------------------------------------------------------------------- 69 | 70 | 71 | CHOICE /C YN /N /M "%i18n_again% [ Y/N ]" 72 | if errorlevel 2 exit 73 | if errorlevel 1 goto _convert_ 74 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import threading 4 | import time 5 | 6 | import wx 7 | import wx.aui 8 | 9 | 10 | def check_for_bom_button(): 11 | # From Miles McCoo's blog 12 | # https://kicad.mmccoo.com/2017/03/05/adding-your-own-command-buttons-to-the-pcbnew-gui/ 13 | def find_pcbnew_window(): 14 | windows = wx.GetTopLevelWindows() 15 | pcbneww = [w for w in windows if "pcbnew" in w.GetTitle().lower()] 16 | if len(pcbneww) != 1: 17 | return None 18 | return pcbneww[0] 19 | 20 | def callback(_): 21 | plugin.Run() 22 | 23 | path = os.path.dirname(__file__) 24 | while not wx.GetApp(): 25 | time.sleep(1) 26 | bm = wx.Bitmap(path + '/icon.png', wx.BITMAP_TYPE_PNG) 27 | button_wx_item_id = 0 28 | 29 | from pcbnew import ID_H_TOOLBAR 30 | while True: 31 | time.sleep(1) 32 | pcbnew_window = find_pcbnew_window() 33 | if not pcbnew_window: 34 | continue 35 | 36 | top_tb = pcbnew_window.FindWindowById(ID_H_TOOLBAR) 37 | if button_wx_item_id == 0 or not top_tb.FindTool(button_wx_item_id): 38 | top_tb.AddSeparator() 39 | button_wx_item_id = wx.NewId() 40 | top_tb.AddTool(button_wx_item_id, "iBOM", bm, 41 | "Generate interactive BOM", wx.ITEM_NORMAL) 42 | top_tb.Bind(wx.EVT_TOOL, callback, id=button_wx_item_id) 43 | top_tb.Realize() 44 | 45 | 46 | if not os.environ.get('INTERACTIVE_HTML_BOM_CLI_MODE', False): 47 | from .ecad.kicad import InteractiveHtmlBomPlugin 48 | 49 | plugin = InteractiveHtmlBomPlugin() 50 | plugin.register() 51 | 52 | # Add a button the hacky way if plugin button is not supported 53 | # in pcbnew, unless this is linux. 54 | if not plugin.pcbnew_icon_support and not sys.platform.startswith('linux'): 55 | t = threading.Thread(target=check_for_bom_button) 56 | t.daemon = True 57 | t.start() 58 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peng-zhihui/InteractiveHtmlBom/b07e364699346a7d9edaac25c2eaeab34eb22ba7/InteractiveHtmlBom/core/__init__.py -------------------------------------------------------------------------------- /InteractiveHtmlBom/core/fontparser.py: -------------------------------------------------------------------------------- 1 | from .newstroke_font import NEWSTROKE_FONT 2 | 3 | 4 | class FontParser: 5 | STROKE_FONT_SCALE = 1.0 / 21.0 6 | FONT_OFFSET = -10 7 | 8 | def __init__(self): 9 | self.parsed_font = {} 10 | 11 | def parse_font_char(self, chr): 12 | lines = [] 13 | line = [] 14 | glyph_x = 0 15 | index = ord(chr) - ord(' ') 16 | if index >= len(NEWSTROKE_FONT): 17 | index = ord('?') - ord(' ') 18 | glyph_str = NEWSTROKE_FONT[index] 19 | for i in range(0, len(glyph_str), 2): 20 | coord = glyph_str[i:i + 2] 21 | 22 | # The first two values contain the width of the char 23 | if i < 2: 24 | glyph_x = (ord(coord[0]) - ord('R')) * self.STROKE_FONT_SCALE 25 | glyph_width = (ord(coord[1]) - ord(coord[0])) * self.STROKE_FONT_SCALE 26 | elif coord[0] == ' ' and coord[1] == 'R': 27 | lines.append(line) 28 | line = [] 29 | else: 30 | line.append([ 31 | (ord(coord[0]) - ord('R')) * self.STROKE_FONT_SCALE - glyph_x, 32 | (ord(coord[1]) - ord('R') + self.FONT_OFFSET) * self.STROKE_FONT_SCALE 33 | ]) 34 | 35 | if len(line) > 0: 36 | lines.append(line) 37 | 38 | return { 39 | 'w': glyph_width, 40 | 'l': lines 41 | } 42 | 43 | def parse_font_for_string(self, s): 44 | for c in s: 45 | if c == '\t' and ' ' not in self.parsed_font: 46 | # tabs rely on space char to calculate offset 47 | self.parsed_font[' '] = self.parse_font_char(' ') 48 | if c not in self.parsed_font and ord(c) >= ord(' '): 49 | self.parsed_font[c] = self.parse_font_char(c) 50 | 51 | def get_parsed_font(self): 52 | return self.parsed_font 53 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/core/ibom.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import io 4 | import json 5 | import logging 6 | import os 7 | import re 8 | import sys 9 | from datetime import datetime 10 | 11 | import wx 12 | 13 | from . import units 14 | from .config import Config 15 | from ..dialog import SettingsDialog 16 | from ..ecad.common import EcadParser, Component 17 | from ..errors import ParsingException 18 | 19 | 20 | class Logger(object): 21 | 22 | def __init__(self, cli=False): 23 | self.cli = cli 24 | self.logger = logging.getLogger('InteractiveHtmlBom') 25 | self.logger.setLevel(logging.INFO) 26 | ch = logging.StreamHandler(sys.stdout) 27 | ch.setLevel(logging.INFO) 28 | formatter = logging.Formatter( 29 | "%(asctime)-15s %(levelname)s %(message)s") 30 | ch.setFormatter(formatter) 31 | self.logger.addHandler(ch) 32 | 33 | def info(self, *args): 34 | if self.cli: 35 | self.logger.info(*args) 36 | 37 | def error(self, msg): 38 | if self.cli: 39 | self.logger.error(msg) 40 | else: 41 | wx.MessageBox(msg) 42 | 43 | def warn(self, msg): 44 | if self.cli: 45 | self.logger.warning(msg) 46 | else: 47 | wx.LogWarning(msg) 48 | 49 | 50 | log = None 51 | 52 | 53 | def skip_component(m, config): 54 | # type: (Component, Config) -> bool 55 | # skip blacklisted components 56 | ref_prefix = re.findall('^[A-Z]*', m.ref)[0] 57 | if m.ref in config.component_blacklist: 58 | return True 59 | if ref_prefix + '*' in config.component_blacklist: 60 | return True 61 | 62 | if config.blacklist_empty_val and m.val in ['', '~']: 63 | return True 64 | 65 | # skip virtual components if needed 66 | if config.blacklist_virtual and m.attr == 'Virtual': 67 | return True 68 | 69 | # skip components with dnp field not empty 70 | if config.dnp_field \ 71 | and config.dnp_field in m.extra_fields \ 72 | and m.extra_fields[config.dnp_field]: 73 | return True 74 | 75 | # skip components with wrong variant field 76 | if config.board_variant_field and config.board_variant_whitelist: 77 | ref_variant = m.extra_fields.get(config.board_variant_field, '') 78 | if ref_variant not in config.board_variant_whitelist: 79 | return True 80 | 81 | if config.board_variant_field and config.board_variant_blacklist: 82 | ref_variant = m.extra_fields.get(config.board_variant_field, '') 83 | if ref_variant and ref_variant in config.board_variant_blacklist: 84 | return True 85 | 86 | return False 87 | 88 | 89 | def generate_bom(pcb_footprints, config): 90 | # type: (list, Config) -> dict 91 | """ 92 | Generate BOM from pcb layout. 93 | :param pcb_footprints: list of footprints on the pcb 94 | :param config: Config object 95 | :return: dict of BOM tables (qty, value, footprint, refs) 96 | and dnp components 97 | """ 98 | 99 | def convert(text): 100 | return int(text) if text.isdigit() else text.lower() 101 | 102 | def alphanum_key(key): 103 | return [convert(c) 104 | for c in re.split('([0-9]+)', key)] 105 | 106 | def natural_sort(lst): 107 | """ 108 | Natural sort for strings containing numbers 109 | """ 110 | 111 | return sorted(lst, key=lambda r: (alphanum_key(r[0]), r[1])) 112 | 113 | # build grouped part list 114 | skipped_components = [] 115 | part_groups = {} 116 | group_by = set(config.group_fields) 117 | index_to_fields = {} 118 | 119 | for i, f in enumerate(pcb_footprints): 120 | if skip_component(f, config): 121 | skipped_components.append(i) 122 | continue 123 | 124 | # group part refs by value and footprint 125 | fields = [] 126 | group_key = [] 127 | 128 | for field in config.show_fields: 129 | if field == "Value": 130 | fields.append(f.val) 131 | if "Value" in group_by: 132 | norm_value, unit = units.componentValue(f.val, f.ref) 133 | group_key.append(norm_value) 134 | group_key.append(unit) 135 | elif field == "Footprint": 136 | fields.append(f.footprint) 137 | if "Footprint" in group_by: 138 | group_key.append(f.footprint) 139 | group_key.append(f.attr) 140 | else: 141 | fields.append(f.extra_fields.get(field, '')) 142 | if field in group_by: 143 | group_key.append(f.extra_fields.get(field, '')) 144 | 145 | index_to_fields[i] = fields 146 | refs = part_groups.setdefault(tuple(group_key), []) 147 | refs.append((f.ref, i)) 148 | 149 | bom_table = [] 150 | 151 | for _, refs in part_groups.items(): 152 | # Fixup values to normalized string 153 | if "Value" in group_by and "Value" in config.show_fields: 154 | index = config.show_fields.index("Value") 155 | value = index_to_fields[refs[0][1]][index] 156 | for ref in refs: 157 | index_to_fields[ref[1]][index] = value 158 | 159 | bom_table.append(natural_sort(refs)) 160 | 161 | # sort table by reference prefix and quantity 162 | def row_sort_key(element): 163 | prefix = re.findall('^[^0-9]*', element[0][0])[0] 164 | if prefix in config.component_sort_order: 165 | ref_ord = config.component_sort_order.index(prefix) 166 | else: 167 | ref_ord = config.component_sort_order.index('~') 168 | return ref_ord, -len(element), alphanum_key(element[0][0]) 169 | 170 | if '~' not in config.component_sort_order: 171 | config.component_sort_order.append('~') 172 | 173 | bom_table = sorted(bom_table, key=row_sort_key) 174 | 175 | result = { 176 | 'both': bom_table, 177 | 'skipped': skipped_components, 178 | 'fields': index_to_fields 179 | } 180 | 181 | for layer in ['F', 'B']: 182 | filtered_table = [] 183 | for row in bom_table: 184 | filtered_refs = [ref for ref in row 185 | if pcb_footprints[ref[1]].layer == layer] 186 | if filtered_refs: 187 | filtered_table.append(filtered_refs) 188 | 189 | result[layer] = sorted(filtered_table, key=row_sort_key) 190 | 191 | return result 192 | 193 | 194 | def open_file(filename): 195 | import subprocess 196 | try: 197 | if sys.platform.startswith('win'): 198 | os.startfile(filename) 199 | elif sys.platform.startswith('darwin'): 200 | subprocess.call(('open', filename)) 201 | elif sys.platform.startswith('linux'): 202 | subprocess.call(('xdg-open', filename)) 203 | except Exception as e: 204 | log.warn('Failed to open browser: {}'.format(e)) 205 | 206 | 207 | def process_substitutions(bom_name_format, pcb_file_name, metadata): 208 | # type: (str, str, dict)->str 209 | name = bom_name_format.replace('%f', os.path.splitext(pcb_file_name)[0]) 210 | name = name.replace('%p', metadata['title']) 211 | name = name.replace('%c', metadata['company']) 212 | name = name.replace('%r', metadata['revision']) 213 | name = name.replace('%d', metadata['date'].replace(':', '-')) 214 | now = datetime.now() 215 | name = name.replace('%D', now.strftime('%Y-%m-%d')) 216 | name = name.replace('%T', now.strftime('%H-%M-%S')) 217 | # sanitize the name to avoid characters illegal in file systems 218 | name = name.replace('\\', '/') 219 | name = re.sub(r'[?%*:|"<>]', '_', name) 220 | return name + '.html' 221 | 222 | 223 | def round_floats(o, precision): 224 | if isinstance(o, float): 225 | return round(o, precision) 226 | if isinstance(o, dict): 227 | return {k: round_floats(v, precision) for k, v in o.items()} 228 | if isinstance(o, (list, tuple)): 229 | return [round_floats(x, precision) for x in o] 230 | return o 231 | 232 | 233 | def get_pcbdata_javascript(pcbdata, compression): 234 | from .lzstring import LZString 235 | 236 | js = "var pcbdata = {}" 237 | pcbdata_str = json.dumps(round_floats(pcbdata, 6)) 238 | 239 | if compression: 240 | log.info("Compressing pcb data") 241 | pcbdata_str = json.dumps(LZString().compress_to_base64(pcbdata_str)) 242 | js = "var pcbdata = JSON.parse(LZString.decompressFromBase64({}))" 243 | 244 | return js.format(pcbdata_str) 245 | 246 | 247 | def generate_file(pcb_file_dir, pcb_file_name, pcbdata, config): 248 | def get_file_content(file_name): 249 | path = os.path.join(os.path.dirname(__file__), "..", "web", file_name) 250 | if not os.path.exists(path): 251 | return "" 252 | with io.open(path, 'r', encoding='utf-8') as f: 253 | return f.read() 254 | 255 | if os.path.isabs(config.bom_dest_dir): 256 | bom_file_dir = config.bom_dest_dir 257 | else: 258 | bom_file_dir = os.path.join(pcb_file_dir, config.bom_dest_dir) 259 | bom_file_name = process_substitutions( 260 | config.bom_name_format, pcb_file_name, pcbdata['metadata']) 261 | bom_file_name = os.path.join(bom_file_dir, bom_file_name) 262 | bom_file_dir = os.path.dirname(bom_file_name) 263 | if not os.path.isdir(bom_file_dir): 264 | os.makedirs(bom_file_dir) 265 | pcbdata_js = get_pcbdata_javascript(pcbdata, config.compression) 266 | log.info("Dumping pcb data") 267 | config_js = "var config = " + config.get_html_config() 268 | html = get_file_content("ibom.html") 269 | html = html.replace('///CSS///', get_file_content('ibom.css')) 270 | html = html.replace('///USERCSS///', get_file_content('user.css')) 271 | html = html.replace('///SPLITJS///', get_file_content('split.js')) 272 | html = html.replace('///LZ-STRING///', 273 | get_file_content('lz-string.js') 274 | if config.compression else '') 275 | html = html.replace('///POINTER_EVENTS_POLYFILL///', 276 | get_file_content('pep.js')) 277 | html = html.replace('///CONFIG///', config_js) 278 | html = html.replace('///UTILJS///', get_file_content('util.js')) 279 | html = html.replace('///RENDERJS///', get_file_content('render.js')) 280 | html = html.replace('///TABLEUTILJS///', get_file_content('table-util.js')) 281 | html = html.replace('///IBOMJS///', get_file_content('ibom.js')) 282 | html = html.replace('///USERJS///', get_file_content('user.js')) 283 | html = html.replace('///USERHEADER///', 284 | get_file_content('userheader.html')) 285 | html = html.replace('///USERFOOTER///', 286 | get_file_content('userfooter.html')) 287 | # Replace pcbdata last for better performance. 288 | html = html.replace('///PCBDATA///', pcbdata_js) 289 | 290 | with io.open(bom_file_name, 'wt', encoding='utf-8') as bom: 291 | bom.write(html) 292 | 293 | log.info("Created file %s", bom_file_name) 294 | return bom_file_name 295 | 296 | 297 | def main(parser, config, logger): 298 | # type: (EcadParser, Config, Logger) -> None 299 | global log 300 | log = logger 301 | pcb_file_name = os.path.basename(parser.file_name) 302 | pcb_file_dir = os.path.dirname(parser.file_name) 303 | 304 | pcbdata, components = parser.parse() 305 | if not pcbdata and not components: 306 | raise ParsingException('Parsing failed.') 307 | 308 | pcbdata["bom"] = generate_bom(components, config) 309 | pcbdata["ibom_version"] = config.version 310 | 311 | # build BOM 312 | bom_file = generate_file(pcb_file_dir, pcb_file_name, pcbdata, config) 313 | 314 | if config.open_browser: 315 | logger.info("Opening file in browser") 316 | open_file(bom_file) 317 | 318 | 319 | def run_with_dialog(parser, config, logger): 320 | # type: (EcadParser, Config, Logger) -> None 321 | def save_config(dialog_panel, locally=False): 322 | config.set_from_dialog(dialog_panel) 323 | config.save(locally) 324 | 325 | config.load_from_ini() 326 | dlg = SettingsDialog(extra_data_func=parser.parse_extra_data, 327 | extra_data_wildcard=parser.extra_data_file_filter(), 328 | config_save_func=save_config, 329 | file_name_format_hint=config.FILE_NAME_FORMAT_HINT, 330 | version=config.version) 331 | try: 332 | config.netlist_initial_directory = os.path.dirname(parser.file_name) 333 | extra_data_file = parser.latest_extra_data( 334 | extra_dirs=[config.bom_dest_dir]) 335 | if extra_data_file is not None: 336 | dlg.set_extra_data_path(extra_data_file) 337 | config.transfer_to_dialog(dlg.panel) 338 | if dlg.ShowModal() == wx.ID_OK: 339 | config.set_from_dialog(dlg.panel) 340 | main(parser, config, logger) 341 | finally: 342 | dlg.Destroy() 343 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/core/lzstring.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2014 Eduard Tomasek 3 | This work is free. You can redistribute it and/or modify it under the 4 | terms of the Do What The Fuck You Want To Public License, Version 2, 5 | as published by Sam Hocevar. See the COPYING file for more details. 6 | """ 7 | import sys 8 | if sys.version_info[0] == 3: 9 | unichr = chr 10 | 11 | 12 | class LZString: 13 | 14 | def __init__(self): 15 | self.keyStr = ( 16 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" 17 | ) 18 | 19 | @staticmethod 20 | def compress(uncompressed): 21 | 22 | if uncompressed is None: 23 | return '' 24 | 25 | context_dictionary = {} 26 | context_dictionary_to_create = {} 27 | context_w = '' 28 | context_enlarge_in = 2 29 | 30 | context_dict_size = 3 31 | context_num_bits = 2 32 | context_data_string = '' 33 | context_data_val = 0 34 | context_data_position = 0 35 | 36 | uncompressed = uncompressed 37 | 38 | for ii in range(len(uncompressed)): 39 | context_c = uncompressed[ii] 40 | 41 | if context_c not in context_dictionary: 42 | context_dictionary[context_c] = context_dict_size 43 | context_dict_size += 1 44 | context_dictionary_to_create[context_c] = True 45 | 46 | context_wc = context_w + context_c 47 | 48 | if context_wc in context_dictionary: 49 | context_w = context_wc 50 | else: 51 | if context_w in context_dictionary_to_create: 52 | if ord(context_w[0]) < 256: 53 | for _ in range(context_num_bits): 54 | context_data_val = (context_data_val << 1) 55 | 56 | if context_data_position == 15: 57 | context_data_position = 0 58 | context_data_string += unichr(context_data_val) 59 | context_data_val = 0 60 | else: 61 | context_data_position += 1 62 | 63 | value = ord(context_w[0]) 64 | 65 | for i in range(8): 66 | context_data_val = ( 67 | (context_data_val << 1) | (value & 1) 68 | ) 69 | 70 | if context_data_position == 15: 71 | context_data_position = 0 72 | context_data_string += unichr(context_data_val) 73 | context_data_val = 0 74 | else: 75 | context_data_position += 1 76 | 77 | value = value >> 1 78 | else: 79 | value = 1 80 | 81 | for i in range(context_num_bits): 82 | context_data_val = (context_data_val << 1) | value 83 | 84 | if context_data_position == 15: 85 | context_data_position = 0 86 | context_data_string += unichr(context_data_val) 87 | context_data_val = 0 88 | else: 89 | context_data_position += 1 90 | 91 | value = 0 92 | 93 | value = ord(context_w[0]) 94 | 95 | for i in range(16): 96 | context_data_val = ( 97 | (context_data_val << 1) | (value & 1) 98 | ) 99 | 100 | if context_data_position == 15: 101 | context_data_position = 0 102 | context_data_string += unichr(context_data_val) 103 | context_data_val = 0 104 | else: 105 | context_data_position += 1 106 | 107 | value = value >> 1 108 | 109 | context_enlarge_in -= 1 110 | 111 | if context_enlarge_in == 0: 112 | context_enlarge_in = pow(2, context_num_bits) 113 | context_num_bits += 1 114 | 115 | context_dictionary_to_create.pop(context_w, None) 116 | # del context_dictionary_to_create[context_w] 117 | else: 118 | value = context_dictionary[context_w] 119 | 120 | for i in range(context_num_bits): 121 | context_data_val = ( 122 | (context_data_val << 1) | (value & 1) 123 | ) 124 | 125 | if context_data_position == 15: 126 | context_data_position = 0 127 | context_data_string += unichr(context_data_val) 128 | context_data_val = 0 129 | else: 130 | context_data_position += 1 131 | 132 | value = value >> 1 133 | 134 | context_enlarge_in -= 1 135 | 136 | if context_enlarge_in == 0: 137 | context_enlarge_in = pow(2, context_num_bits) 138 | context_num_bits += 1 139 | 140 | context_dictionary[context_wc] = context_dict_size 141 | context_dict_size += 1 142 | context_w = context_c 143 | if context_w != '': 144 | if context_w in context_dictionary_to_create: 145 | if ord(context_w[0]) < 256: 146 | for i in range(context_num_bits): 147 | context_data_val = (context_data_val << 1) 148 | 149 | if context_data_position == 15: 150 | context_data_position = 0 151 | context_data_string += unichr(context_data_val) 152 | context_data_val = 0 153 | else: 154 | context_data_position += 1 155 | 156 | value = ord(context_w[0]) 157 | 158 | for i in range(8): 159 | context_data_val = ( 160 | (context_data_val << 1) | (value & 1) 161 | ) 162 | 163 | if context_data_position == 15: 164 | context_data_position = 0 165 | context_data_string += unichr(context_data_val) 166 | context_data_val = 0 167 | else: 168 | context_data_position += 1 169 | 170 | value = value >> 1 171 | else: 172 | value = 1 173 | 174 | for i in range(context_num_bits): 175 | context_data_val = (context_data_val << 1) | value 176 | 177 | if context_data_position == 15: 178 | context_data_position = 0 179 | context_data_string += unichr(context_data_val) 180 | context_data_val = 0 181 | else: 182 | context_data_position += 1 183 | 184 | value = 0 185 | 186 | value = ord(context_w[0]) 187 | 188 | for i in range(16): 189 | context_data_val = ( 190 | (context_data_val << 1) | (value & 1) 191 | ) 192 | 193 | if context_data_position == 15: 194 | context_data_position = 0 195 | context_data_string += unichr(context_data_val) 196 | context_data_val = 0 197 | else: 198 | context_data_position += 1 199 | 200 | value = value >> 1 201 | 202 | context_enlarge_in -= 1 203 | 204 | if context_enlarge_in == 0: 205 | context_enlarge_in = pow(2, context_num_bits) 206 | context_num_bits += 1 207 | 208 | context_dictionary_to_create.pop(context_w, None) 209 | # del context_dictionary_to_create[context_w] 210 | else: 211 | value = context_dictionary[context_w] 212 | 213 | for i in range(context_num_bits): 214 | context_data_val = (context_data_val << 1) | (value & 1) 215 | 216 | if context_data_position == 15: 217 | context_data_position = 0 218 | context_data_string += unichr(context_data_val) 219 | context_data_val = 0 220 | else: 221 | context_data_position += 1 222 | 223 | value = value >> 1 224 | 225 | context_enlarge_in -= 1 226 | 227 | if context_enlarge_in == 0: 228 | context_num_bits += 1 229 | 230 | value = 2 231 | 232 | for i in range(context_num_bits): 233 | context_data_val = (context_data_val << 1) | (value & 1) 234 | 235 | if context_data_position == 15: 236 | context_data_position = 0 237 | context_data_string += unichr(context_data_val) 238 | context_data_val = 0 239 | else: 240 | context_data_position += 1 241 | 242 | value = value >> 1 243 | 244 | context_data_val = (context_data_val << 1) 245 | while context_data_position != 15: 246 | context_data_position += 1 247 | context_data_val = (context_data_val << 1) 248 | context_data_string += unichr(context_data_val) 249 | 250 | return context_data_string 251 | 252 | def compress_to_base64(self, string): 253 | if string is None: 254 | return '' 255 | 256 | output = '' 257 | 258 | string = self.compress(string) 259 | str_len = len(string) 260 | 261 | for i in range(0, str_len * 2, 3): 262 | if (i % 2) == 0: 263 | chr1 = ord(string[i // 2]) >> 8 264 | chr2 = ord(string[i // 2]) & 255 265 | 266 | if (i / 2) + 1 < str_len: 267 | chr3 = ord(string[(i // 2) + 1]) >> 8 268 | else: 269 | chr3 = None 270 | else: 271 | chr1 = ord(string[(i - 1) // 2]) & 255 272 | if (i + 1) / 2 < str_len: 273 | chr2 = ord(string[(i + 1) // 2]) >> 8 274 | chr3 = ord(string[(i + 1) // 2]) & 255 275 | else: 276 | chr2 = None 277 | chr3 = None 278 | 279 | # python dont support bit operation with NaN like javascript 280 | enc1 = chr1 >> 2 281 | enc2 = ( 282 | ((chr1 & 3) << 4) | 283 | (chr2 >> 4 if chr2 is not None else 0) 284 | ) 285 | enc3 = ( 286 | ((chr2 & 15 if chr2 is not None else 0) << 2) | 287 | (chr3 >> 6 if chr3 is not None else 0) 288 | ) 289 | enc4 = (chr3 if chr3 is not None else 0) & 63 290 | 291 | if chr2 is None: 292 | enc3 = 64 293 | enc4 = 64 294 | elif chr3 is None: 295 | enc4 = 64 296 | 297 | output += ( 298 | self.keyStr[enc1] + 299 | self.keyStr[enc2] + 300 | self.keyStr[enc3] + 301 | self.keyStr[enc4] 302 | ) 303 | 304 | return output 305 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/core/units.py: -------------------------------------------------------------------------------- 1 | # _*_ coding:utf-8 _*_ 2 | 3 | # Stolen from 4 | # https://github.com/SchrodingersGat/KiBoM/blob/master/KiBOM/units.py 5 | 6 | """ 7 | 8 | This file contains a set of functions for matching values which may be written 9 | in different formats e.g. 10 | 0.1uF = 100n (different suffix specified, one has missing unit) 11 | 0R1 = 0.1Ohm (Unit replaces decimal, different units) 12 | 13 | """ 14 | 15 | import re 16 | import locale 17 | 18 | current_locale = locale.setlocale(locale.LC_NUMERIC) 19 | try: 20 | locale.setlocale(locale.LC_NUMERIC, '') 21 | except Exception: 22 | # sometimes setlocale with empty string doesn't work on OSX 23 | pass 24 | decimal_separator = locale.localeconv()['decimal_point'] 25 | locale.setlocale(locale.LC_NUMERIC, current_locale) 26 | 27 | PREFIX_MICRO = [u"μ", u"µ", "u", "micro"] # first is \u03BC second is \u00B5 28 | PREFIX_MILLI = ["milli", "m"] 29 | PREFIX_NANO = ["nano", "n"] 30 | PREFIX_PICO = ["pico", "p"] 31 | PREFIX_KILO = ["kilo", "k"] 32 | PREFIX_MEGA = ["mega", "meg"] 33 | PREFIX_GIGA = ["giga", "g"] 34 | 35 | # All prefices 36 | PREFIX_ALL = PREFIX_PICO + PREFIX_NANO + PREFIX_MICRO + \ 37 | PREFIX_MILLI + PREFIX_KILO + PREFIX_MEGA + PREFIX_GIGA 38 | 39 | # Common methods of expressing component units 40 | UNIT_R = ["r", "ohms", "ohm", u"Ω", u"ω"] 41 | UNIT_C = ["farad", "f"] 42 | UNIT_L = ["henry", "h"] 43 | 44 | UNIT_ALL = UNIT_R + UNIT_C + UNIT_L 45 | 46 | VALUE_REGEX = re.compile( 47 | "^([0-9\\.]+)(" + "|".join(PREFIX_ALL) + ")*(" + "|".join( 48 | UNIT_ALL) + ")*(\\d*)$") 49 | 50 | REFERENCE_REGEX = re.compile("^(r|rv|c|l)(\\d+)$") 51 | 52 | 53 | def getUnit(unit): 54 | """ 55 | Return a simplified version of a units string, for comparison purposes 56 | """ 57 | if not unit: 58 | return None 59 | 60 | unit = unit.lower() 61 | 62 | if unit in UNIT_R: 63 | return "R" 64 | if unit in UNIT_C: 65 | return "F" 66 | if unit in UNIT_L: 67 | return "H" 68 | 69 | return None 70 | 71 | 72 | def getPrefix(prefix): 73 | """ 74 | Return the (numerical) value of a given prefix 75 | """ 76 | if not prefix: 77 | return 1 78 | 79 | prefix = prefix.lower() 80 | 81 | if prefix in PREFIX_PICO: 82 | return 1.0e-12 83 | if prefix in PREFIX_NANO: 84 | return 1.0e-9 85 | if prefix in PREFIX_MICRO: 86 | return 1.0e-6 87 | if prefix in PREFIX_MILLI: 88 | return 1.0e-3 89 | if prefix in PREFIX_KILO: 90 | return 1.0e3 91 | if prefix in PREFIX_MEGA: 92 | return 1.0e6 93 | if prefix in PREFIX_GIGA: 94 | return 1.0e9 95 | 96 | return 1 97 | 98 | 99 | def compMatch(component): 100 | """ 101 | Return a normalized value and units for a given component value string 102 | e.g. compMatch("10R2") returns (1000, R) 103 | e.g. compMatch("3.3mOhm") returns (0.0033, R) 104 | """ 105 | component = component.strip().lower() 106 | if decimal_separator == ',': 107 | # replace separator with dot 108 | component = component.replace(",", ".") 109 | else: 110 | # remove thousands separator 111 | component = component.replace(",", "") 112 | 113 | result = VALUE_REGEX.match(component) 114 | 115 | if not result: 116 | return None 117 | 118 | if not len(result.groups()) == 4: 119 | return None 120 | 121 | value, prefix, units, post = result.groups() 122 | 123 | # special case where units is in the middle of the string 124 | # e.g. "0R05" for 0.05Ohm 125 | # in this case, we will NOT have a decimal 126 | # we will also have a trailing number 127 | 128 | if post and "." not in value: 129 | try: 130 | value = float(int(value)) 131 | postValue = float(int(post)) / (10 ** len(post)) 132 | value = value * 1.0 + postValue 133 | except ValueError: 134 | return None 135 | 136 | try: 137 | val = float(value) 138 | except ValueError: 139 | return None 140 | 141 | val = "{0:.15f}".format(val * 1.0 * getPrefix(prefix)) 142 | 143 | return (val, getUnit(units)) 144 | 145 | 146 | def componentValue(valString, reference): 147 | # type: (str, str) -> tuple 148 | result = compMatch(valString) 149 | 150 | if not result: 151 | return valString, None # return the same string back with `None` unit 152 | 153 | if not len(result) == 2: # result length is incorrect 154 | return valString, None # return the same string back with `None` unit 155 | 156 | if result[1] is None: 157 | # try to infer unit from reference 158 | match = REFERENCE_REGEX.match(reference.lower()) 159 | if match and len(match.groups()) == 2: 160 | prefix, _ = match.groups() 161 | unit = None 162 | if prefix in ['r', 'rv']: 163 | unit = 'R' 164 | if prefix == 'c': 165 | unit = 'F' 166 | if prefix == 'l': 167 | unit = 'H' 168 | result = (result[0], unit) 169 | 170 | return result # (val,unit) 171 | 172 | 173 | def compareValues(c1, c2): 174 | r1 = compMatch(c1) 175 | r2 = compMatch(c2) 176 | 177 | if not r1 or not r2: 178 | return False 179 | 180 | (v1, u1) = r1 181 | (v2, u2) = r2 182 | 183 | if v1 == v2: 184 | # values match 185 | if u1 == u2: 186 | return True # units match 187 | if not u1: 188 | return True # no units for component 1 189 | if not u2: 190 | return True # no units for component 2 191 | 192 | return False 193 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/__init__.py: -------------------------------------------------------------------------------- 1 | from .settings_dialog import SettingsDialog, GeneralSettingsPanel 2 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/bitmaps/btn-arrow-down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peng-zhihui/InteractiveHtmlBom/b07e364699346a7d9edaac25c2eaeab34eb22ba7/InteractiveHtmlBom/dialog/bitmaps/btn-arrow-down.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/bitmaps/btn-arrow-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peng-zhihui/InteractiveHtmlBom/b07e364699346a7d9edaac25c2eaeab34eb22ba7/InteractiveHtmlBom/dialog/bitmaps/btn-arrow-up.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/bitmaps/btn-minus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peng-zhihui/InteractiveHtmlBom/b07e364699346a7d9edaac25c2eaeab34eb22ba7/InteractiveHtmlBom/dialog/bitmaps/btn-minus.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/bitmaps/btn-plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peng-zhihui/InteractiveHtmlBom/b07e364699346a7d9edaac25c2eaeab34eb22ba7/InteractiveHtmlBom/dialog/bitmaps/btn-plus.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/bitmaps/btn-question.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peng-zhihui/InteractiveHtmlBom/b07e364699346a7d9edaac25c2eaeab34eb22ba7/InteractiveHtmlBom/dialog/bitmaps/btn-question.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/settings_dialog.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import wx 5 | import wx.grid 6 | 7 | from . import dialog_base 8 | 9 | 10 | def pop_error(msg): 11 | wx.MessageBox(msg, 'Error', wx.OK | wx.ICON_ERROR) 12 | 13 | 14 | class SettingsDialog(dialog_base.SettingsDialogBase): 15 | def __init__(self, extra_data_func, extra_data_wildcard, config_save_func, 16 | file_name_format_hint, version): 17 | dialog_base.SettingsDialogBase.__init__(self, None) 18 | self.panel = SettingsDialogPanel( 19 | self, extra_data_func, extra_data_wildcard, config_save_func, 20 | file_name_format_hint) 21 | best_size = self.panel.BestSize 22 | # hack for some gtk themes that incorrectly calculate best size 23 | best_size.IncBy(dx=0, dy=30) 24 | self.SetClientSize(best_size) 25 | self.SetTitle('InteractiveHtmlBom %s' % version) 26 | 27 | # hack for new wxFormBuilder generating code incompatible with old wxPython 28 | # noinspection PyMethodOverriding 29 | def SetSizeHints(self, sz1, sz2): 30 | try: 31 | # wxPython 4 32 | super(SettingsDialog, self).SetSizeHints(sz1, sz2) 33 | except TypeError: 34 | # wxPython 3 35 | self.SetSizeHintsSz(sz1, sz2) 36 | 37 | def set_extra_data_path(self, extra_data_file): 38 | self.panel.fields.extraDataFilePicker.Path = extra_data_file 39 | self.panel.fields.OnExtraDataFileChanged(None) 40 | 41 | 42 | # Implementing settings_dialog 43 | class SettingsDialogPanel(dialog_base.SettingsDialogPanel): 44 | def __init__(self, parent, extra_data_func, extra_data_wildcard, 45 | config_save_func, file_name_format_hint): 46 | self.config_save_func = config_save_func 47 | dialog_base.SettingsDialogPanel.__init__(self, parent) 48 | self.general = GeneralSettingsPanel(self.notebook, 49 | file_name_format_hint) 50 | self.html = HtmlSettingsPanel(self.notebook) 51 | self.fields = FieldsPanel(self.notebook, extra_data_func, 52 | extra_data_wildcard) 53 | self.notebook.AddPage(self.general, "General") 54 | self.notebook.AddPage(self.html, "Html defaults") 55 | self.notebook.AddPage(self.fields, "Fields") 56 | 57 | self.save_menu = wx.Menu() 58 | self.save_locally = self.save_menu.Append( 59 | wx.ID_ANY, u"Locally", wx.EmptyString, wx.ITEM_NORMAL) 60 | self.save_globally = self.save_menu.Append( 61 | wx.ID_ANY, u"Globally", wx.EmptyString, wx.ITEM_NORMAL) 62 | 63 | self.Bind( 64 | wx.EVT_MENU, self.OnSaveLocally, id=self.save_locally.GetId()) 65 | self.Bind( 66 | wx.EVT_MENU, self.OnSaveGlobally, id=self.save_globally.GetId()) 67 | 68 | def OnExit(self, event): 69 | self.GetParent().EndModal(wx.ID_CANCEL) 70 | 71 | def OnGenerateBom(self, event): 72 | self.GetParent().EndModal(wx.ID_OK) 73 | 74 | def finish_init(self): 75 | self.html.OnBoardRotationSlider(None) 76 | 77 | def OnSave(self, event): 78 | # type: (wx.CommandEvent) -> None 79 | pos = wx.Point(0, event.GetEventObject().GetSize().y) 80 | self.saveSettingsBtn.PopupMenu(self.save_menu, pos) 81 | 82 | def OnSaveGlobally(self, event): 83 | self.config_save_func(self) 84 | 85 | def OnSaveLocally(self, event): 86 | self.config_save_func(self, locally=True) 87 | 88 | 89 | # Implementing HtmlSettingsPanelBase 90 | class HtmlSettingsPanel(dialog_base.HtmlSettingsPanelBase): 91 | def __init__(self, parent): 92 | dialog_base.HtmlSettingsPanelBase.__init__(self, parent) 93 | 94 | # Handlers for HtmlSettingsPanelBase events. 95 | def OnBoardRotationSlider(self, event): 96 | degrees = self.boardRotationSlider.Value * 5 97 | self.rotationDegreeLabel.LabelText = u"{}\u00B0".format(degrees) 98 | 99 | 100 | # Implementing GeneralSettingsPanelBase 101 | class GeneralSettingsPanel(dialog_base.GeneralSettingsPanelBase): 102 | 103 | def __init__(self, parent, file_name_format_hint): 104 | dialog_base.GeneralSettingsPanelBase.__init__(self, parent) 105 | self.file_name_format_hint = file_name_format_hint 106 | bitmaps = os.path.join(os.path.dirname(__file__), "bitmaps") 107 | self.m_btnSortUp.SetBitmap(wx.Bitmap( 108 | os.path.join(bitmaps, "btn-arrow-up.png"), wx.BITMAP_TYPE_PNG)) 109 | self.m_btnSortDown.SetBitmap(wx.Bitmap( 110 | os.path.join(bitmaps, "btn-arrow-down.png"), wx.BITMAP_TYPE_PNG)) 111 | self.m_btnSortAdd.SetBitmap(wx.Bitmap( 112 | os.path.join(bitmaps, "btn-plus.png"), wx.BITMAP_TYPE_PNG)) 113 | self.m_btnSortRemove.SetBitmap(wx.Bitmap( 114 | os.path.join(bitmaps, "btn-minus.png"), wx.BITMAP_TYPE_PNG)) 115 | self.m_bpButton5.SetBitmap(wx.Bitmap( 116 | os.path.join(bitmaps, "btn-question.png"), wx.BITMAP_TYPE_PNG)) 117 | self.m_btnBlacklistAdd.SetBitmap(wx.Bitmap( 118 | os.path.join(bitmaps, "btn-plus.png"), wx.BITMAP_TYPE_PNG)) 119 | self.m_btnBlacklistRemove.SetBitmap(wx.Bitmap( 120 | os.path.join(bitmaps, "btn-minus.png"), wx.BITMAP_TYPE_PNG)) 121 | 122 | # Handlers for GeneralSettingsPanelBase events. 123 | def OnComponentSortOrderUp(self, event): 124 | selection = self.componentSortOrderBox.Selection 125 | if selection != wx.NOT_FOUND and selection > 0: 126 | item = self.componentSortOrderBox.GetString(selection) 127 | self.componentSortOrderBox.Delete(selection) 128 | self.componentSortOrderBox.Insert(item, selection - 1) 129 | self.componentSortOrderBox.SetSelection(selection - 1) 130 | 131 | def OnComponentSortOrderDown(self, event): 132 | selection = self.componentSortOrderBox.Selection 133 | size = self.componentSortOrderBox.Count 134 | if selection != wx.NOT_FOUND and selection < size - 1: 135 | item = self.componentSortOrderBox.GetString(selection) 136 | self.componentSortOrderBox.Delete(selection) 137 | self.componentSortOrderBox.Insert(item, selection + 1) 138 | self.componentSortOrderBox.SetSelection(selection + 1) 139 | 140 | def OnComponentSortOrderAdd(self, event): 141 | item = wx.GetTextFromUser( 142 | "Characters other than A-Z will be ignored.", 143 | "Add sort order item") 144 | item = re.sub('[^A-Z]', '', item.upper()) 145 | if item == '': 146 | return 147 | found = self.componentSortOrderBox.FindString(item) 148 | if found != wx.NOT_FOUND: 149 | self.componentSortOrderBox.SetSelection(found) 150 | return 151 | self.componentSortOrderBox.Append(item) 152 | self.componentSortOrderBox.SetSelection( 153 | self.componentSortOrderBox.Count - 1) 154 | 155 | def OnComponentSortOrderRemove(self, event): 156 | selection = self.componentSortOrderBox.Selection 157 | if selection != wx.NOT_FOUND: 158 | item = self.componentSortOrderBox.GetString(selection) 159 | if item == '~': 160 | pop_error("You can not delete '~' item") 161 | return 162 | self.componentSortOrderBox.Delete(selection) 163 | if self.componentSortOrderBox.Count > 0: 164 | self.componentSortOrderBox.SetSelection(max(selection - 1, 0)) 165 | 166 | def OnComponentBlacklistAdd(self, event): 167 | item = wx.GetTextFromUser( 168 | "Characters other than A-Z 0-9 and * will be ignored.", 169 | "Add blacklist item") 170 | item = re.sub('[^A-Z0-9*]', '', item.upper()) 171 | if item == '': 172 | return 173 | found = self.blacklistBox.FindString(item) 174 | if found != wx.NOT_FOUND: 175 | self.blacklistBox.SetSelection(found) 176 | return 177 | self.blacklistBox.Append(item) 178 | self.blacklistBox.SetSelection(self.blacklistBox.Count - 1) 179 | 180 | def OnComponentBlacklistRemove(self, event): 181 | selection = self.blacklistBox.Selection 182 | if selection != wx.NOT_FOUND: 183 | self.blacklistBox.Delete(selection) 184 | if self.blacklistBox.Count > 0: 185 | self.blacklistBox.SetSelection(max(selection - 1, 0)) 186 | 187 | def OnNameFormatHintClick(self, event): 188 | wx.MessageBox(self.file_name_format_hint, 'File name format help', 189 | style=wx.ICON_NONE | wx.OK) 190 | 191 | def OnSize(self, event): 192 | # Trick the listCheckBox best size calculations 193 | tmp = self.componentSortOrderBox.GetStrings() 194 | self.componentSortOrderBox.SetItems([]) 195 | self.Layout() 196 | self.componentSortOrderBox.SetItems(tmp) 197 | 198 | 199 | # Implementing FieldsPanelBase 200 | class FieldsPanel(dialog_base.FieldsPanelBase): 201 | NONE_STRING = '' 202 | FIELDS_GRID_COLUMNS = 3 203 | 204 | def __init__(self, parent, extra_data_func, extra_data_wildcard): 205 | dialog_base.FieldsPanelBase.__init__(self, parent) 206 | self.extra_data_func = extra_data_func 207 | self.extra_field_data = None 208 | bitmaps = os.path.join(os.path.dirname(__file__), "bitmaps") 209 | self.m_btnUp.SetBitmap(wx.Bitmap( 210 | os.path.join(bitmaps, "btn-arrow-up.png"), wx.BITMAP_TYPE_PNG)) 211 | self.m_btnDown.SetBitmap(wx.Bitmap( 212 | os.path.join(bitmaps, "btn-arrow-down.png"), wx.BITMAP_TYPE_PNG)) 213 | self.set_file_picker_wildcard(extra_data_wildcard) 214 | self._setFieldsList([]) 215 | for i in range(2): 216 | box = self.GetTextExtent(self.fieldsGrid.GetColLabelValue(i)) 217 | if hasattr(box, "x"): 218 | width = box.x 219 | else: 220 | width = box[0] 221 | width = int(width * 1.1 + 5) 222 | self.fieldsGrid.SetColMinimalWidth(i, width) 223 | self.fieldsGrid.SetColSize(i, width) 224 | 225 | def set_file_picker_wildcard(self, extra_data_wildcard): 226 | if extra_data_wildcard is None: 227 | self.extraDataFilePicker.Disable() 228 | return 229 | 230 | # wxFilePickerCtrl doesn't support changing wildcard at runtime 231 | # so we have to replace it 232 | picker_parent = self.extraDataFilePicker.GetParent() 233 | new_picker = wx.FilePickerCtrl( 234 | picker_parent, wx.ID_ANY, wx.EmptyString, 235 | u"Select a file", 236 | extra_data_wildcard, 237 | wx.DefaultPosition, wx.DefaultSize, 238 | (wx.FLP_DEFAULT_STYLE | wx.FLP_FILE_MUST_EXIST | wx.FLP_OPEN | 239 | wx.FLP_SMALL | wx.FLP_USE_TEXTCTRL | wx.BORDER_SIMPLE)) 240 | self.GetSizer().Replace(self.extraDataFilePicker, new_picker, 241 | recursive=True) 242 | self.extraDataFilePicker.Destroy() 243 | self.extraDataFilePicker = new_picker 244 | self.Layout() 245 | 246 | def _swapRows(self, a, b): 247 | for i in range(self.FIELDS_GRID_COLUMNS): 248 | va = self.fieldsGrid.GetCellValue(a, i) 249 | vb = self.fieldsGrid.GetCellValue(b, i) 250 | self.fieldsGrid.SetCellValue(a, i, vb) 251 | self.fieldsGrid.SetCellValue(b, i, va) 252 | 253 | # Handlers for FieldsPanelBase events. 254 | def OnGridCellClicked(self, event): 255 | self.fieldsGrid.ClearSelection() 256 | self.fieldsGrid.SelectRow(event.Row) 257 | if event.Col < 2: 258 | # toggle checkbox 259 | val = self.fieldsGrid.GetCellValue(event.Row, event.Col) 260 | val = "" if val else "1" 261 | self.fieldsGrid.SetCellValue(event.Row, event.Col, val) 262 | # group shouldn't be enabled without show 263 | if event.Col == 0 and val == "": 264 | self.fieldsGrid.SetCellValue(event.Row, 1, val) 265 | if event.Col == 1 and val == "1": 266 | self.fieldsGrid.SetCellValue(event.Row, 0, val) 267 | 268 | def OnFieldsUp(self, event): 269 | selection = self.fieldsGrid.SelectedRows 270 | if len(selection) == 1 and selection[0] > 0: 271 | self._swapRows(selection[0], selection[0] - 1) 272 | self.fieldsGrid.ClearSelection() 273 | self.fieldsGrid.SelectRow(selection[0] - 1) 274 | 275 | def OnFieldsDown(self, event): 276 | selection = self.fieldsGrid.SelectedRows 277 | size = self.fieldsGrid.NumberRows 278 | if len(selection) == 1 and selection[0] < size - 1: 279 | self._swapRows(selection[0], selection[0] + 1) 280 | self.fieldsGrid.ClearSelection() 281 | self.fieldsGrid.SelectRow(selection[0] + 1) 282 | 283 | def _setFieldsList(self, fields): 284 | if self.fieldsGrid.NumberRows: 285 | self.fieldsGrid.DeleteRows(0, self.fieldsGrid.NumberRows) 286 | self.fieldsGrid.AppendRows(len(fields)) 287 | row = 0 288 | for f in fields: 289 | self.fieldsGrid.SetCellValue(row, 0, "1") 290 | self.fieldsGrid.SetCellValue(row, 1, "1") 291 | self.fieldsGrid.SetCellRenderer( 292 | row, 0, wx.grid.GridCellBoolRenderer()) 293 | self.fieldsGrid.SetCellRenderer( 294 | row, 1, wx.grid.GridCellBoolRenderer()) 295 | self.fieldsGrid.SetCellValue(row, 2, f) 296 | self.fieldsGrid.SetCellAlignment( 297 | row, 2, wx.ALIGN_LEFT, wx.ALIGN_TOP) 298 | self.fieldsGrid.SetReadOnly(row, 2) 299 | row += 1 300 | 301 | def OnExtraDataFileChanged(self, event): 302 | extra_data_file = self.extraDataFilePicker.Path 303 | if not os.path.isfile(extra_data_file): 304 | return 305 | 306 | self.extra_field_data = None 307 | try: 308 | self.extra_field_data = self.extra_data_func( 309 | extra_data_file, self.normalizeCaseCheckbox.Value) 310 | except Exception as e: 311 | pop_error( 312 | "Failed to parse file %s\n\n%s" % (extra_data_file, e)) 313 | self.extraDataFilePicker.Path = '' 314 | 315 | if self.extra_field_data is not None: 316 | field_list = list(self.extra_field_data[0]) 317 | self._setFieldsList(["Value", "Footprint"] + field_list) 318 | field_list.append(self.NONE_STRING) 319 | self.boardVariantFieldBox.SetItems(field_list) 320 | self.boardVariantFieldBox.SetStringSelection(self.NONE_STRING) 321 | self.boardVariantWhitelist.Clear() 322 | self.boardVariantBlacklist.Clear() 323 | self.dnpFieldBox.SetItems(field_list) 324 | self.dnpFieldBox.SetStringSelection(self.NONE_STRING) 325 | 326 | def OnBoardVariantFieldChange(self, event): 327 | selection = self.boardVariantFieldBox.Value 328 | if not selection or selection == self.NONE_STRING \ 329 | or self.extra_field_data is None: 330 | self.boardVariantWhitelist.Clear() 331 | self.boardVariantBlacklist.Clear() 332 | return 333 | variant_set = set() 334 | for _, field_dict in self.extra_field_data[1].items(): 335 | if selection in field_dict: 336 | variant_set.add(field_dict[selection]) 337 | self.boardVariantWhitelist.SetItems(list(variant_set)) 338 | self.boardVariantBlacklist.SetItems(list(variant_set)) 339 | 340 | def OnSize(self, event): 341 | self.Layout() 342 | g = self.fieldsGrid 343 | g.SetColSize( 344 | 2, g.GetClientSize().x - g.GetColSize(0) - g.GetColSize(1) - 30) 345 | 346 | def GetShowFields(self): 347 | result = [] 348 | for row in range(self.fieldsGrid.NumberRows): 349 | if self.fieldsGrid.GetCellValue(row, 0) == "1": 350 | result.append(self.fieldsGrid.GetCellValue(row, 2)) 351 | return result 352 | 353 | def GetGroupFields(self): 354 | result = [] 355 | for row in range(self.fieldsGrid.NumberRows): 356 | if self.fieldsGrid.GetCellValue(row, 1) == "1": 357 | result.append(self.fieldsGrid.GetCellValue(row, 2)) 358 | return result 359 | 360 | def SetCheckedFields(self, show, group): 361 | group = [s for s in group if s in show] 362 | current = [] 363 | for row in range(self.fieldsGrid.NumberRows): 364 | current.append(self.fieldsGrid.GetCellValue(row, 2)) 365 | new = [s for s in current if s not in show] 366 | self._setFieldsList(show + new) 367 | for row in range(self.fieldsGrid.NumberRows): 368 | field = self.fieldsGrid.GetCellValue(row, 2) 369 | self.fieldsGrid.SetCellValue(row, 0, "1" if field in show else "") 370 | self.fieldsGrid.SetCellValue(row, 1, "1" if field in group else "") 371 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog_test.py: -------------------------------------------------------------------------------- 1 | import wx 2 | from dialog.settings_dialog import SettingsDialog 3 | 4 | 5 | class MyApp(wx.App): 6 | def OnInit(self): 7 | frame = SettingsDialog(lambda: None, None, lambda x: None, "Hi", 'test') 8 | if frame.ShowModal() == wx.ID_OK: 9 | print("Should generate bom") 10 | frame.Destroy() 11 | return True 12 | 13 | 14 | app = MyApp() 15 | app.MainLoop() 16 | 17 | print("Done") 18 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def get_parser_by_extension(file_name, config, logger): 5 | ext = os.path.splitext(file_name)[1] 6 | if ext == '.kicad_pcb': 7 | return get_kicad_parser(file_name, config, logger) 8 | elif ext == '.json': 9 | """.json file may be from EasyEDA or a generic json format""" 10 | import io 11 | import json 12 | with io.open(file_name, 'r', encoding='utf-8') as f: 13 | obj = json.load(f) 14 | if 'pcbdata' in obj: 15 | return get_generic_json_parser(file_name, config, logger) 16 | else: 17 | return get_easyeda_parser(file_name, config, logger) 18 | else: 19 | return None 20 | 21 | 22 | def get_kicad_parser(file_name, config, logger, board=None): 23 | from .kicad import PcbnewParser 24 | return PcbnewParser(file_name, config, logger, board) 25 | 26 | 27 | def get_easyeda_parser(file_name, config, logger): 28 | from .easyeda import EasyEdaParser 29 | return EasyEdaParser(file_name, config, logger) 30 | 31 | 32 | def get_generic_json_parser(file_name, config, logger): 33 | from .genericjson import GenericJsonParser 34 | return GenericJsonParser(file_name, config, logger) 35 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/common.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from .svgpath import parse_path 4 | 5 | 6 | class EcadParser(object): 7 | 8 | def __init__(self, file_name, config, logger): 9 | """ 10 | :param file_name: path to file that should be parsed. 11 | :param config: Config instance 12 | :param logger: logging object. 13 | """ 14 | self.file_name = file_name 15 | self.config = config 16 | self.logger = logger 17 | 18 | def parse(self): 19 | """ 20 | Abstract method that should be overridden in implementations. 21 | Performs all the parsing and returns a tuple of 22 | (pcbdata, components) 23 | pcbdata is described in DATAFORMAT.md 24 | components is list of Component objects 25 | :return: 26 | """ 27 | pass 28 | 29 | @staticmethod 30 | def normalize_field_names(data): 31 | field_map = {f.lower(): f for f in reversed(data[0])} 32 | 33 | def remap(ref_fields): 34 | return {field_map[f.lower()]: v for (f, v) in 35 | sorted(ref_fields.items(), reverse=True)} 36 | 37 | field_data = {r: remap(d) for (r, d) in data[1].items()} 38 | return field_map.values(), field_data 39 | 40 | def get_extra_field_data(self, file_name): 41 | """ 42 | Abstract method that may be overridden in implementations that support 43 | extra field data. 44 | :return: tuple of the format 45 | ( 46 | [field_name1, field_name2,... ], 47 | { 48 | ref1: { 49 | field_name1: field_value1, 50 | field_name2: field_value2, 51 | ... 52 | ], 53 | ref2: ... 54 | } 55 | ) 56 | """ 57 | return [], {} 58 | 59 | def parse_extra_data(self, file_name, normalize_case): 60 | """ 61 | Parses the file and returns extra field data. 62 | :param file_name: path to file containing extra data 63 | :param normalize_case: if true, normalize case so that 64 | "mpn", "Mpn", "MPN" fields are combined 65 | :return: 66 | """ 67 | data = self.get_extra_field_data(file_name) 68 | if normalize_case: 69 | data = self.normalize_field_names(data) 70 | return sorted(data[0]), data[1] 71 | 72 | def latest_extra_data(self, extra_dirs=None): 73 | """ 74 | Abstract method that may be overridden in implementations that support 75 | extra field data. 76 | :param extra_dirs: List of extra directories to search. 77 | :return: File name of most recent file with extra field data. 78 | """ 79 | return None 80 | 81 | def extra_data_file_filter(self): 82 | """ 83 | Abstract method that may be overridden in implementations that support 84 | extra field data. 85 | :return: File open dialog filter string, eg: 86 | "Netlist and xml files (*.net; *.xml)|*.net;*.xml" 87 | """ 88 | return None 89 | 90 | def add_drawing_bounding_box(self, drawing, bbox): 91 | # type: (dict, BoundingBox) -> None 92 | 93 | def add_segment(): 94 | bbox.add_segment(drawing['start'][0], drawing['start'][1], 95 | drawing['end'][0], drawing['end'][1], 96 | drawing['width'] / 2) 97 | 98 | def add_circle(): 99 | bbox.add_circle(drawing['start'][0], drawing['start'][1], 100 | drawing['radius'] + drawing['width'] / 2) 101 | 102 | def add_svgpath(): 103 | width = drawing.get('width', 0) 104 | bbox.add_svgpath(drawing['svgpath'], width, self.logger) 105 | 106 | def add_polygon(): 107 | if 'polygons' not in drawing: 108 | add_svgpath() 109 | return 110 | polygon = drawing['polygons'][0] 111 | for point in polygon: 112 | bbox.add_point(point[0], point[1]) 113 | 114 | { 115 | 'segment': add_segment, 116 | 'rect': add_segment, # bbox of a rect and segment are the same 117 | 'circle': add_circle, 118 | 'arc': add_svgpath, 119 | 'polygon': add_polygon, 120 | 'text': lambda: None, # text is not really needed for bounding box 121 | }.get(drawing['type'])() 122 | 123 | 124 | class Component(object): 125 | """Simple data object to store component data needed for bom table.""" 126 | 127 | def __init__(self, ref, val, footprint, layer, attr=None, extra_fields={}): 128 | self.ref = ref 129 | self.val = val 130 | self.footprint = footprint 131 | self.layer = layer 132 | self.attr = attr 133 | self.extra_fields = extra_fields 134 | 135 | 136 | class BoundingBox(object): 137 | """Geometry util to calculate and compound bounding box of simple shapes.""" 138 | 139 | def __init__(self): 140 | self._x0 = None 141 | self._y0 = None 142 | self._x1 = None 143 | self._y1 = None 144 | 145 | def to_dict(self): 146 | # type: () -> dict 147 | return { 148 | "minx": self._x0, 149 | "miny": self._y0, 150 | "maxx": self._x1, 151 | "maxy": self._y1, 152 | } 153 | 154 | def to_component_dict(self): 155 | # type: () -> dict 156 | return { 157 | "pos": [self._x0, self._y0], 158 | "relpos": [0, 0], 159 | "size": [self._x1 - self._x0, self._y1 - self._y0], 160 | "angle": 0, 161 | } 162 | 163 | def add(self, other): 164 | """Add another bounding box. 165 | :type other: BoundingBox 166 | """ 167 | if other._x0 is not None: 168 | self.add_point(other._x0, other._y0) 169 | self.add_point(other._x1, other._y1) 170 | return self 171 | 172 | @staticmethod 173 | def _rotate(x, y, rx, ry, angle): 174 | sin = math.sin(math.radians(angle)) 175 | cos = math.cos(math.radians(angle)) 176 | new_x = rx + (x - rx) * cos - (y - ry) * sin 177 | new_y = ry + (x - rx) * sin + (y - ry) * cos 178 | return new_x, new_y 179 | 180 | def add_point(self, x, y, rx=0, ry=0, angle=0): 181 | x, y = self._rotate(x, y, rx, ry, angle) 182 | if self._x0 is None: 183 | self._x0 = x 184 | self._y0 = y 185 | self._x1 = x 186 | self._y1 = y 187 | else: 188 | self._x0 = min(self._x0, x) 189 | self._y0 = min(self._y0, y) 190 | self._x1 = max(self._x1, x) 191 | self._y1 = max(self._y1, y) 192 | return self 193 | 194 | def add_segment(self, x0, y0, x1, y1, r): 195 | self.add_circle(x0, y0, r) 196 | self.add_circle(x1, y1, r) 197 | return self 198 | 199 | def add_rectangle(self, x, y, w, h, angle=0): 200 | self.add_point(x - w / 2, y - h / 2, x, y, angle) 201 | self.add_point(x + w / 2, y - h / 2, x, y, angle) 202 | self.add_point(x - w / 2, y + h / 2, x, y, angle) 203 | self.add_point(x + w / 2, y + h / 2, x, y, angle) 204 | return self 205 | 206 | def add_circle(self, x, y, r): 207 | self.add_point(x - r, y) 208 | self.add_point(x, y - r) 209 | self.add_point(x + r, y) 210 | self.add_point(x, y + r) 211 | return self 212 | 213 | def add_svgpath(self, svgpath, width, logger): 214 | w = width / 2 215 | for segment in parse_path(svgpath, logger): 216 | x0, x1, y0, y1 = segment.bbox() 217 | self.add_point(x0 - w, y0 - w) 218 | self.add_point(x1 + w, y1 + w) 219 | 220 | def pad(self, amount): 221 | """Add small padding to the box.""" 222 | if self._x0 is not None: 223 | self._x0 -= amount 224 | self._y0 -= amount 225 | self._x1 += amount 226 | self._y1 += amount 227 | 228 | def initialized(self): 229 | return self._x0 is not None 230 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/easyeda.py: -------------------------------------------------------------------------------- 1 | import io 2 | import sys 3 | 4 | from .common import EcadParser, Component, BoundingBox 5 | 6 | 7 | if sys.version_info >= (3, 0): 8 | string_types = str 9 | else: 10 | string_types = basestring # noqa F821: ignore undefined 11 | 12 | 13 | class EasyEdaParser(EcadParser): 14 | TOP_COPPER_LAYER = 1 15 | BOT_COPPER_LAYER = 2 16 | TOP_SILK_LAYER = 3 17 | BOT_SILK_LAYER = 4 18 | BOARD_OUTLINE_LAYER = 10 19 | TOP_ASSEMBLY_LAYER = 13 20 | BOT_ASSEMBLY_LAYER = 14 21 | ALL_LAYERS = 11 22 | 23 | def get_easyeda_pcb(self): 24 | import json 25 | with io.open(self.file_name, 'r', encoding='utf-8') as f: 26 | return json.load(f) 27 | 28 | @staticmethod 29 | def tilda_split(s): 30 | # type: (str) -> list 31 | return s.split('~') 32 | 33 | @staticmethod 34 | def sharp_split(s): 35 | # type: (str) -> list 36 | return s.split('#@$') 37 | 38 | def _verify(self, pcb): 39 | """Spot check the pcb object.""" 40 | if 'head' not in pcb: 41 | self.logger.error('No head attribute.') 42 | return False 43 | head = pcb['head'] 44 | if len(head) < 2: 45 | self.logger.error('Incorrect head attribute ' + pcb['head']) 46 | return False 47 | if head['docType'] != '3': 48 | self.logger.error('Incorrect document type: ' + head['docType']) 49 | return False 50 | if 'canvas' not in pcb: 51 | self.logger.error('No canvas attribute.') 52 | return False 53 | canvas = self.tilda_split(pcb['canvas']) 54 | if len(canvas) < 18: 55 | self.logger.error('Incorrect canvas attribute ' + pcb['canvas']) 56 | return False 57 | self.logger.info('EasyEDA editor version ' + head['editorVersion']) 58 | return True 59 | 60 | @staticmethod 61 | def normalize(v): 62 | if isinstance(v, string_types): 63 | v = float(v) 64 | return v 65 | 66 | def parse_track(self, shape): 67 | shape = self.tilda_split(shape) 68 | assert len(shape) >= 5, 'Invalid track ' + str(shape) 69 | width = self.normalize(shape[0]) 70 | layer = int(shape[1]) 71 | points = [self.normalize(v) for v in shape[3].split(' ')] 72 | 73 | points_xy = [[points[i], points[i + 1]] for i in 74 | range(0, len(points), 2)] 75 | segments = [(points_xy[i], points_xy[i + 1]) for i in 76 | range(len(points_xy) - 1)] 77 | segments_json = [] 78 | for segment in segments: 79 | segments_json.append({ 80 | "type": "segment", 81 | "start": segment[0], 82 | "end": segment[1], 83 | "width": width, 84 | }) 85 | 86 | return layer, segments_json 87 | 88 | def parse_rect(self, shape): 89 | shape = self.tilda_split(shape) 90 | assert len(shape) >= 9, 'Invalid rect ' + str(shape) 91 | x = self.normalize(shape[0]) 92 | y = self.normalize(shape[1]) 93 | width = self.normalize(shape[2]) 94 | height = self.normalize(shape[3]) 95 | layer = int(shape[4]) 96 | fill = shape[8] 97 | 98 | if fill == "none": 99 | thickness = self.normalize(shape[7]) 100 | return layer, [{ 101 | "type": "rect", 102 | "start": [x, y], 103 | "end": [x + width, y + height], 104 | "width": thickness, 105 | }] 106 | else: 107 | return layer, [{ 108 | "type": "polygon", 109 | "pos": [x, y], 110 | "angle": 0, 111 | "polygons": [ 112 | [[0, 0], [width, 0], [width, height], [0, height]] 113 | ] 114 | }] 115 | 116 | def parse_circle(self, shape): 117 | shape = self.tilda_split(shape) 118 | assert len(shape) >= 6, 'Invalid circle ' + str(shape) 119 | cx = self.normalize(shape[0]) 120 | cy = self.normalize(shape[1]) 121 | r = self.normalize(shape[2]) 122 | width = self.normalize(shape[3]) 123 | layer = int(shape[4]) 124 | 125 | return layer, [{ 126 | "type": "circle", 127 | "start": [cx, cy], 128 | "radius": r, 129 | "width": width 130 | }] 131 | 132 | def parse_solid_region(self, shape): 133 | shape = self.tilda_split(shape) 134 | assert len(shape) >= 5, 'Invalid solid region ' + str(shape) 135 | layer = int(shape[0]) 136 | svgpath = shape[2] 137 | 138 | return layer, [{ 139 | "type": "polygon", 140 | "svgpath": svgpath, 141 | }] 142 | 143 | def parse_text(self, shape): 144 | shape = self.tilda_split(shape) 145 | assert len(shape) >= 12, 'Invalid text ' + str(shape) 146 | text_type = shape[0] 147 | stroke_width = self.normalize(shape[3]) 148 | layer = int(shape[6]) 149 | text = shape[9] 150 | svgpath = shape[10] 151 | hide = shape[11] 152 | 153 | return layer, [{ 154 | "type": "text", 155 | "text": text, 156 | "thickness": stroke_width, 157 | "attr": [], 158 | "svgpath": svgpath, 159 | "hide": hide, 160 | "text_type": text_type, 161 | }] 162 | 163 | def parse_arc(self, shape): 164 | shape = self.tilda_split(shape) 165 | assert len(shape) >= 6, 'Invalid arc ' + str(shape) 166 | width = self.normalize(shape[0]) 167 | layer = int(shape[1]) 168 | svgpath = shape[3] 169 | 170 | return layer, [{ 171 | "type": "arc", 172 | "svgpath": svgpath, 173 | "width": width 174 | }] 175 | 176 | def parse_hole(self, shape): 177 | shape = self.tilda_split(shape) 178 | assert len(shape) >= 4, 'Invalid hole ' + str(shape) 179 | cx = self.normalize(shape[0]) 180 | cy = self.normalize(shape[1]) 181 | radius = self.normalize(shape[2]) 182 | 183 | return self.BOARD_OUTLINE_LAYER, [{ 184 | "type": "circle", 185 | "start": [cx, cy], 186 | "radius": radius, 187 | "width": 0.1, # 1 mil 188 | }] 189 | 190 | def parse_pad(self, shape): 191 | shape = self.tilda_split(shape) 192 | assert len(shape) >= 15, 'Invalid pad ' + str(shape) 193 | pad_shape = shape[0] 194 | x = self.normalize(shape[1]) 195 | y = self.normalize(shape[2]) 196 | width = self.normalize(shape[3]) 197 | height = self.normalize(shape[4]) 198 | layer = int(shape[5]) 199 | number = shape[7] 200 | hole_radius = self.normalize(shape[8]) 201 | if shape[9]: 202 | points = [self.normalize(v) for v in shape[9].split(' ')] 203 | else: 204 | points = [] 205 | angle = int(shape[10]) 206 | hole_length = self.normalize(shape[12]) if shape[12] else 0 207 | 208 | pad_layers = { 209 | self.TOP_COPPER_LAYER: ['F'], 210 | self.BOT_COPPER_LAYER: ['B'], 211 | self.ALL_LAYERS: ['F', 'B'] 212 | }.get(layer) 213 | pad_shape = { 214 | "ELLIPSE": "circle", 215 | "RECT": "rect", 216 | "OVAL": "oval", 217 | "POLYGON": "custom", 218 | }.get(pad_shape) 219 | pad_type = "smd" if len(pad_layers) == 1 else "th" 220 | 221 | json = { 222 | "layers": pad_layers, 223 | "pos": [x, y], 224 | "size": [width, height], 225 | "angle": angle, 226 | "shape": pad_shape, 227 | "type": pad_type, 228 | } 229 | if number == '1': 230 | json['pin1'] = 1 231 | if pad_shape == "custom": 232 | polygon = [(points[i], points[i + 1]) for i in 233 | range(0, len(points), 2)] 234 | # translate coordinates to be relative to footprint 235 | polygon = [(p[0] - x, p[1] - y) for p in polygon] 236 | json["polygons"] = [polygon] 237 | json["angle"] = 0 238 | if pad_type == "th": 239 | if hole_length > 1e-6: 240 | json["drillshape"] = "oblong" 241 | json["drillsize"] = [hole_radius * 2, hole_length] 242 | else: 243 | json["drillshape"] = "circle" 244 | json["drillsize"] = [hole_radius * 2, hole_radius * 2] 245 | 246 | return layer, [{ 247 | "type": "pad", 248 | "pad": json, 249 | }] 250 | 251 | @staticmethod 252 | def add_pad_bounding_box(pad, bbox): 253 | # type: (dict, BoundingBox) -> None 254 | 255 | def add_circle(): 256 | bbox.add_circle(pad['pos'][0], pad['pos'][1], pad['size'][0] / 2) 257 | 258 | def add_rect(): 259 | bbox.add_rectangle(pad['pos'][0], pad['pos'][1], 260 | pad['size'][0], pad['size'][1], 261 | pad['angle']) 262 | 263 | def add_custom(): 264 | x = pad['pos'][0] 265 | y = pad['pos'][1] 266 | polygon = pad['polygons'][0] 267 | for point in polygon: 268 | bbox.add_point(x + point[0], y + point[1]) 269 | 270 | { 271 | 'circle': add_circle, 272 | 'rect': add_rect, 273 | 'oval': add_rect, 274 | 'custom': add_custom, 275 | }.get(pad['shape'])() 276 | 277 | def parse_lib(self, shape): 278 | parts = self.sharp_split(shape) 279 | head = self.tilda_split(parts[0]) 280 | inner_shapes, _, _ = self.parse_shapes(parts[1:]) 281 | x = self.normalize(head[0]) 282 | y = self.normalize(head[1]) 283 | attr = head[2] 284 | fp_layer = int(head[6]) 285 | 286 | attr = attr.split('`') 287 | if len(attr) % 2 != 0: 288 | attr.pop() 289 | attr = {attr[i]: attr[i + 1] for i in range(0, len(attr), 2)} 290 | fp_layer = 'F' if fp_layer == self.TOP_COPPER_LAYER else 'B' 291 | val = '??' 292 | ref = '??' 293 | footprint = attr.get('package', '??') 294 | 295 | pads = [] 296 | copper_drawings = [] 297 | extra_drawings = [] 298 | bbox = BoundingBox() 299 | for layer, shapes in inner_shapes.items(): 300 | for s in shapes: 301 | if s["type"] == "pad": 302 | pads.append(s["pad"]) 303 | continue 304 | if s["type"] == "text": 305 | if s["text_type"] == "N": 306 | val = s["text"] 307 | if s["text_type"] == "P": 308 | ref = s["text"] 309 | del s["text_type"] 310 | if s["hide"]: 311 | continue 312 | if layer in [self.TOP_COPPER_LAYER, self.BOT_COPPER_LAYER]: 313 | copper_drawings.append({ 314 | "layer": ( 315 | 'F' if layer == self.TOP_COPPER_LAYER else 'B'), 316 | "drawing": s, 317 | }) 318 | elif layer in [self.TOP_SILK_LAYER, 319 | self.BOT_SILK_LAYER, 320 | self.TOP_ASSEMBLY_LAYER, 321 | self.BOT_ASSEMBLY_LAYER, 322 | self.BOARD_OUTLINE_LAYER]: 323 | extra_drawings.append((layer, s)) 324 | 325 | for pad in pads: 326 | self.add_pad_bounding_box(pad, bbox) 327 | for drawing in copper_drawings: 328 | self.add_drawing_bounding_box(drawing['drawing'], bbox) 329 | for _, drawing in extra_drawings: 330 | self.add_drawing_bounding_box(drawing, bbox) 331 | bbox.pad(0.5) # pad by 5 mil 332 | if not bbox.initialized(): 333 | # if bounding box is not calculated yet 334 | # set it to 100x100 mil square 335 | bbox.add_rectangle(x, y, 10, 10, 0) 336 | 337 | footprint_json = { 338 | "ref": ref, 339 | "center": [x, y], 340 | "bbox": bbox.to_component_dict(), 341 | "pads": pads, 342 | "drawings": copper_drawings, 343 | "layer": fp_layer, 344 | } 345 | 346 | component = Component(ref, val, footprint, fp_layer) 347 | 348 | return fp_layer, component, footprint_json, extra_drawings 349 | 350 | def parse_shapes(self, shapes): 351 | drawings = {} 352 | footprints = [] 353 | components = [] 354 | 355 | for shape_str in shapes: 356 | shape = shape_str.split('~', 1) 357 | parse_func = { 358 | 'TRACK': self.parse_track, 359 | 'RECT': self.parse_rect, 360 | 'CIRCLE': self.parse_circle, 361 | 'SOLIDREGION': self.parse_solid_region, 362 | 'TEXT': self.parse_text, 363 | 'ARC': self.parse_arc, 364 | 'PAD': self.parse_pad, 365 | 'HOLE': self.parse_hole, 366 | }.get(shape[0], None) 367 | if parse_func: 368 | layer, json_list = parse_func(shape[1]) 369 | drawings.setdefault(layer, []).extend(json_list) 370 | if shape[0] == 'LIB': 371 | layer, component, json, extras = self.parse_lib(shape[1]) 372 | for drawing_layer, drawing in extras: 373 | drawings.setdefault(drawing_layer, []).append(drawing) 374 | footprints.append(json) 375 | components.append(component) 376 | 377 | return drawings, footprints, components 378 | 379 | def get_metadata(self, pcb): 380 | if hasattr(pcb, 'metadata'): 381 | return pcb.metadata 382 | else: 383 | import os 384 | from datetime import datetime 385 | pcb_file_name = os.path.basename(self.file_name) 386 | title = os.path.splitext(pcb_file_name)[0] 387 | file_mtime = os.path.getmtime(self.file_name) 388 | file_date = datetime.fromtimestamp(file_mtime).strftime( 389 | '%Y-%m-%d %H:%M:%S') 390 | return { 391 | "title": title, 392 | "revision": "", 393 | "company": "", 394 | "date": file_date, 395 | } 396 | 397 | def parse(self): 398 | pcb = self.get_easyeda_pcb() 399 | if not self._verify(pcb): 400 | self.logger.error( 401 | 'File ' + self.file_name + 402 | ' does not appear to be valid EasyEDA json file.') 403 | return None, None 404 | 405 | drawings, footprints, components = self.parse_shapes(pcb['shape']) 406 | 407 | board_outline_bbox = BoundingBox() 408 | for drawing in drawings.get(self.BOARD_OUTLINE_LAYER, []): 409 | self.add_drawing_bounding_box(drawing, board_outline_bbox) 410 | if board_outline_bbox.initialized(): 411 | bbox = board_outline_bbox.to_dict() 412 | else: 413 | # if nothing is drawn on outline layer then rely on EasyEDA bbox 414 | x = self.normalize(pcb['BBox']['x']) 415 | y = self.normalize(pcb['BBox']['y']) 416 | bbox = { 417 | "minx": x, 418 | "miny": y, 419 | "maxx": x + self.normalize(pcb['BBox']['width']), 420 | "maxy": y + self.normalize(pcb['BBox']['height']) 421 | } 422 | 423 | pcbdata = { 424 | "edges_bbox": bbox, 425 | "edges": drawings.get(self.BOARD_OUTLINE_LAYER, []), 426 | "drawings": { 427 | "silkscreen": { 428 | 'F': drawings.get(self.TOP_SILK_LAYER, []), 429 | 'B': drawings.get(self.BOT_SILK_LAYER, []), 430 | }, 431 | "fabrication": { 432 | 'F': drawings.get(self.TOP_ASSEMBLY_LAYER, []), 433 | 'B': drawings.get(self.BOT_ASSEMBLY_LAYER, []), 434 | }, 435 | }, 436 | "footprints": footprints, 437 | "metadata": self.get_metadata(pcb), 438 | "bom": {}, 439 | "font_data": {} 440 | } 441 | 442 | if self.config.include_tracks: 443 | def filter_tracks(drawing_list, drawing_type, keys): 444 | result = [] 445 | for d in drawing_list: 446 | if d["type"] == drawing_type: 447 | r = {} 448 | for key in keys: 449 | r[key] = d[key] 450 | result.append(r) 451 | return result 452 | 453 | pcbdata["tracks"] = { 454 | 'F': filter_tracks(drawings.get(self.TOP_COPPER_LAYER, []), 455 | "segment", ["start", "end", "width"]), 456 | 'B': filter_tracks(drawings.get(self.BOT_COPPER_LAYER, []), 457 | "segment", ["start", "end", "width"]), 458 | } 459 | # zones are not supported 460 | pcbdata["zones"] = {'F': [], 'B': []} 461 | 462 | return pcbdata, components 463 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/genericjson.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import os.path 4 | from jsonschema import validate, ValidationError 5 | 6 | from .common import EcadParser, Component, BoundingBox 7 | 8 | 9 | class GenericJsonParser(EcadParser): 10 | COMPATIBLE_SPEC_VERSIONS = [1] 11 | 12 | def extra_data_file_filter(self): 13 | return "Json file ({f})|{f}".format(f=os.path.basename(self.file_name)) 14 | 15 | def latest_extra_data(self, extra_dirs=None): 16 | return self.file_name 17 | 18 | def get_extra_field_data(self, file_name): 19 | if os.path.abspath(file_name) != os.path.abspath(self.file_name): 20 | return None 21 | 22 | _, components = self._parse() 23 | field_set = set() 24 | comp_dict = {} 25 | 26 | for c in components: 27 | ref_fields = comp_dict.setdefault(c.ref, {}) 28 | 29 | for k, v in c.extra_fields.items(): 30 | field_set.add(k) 31 | ref_fields[k] = v 32 | 33 | return list(field_set), comp_dict 34 | 35 | def get_generic_json_pcb(self): 36 | with io.open(self.file_name, 'r', encoding='utf-8') as f: 37 | pcb = json.load(f) 38 | 39 | if 'spec_version' not in pcb: 40 | raise ValidationError("'spec_version' is a required property") 41 | 42 | if pcb['spec_version'] not in self.COMPATIBLE_SPEC_VERSIONS: 43 | raise ValidationError("Unsupported spec_version ({})" 44 | .format(pcb['spec_version'])) 45 | 46 | schema_dir = os.path.join(os.path.dirname(__file__), 'schema') 47 | schema_file_name = os.path.join(schema_dir, 48 | 'genericjsonpcbdata_v{}.schema'.format( 49 | pcb['spec_version'])) 50 | 51 | with io.open(schema_file_name, 'r', encoding='utf-8') as f: 52 | schema = json.load(f) 53 | 54 | validate(instance=pcb, schema=schema) 55 | 56 | return pcb 57 | 58 | def _verify(self, pcb): 59 | """Spot check the pcb object.""" 60 | 61 | if len(pcb['pcbdata']['footprints']) != len(pcb['components']): 62 | self.logger.error("Length of components list doesn't match" 63 | " length of footprints list.") 64 | return False 65 | 66 | return True 67 | 68 | def _parse(self): 69 | try: 70 | pcb = self.get_generic_json_pcb() 71 | except ValidationError as e: 72 | self.logger.error('File {f} does not comply with json schema. {m}' 73 | .format(f=self.file_name, m=e.message)) 74 | return None, None 75 | 76 | if not self._verify(pcb): 77 | self.logger.error('File {} does not appear to be valid generic' 78 | ' InteractiveHtmlBom json file.' 79 | .format(self.file_name)) 80 | return None, None 81 | 82 | pcbdata = pcb['pcbdata'] 83 | components = [Component(**c) for c in pcb['components']] 84 | 85 | self.logger.info('Successfully parsed {}'.format(self.file_name)) 86 | 87 | return pcbdata, components 88 | 89 | def parse(self): 90 | pcbdata, components = self._parse() 91 | 92 | # override board bounding box based on edges 93 | board_outline_bbox = BoundingBox() 94 | for drawing in pcbdata['edges']: 95 | self.add_drawing_bounding_box(drawing, board_outline_bbox) 96 | if board_outline_bbox.initialized(): 97 | pcbdata['edges_bbox'] = board_outline_bbox.to_dict() 98 | 99 | extra_fields = set(self.config.show_fields) 100 | extra_fields.discard("Footprint") 101 | extra_fields.discard("Value") 102 | if extra_fields: 103 | for c in components: 104 | c.extra_fields = { 105 | f: c.extra_fields.get(f, "") for f in extra_fields} 106 | 107 | return pcbdata, components 108 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/kicad_extra/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pcbnew 3 | 4 | from .xmlparser import XmlParser 5 | from .netlistparser import NetlistParser 6 | 7 | PARSERS = { 8 | '.xml': XmlParser, 9 | '.net': NetlistParser, 10 | } 11 | 12 | 13 | if hasattr(pcbnew, 'FOOTPRINT'): 14 | PARSERS['.kicad_pcb'] = None 15 | 16 | 17 | def parse_schematic_data(file_name): 18 | if not os.path.isfile(file_name): 19 | return None 20 | extension = os.path.splitext(file_name)[1] 21 | if extension not in PARSERS: 22 | return None 23 | else: 24 | parser_cls = PARSERS[extension] 25 | if parser_cls is None: 26 | return None 27 | parser = parser_cls(file_name) 28 | return parser.get_extra_field_data() 29 | 30 | 31 | def find_latest_schematic_data(base_name, directories): 32 | """ 33 | :param base_name: base name of pcb file 34 | :param directories: list of directories to search 35 | :return: last modified parsable file path or None if not found 36 | """ 37 | files = [] 38 | for d in directories: 39 | files.extend(_find_in_dir(d)) 40 | # sort by decreasing modification time 41 | files = sorted(files, reverse=True) 42 | if files: 43 | # try to find first (last modified) file that has name matching pcb file 44 | for _, f in files: 45 | if os.path.splitext(os.path.basename(f))[0] == base_name: 46 | return f 47 | # if no such file is found just return last modified 48 | return files[0][1] 49 | else: 50 | return None 51 | 52 | 53 | def _find_in_dir(dir): 54 | _, _, files = next(os.walk(dir), (None, None, [])) 55 | # filter out files that we can not parse 56 | files = [f for f in files if os.path.splitext(f)[1] in PARSERS.keys()] 57 | files = [os.path.join(dir, f) for f in files] 58 | # get their modification time and sort in descending order 59 | return [(os.path.getmtime(f), f) for f in files] 60 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/kicad_extra/netlistparser.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from .parser_base import ParserBase 4 | from .sexpressions import parse_sexpression 5 | 6 | 7 | class NetlistParser(ParserBase): 8 | def get_extra_field_data(self): 9 | with io.open(self.file_name, 'r', encoding='utf-8') as f: 10 | sexpression = parse_sexpression(f.read()) 11 | components = None 12 | for s in sexpression: 13 | if s[0] == 'components': 14 | components = s[1:] 15 | if components is None: 16 | return None 17 | field_set = set() 18 | comp_dict = {} 19 | for c in components: 20 | ref = None 21 | fields = None 22 | datasheet = None 23 | libsource = None 24 | for f in c[1:]: 25 | if f[0] == 'ref': 26 | ref = f[1] 27 | if f[0] == 'fields': 28 | fields = f[1:] 29 | if f[0] == 'datasheet': 30 | datasheet = f[1] 31 | if f[0] == 'libsource': 32 | libsource = f[1:] 33 | if ref is None: 34 | return None 35 | ref_fields = comp_dict.setdefault(ref, {}) 36 | if datasheet and datasheet != '~': 37 | field_set.add('Datasheet') 38 | ref_fields['Datasheet'] = datasheet 39 | if libsource is not None: 40 | for lib_field in libsource: 41 | if lib_field[0] == 'description': 42 | field_set.add('Description') 43 | ref_fields['Description'] = lib_field[1] 44 | if fields is None: 45 | continue 46 | for f in fields: 47 | if len(f) > 1: 48 | field_set.add(f[1][1]) 49 | if len(f) > 2: 50 | ref_fields[f[1][1]] = f[2] 51 | 52 | return list(field_set), comp_dict 53 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/kicad_extra/parser_base.py: -------------------------------------------------------------------------------- 1 | class ParserBase: 2 | 3 | def __init__(self, file_name): 4 | """ 5 | :param file_name: path to file that should be parsed. 6 | """ 7 | self.file_name = file_name 8 | 9 | def get_extra_field_data(self): 10 | # type: () -> tuple 11 | """ 12 | Parses the file and returns extra field data. 13 | :return: tuple of the format 14 | ( 15 | [field_name1, field_name2,... ], 16 | { 17 | ref1: { 18 | field_name1: field_value1, 19 | field_name2: field_value2, 20 | ... 21 | ], 22 | ref2: ... 23 | } 24 | ) 25 | """ 26 | pass 27 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/kicad_extra/sexpressions.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | term_regex = r'''(?mx) 4 | \s*(?: 5 | (?P\()| 6 | (?P\))| 7 | (?P"(?:\\\\|\\"|[^"])*")| 8 | (?P[^(^)\s]+) 9 | )''' 10 | pattern = re.compile(term_regex) 11 | 12 | 13 | def parse_sexpression(sexpression): 14 | stack = [] 15 | out = [] 16 | for terms in pattern.finditer(sexpression): 17 | term, value = [(t, v) for t, v in terms.groupdict().items() if v][0] 18 | if term == 'open': 19 | stack.append(out) 20 | out = [] 21 | elif term == 'close': 22 | assert stack, "Trouble with nesting of brackets" 23 | tmp, out = out, stack.pop(-1) 24 | out.append(tmp) 25 | elif term == 'sq': 26 | out.append(value[1:-1].replace('\\\\', '\\').replace('\\"', '"')) 27 | elif term == 's': 28 | out.append(value) 29 | else: 30 | raise NotImplementedError("Error: %s, %s" % (term, value)) 31 | assert not stack, "Trouble with nesting of brackets" 32 | return out[0] 33 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/kicad_extra/xmlparser.py: -------------------------------------------------------------------------------- 1 | from xml.dom import minidom 2 | 3 | from .parser_base import ParserBase 4 | 5 | 6 | class XmlParser(ParserBase): 7 | @staticmethod 8 | def get_text(nodelist): 9 | rc = [] 10 | for node in nodelist: 11 | if node.nodeType == node.TEXT_NODE: 12 | rc.append(node.data) 13 | return ''.join(rc) 14 | 15 | def get_extra_field_data(self): 16 | xml = minidom.parse(self.file_name) 17 | components = xml.getElementsByTagName('comp') 18 | field_set = set() 19 | comp_dict = {} 20 | for c in components: 21 | ref_fields = comp_dict.setdefault(c.attributes['ref'].value, {}) 22 | datasheet = c.getElementsByTagName('datasheet') 23 | if datasheet: 24 | datasheet = self.get_text(datasheet[0].childNodes) 25 | if datasheet != '~': 26 | field_set.add('Datasheet') 27 | ref_fields['Datasheet'] = datasheet 28 | libsource = c.getElementsByTagName('libsource') 29 | if libsource and libsource[0].hasAttribute('description'): 30 | field_set.add('Description') 31 | attr = libsource[0].attributes['description'] 32 | ref_fields['Description'] = attr.value 33 | for f in c.getElementsByTagName('field'): 34 | name = f.attributes['name'].value 35 | field_set.add(name) 36 | ref_fields[name] = self.get_text(f.childNodes) 37 | 38 | return list(field_set), comp_dict 39 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/errors.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | class ExitCodes(): 5 | ERROR_PARSE = 3 6 | ERROR_FILE_NOT_FOUND = 4 7 | ERROR_NO_DISPLAY = 5 8 | 9 | 10 | class ParsingException(Exception): 11 | pass 12 | 13 | 14 | def exit_error(logger, code, err): 15 | logger.error(err) 16 | sys.exit(code) 17 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/generate_interactive_bom.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | from __future__ import absolute_import 3 | 4 | import argparse 5 | import os 6 | import sys 7 | 8 | 9 | # python 2 and 3 compatibility hack 10 | def to_utf(s): 11 | if isinstance(s, bytes): 12 | return s.decode('utf-8') 13 | else: 14 | return s 15 | 16 | 17 | if __name__ == "__main__": 18 | # Add ../ to the path 19 | # Works if this script is executed without installing the module 20 | script_dir = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) 21 | sys.path.insert(0, os.path.dirname(script_dir)) 22 | os.environ['INTERACTIVE_HTML_BOM_CLI_MODE'] = 'True' 23 | 24 | from InteractiveHtmlBom.core import ibom 25 | from InteractiveHtmlBom.core.config import Config 26 | from InteractiveHtmlBom.ecad import get_parser_by_extension 27 | from InteractiveHtmlBom.version import version 28 | from InteractiveHtmlBom.errors import (ExitCodes, ParsingException, 29 | exit_error) 30 | 31 | create_wx_app = 'INTERACTIVE_HTML_BOM_NO_DISPLAY' not in os.environ 32 | if create_wx_app: 33 | import wx 34 | 35 | app = wx.App() 36 | 37 | parser = argparse.ArgumentParser( 38 | description='KiCad InteractiveHtmlBom plugin CLI.', 39 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 40 | parser.add_argument('file', 41 | type=lambda s: to_utf(s), 42 | help="KiCad PCB file") 43 | 44 | Config.add_options(parser) 45 | args = parser.parse_args() 46 | logger = ibom.Logger(cli=True) 47 | 48 | if not os.path.isfile(args.file): 49 | exit_error(logger, ExitCodes.ERROR_FILE_NOT_FOUND, 50 | "File %s does not exist." % args.file) 51 | 52 | print("Loading %s" % args.file) 53 | 54 | config = Config(version, os.path.dirname(os.path.abspath(args.file))) 55 | 56 | parser = get_parser_by_extension( 57 | os.path.abspath(args.file), config, logger) 58 | 59 | if args.show_dialog: 60 | if not create_wx_app: 61 | exit_error(logger, ExitCodes.ERROR_NO_DISPLAY, 62 | "Can not show dialog when " 63 | "INTERACTIVE_HTML_BOM_NO_DISPLAY is set.") 64 | try: 65 | ibom.run_with_dialog(parser, config, logger) 66 | except ParsingException as e: 67 | exit_error(logger, ExitCodes.ERROR_PARSE, e) 68 | else: 69 | config.set_from_args(args) 70 | try: 71 | ibom.main(parser, config, logger) 72 | except ParsingException as e: 73 | exit_error(logger, ExitCodes.ERROR_PARSE, str(e)) 74 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/i18n/language_en.bat: -------------------------------------------------------------------------------- 1 | ::start up echo 2 | set i18n_gitAddr= https://github.com/openscopeproject/InteractiveHtmlBom 3 | set i18n_batScar= Bat file by Scarrrr0725 4 | set i18n_thx4using= Thank You For Using Generate Interactive Bom 5 | 6 | ::convert 7 | set i18n_draghere=Please Drag the EasyEDA PCB source file here : 8 | set i18n_converting=Converting . . . . . . 9 | 10 | ::converted 11 | set i18n_again=Do you want to convert another file ? 12 | set i18n_converted= EDA source file is converted to bom successfully! -------------------------------------------------------------------------------- /InteractiveHtmlBom/i18n/language_zh.bat: -------------------------------------------------------------------------------- 1 | ::This file needs to be in 'UTF-8 encoding' AND 'Windows CR LF' to work. 2 | 3 | ::set active code page as UTF-8/65001 4 | set PYTHONIOENCODING=utf-8 5 | chcp 65001 6 | ::start up echo 7 | set i18n_gitAddr= https://github.com/openscopeproject/InteractiveHtmlBom 8 | set i18n_batScar= Bat 文件: Scarrrr0725/XiaoMingXD 9 | set i18n_thx4using= 感谢使用 Generate Interactive Bom 10 | 11 | ::convert 12 | set i18n_draghere=请将您的 EDA PCB 源文件拖移至此: 13 | set i18n_converting=导出中 . . . . . ." 14 | 15 | ::converted 16 | set i18n_again=请问是否转换其他文件? 17 | set i18n_converted= 您的 EDA 源文件已成功导出 Bom! 18 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peng-zhihui/InteractiveHtmlBom/b07e364699346a7d9edaac25c2eaeab34eb22ba7/InteractiveHtmlBom/icon.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/version.py: -------------------------------------------------------------------------------- 1 | # Update this when new version is tagged. 2 | import os 3 | import subprocess 4 | 5 | 6 | LAST_TAG = 'v2.4.1' 7 | 8 | 9 | def _get_git_version(): 10 | plugin_path = os.path.realpath(os.path.dirname(__file__)) 11 | try: 12 | git_version = subprocess.check_output( 13 | ['git', 'describe', '--tags', '--abbrev=4', '--dirty=-*'], 14 | cwd=plugin_path) 15 | if isinstance(git_version, bytes): 16 | return git_version.decode('utf-8').rstrip() 17 | else: 18 | return git_version.rstrip() 19 | except subprocess.CalledProcessError as e: 20 | print('Git version check failed: ' + str(e)) 21 | except Exception as e: 22 | print('Git process cannot be launched: ' + str(e)) 23 | return None 24 | 25 | 26 | version = _get_git_version() or LAST_TAG 27 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/ibom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Interactive BOM for KiCAD 8 | 12 | 53 | 54 | 55 | 56 | ///USERHEADER/// 57 |
58 |
59 |
60 | 135 |
137 | 140 | 143 | 146 |
147 |
149 | 151 | 153 | 155 |
156 |
158 | 160 | 162 | 164 |
165 | 210 | 240 |
241 |
242 | 243 | 244 | 245 | 248 | 251 | 252 | 253 | 256 | 259 | 260 | 261 |
246 | Title 247 | 249 | Revision 250 |
254 | Company 255 | 257 | Date 258 |
262 |
263 |
264 |
265 |
266 |
267 | 269 | 271 |
272 | 274 |
275 |
276 |
277 | 278 | 279 | 280 | 281 | 282 |
283 |
284 |
285 |
286 |
287 | 288 | 289 | 290 | 291 |
292 |
293 |
294 |
295 | 296 | 297 | 298 | 299 |
300 |
301 |
302 |
303 |
304 | ///USERFOOTER/// 305 | 306 | 307 | 308 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/lz-string.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013 Pieroxy 2 | // This work is free. You can redistribute it and/or modify it 3 | // under the terms of the WTFPL, Version 2 4 | // For more information see LICENSE.txt or http://www.wtfpl.net/ 5 | // 6 | // For more information, the home page: 7 | // http://pieroxy.net/blog/pages/lz-string/testing.html 8 | // 9 | // LZ-based compression algorithm, version 1.4.4 10 | var LZString=function(){var o=String.fromCharCode,i={};var n={decompressFromBase64:function(o){return null==o?"":""==o?null:n._decompress(o.length,32,function(n){return function(o,n){if(!i[o]){i[o]={};for(var t=0;t>=1,0==m.position&&(m.position=n,m.val=t(m.index++)),a|=(s>0?1:0)*u,u<<=1;switch(a){case 0:for(a=0,p=Math.pow(2,8),u=1;u!=p;)s=m.val&m.position,m.position>>=1,0==m.position&&(m.position=n,m.val=t(m.index++)),a|=(s>0?1:0)*u,u<<=1;l=o(a);break;case 1:for(a=0,p=Math.pow(2,16),u=1;u!=p;)s=m.val&m.position,m.position>>=1,0==m.position&&(m.position=n,m.val=t(m.index++)),a|=(s>0?1:0)*u,u<<=1;l=o(a);break;case 2:return""}for(f[3]=l,e=l,g.push(l);;){if(m.index>i)return"";for(a=0,p=Math.pow(2,h),u=1;u!=p;)s=m.val&m.position,m.position>>=1,0==m.position&&(m.position=n,m.val=t(m.index++)),a|=(s>0?1:0)*u,u<<=1;switch(l=a){case 0:for(a=0,p=Math.pow(2,8),u=1;u!=p;)s=m.val&m.position,m.position>>=1,0==m.position&&(m.position=n,m.val=t(m.index++)),a|=(s>0?1:0)*u,u<<=1;f[d++]=o(a),l=d-1,c--;break;case 1:for(a=0,p=Math.pow(2,16),u=1;u!=p;)s=m.val&m.position,m.position>>=1,0==m.position&&(m.position=n,m.val=t(m.index++)),a|=(s>0?1:0)*u,u<<=1;f[d++]=o(a),l=d-1,c--;break;case 2:return g.join("")}if(0==c&&(c=Math.pow(2,h),h++),f[l])v=f[l];else{if(l!==d)return null;v=e+e.charAt(0)}g.push(v),f[d++]=e+v.charAt(0),e=v,0==--c&&(c=Math.pow(2,h),h++)}}};return n}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module?module.exports=LZString:"undefined"!=typeof angular&&null!=angular&&angular.module("LZString",[]).factory("LZString",function(){return LZString}); -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/pep.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * PEP v0.4.3 | https://github.com/jquery/PEP 3 | * Copyright jQuery Foundation and other contributors | http://jquery.org/license 4 | */ 5 | !function(a,b){"object"==typeof exports&&"undefined"!=typeof module?module.exports=b():"function"==typeof define&&define.amd?define(b):a.PointerEventsPolyfill=b()}(this,function(){"use strict";function a(a,b){b=b||Object.create(null);var c=document.createEvent("Event");c.initEvent(a,b.bubbles||!1,b.cancelable||!1); 6 | for(var d,e=2;e=h}return this.firstXY=null,b}},findTouch:function(a,b){for(var c,d=0,e=a.length;d=b.length){var c=[];R.forEach(function(a,d){ 37 | if(1!==d&&!this.findTouch(b,d-2)){var e=a.out;c.push(e)}},this),c.forEach(this.cancelOut,this)}},touchstart:function(a){this.vacuumTouches(a),this.setPrimaryTouch(a.changedTouches[0]),this.dedupSynthMouse(a),this.scrolling||(this.clickCount++,this.processTouches(a,this.overDown))},overDown:function(a){R.set(a.pointerId,{target:a.target,out:a,outTarget:a.target}),u.enterOver(a),u.down(a)},touchmove:function(a){this.scrolling||(this.shouldScroll(a)?(this.scrolling=!0,this.touchcancel(a)):(a.preventDefault(),this.processTouches(a,this.moveOverOut)))},moveOverOut:function(a){var b=a,c=R.get(b.pointerId); 38 | if(c){var d=c.out,e=c.outTarget;u.move(b),d&&e!==b.target&&(d.relatedTarget=b.target,b.relatedTarget=e, 39 | d.target=e,b.target?(u.leaveOut(d),u.enterOver(b)):( 40 | b.target=e,b.relatedTarget=null,this.cancelOut(b))),c.out=b,c.outTarget=b.target}},touchend:function(a){this.dedupSynthMouse(a),this.processTouches(a,this.upOut)},upOut:function(a){this.scrolling||(u.up(a),u.leaveOut(a)),this.cleanUpPointer(a)},touchcancel:function(a){this.processTouches(a,this.cancelOut)},cancelOut:function(a){u.cancel(a),u.leaveOut(a),this.cleanUpPointer(a)},cleanUpPointer:function(a){R["delete"](a.pointerId),this.removePrimaryPointer(a)}, 41 | dedupSynthMouse:function(a){var b=N.lastTouches,c=a.changedTouches[0]; 42 | if(this.isPrimaryTouch(c)){ 43 | var d={x:c.clientX,y:c.clientY};b.push(d);var e=function(a,b){var c=a.indexOf(b);c>-1&&a.splice(c,1)}.bind(null,b,d);setTimeout(e,S)}}};M=new c(V.elementAdded,V.elementRemoved,V.elementChanged,V);var W,X,Y,Z=u.pointermap,$=window.MSPointerEvent&&"number"==typeof window.MSPointerEvent.MSPOINTER_TYPE_MOUSE,_={events:["MSPointerDown","MSPointerMove","MSPointerUp","MSPointerOut","MSPointerOver","MSPointerCancel","MSGotPointerCapture","MSLostPointerCapture"],register:function(a){u.listen(a,this.events)},unregister:function(a){u.unlisten(a,this.events)},POINTER_TYPES:["","unavailable","touch","pen","mouse"],prepareEvent:function(a){var b=a;return $&&(b=u.cloneEvent(a),b.pointerType=this.POINTER_TYPES[a.pointerType]),b},cleanup:function(a){Z["delete"](a)},MSPointerDown:function(a){Z.set(a.pointerId,a);var b=this.prepareEvent(a);u.down(b)},MSPointerMove:function(a){var b=this.prepareEvent(a);u.move(b)},MSPointerUp:function(a){var b=this.prepareEvent(a);u.up(b),this.cleanup(a.pointerId)},MSPointerOut:function(a){var b=this.prepareEvent(a);u.leaveOut(b)},MSPointerOver:function(a){var b=this.prepareEvent(a);u.enterOver(b)},MSPointerCancel:function(a){var b=this.prepareEvent(a);u.cancel(b),this.cleanup(a.pointerId)},MSLostPointerCapture:function(a){var b=u.makeEvent("lostpointercapture",a);u.dispatchEvent(b)},MSGotPointerCapture:function(a){var b=u.makeEvent("gotpointercapture",a);u.dispatchEvent(b)}},aa=window.navigator;aa.msPointerEnabled?(W=function(a){i(a),j(this),k(a)&&(u.setCapture(a,this,!0),this.msSetPointerCapture(a))},X=function(a){i(a),u.releaseCapture(a,!0),this.msReleasePointerCapture(a)}):(W=function(a){i(a),j(this),k(a)&&u.setCapture(a,this)},X=function(a){i(a),u.releaseCapture(a)}),Y=function(a){return!!u.captureInfo[a]},g(),h(),l();var ba={dispatcher:u,Installer:c,PointerEvent:a,PointerMap:p,targetFinding:v};return ba}); 44 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/split.js: -------------------------------------------------------------------------------- 1 | /* 2 | Split.js - v1.3.5 3 | MIT License 4 | https://github.com/nathancahill/Split.js 5 | */ 6 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.Split=t()}(this,function(){"use strict";var e=window,t=e.document,n="addEventListener",i="removeEventListener",r="getBoundingClientRect",s=function(){return!1},o=e.attachEvent&&!e[n],a=["","-webkit-","-moz-","-o-"].filter(function(e){var n=t.createElement("div");return n.style.cssText="width:"+e+"calc(9px)",!!n.style.length}).shift()+"calc",l=function(e){return"string"==typeof e||e instanceof String?t.querySelector(e):e};return function(u,c){function z(e,t,n){var i=A(y,t,n);Object.keys(i).forEach(function(t){return e.style[t]=i[t]})}function h(e,t){var n=B(y,t);Object.keys(n).forEach(function(t){return e.style[t]=n[t]})}function f(e){var t=E[this.a],n=E[this.b],i=t.size+n.size;t.size=e/this.size*i,n.size=i-e/this.size*i,z(t.element,t.size,this.aGutterSize),z(n.element,n.size,this.bGutterSize)}function m(e){var t;this.dragging&&((t="touches"in e?e.touches[0][b]-this.start:e[b]-this.start)<=E[this.a].minSize+M+this.aGutterSize?t=E[this.a].minSize+this.aGutterSize:t>=this.size-(E[this.b].minSize+M+this.bGutterSize)&&(t=this.size-(E[this.b].minSize+this.bGutterSize)),f.call(this,t),c.onDrag&&c.onDrag())}function g(){var e=E[this.a].element,t=E[this.b].element;this.size=e[r]()[y]+t[r]()[y]+this.aGutterSize+this.bGutterSize,this.start=e[r]()[G]}function d(){var t=this,n=E[t.a].element,r=E[t.b].element;t.dragging&&c.onDragEnd&&c.onDragEnd(),t.dragging=!1,e[i]("mouseup",t.stop),e[i]("touchend",t.stop),e[i]("touchcancel",t.stop),t.parent[i]("mousemove",t.move),t.parent[i]("touchmove",t.move),delete t.stop,delete t.move,n[i]("selectstart",s),n[i]("dragstart",s),r[i]("selectstart",s),r[i]("dragstart",s),n.style.userSelect="",n.style.webkitUserSelect="",n.style.MozUserSelect="",n.style.pointerEvents="",r.style.userSelect="",r.style.webkitUserSelect="",r.style.MozUserSelect="",r.style.pointerEvents="",t.gutter.style.cursor="",t.parent.style.cursor=""}function S(t){var i=this,r=E[i.a].element,o=E[i.b].element;!i.dragging&&c.onDragStart&&c.onDragStart(),t.preventDefault(),i.dragging=!0,i.move=m.bind(i),i.stop=d.bind(i),e[n]("mouseup",i.stop),e[n]("touchend",i.stop),e[n]("touchcancel",i.stop),i.parent[n]("mousemove",i.move),i.parent[n]("touchmove",i.move),r[n]("selectstart",s),r[n]("dragstart",s),o[n]("selectstart",s),o[n]("dragstart",s),r.style.userSelect="none",r.style.webkitUserSelect="none",r.style.MozUserSelect="none",r.style.pointerEvents="none",o.style.userSelect="none",o.style.webkitUserSelect="none",o.style.MozUserSelect="none",o.style.pointerEvents="none",i.gutter.style.cursor=j,i.parent.style.cursor=j,g.call(i)}function v(e){e.forEach(function(t,n){if(n>0){var i=F[n-1],r=E[i.a],s=E[i.b];r.size=e[n-1],s.size=t,z(r.element,r.size,i.aGutterSize),z(s.element,s.size,i.bGutterSize)}})}function p(){F.forEach(function(e){e.parent.removeChild(e.gutter),E[e.a].element.style[y]="",E[e.b].element.style[y]=""})}void 0===c&&(c={});var y,b,G,E,w=l(u[0]).parentNode,D=e.getComputedStyle(w).flexDirection,U=c.sizes||u.map(function(){return 100/u.length}),k=void 0!==c.minSize?c.minSize:100,x=Array.isArray(k)?k:u.map(function(){return k}),L=void 0!==c.gutterSize?c.gutterSize:10,M=void 0!==c.snapOffset?c.snapOffset:30,O=c.direction||"horizontal",j=c.cursor||("horizontal"===O?"ew-resize":"ns-resize"),C=c.gutter||function(e,n){var i=t.createElement("div");return i.className="gutter gutter-"+n,i},A=c.elementStyle||function(e,t,n){var i={};return"string"==typeof t||t instanceof String?i[e]=t:i[e]=o?t+"%":a+"("+t+"% - "+n+"px)",i},B=c.gutterStyle||function(e,t){return n={},n[e]=t+"px",n;var n};"horizontal"===O?(y="width","clientWidth",b="clientX",G="left","paddingLeft"):"vertical"===O&&(y="height","clientHeight",b="clientY",G="top","paddingTop");var F=[];return E=u.map(function(e,t){var i,s={element:l(e),size:U[t],minSize:x[t]};if(t>0&&(i={a:t-1,b:t,dragging:!1,isFirst:1===t,isLast:t===u.length-1,direction:O,parent:w},i.aGutterSize=L,i.bGutterSize=L,i.isFirst&&(i.aGutterSize=L/2),i.isLast&&(i.bGutterSize=L/2),"row-reverse"===D||"column-reverse"===D)){var a=i.a;i.a=i.b,i.b=a}if(!o&&t>0){var c=C(t,O);h(c,L),c[n]("mousedown",S.bind(i)),c[n]("touchstart",S.bind(i)),w.insertBefore(c,s.element),i.gutter=c}0===t||t===u.length-1?z(s.element,s.size,L/2):z(s.element,s.size,L);var f=s.element[r]()[y];return f0&&F.push(i),s}),o?{setSizes:v,destroy:p}:{setSizes:v,getSizes:function(){return E.map(function(e){return e.size})},collapse:function(e){if(e===F.length){var t=F[e-1];g.call(t),o||f.call(t,t.size-t.bGutterSize)}else{var n=F[e];g.call(n),o||f.call(n,n.aGutterSize)}},destroy:p}}}); 7 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/table-util.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Table reordering via Drag'n'Drop 3 | * Inspired by: https://htmldom.dev/drag-and-drop-table-column 4 | */ 5 | 6 | function setBomHandlers() { 7 | 8 | const bom = document.getElementById('bomtable'); 9 | 10 | let dragName; 11 | let placeHolderElements; 12 | let draggingElement; 13 | let forcePopulation; 14 | let xOffset; 15 | let yOffset; 16 | let wasDragged; 17 | 18 | const mouseUpHandler = function(e) { 19 | // Delete dragging element 20 | draggingElement.remove(); 21 | 22 | // Make BOM selectable again 23 | bom.style.removeProperty("userSelect"); 24 | 25 | // Remove listeners 26 | document.removeEventListener('mousemove', mouseMoveHandler); 27 | document.removeEventListener('mouseup', mouseUpHandler); 28 | 29 | if (wasDragged) { 30 | // Redraw whole BOM 31 | populateBomTable(); 32 | } 33 | } 34 | 35 | const mouseMoveHandler = function(e) { 36 | // Notice the dragging 37 | wasDragged = true; 38 | 39 | // Make the dragged element visible 40 | draggingElement.style.removeProperty("display"); 41 | 42 | // Set elements position to mouse position 43 | draggingElement.style.left = `${e.screenX - xOffset}px`; 44 | draggingElement.style.top = `${e.screenY - yOffset}px`; 45 | 46 | // Forced redrawing of BOM table 47 | if (forcePopulation) { 48 | forcePopulation = false; 49 | // Copy array 50 | phe = Array.from(placeHolderElements); 51 | // populate BOM table again 52 | populateBomHeader(dragName, phe); 53 | populateBomBody(dragName, phe); 54 | } 55 | 56 | // Set up array of hidden columns 57 | var hiddenColumns = Array.from(settings.hiddenColumns); 58 | // In the ungrouped mode, quantity don't exist 59 | if (settings.bommode === "ungrouped") 60 | hiddenColumns.push("Quantity"); 61 | // If no checkbox fields can be found, we consider them hidden 62 | if (settings.checkboxes.length == 0) 63 | hiddenColumns.push("checkboxes"); 64 | 65 | // Get table headers and group them into checkboxes, extrafields and normal headers 66 | const bh = document.getElementById("bomhead"); 67 | headers = Array.from(bh.querySelectorAll("th")) 68 | headers.shift() // numCol is not part of the columnOrder 69 | headerGroups = [] 70 | lastCompoundClass = null; 71 | for (i = 0; i < settings.columnOrder.length; i++) { 72 | cElem = settings.columnOrder[i]; 73 | if (hiddenColumns.includes(cElem)) { 74 | // Hidden columns appear as a dummy element 75 | headerGroups.push([]); 76 | continue; 77 | } 78 | elem = headers.filter(e => getColumnOrderName(e) === cElem)[0]; 79 | if (elem.classList.contains("bom-checkbox")) { 80 | if (lastCompoundClass === "bom-checkbox") { 81 | cbGroup = headerGroups.pop(); 82 | cbGroup.push(elem); 83 | headerGroups.push(cbGroup); 84 | } else { 85 | lastCompoundClass = "bom-checkbox"; 86 | headerGroups.push([elem]) 87 | } 88 | } else { 89 | headerGroups.push([elem]) 90 | } 91 | } 92 | 93 | // Copy settings.columnOrder 94 | var columns = Array.from(settings.columnOrder) 95 | 96 | // Set up array with indices of hidden columns 97 | var hiddenIndices = hiddenColumns.map(e => settings.columnOrder.indexOf(e)); 98 | var dragIndex = columns.indexOf(dragName); 99 | var swapIndex = dragIndex; 100 | var swapDone = false; 101 | 102 | // Check if the current dragged element is swapable with the left or right element 103 | if (dragIndex > 0) { 104 | // Get left headers boundingbox 105 | swapIndex = dragIndex - 1; 106 | while (hiddenIndices.includes(swapIndex) && swapIndex > 0) 107 | swapIndex--; 108 | if (!hiddenIndices.includes(swapIndex)) { 109 | box = getBoundingClientRectFromMultiple(headerGroups[swapIndex]); 110 | if (e.clientX < box.left + window.scrollX + (box.width / 2)) { 111 | swapElement = columns[dragIndex]; 112 | columns.splice(dragIndex, 1); 113 | columns.splice(swapIndex, 0, swapElement); 114 | forcePopulation = true; 115 | swapDone = true; 116 | } 117 | } 118 | } 119 | if ((!swapDone) && dragIndex < headerGroups.length - 1) { 120 | // Get right headers boundingbox 121 | swapIndex = dragIndex + 1; 122 | while (hiddenIndices.includes(swapIndex)) 123 | swapIndex++; 124 | if (swapIndex < headerGroups.length) { 125 | box = getBoundingClientRectFromMultiple(headerGroups[swapIndex]); 126 | if (e.clientX > box.left + window.scrollX + (box.width / 2)) { 127 | swapElement = columns[dragIndex]; 128 | columns.splice(dragIndex, 1); 129 | columns.splice(swapIndex, 0, swapElement); 130 | forcePopulation = true; 131 | swapDone = true; 132 | } 133 | } 134 | } 135 | 136 | // Write back change to storage 137 | if (swapDone) { 138 | settings.columnOrder = columns 139 | writeStorage("columnOrder", JSON.stringify(columns)); 140 | } 141 | 142 | } 143 | 144 | const mouseDownHandler = function(e) { 145 | var target = e.target; 146 | if (target.tagName.toLowerCase() != "td") 147 | target = target.parentElement; 148 | 149 | // Used to check if a dragging has ever happened 150 | wasDragged = false; 151 | 152 | // Create new element which will be displayed as the dragged column 153 | draggingElement = document.createElement("div") 154 | draggingElement.classList.add("dragging"); 155 | draggingElement.style.display = "none"; 156 | draggingElement.style.position = "absolute"; 157 | draggingElement.style.overflow = "hidden"; 158 | 159 | // Get bomhead and bombody elements 160 | const bh = document.getElementById("bomhead"); 161 | const bb = document.getElementById("bombody"); 162 | 163 | // Get all compound headers for the current column 164 | var compoundHeaders; 165 | if (target.classList.contains("bom-checkbox")) { 166 | compoundHeaders = Array.from(bh.querySelectorAll("th.bom-checkbox")); 167 | } else { 168 | compoundHeaders = [target]; 169 | } 170 | 171 | // Create new table which will display the column 172 | var newTable = document.createElement("table"); 173 | newTable.classList.add("bom"); 174 | newTable.style.background = "white"; 175 | draggingElement.append(newTable); 176 | 177 | // Create new header element 178 | var newHeader = document.createElement("thead"); 179 | newTable.append(newHeader); 180 | 181 | // Set up array for storing all placeholder elements 182 | placeHolderElements = []; 183 | 184 | // Add all compound headers to the new thead element and placeholders 185 | compoundHeaders.forEach(function(h) { 186 | clone = cloneElementWithDimensions(h); 187 | newHeader.append(clone); 188 | placeHolderElements.push(clone); 189 | }); 190 | 191 | // Create new body element 192 | var newBody = document.createElement("tbody"); 193 | newTable.append(newBody); 194 | 195 | // Get indices for compound headers 196 | var idxs = compoundHeaders.map(e => getBomTableHeaderIndex(e)); 197 | 198 | // For each row in the BOM body... 199 | var rows = bb.querySelectorAll("tr"); 200 | rows.forEach(function(row) { 201 | // ..get the cells for the compound column 202 | const tds = row.querySelectorAll("td"); 203 | var copytds = idxs.map(i => tds[i]); 204 | // Add them to the new element and the placeholders 205 | var newRow = document.createElement("tr"); 206 | copytds.forEach(function(td) { 207 | clone = cloneElementWithDimensions(td); 208 | newRow.append(clone); 209 | placeHolderElements.push(clone); 210 | }); 211 | newBody.append(newRow); 212 | }); 213 | 214 | // Compute width for compound header 215 | var width = compoundHeaders.reduce((acc, x) => acc + x.clientWidth, 0); 216 | draggingElement.style.width = `${width}px`; 217 | 218 | // Insert the new dragging element and disable selection on BOM 219 | bom.insertBefore(draggingElement, null); 220 | bom.style.userSelect = "none"; 221 | 222 | // Determine the mouse position offset 223 | xOffset = e.screenX - compoundHeaders.reduce((acc, x) => Math.min(acc, x.offsetLeft), compoundHeaders[0].offsetLeft); 224 | yOffset = e.screenY - compoundHeaders[0].offsetTop; 225 | 226 | // Get name for the column in settings.columnOrder 227 | dragName = getColumnOrderName(target); 228 | 229 | // Change text and class for placeholder elements 230 | placeHolderElements = placeHolderElements.map(function(e) { 231 | newElem = cloneElementWithDimensions(e); 232 | newElem.textContent = ""; 233 | newElem.classList.add("placeholder"); 234 | return newElem; 235 | }); 236 | 237 | // On next mouse move, the whole BOM needs to be redrawn to show the placeholders 238 | forcePopulation = true; 239 | 240 | // Add listeners for move and up on mouse 241 | document.addEventListener('mousemove', mouseMoveHandler); 242 | document.addEventListener('mouseup', mouseUpHandler); 243 | } 244 | 245 | // In netlist mode, there is nothing to reorder 246 | if (settings.bommode === "netlist") 247 | return; 248 | 249 | // Add mouseDownHandler to every column except the numCol 250 | bom.querySelectorAll("th") 251 | .forEach(function(head) { 252 | if (!head.classList.contains("numCol")) { 253 | head.onmousedown = mouseDownHandler; 254 | } 255 | }); 256 | 257 | } 258 | 259 | function getBoundingClientRectFromMultiple(elements) { 260 | var elems = Array.from(elements); 261 | 262 | if (elems.length == 0) 263 | return null; 264 | 265 | var box = elems.shift() 266 | .getBoundingClientRect(); 267 | 268 | elems.forEach(function(elem) { 269 | var elembox = elem.getBoundingClientRect(); 270 | box.left = Math.min(elembox.left, box.left); 271 | box.top = Math.min(elembox.top, box.top); 272 | box.width += elembox.width; 273 | box.height = Math.max(elembox.height, box.height); 274 | }); 275 | 276 | return box; 277 | } 278 | 279 | function cloneElementWithDimensions(elem) { 280 | var newElem = elem.cloneNode(true); 281 | newElem.style.height = window.getComputedStyle(elem).height; 282 | newElem.style.width = window.getComputedStyle(elem).width; 283 | return newElem; 284 | } 285 | 286 | function getBomTableHeaderIndex(elem) { 287 | const bh = document.getElementById('bomhead'); 288 | const ths = Array.from(bh.querySelectorAll("th")); 289 | return ths.indexOf(elem); 290 | } 291 | 292 | function getColumnOrderName(elem) { 293 | var cname = elem.getAttribute("col_name"); 294 | if (cname === "bom-checkbox") 295 | return "checkboxes"; 296 | else 297 | return cname; 298 | } 299 | 300 | function resizableGrid(tablehead) { 301 | var cols = tablehead.firstElementChild.children; 302 | var rowWidth = tablehead.offsetWidth; 303 | 304 | for (var i = 1; i < cols.length; i++) { 305 | if (cols[i].classList.contains("bom-checkbox")) 306 | continue; 307 | cols[i].style.width = ((cols[i].clientWidth - paddingDiff(cols[i])) * 100 / rowWidth) + '%'; 308 | } 309 | 310 | for (var i = 1; i < cols.length - 1; i++) { 311 | var div = document.createElement('div'); 312 | div.className = "column-width-handle"; 313 | cols[i].appendChild(div); 314 | setListeners(div); 315 | } 316 | 317 | function setListeners(div) { 318 | var startX, curCol, nxtCol, curColWidth, nxtColWidth, rowWidth; 319 | 320 | div.addEventListener('mousedown', function(e) { 321 | e.preventDefault(); 322 | e.stopPropagation(); 323 | 324 | curCol = e.target.parentElement; 325 | nxtCol = curCol.nextElementSibling; 326 | startX = e.pageX; 327 | 328 | var padding = paddingDiff(curCol); 329 | 330 | rowWidth = curCol.parentElement.offsetWidth; 331 | curColWidth = curCol.clientWidth - padding; 332 | nxtColWidth = nxtCol.clientWidth - padding; 333 | }); 334 | 335 | document.addEventListener('mousemove', function(e) { 336 | if (startX) { 337 | var diffX = e.pageX - startX; 338 | diffX = -Math.min(-diffX, curColWidth - 20); 339 | diffX = Math.min(diffX, nxtColWidth - 20); 340 | 341 | curCol.style.width = ((curColWidth + diffX) * 100 / rowWidth) + '%'; 342 | nxtCol.style.width = ((nxtColWidth - diffX) * 100 / rowWidth) + '%'; 343 | console.log(`${curColWidth + nxtColWidth} ${(curColWidth + diffX) * 100 / rowWidth + (nxtColWidth - diffX) * 100 / rowWidth}`); 344 | } 345 | }); 346 | 347 | document.addEventListener('mouseup', function(e) { 348 | curCol = undefined; 349 | nxtCol = undefined; 350 | startX = undefined; 351 | nxtColWidth = undefined; 352 | curColWidth = undefined 353 | }); 354 | } 355 | 356 | function paddingDiff(col) { 357 | 358 | if (getStyleVal(col, 'box-sizing') == 'border-box') { 359 | return 0; 360 | } 361 | 362 | var padLeft = getStyleVal(col, 'padding-left'); 363 | var padRight = getStyleVal(col, 'padding-right'); 364 | return (parseInt(padLeft) + parseInt(padRight)); 365 | 366 | } 367 | 368 | function getStyleVal(elm, css) { 369 | return (window.getComputedStyle(elm, null).getPropertyValue(css)) 370 | } 371 | } 372 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/user-file-examples/user.css: -------------------------------------------------------------------------------- 1 | /* Add custom css styles and overrides here. */ 2 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/user-file-examples/user.js: -------------------------------------------------------------------------------- 1 | // Example event listener. 2 | // Event argument will always be {eventType, args} dict 3 | 4 | EventHandler.registerCallback(IBOM_EVENT_TYPES.ALL, (e) => console.log(e)); 5 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/user-file-examples/userfooter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | Hello footer 5 |
6 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/user-file-examples/userheader.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | Hello header 4 |
5 |
6 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/web/util.js: -------------------------------------------------------------------------------- 1 | /* Utility functions */ 2 | 3 | var storagePrefix = 'KiCad_HTML_BOM__' + pcbdata.metadata.title + '__' + 4 | pcbdata.metadata.revision + '__#'; 5 | var storage; 6 | 7 | function initStorage(key) { 8 | try { 9 | window.localStorage.getItem("blank"); 10 | storage = window.localStorage; 11 | } catch (e) { 12 | // localStorage not available 13 | } 14 | if (!storage) { 15 | try { 16 | window.sessionStorage.getItem("blank"); 17 | storage = window.sessionStorage; 18 | } catch (e) { 19 | // sessionStorage also not available 20 | } 21 | } 22 | } 23 | 24 | function readStorage(key) { 25 | if (storage) { 26 | return storage.getItem(storagePrefix + key); 27 | } else { 28 | return null; 29 | } 30 | } 31 | 32 | function writeStorage(key, value) { 33 | if (storage) { 34 | storage.setItem(storagePrefix + key, value); 35 | } 36 | } 37 | 38 | function fancyDblClickHandler(el, onsingle, ondouble) { 39 | return function() { 40 | if (el.getAttribute("data-dblclick") == null) { 41 | el.setAttribute("data-dblclick", 1); 42 | setTimeout(function() { 43 | if (el.getAttribute("data-dblclick") == 1) { 44 | onsingle(); 45 | } 46 | el.removeAttribute("data-dblclick"); 47 | }, 200); 48 | } else { 49 | el.removeAttribute("data-dblclick"); 50 | ondouble(); 51 | } 52 | } 53 | } 54 | 55 | function smoothScrollToRow(rowid) { 56 | document.getElementById(rowid).scrollIntoView({ 57 | behavior: "smooth", 58 | block: "center", 59 | inline: "nearest" 60 | }); 61 | } 62 | 63 | function focusInputField(input) { 64 | input.scrollIntoView(false); 65 | input.focus(); 66 | input.select(); 67 | } 68 | 69 | function copyToClipboard() { 70 | var text = ''; 71 | for (var node of bomhead.childNodes[0].childNodes) { 72 | if (node.firstChild) { 73 | text = text + node.firstChild.nodeValue; 74 | } 75 | if (node != bomhead.childNodes[0].lastChild) { 76 | text += '\t'; 77 | } 78 | } 79 | text += '\n'; 80 | for (var row of bombody.childNodes) { 81 | for (var cell of row.childNodes) { 82 | for (var node of cell.childNodes) { 83 | if (node.nodeName == "INPUT") { 84 | if (node.checked) { 85 | text = text + '✓'; 86 | } 87 | } else if (node.nodeName == "MARK") { 88 | text = text + node.firstChild.nodeValue; 89 | } else { 90 | text = text + node.nodeValue; 91 | } 92 | } 93 | if (cell != row.lastChild) { 94 | text += '\t'; 95 | } 96 | } 97 | text += '\n'; 98 | } 99 | var textArea = document.createElement("textarea"); 100 | textArea.classList.add('clipboard-temp'); 101 | textArea.value = text; 102 | 103 | document.body.appendChild(textArea); 104 | textArea.focus(); 105 | textArea.select(); 106 | 107 | try { 108 | if (document.execCommand('copy')) { 109 | console.log('Bom copied to clipboard.'); 110 | } 111 | } catch (err) { 112 | console.log('Can not copy to clipboard.'); 113 | } 114 | 115 | document.body.removeChild(textArea); 116 | } 117 | 118 | function removeGutterNode(node) { 119 | for (var i = 0; i < node.childNodes.length; i++) { 120 | if (node.childNodes[i].classList && 121 | node.childNodes[i].classList.contains("gutter")) { 122 | node.removeChild(node.childNodes[i]); 123 | break; 124 | } 125 | } 126 | } 127 | 128 | function cleanGutters() { 129 | removeGutterNode(document.getElementById("bot")); 130 | removeGutterNode(document.getElementById("canvasdiv")); 131 | } 132 | 133 | var units = { 134 | prefixes: { 135 | giga: ["G", "g", "giga", "Giga", "GIGA"], 136 | mega: ["M", "mega", "Mega", "MEGA"], 137 | kilo: ["K", "k", "kilo", "Kilo", "KILO"], 138 | milli: ["m", "milli", "Milli", "MILLI"], 139 | micro: ["U", "u", "micro", "Micro", "MICRO", "μ", "µ"], // different utf8 μ 140 | nano: ["N", "n", "nano", "Nano", "NANO"], 141 | pico: ["P", "p", "pico", "Pico", "PICO"], 142 | }, 143 | unitsShort: ["R", "r", "Ω", "F", "f", "H", "h"], 144 | unitsLong: [ 145 | "OHM", "Ohm", "ohm", "ohms", 146 | "FARAD", "Farad", "farad", 147 | "HENRY", "Henry", "henry" 148 | ], 149 | getMultiplier: function(s) { 150 | if (this.prefixes.giga.includes(s)) return 1e9; 151 | if (this.prefixes.mega.includes(s)) return 1e6; 152 | if (this.prefixes.kilo.includes(s)) return 1e3; 153 | if (this.prefixes.milli.includes(s)) return 1e-3; 154 | if (this.prefixes.micro.includes(s)) return 1e-6; 155 | if (this.prefixes.nano.includes(s)) return 1e-9; 156 | if (this.prefixes.pico.includes(s)) return 1e-12; 157 | return 1; 158 | }, 159 | valueRegex: null, 160 | } 161 | 162 | function initUtils() { 163 | var allPrefixes = units.prefixes.giga 164 | .concat(units.prefixes.mega) 165 | .concat(units.prefixes.kilo) 166 | .concat(units.prefixes.milli) 167 | .concat(units.prefixes.micro) 168 | .concat(units.prefixes.nano) 169 | .concat(units.prefixes.pico); 170 | var allUnits = units.unitsShort.concat(units.unitsLong); 171 | units.valueRegex = new RegExp("^([0-9\.]+)" + 172 | "\\s*(" + allPrefixes.join("|") + ")?" + 173 | "(" + allUnits.join("|") + ")?" + 174 | "(\\b.*)?$", ""); 175 | units.valueAltRegex = new RegExp("^([0-9]*)" + 176 | "(" + units.unitsShort.join("|") + ")?" + 177 | "([GgMmKkUuNnPp])?" + 178 | "([0-9]*)" + 179 | "(\\b.*)?$", ""); 180 | if (config.fields.includes("Value")) { 181 | var index = config.fields.indexOf("Value"); 182 | pcbdata.bom["parsedValues"] = {}; 183 | for (var id in pcbdata.bom.fields) { 184 | pcbdata.bom.parsedValues[id] = parseValue(pcbdata.bom.fields[id][index]) 185 | } 186 | } 187 | } 188 | 189 | function parseValue(val, ref) { 190 | var inferUnit = (unit, ref) => { 191 | if (unit) { 192 | unit = unit.toLowerCase(); 193 | if (unit == 'Ω' || unit == "ohm" || unit == "ohms") { 194 | unit = 'r'; 195 | } 196 | unit = unit[0]; 197 | } else { 198 | ref = /^([a-z]+)\d+$/i.exec(ref); 199 | if (ref) { 200 | ref = ref[1].toLowerCase(); 201 | if (ref == "c") unit = 'f'; 202 | else if (ref == "l") unit = 'h'; 203 | else if (ref == "r" || ref == "rv") unit = 'r'; 204 | else unit = null; 205 | } 206 | } 207 | return unit; 208 | }; 209 | val = val.replace(/,/g, ""); 210 | var match = units.valueRegex.exec(val); 211 | var unit; 212 | if (match) { 213 | val = parseFloat(match[1]); 214 | if (match[2]) { 215 | val = val * units.getMultiplier(match[2]); 216 | } 217 | unit = inferUnit(match[3], ref); 218 | if (!unit) return null; 219 | else return { 220 | val: val, 221 | unit: unit, 222 | extra: match[4], 223 | } 224 | } 225 | match = units.valueAltRegex.exec(val); 226 | if (match && (match[1] || match[4])) { 227 | val = parseFloat(match[1] + "." + match[4]); 228 | if (match[3]) { 229 | val = val * units.getMultiplier(match[3]); 230 | } 231 | unit = inferUnit(match[2], ref); 232 | if (!unit) return null; 233 | else return { 234 | val: val, 235 | unit: unit, 236 | extra: match[5], 237 | } 238 | } 239 | return null; 240 | } 241 | 242 | function valueCompare(a, b, stra, strb) { 243 | if (a === null && b === null) { 244 | // Failed to parse both values, compare them as strings. 245 | if (stra != strb) return stra > strb ? 1 : -1; 246 | else return 0; 247 | } else if (a === null) { 248 | return 1; 249 | } else if (b === null) { 250 | return -1; 251 | } else { 252 | if (a.unit != b.unit) return a.unit > b.unit ? 1 : -1; 253 | else if (a.val != b.val) return a.val > b.val ? 1 : -1; 254 | else if (a.extra != b.extra) return a.extra > b.extra ? 1 : -1; 255 | else return 0; 256 | } 257 | } 258 | 259 | function validateSaveImgDimension(element) { 260 | var valid = false; 261 | var intValue = 0; 262 | if (/^[1-9]\d*$/.test(element.value)) { 263 | intValue = parseInt(element.value); 264 | if (intValue <= 16000) { 265 | valid = true; 266 | } 267 | } 268 | if (valid) { 269 | element.classList.remove("invalid"); 270 | } else { 271 | element.classList.add("invalid"); 272 | } 273 | return intValue; 274 | } 275 | 276 | function saveImage(layer) { 277 | var width = validateSaveImgDimension(document.getElementById("render-save-width")); 278 | var height = validateSaveImgDimension(document.getElementById("render-save-height")); 279 | var bgcolor = null; 280 | if (!document.getElementById("render-save-transparent").checked) { 281 | var style = getComputedStyle(topmostdiv); 282 | bgcolor = style.getPropertyValue("background-color"); 283 | } 284 | if (!width || !height) return; 285 | 286 | // Prepare image 287 | var canvas = document.createElement("canvas"); 288 | var layerdict = { 289 | transform: { 290 | x: 0, 291 | y: 0, 292 | s: 1, 293 | panx: 0, 294 | pany: 0, 295 | zoom: 1, 296 | }, 297 | bg: canvas, 298 | fab: canvas, 299 | silk: canvas, 300 | highlight: canvas, 301 | layer: layer, 302 | } 303 | // Do the rendering 304 | recalcLayerScale(layerdict, width, height); 305 | prepareLayer(layerdict); 306 | clearCanvas(canvas, bgcolor); 307 | drawBackground(layerdict, false); 308 | drawHighlightsOnLayer(layerdict, false); 309 | 310 | // Save image 311 | var imgdata = canvas.toDataURL("image/png"); 312 | 313 | var filename = pcbdata.metadata.title; 314 | if (pcbdata.metadata.revision) { 315 | filename += `.${pcbdata.metadata.revision}`; 316 | } 317 | filename += `.${layer}.png`; 318 | saveFile(filename, dataURLtoBlob(imgdata)); 319 | } 320 | 321 | function saveSettings() { 322 | var data = { 323 | type: "InteractiveHtmlBom settings", 324 | version: 1, 325 | pcbmetadata: pcbdata.metadata, 326 | settings: settings, 327 | } 328 | var blob = new Blob([JSON.stringify(data, null, 4)], { 329 | type: "application/json" 330 | }); 331 | saveFile(`${pcbdata.metadata.title}.settings.json`, blob); 332 | } 333 | 334 | function loadSettings() { 335 | var input = document.createElement("input"); 336 | input.type = "file"; 337 | input.accept = ".settings.json"; 338 | input.onchange = function(e) { 339 | var file = e.target.files[0]; 340 | var reader = new FileReader(); 341 | reader.onload = readerEvent => { 342 | var content = readerEvent.target.result; 343 | var newSettings; 344 | try { 345 | newSettings = JSON.parse(content); 346 | } catch (e) { 347 | alert("Selected file is not InteractiveHtmlBom settings file."); 348 | return; 349 | } 350 | if (newSettings.type != "InteractiveHtmlBom settings") { 351 | alert("Selected file is not InteractiveHtmlBom settings file."); 352 | return; 353 | } 354 | var metadataMatches = newSettings.hasOwnProperty("pcbmetadata"); 355 | if (metadataMatches) { 356 | for (var k in pcbdata.metadata) { 357 | if (!newSettings.pcbmetadata.hasOwnProperty(k) || newSettings.pcbmetadata[k] != pcbdata.metadata[k]) { 358 | metadataMatches = false; 359 | } 360 | } 361 | } 362 | if (!metadataMatches) { 363 | var currentMetadata = JSON.stringify(pcbdata.metadata, null, 4); 364 | var fileMetadata = JSON.stringify(newSettings.pcbmetadata, null, 4); 365 | if (!confirm( 366 | `Settins file metadata does not match current metadata.\n\n` + 367 | `Page metadata:\n${currentMetadata}\n\n` + 368 | `Settings file metadata:\n${fileMetadata}\n\n` + 369 | `Press OK if you would like to import settings anyway.`)) { 370 | return; 371 | } 372 | } 373 | overwriteSettings(newSettings.settings); 374 | } 375 | reader.readAsText(file, 'UTF-8'); 376 | } 377 | input.click(); 378 | } 379 | 380 | function overwriteSettings(newSettings) { 381 | initDone = false; 382 | Object.assign(settings, newSettings); 383 | writeStorage("bomlayout", settings.bomlayout); 384 | writeStorage("bommode", settings.bommode); 385 | writeStorage("canvaslayout", settings.canvaslayout); 386 | writeStorage("bomCheckboxes", settings.checkboxes.join(",")); 387 | document.getElementById("bomCheckboxes").value = settings.checkboxes.join(","); 388 | for (var checkbox of settings.checkboxes) { 389 | writeStorage("checkbox_" + checkbox, settings.checkboxStoredRefs[checkbox]); 390 | } 391 | writeStorage("markWhenChecked", settings.markWhenChecked); 392 | padsVisible(settings.renderPads); 393 | document.getElementById("padsCheckbox").checked = settings.renderPads; 394 | fabricationVisible(settings.renderFabrication); 395 | document.getElementById("fabricationCheckbox").checked = settings.renderFabrication; 396 | silkscreenVisible(settings.renderSilkscreen); 397 | document.getElementById("silkscreenCheckbox").checked = settings.renderSilkscreen; 398 | referencesVisible(settings.renderReferences); 399 | document.getElementById("referencesCheckbox").checked = settings.renderReferences; 400 | valuesVisible(settings.renderValues); 401 | document.getElementById("valuesCheckbox").checked = settings.renderValues; 402 | tracksVisible(settings.renderTracks); 403 | document.getElementById("tracksCheckbox").checked = settings.renderTracks; 404 | zonesVisible(settings.renderZones); 405 | document.getElementById("zonesCheckbox").checked = settings.renderZones; 406 | dnpOutline(settings.renderDnpOutline); 407 | document.getElementById("dnpOutlineCheckbox").checked = settings.renderDnpOutline; 408 | setRedrawOnDrag(settings.redrawOnDrag); 409 | document.getElementById("dragCheckbox").checked = settings.redrawOnDrag; 410 | setDarkMode(settings.darkMode); 411 | document.getElementById("darkmodeCheckbox").checked = settings.darkMode; 412 | setHighlightPin1(settings.highlightpin1); 413 | document.getElementById("highlightpin1Checkbox").checked = settings.highlightpin1; 414 | showFootprints(settings.show_footprints); 415 | writeStorage("boardRotation", settings.boardRotation); 416 | document.getElementById("boardRotation").value = settings.boardRotation / 5; 417 | document.getElementById("rotationDegree").textContent = settings.boardRotation; 418 | initDone = true; 419 | prepCheckboxes(); 420 | changeBomLayout(settings.bomlayout); 421 | } 422 | 423 | function saveFile(filename, blob) { 424 | var link = document.createElement("a"); 425 | var objurl = URL.createObjectURL(blob); 426 | link.download = filename; 427 | link.href = objurl; 428 | link.click(); 429 | } 430 | 431 | function dataURLtoBlob(dataurl) { 432 | var arr = dataurl.split(','), 433 | mime = arr[0].match(/:(.*?);/)[1], 434 | bstr = atob(arr[1]), 435 | n = bstr.length, 436 | u8arr = new Uint8Array(n); 437 | while (n--) { 438 | u8arr[n] = bstr.charCodeAt(n); 439 | } 440 | return new Blob([u8arr], { 441 | type: mime 442 | }); 443 | } 444 | 445 | var settings = { 446 | canvaslayout: "default", 447 | bomlayout: "default", 448 | bommode: "grouped", 449 | checkboxes: [], 450 | checkboxStoredRefs: {}, 451 | darkMode: false, 452 | highlightpin1: false, 453 | redrawOnDrag: true, 454 | boardRotation: 0, 455 | renderPads: true, 456 | renderReferences: true, 457 | renderValues: true, 458 | renderSilkscreen: true, 459 | renderFabrication: true, 460 | renderDnpOutline: false, 461 | renderTracks: true, 462 | renderZones: true, 463 | columnOrder: [], 464 | hiddenColumns: [], 465 | } 466 | 467 | function initDefaults() { 468 | settings.bomlayout = readStorage("bomlayout"); 469 | if (settings.bomlayout === null) { 470 | settings.bomlayout = config.bom_view; 471 | } 472 | if (!['bom-only', 'left-right', 'top-bottom'].includes(settings.bomlayout)) { 473 | settings.bomlayout = config.bom_view; 474 | } 475 | settings.bommode = readStorage("bommode"); 476 | if (settings.bommode === null) { 477 | settings.bommode = "grouped"; 478 | } 479 | if (!["grouped", "ungrouped", "netlist"].includes(settings.bommode)) { 480 | settings.bommode = "grouped"; 481 | } 482 | settings.canvaslayout = readStorage("canvaslayout"); 483 | if (settings.canvaslayout === null) { 484 | settings.canvaslayout = config.layer_view; 485 | } 486 | var bomCheckboxes = readStorage("bomCheckboxes"); 487 | if (bomCheckboxes === null) { 488 | bomCheckboxes = config.checkboxes; 489 | } 490 | settings.checkboxes = bomCheckboxes.split(",").filter((e) => e); 491 | document.getElementById("bomCheckboxes").value = bomCheckboxes; 492 | 493 | settings.markWhenChecked = readStorage("markWhenChecked") || ""; 494 | populateMarkWhenCheckedOptions(); 495 | 496 | function initBooleanSetting(storageString, def, elementId, func) { 497 | var b = readStorage(storageString); 498 | if (b === null) { 499 | b = def; 500 | } else { 501 | b = (b == "true"); 502 | } 503 | document.getElementById(elementId).checked = b; 504 | func(b); 505 | } 506 | 507 | initBooleanSetting("padsVisible", config.show_pads, "padsCheckbox", padsVisible); 508 | initBooleanSetting("fabricationVisible", config.show_fabrication, "fabricationCheckbox", fabricationVisible); 509 | initBooleanSetting("silkscreenVisible", config.show_silkscreen, "silkscreenCheckbox", silkscreenVisible); 510 | initBooleanSetting("referencesVisible", true, "referencesCheckbox", referencesVisible); 511 | initBooleanSetting("valuesVisible", true, "valuesCheckbox", valuesVisible); 512 | if ("tracks" in pcbdata) { 513 | initBooleanSetting("tracksVisible", true, "tracksCheckbox", tracksVisible); 514 | initBooleanSetting("zonesVisible", true, "zonesCheckbox", zonesVisible); 515 | } else { 516 | document.getElementById("tracksAndZonesCheckboxes").style.display = "none"; 517 | tracksVisible(false); 518 | zonesVisible(false); 519 | } 520 | initBooleanSetting("dnpOutline", false, "dnpOutlineCheckbox", dnpOutline); 521 | initBooleanSetting("redrawOnDrag", config.redraw_on_drag, "dragCheckbox", setRedrawOnDrag); 522 | initBooleanSetting("darkmode", config.dark_mode, "darkmodeCheckbox", setDarkMode); 523 | initBooleanSetting("highlightpin1", config.highlight_pin1, "highlightpin1Checkbox", setHighlightPin1); 524 | 525 | var fields = ["checkboxes", "References"].concat(config.fields).concat(["Quantity"]); 526 | var hcols = JSON.parse(readStorage("hiddenColumns")); 527 | if (hcols === null) { 528 | hcols = []; 529 | } 530 | settings.hiddenColumns = hcols.filter(e => fields.includes(e)); 531 | 532 | var cord = JSON.parse(readStorage("columnOrder")); 533 | if (cord === null) { 534 | cord = fields; 535 | } else { 536 | cord = cord.filter(e => fields.includes(e)); 537 | if (cord.length != fields.length) 538 | cord = fields; 539 | } 540 | settings.columnOrder = cord; 541 | 542 | settings.boardRotation = readStorage("boardRotation"); 543 | if (settings.boardRotation === null) { 544 | settings.boardRotation = config.board_rotation * 5; 545 | } else { 546 | settings.boardRotation = parseInt(settings.boardRotation); 547 | } 548 | document.getElementById("boardRotation").value = settings.boardRotation / 5; 549 | document.getElementById("rotationDegree").textContent = settings.boardRotation; 550 | } 551 | 552 | // Helper classes for user js callbacks. 553 | 554 | const IBOM_EVENT_TYPES = { 555 | ALL: "all", 556 | HIGHLIGHT_EVENT: "highlightEvent", 557 | CHECKBOX_CHANGE_EVENT: "checkboxChangeEvent", 558 | BOM_BODY_CHANGE_EVENT: "bomBodyChangeEvent", 559 | } 560 | 561 | const EventHandler = { 562 | callbacks: {}, 563 | init: function() { 564 | for (eventType of Object.values(IBOM_EVENT_TYPES)) 565 | this.callbacks[eventType] = []; 566 | }, 567 | registerCallback: function(eventType, callback) { 568 | this.callbacks[eventType].push(callback); 569 | }, 570 | emitEvent: function(eventType, eventArgs) { 571 | event = { 572 | eventType: eventType, 573 | args: eventArgs, 574 | } 575 | var callback; 576 | for (callback of this.callbacks[eventType]) 577 | callback(event); 578 | for (callback of this.callbacks[IBOM_EVENT_TYPES.ALL]) 579 | callback(event); 580 | } 581 | } 582 | EventHandler.init(); 583 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 qu1ck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interactive HTML BOM plugin for KiCad 2 | ## Supports EasyEDA, Eagle and Fusion360 3 | 4 | ![icon](https://i.imgur.com/js4kDOn.png) 5 | 6 | This plugin generates convenient BOM listing with ability to visually correlate 7 | and easily search for components and their placements on the pcb. 8 | 9 | This is really useful when hand soldering your prototype and you have to find 50 10 | places where 0.1uF cap should be or which of the SOP8 footprints are for the same 11 | micro. Dynamically highlighting all components in the same group on the rendering 12 | of the pcb makes manually populating the board much easier. 13 | 14 | This plugin utilizes Pcbnew python bindings to read pcb data and render 15 | silkscreen, fab layer, footprint pads, text and drawings. Additionally it can 16 | pull data from schematic if you export it through netlist or xml file that 17 | Eeschema can generate from it's internal bom tool. That extra data can be added 18 | as additional columns in the BOM table (for example manufacturer id) or it can be 19 | used to indicate which components should be omitted altogether (dnp field). For 20 | full description of functionality see [wiki](https://github.com/openscopeproject/InteractiveHtmlBom/wiki). 21 | 22 | Generated html page is fully self contained, doesn't need internet connection to work 23 | and can be packaged with documentation of your project or hosted anywhere on the web. 24 | 25 | [Demo is worth a thousand words.](https://openscopeproject.org/InteractiveHtmlBomDemo/) 26 | 27 | ## Installation and Usage 28 | 29 | See [project wiki](https://github.com/openscopeproject/InteractiveHtmlBom/wiki) for instructions. 30 | 31 | ## License and credits 32 | 33 | Plugin code is licensed under MIT license, see `LICENSE` for more info. 34 | 35 | Html page uses [Split.js](https://github.com/nathancahill/Split.js), 36 | [PEP.js](https://github.com/jquery/PEP) and (stripped down) 37 | [lz-string.js](https://github.com/pieroxy/lz-string) libraries that get embedded into 38 | generated bom page. 39 | 40 | `units.py` is borrowed from [KiBom](https://github.com/SchrodingersGat/KiBoM) 41 | plugin (MIT license). 42 | 43 | `svgpath.py` is heavily based on 44 | [svgpathtools](https://github.com/mathandy/svgpathtools) module (MIT license). 45 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .InteractiveHtmlBom import plugin 2 | -------------------------------------------------------------------------------- /icons/baseline-settings-20px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /icons/bom-grouped-32px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /icons/bom-left-right-32px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | F 9 | B 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /icons/bom-netlist-32px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /icons/bom-only-32px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /icons/bom-top-bot-32px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | F 9 | B 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /icons/bom-ungrouped-32px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /icons/btn-arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 44 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 64 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /icons/btn-arrow-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 44 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 64 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /icons/btn-minus.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 44 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 64 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /icons/btn-plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 44 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 64 | 71 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /icons/btn-question.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 44 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 64 | ? 74 | ? 86 | 87 | 88 | -------------------------------------------------------------------------------- /icons/copy-48px.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/io-36px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /icons/plugin.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 47 | 55 | 59 | 63 | 67 | 71 | 72 | 74 | 75 | 77 | image/svg+xml 78 | 80 | 81 | 82 | 83 | 84 | 89 | 97 | 103 | 109 | 115 | 121 | F 133 | B 145 | 150 | 155 | 160 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /icons/plugin_icon_big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peng-zhihui/InteractiveHtmlBom/b07e364699346a7d9edaac25c2eaeab34eb22ba7/icons/plugin_icon_big.png -------------------------------------------------------------------------------- /icons/stats-36px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | --------------------------------------------------------------------------------