├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .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 │ ├── fusion_eagle.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 ├── pyproject.toml ├── settings_dialog.fbp └── tests └── test_module.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - master 9 | workflow_dispatch: 10 | 11 | jobs: 12 | Build: 13 | name: KiCAD ${{ matrix.kicad-version }} 14 | 15 | strategy: 16 | matrix: 17 | os: 18 | - Ubuntu 19 | kicad-version: 20 | - "8.0" 21 | - "9.0" 22 | 23 | runs-on: ${{ matrix.os }}-latest 24 | container: 25 | image: kicad/kicad:${{ matrix.kicad-version }} 26 | options: --user root 27 | 28 | steps: 29 | - name: Check out repository 30 | uses: actions/checkout@v4 31 | 32 | - name: Set up build environment 33 | shell: bash 34 | run: | 35 | apt update && apt install -y python3-pip 36 | python3 -m pip install --break-system-packages --upgrade hatch 37 | 38 | - name: Test 39 | run: hatch -v run pytest -vv tests 40 | 41 | concurrency: 42 | group: ${{ github.workflow }}-${{ github.ref }} 43 | cancel-in-progress: false 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish a release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - v** 8 | 9 | jobs: 10 | pypi-deploy: 11 | runs-on: ubuntu-latest 12 | 13 | environment: release 14 | permissions: 15 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.11' 23 | cache: 'pip' 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install hatch 28 | - name: Build package 29 | run: hatch build 30 | - name: Publish package distributions to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | -------------------------------------------------------------------------------- /.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 | dist/ 12 | -------------------------------------------------------------------------------- /.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 | // Optional drill diameter (un-tented vias only) 65 | "drillsize": x 66 | }, 67 | ... 68 | ], 69 | "B": [...] 70 | }, 71 | // Optional zone data (should be present if tracks are present). 72 | "zones": { 73 | "F": [ 74 | { 75 | // SVG path of the polygon given as 'd' attribute of svg spec. 76 | // If "svgpath" is present "polygons" is ignored. 77 | "svgpath": svgpath, 78 | // optional fillrule flag, defaults to nonzero 79 | "fillrule": "nonzero" | "evenodd", 80 | "polygons": [ 81 | // Set of polylines same as in polygon drawing. 82 | [[point1x, point1y], [point2x, point2y], ...], 83 | ], 84 | // Optional net name. 85 | "net": netname, 86 | }, 87 | ... 88 | ], 89 | "B": [...] 90 | }, 91 | // Optional net name list. 92 | "nets": [net1, net2, ...], 93 | // PCB metadata from the title block. 94 | "metadata": { 95 | "title": "title", 96 | "revision": "rev", 97 | "company": "Horns and Hoofs", 98 | "date": "2019-04-18", 99 | }, 100 | // Contains full bom table as well as filtered by front/back. 101 | // See bom row description below. 102 | "bom": { 103 | "both": [bomrow1, bomrow2, ...], 104 | "F": [bomrow1, bomrow2, ...], 105 | "B": [bomrow1, bomrow2, ...], 106 | // numeric IDs of DNP components that are not in BOM 107 | "skipped": [id1, id2, ...] 108 | // Fields map is keyed on component ID with values being field data. 109 | // It's order corresponds to order of fields data in config struct. 110 | "fields" { 111 | id1: [field1, field2, ...], 112 | id2: [field1, field2, ...], 113 | ... 114 | } 115 | }, 116 | // Contains parsed stroke data from newstroke font for 117 | // characters used on the pcb. 118 | "font_data": { 119 | "a": { 120 | "w": character_width, 121 | // Contains array of polylines that form the character shape. 122 | "l": [ 123 | [[point1x, point1y], [point2x, point2y],...], 124 | ... 125 | ] 126 | }, 127 | "%": { 128 | ... 129 | }, 130 | ... 131 | }, 132 | } 133 | ``` 134 | 135 | # drawing struct 136 | 137 | All drawings are either graphical items (arcs, lines, circles, curves) 138 | or text. 139 | 140 | Rendering method and properties are determined based on `type` 141 | attribute. 142 | 143 | 144 | ## graphical items 145 | 146 | ### segment 147 | 148 | ```js 149 | { 150 | "type": "segment", 151 | "start": [x, y], 152 | "end": [x, y], 153 | "width": width, 154 | } 155 | ``` 156 | 157 | ### rect 158 | 159 | ```js 160 | { 161 | "type": "rect", 162 | "start": [x, y], // coordinates of opposing corners 163 | "end": [x, y], 164 | "width": width, 165 | } 166 | ``` 167 | 168 | ### circle 169 | 170 | ```js 171 | { 172 | "type": "circle", 173 | "start": [x, y], 174 | "radius": radius, 175 | // Optional boolean, defaults to 0 176 | "filled": 0, 177 | // Line width (only has effect for non-filled shapes) 178 | "width": width, 179 | } 180 | ``` 181 | 182 | ### arc 183 | 184 | ```js 185 | { 186 | "type": "arc", 187 | "width": width, 188 | // SVG path of the arc given as 'd' attribute of svg spec. 189 | // If this parameter is specified everything below it is ignored. 190 | "svgpath": svgpath, 191 | "start": [x, y], // arc center 192 | "radius": radius, 193 | "startangle": angle1, 194 | "endangle": angle2, 195 | } 196 | ``` 197 | 198 | ### curve 199 | 200 | ```js 201 | { 202 | "type": "curve", // Bezier curve 203 | "start": [x, y], 204 | "end": [x, y], 205 | "cpa": [x, y], // control point A 206 | "cpb": [x, y], // control point B 207 | "width": width, 208 | } 209 | ``` 210 | 211 | ### polygon 212 | 213 | ```js 214 | { 215 | "type": "polygon", 216 | // Optional, defaults to 1 217 | "filled": 1, 218 | // Line width (only has effect for non-filled shapes) 219 | "width": width 220 | // SVG path of the polygon given as 'd' attribute of svg spec. 221 | // If this parameter is specified everything below it is ignored. 222 | "svgpath": svgpath, 223 | "pos": [x, y], 224 | "angle": angle, 225 | "polygons": [ 226 | // Polygons are described as set of outlines. 227 | [ 228 | [point1x, point1y], [point2x, point2y], ... 229 | ], 230 | ... 231 | ] 232 | } 233 | ``` 234 | 235 | ## text 236 | 237 | ```js 238 | { 239 | "pos": [x, y], 240 | "text": text, 241 | // SVG path of the text given as 'd' attribute of svg spec. 242 | // If this parameter is specified then height, width, angle, 243 | // text attributes and justification is ignored. Rendering engine 244 | // will not attempt to read character data from newstroke font and 245 | // will draw the path as is. "thickness" will be used as stroke width. 246 | "svgpath": svgpath, 247 | // If polygons are specified then remaining attributes are ignored 248 | "polygons": [ 249 | // Polygons are described as set of outlines. 250 | [ 251 | [point1x, point1y], [point2x, point2y], ... 252 | ], 253 | ... 254 | ], 255 | "height": height, 256 | "width": width, 257 | // -1: justify left/top 258 | // 0: justify center 259 | // 1: justify right/bot 260 | "justify": [horizontal, vertical], 261 | "thickness": thickness, 262 | "attr": [ 263 | // may include none, one or both 264 | "italic", "mirrored" 265 | ], 266 | "angle": angle, 267 | // Present only if text is reference designator 268 | "ref": 1, 269 | // Present only if text is component value 270 | "val": 1, 271 | } 272 | ``` 273 | 274 | # footprint struct 275 | 276 | Footprints are a collection of pads, drawings and some metadata. 277 | 278 | ```js 279 | { 280 | "ref": reference, 281 | "center": [x, y], 282 | "bbox": { 283 | // Position of the rotation center of the bounding box. 284 | "pos": [x, y], 285 | // Rotation angle in degrees. 286 | "angle": angle, 287 | // Left top corner position relative to center (after rotation) 288 | "relpos": [x, y], 289 | "size": [x, y], 290 | }, 291 | "pads": [ 292 | { 293 | "layers": [ 294 | // Contains one or both 295 | "F", "B", 296 | ], 297 | "pos": [x, y], 298 | "size": [x, y], 299 | "angle": angle, 300 | // Only present if pad is considered first pin. 301 | // Pins are considered first if it's name is one of 302 | // 1, A, A1, P1, PAD1 303 | // OR footprint has no pads named as one of above and 304 | // current pad's name is lexicographically smallest. 305 | "pin1": 1, 306 | // Shape is one of "rect", "oval", "circle", "roundrect", "chamfrect", custom". 307 | "shape": shape, 308 | // Only present if shape is "custom". 309 | // SVG path of the polygon given as 'd' attribute of svg spec. 310 | // If "svgpath" is present "polygons", "pos", "angle" are ignored. 311 | "svgpath": svgpath, 312 | "polygons": [ 313 | // Set of polylines same as in polygon drawing. 314 | [[point1x, point1y], [point2x, point2y], ...], 315 | ... 316 | ], 317 | // Only present if shape is "roundrect" or "chamfrect". 318 | "radius": radius, 319 | // Only present if shape is "chamfrect". 320 | // chamfpos is a bitmask, left = 1, right = 2, bottom left = 4, bottom right = 8 321 | "chamfpos": chamfpos, 322 | "chamfratio": ratio, 323 | // Pad type is "th" for standard and NPTH pads 324 | // "smd" otherwise. 325 | "type": type, 326 | // Present only if type is "th". 327 | // One of "circle", "oblong" or "rect". 328 | "drillshape": drillshape, 329 | // Present only if type is "th". In case of circle shape x is diameter, y is ignored. 330 | "drillsize": [x, y], 331 | // Optional attribute. 332 | "offset": [x, y], 333 | // Optional net name 334 | "net": netname, 335 | }, 336 | ... 337 | ], 338 | "drawings": [ 339 | // Contains only copper F_Cu, B_Cu drawings of the footprint. 340 | { 341 | // One of "F", "B". 342 | "layer": layer, 343 | // See drawing struct description above. 344 | "drawing": drawing, 345 | }, 346 | ... 347 | ], 348 | // One of "F", "B". 349 | "layer": layer, 350 | } 351 | ``` 352 | 353 | # bom row struct 354 | 355 | Bom row is a list of reference sets 356 | 357 | Reference set is array of tuples of (ref, id) where id is just 358 | a unique numeric identifier for each footprint that helps avoid 359 | collisions when references are duplicated. 360 | 361 | ```js 362 | [ 363 | [reference_name, footprint_id], 364 | ... 365 | ] 366 | ``` 367 | 368 | # config struct 369 | 370 | ```js 371 | config = { 372 | "dark_mode": bool, 373 | "show_pads": bool, 374 | "show_fabrication": bool, 375 | "show_silkscreen": bool, 376 | "highlight_pin1": "none" | "all" | "selected", 377 | "redraw_on_drag": bool, 378 | "board_rotation": int, 379 | "checkboxes": "checkbox1,checkbox2,...", 380 | "bom_view": "bom-only" | "left-right" | "top-bottom", 381 | "layer_view": "F" | "FB" | "B", 382 | "extra_fields": ["field1_name", "field2_name", ...], 383 | } 384 | ``` 385 | -------------------------------------------------------------------------------- /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) and 47 | not os.path.basename(sys.argv[0]).startswith('generate_interactive_bom')): 48 | from .ecad.kicad import InteractiveHtmlBomPlugin 49 | 50 | plugin = InteractiveHtmlBomPlugin() 51 | plugin.register() 52 | 53 | # Add a button the hacky way if plugin button is not supported 54 | # in pcbnew, unless this is linux. 55 | if not plugin.pcbnew_icon_support and not sys.platform.startswith('linux'): 56 | t = threading.Thread(target=check_for_bom_button) 57 | t.daemon = True 58 | t.start() 59 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscopeproject/InteractiveHtmlBom/f42ee38fc93ecb7d619eb63db897cb2477d98423/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 | empty_str = '' 77 | if config.board_variant_field and config.board_variant_whitelist: 78 | ref_variant = m.extra_fields.get(config.board_variant_field, '') 79 | if ref_variant == '': 80 | ref_variant = empty_str 81 | if ref_variant not in config.board_variant_whitelist: 82 | return True 83 | 84 | if config.board_variant_field and config.board_variant_blacklist: 85 | ref_variant = m.extra_fields.get(config.board_variant_field, '') 86 | if ref_variant == '': 87 | ref_variant = empty_str 88 | if ref_variant != empty_str and ref_variant in config.board_variant_blacklist: 89 | return True 90 | 91 | return False 92 | 93 | 94 | def generate_bom(pcb_footprints, config): 95 | # type: (list, Config) -> dict 96 | """ 97 | Generate BOM from pcb layout. 98 | :param pcb_footprints: list of footprints on the pcb 99 | :param config: Config object 100 | :return: dict of BOM tables (qty, value, footprint, refs) 101 | and dnp components 102 | """ 103 | 104 | def convert(text): 105 | return int(text) if text.isdigit() else text.lower() 106 | 107 | def alphanum_key(key): 108 | return [convert(c) 109 | for c in re.split('([0-9]+)', key)] 110 | 111 | def natural_sort(lst): 112 | """ 113 | Natural sort for strings containing numbers 114 | """ 115 | 116 | return sorted(lst, key=lambda r: (alphanum_key(r[0]), r[1])) 117 | 118 | # build grouped part list 119 | skipped_components = [] 120 | part_groups = {} 121 | group_by = set(config.group_fields) 122 | index_to_fields = {} 123 | 124 | for i, f in enumerate(pcb_footprints): 125 | if skip_component(f, config): 126 | skipped_components.append(i) 127 | continue 128 | 129 | # group part refs by value and footprint 130 | fields = [] 131 | group_key = [] 132 | 133 | for field in config.show_fields: 134 | if field == "Value": 135 | fields.append(f.val) 136 | if "Value" in group_by: 137 | norm_value, unit = units.componentValue(f.val, f.ref) 138 | group_key.append(norm_value) 139 | group_key.append(unit) 140 | elif field == "Footprint": 141 | fields.append(f.footprint) 142 | if "Footprint" in group_by: 143 | group_key.append(f.footprint) 144 | group_key.append(f.attr) 145 | else: 146 | field_key = field 147 | if config.normalize_field_case: 148 | field_key = field.lower() 149 | fields.append(f.extra_fields.get(field_key, '')) 150 | if field in group_by: 151 | group_key.append(f.extra_fields.get(field_key, '')) 152 | 153 | index_to_fields[i] = fields 154 | refs = part_groups.setdefault(tuple(group_key), []) 155 | refs.append((f.ref, i)) 156 | 157 | bom_table = [] 158 | 159 | # If some extra fields are just integers then convert the whole column 160 | # so that sorting will work naturally 161 | for i, field in enumerate(config.show_fields): 162 | if field in ["Value", "Footprint"]: 163 | continue 164 | all_num = True 165 | for f in index_to_fields.values(): 166 | if not f[i].isdigit() and len(f[i].strip()) > 0: 167 | all_num = False 168 | break 169 | if all_num: 170 | for f in index_to_fields.values(): 171 | if f[i].isdigit(): 172 | f[i] = int(f[i]) 173 | 174 | for _, refs in part_groups.items(): 175 | # Fixup values to normalized string 176 | if "Value" in group_by and "Value" in config.show_fields: 177 | index = config.show_fields.index("Value") 178 | value = index_to_fields[refs[0][1]][index] 179 | for ref in refs: 180 | index_to_fields[ref[1]][index] = value 181 | 182 | bom_table.append(natural_sort(refs)) 183 | 184 | # sort table by reference prefix and quantity 185 | def row_sort_key(element): 186 | prefix = re.findall('^[^0-9]*', element[0][0])[0] 187 | if prefix in config.component_sort_order: 188 | ref_ord = config.component_sort_order.index(prefix) 189 | else: 190 | ref_ord = config.component_sort_order.index('~') 191 | return ref_ord, -len(element), alphanum_key(element[0][0]) 192 | 193 | if '~' not in config.component_sort_order: 194 | config.component_sort_order.append('~') 195 | 196 | bom_table = sorted(bom_table, key=row_sort_key) 197 | 198 | result = { 199 | 'both': bom_table, 200 | 'skipped': skipped_components, 201 | 'fields': index_to_fields 202 | } 203 | 204 | for layer in ['F', 'B']: 205 | filtered_table = [] 206 | for row in bom_table: 207 | filtered_refs = [ref for ref in row 208 | if pcb_footprints[ref[1]].layer == layer] 209 | if filtered_refs: 210 | filtered_table.append(filtered_refs) 211 | 212 | result[layer] = sorted(filtered_table, key=row_sort_key) 213 | 214 | return result 215 | 216 | 217 | def open_file(filename): 218 | import subprocess 219 | try: 220 | if sys.platform.startswith('win'): 221 | os.startfile(filename) 222 | elif sys.platform.startswith('darwin'): 223 | subprocess.call(('open', filename)) 224 | elif sys.platform.startswith('linux'): 225 | subprocess.call(('xdg-open', filename)) 226 | except Exception as e: 227 | log.warn('Failed to open browser: {}'.format(e)) 228 | 229 | 230 | def process_substitutions(bom_name_format, pcb_file_name, metadata): 231 | # type: (str, str, dict)->str 232 | name = bom_name_format.replace('%f', os.path.splitext(pcb_file_name)[0]) 233 | name = name.replace('%p', metadata['title']) 234 | name = name.replace('%c', metadata['company']) 235 | name = name.replace('%r', metadata['revision']) 236 | name = name.replace('%d', metadata['date'].replace(':', '-')) 237 | now = datetime.now() 238 | name = name.replace('%D', now.strftime('%Y-%m-%d')) 239 | name = name.replace('%T', now.strftime('%H-%M-%S')) 240 | # sanitize the name to avoid characters illegal in file systems 241 | name = name.replace('\\', '/') 242 | name = re.sub(r'[?%*:|"<>]', '_', name) 243 | return name + '.html' 244 | 245 | 246 | def round_floats(o, precision): 247 | if isinstance(o, float): 248 | return round(o, precision) 249 | if isinstance(o, dict): 250 | return {k: round_floats(v, precision) for k, v in o.items()} 251 | if isinstance(o, (list, tuple)): 252 | return [round_floats(x, precision) for x in o] 253 | return o 254 | 255 | 256 | def get_pcbdata_javascript(pcbdata, compression): 257 | from .lzstring import LZString 258 | 259 | js = "var pcbdata = {}" 260 | pcbdata_str = json.dumps(round_floats(pcbdata, 6)) 261 | 262 | if compression: 263 | log.info("Compressing pcb data") 264 | pcbdata_str = json.dumps(LZString().compress_to_base64(pcbdata_str)) 265 | js = "var pcbdata = JSON.parse(LZString.decompressFromBase64({}))" 266 | 267 | return js.format(pcbdata_str) 268 | 269 | 270 | def generate_file(pcb_file_dir, pcb_file_name, pcbdata, config): 271 | def get_file_content(file_name): 272 | path = os.path.join(os.path.dirname(__file__), "..", "web", file_name) 273 | if not os.path.exists(path): 274 | return "" 275 | with io.open(path, 'r', encoding='utf-8') as f: 276 | return f.read() 277 | 278 | if os.path.isabs(config.bom_dest_dir): 279 | bom_file_dir = config.bom_dest_dir 280 | else: 281 | bom_file_dir = os.path.join(pcb_file_dir, config.bom_dest_dir) 282 | bom_file_name = process_substitutions( 283 | config.bom_name_format, pcb_file_name, pcbdata['metadata']) 284 | bom_file_name = os.path.join(bom_file_dir, bom_file_name) 285 | bom_file_dir = os.path.dirname(bom_file_name) 286 | if not os.path.isdir(bom_file_dir): 287 | os.makedirs(bom_file_dir) 288 | pcbdata_js = get_pcbdata_javascript(pcbdata, config.compression) 289 | log.info("Dumping pcb data") 290 | config_js = "var config = " + config.get_html_config() 291 | html = get_file_content("ibom.html") 292 | html = html.replace('///CSS///', get_file_content('ibom.css')) 293 | html = html.replace('///USERCSS///', get_file_content('user.css')) 294 | html = html.replace('///SPLITJS///', get_file_content('split.js')) 295 | html = html.replace('///LZ-STRING///', 296 | get_file_content('lz-string.js') 297 | if config.compression else '') 298 | html = html.replace('///POINTER_EVENTS_POLYFILL///', 299 | get_file_content('pep.js')) 300 | html = html.replace('///CONFIG///', config_js) 301 | html = html.replace('///UTILJS///', get_file_content('util.js')) 302 | html = html.replace('///RENDERJS///', get_file_content('render.js')) 303 | html = html.replace('///TABLEUTILJS///', get_file_content('table-util.js')) 304 | html = html.replace('///IBOMJS///', get_file_content('ibom.js')) 305 | html = html.replace('///USERJS///', get_file_content('user.js')) 306 | html = html.replace('///USERHEADER///', 307 | get_file_content('userheader.html')) 308 | html = html.replace('///USERFOOTER///', 309 | get_file_content('userfooter.html')) 310 | # Replace pcbdata last for better performance. 311 | html = html.replace('///PCBDATA///', pcbdata_js) 312 | 313 | with io.open(bom_file_name, 'wt', encoding='utf-8') as bom: 314 | bom.write(html) 315 | 316 | log.info("Created file %s", bom_file_name) 317 | return bom_file_name 318 | 319 | 320 | def main(parser, config, logger): 321 | # type: (EcadParser, Config, Logger) -> None 322 | global log 323 | log = logger 324 | pcb_file_name = os.path.basename(parser.file_name) 325 | pcb_file_dir = os.path.dirname(parser.file_name) 326 | 327 | pcbdata, components = parser.parse() 328 | if not pcbdata and not components: 329 | raise ParsingException('Parsing failed.') 330 | 331 | pcbdata["bom"] = generate_bom(components, config) 332 | pcbdata["ibom_version"] = config.version 333 | 334 | # build BOM 335 | bom_file = generate_file(pcb_file_dir, pcb_file_name, pcbdata, config) 336 | 337 | if config.open_browser: 338 | logger.info("Opening file in browser") 339 | open_file(bom_file) 340 | 341 | 342 | def run_with_dialog(parser, config, logger): 343 | # type: (EcadParser, Config, Logger) -> None 344 | def save_config(dialog_panel, locally=False): 345 | config.set_from_dialog(dialog_panel) 346 | config.save(locally) 347 | 348 | config.load_from_ini() 349 | dlg = SettingsDialog(extra_data_func=parser.parse_extra_data, 350 | extra_data_wildcard=parser.extra_data_file_filter(), 351 | config_save_func=save_config, 352 | file_name_format_hint=config.FILE_NAME_FORMAT_HINT, 353 | version=config.version) 354 | try: 355 | config.netlist_initial_directory = os.path.dirname(parser.file_name) 356 | extra_data_file = parser.latest_extra_data( 357 | extra_dirs=[config.bom_dest_dir]) 358 | if extra_data_file is not None: 359 | dlg.set_extra_data_path(extra_data_file) 360 | config.transfer_to_dialog(dlg.panel) 361 | if dlg.ShowModal() == wx.ID_OK: 362 | config.set_from_dialog(dlg.panel) 363 | main(parser, config, logger) 364 | finally: 365 | dlg.Destroy() 366 | -------------------------------------------------------------------------------- /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/openscopeproject/InteractiveHtmlBom/f42ee38fc93ecb7d619eb63db897cb2477d98423/InteractiveHtmlBom/dialog/bitmaps/btn-arrow-down.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/bitmaps/btn-arrow-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscopeproject/InteractiveHtmlBom/f42ee38fc93ecb7d619eb63db897cb2477d98423/InteractiveHtmlBom/dialog/bitmaps/btn-arrow-up.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/bitmaps/btn-minus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscopeproject/InteractiveHtmlBom/f42ee38fc93ecb7d619eb63db897cb2477d98423/InteractiveHtmlBom/dialog/bitmaps/btn-minus.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/bitmaps/btn-plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscopeproject/InteractiveHtmlBom/f42ee38fc93ecb7d619eb63db897cb2477d98423/InteractiveHtmlBom/dialog/bitmaps/btn-plus.png -------------------------------------------------------------------------------- /InteractiveHtmlBom/dialog/bitmaps/btn-question.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openscopeproject/InteractiveHtmlBom/f42ee38fc93ecb7d619eb63db897cb2477d98423/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 | if hasattr(wx, "GetLibraryVersionInfo"): 10 | WX_VERSION = wx.GetLibraryVersionInfo() # type: wx.VersionInfo 11 | WX_VERSION = (WX_VERSION.Major, WX_VERSION.Minor, WX_VERSION.Micro) 12 | else: 13 | # old kicad used this (exact version doesnt matter) 14 | WX_VERSION = (3, 0, 2) 15 | 16 | 17 | def pop_error(msg): 18 | wx.MessageBox(msg, 'Error', wx.OK | wx.ICON_ERROR) 19 | 20 | 21 | def get_btn_bitmap(bitmap): 22 | path = os.path.join(os.path.dirname(__file__), "bitmaps", bitmap) 23 | png = wx.Bitmap(path, wx.BITMAP_TYPE_PNG) 24 | 25 | if WX_VERSION >= (3, 1, 6): 26 | return wx.BitmapBundle(png) 27 | else: 28 | return png 29 | 30 | 31 | class SettingsDialog(dialog_base.SettingsDialogBase): 32 | def __init__(self, extra_data_func, extra_data_wildcard, config_save_func, 33 | file_name_format_hint, version): 34 | dialog_base.SettingsDialogBase.__init__(self, None) 35 | self.panel = SettingsDialogPanel( 36 | self, extra_data_func, extra_data_wildcard, config_save_func, 37 | file_name_format_hint) 38 | best_size = self.panel.BestSize 39 | # hack for some gtk themes that incorrectly calculate best size 40 | best_size.IncBy(dx=0, dy=30) 41 | self.SetClientSize(best_size) 42 | self.SetTitle('InteractiveHtmlBom %s' % version) 43 | 44 | # hack for new wxFormBuilder generating code incompatible with old wxPython 45 | # noinspection PyMethodOverriding 46 | def SetSizeHints(self, sz1, sz2): 47 | try: 48 | # wxPython 4 49 | super(SettingsDialog, self).SetSizeHints(sz1, sz2) 50 | except TypeError: 51 | # wxPython 3 52 | self.SetSizeHintsSz(sz1, sz2) 53 | 54 | def set_extra_data_path(self, extra_data_file): 55 | self.panel.fields.extraDataFilePicker.Path = extra_data_file 56 | self.panel.fields.OnExtraDataFileChanged(None) 57 | 58 | 59 | # Implementing settings_dialog 60 | class SettingsDialogPanel(dialog_base.SettingsDialogPanel): 61 | def __init__(self, parent, extra_data_func, extra_data_wildcard, 62 | config_save_func, file_name_format_hint): 63 | self.config_save_func = config_save_func 64 | dialog_base.SettingsDialogPanel.__init__(self, parent) 65 | self.general = GeneralSettingsPanel(self.notebook, 66 | file_name_format_hint) 67 | self.html = HtmlSettingsPanel(self.notebook) 68 | self.fields = FieldsPanel(self.notebook, extra_data_func, 69 | extra_data_wildcard) 70 | self.notebook.AddPage(self.general, "General") 71 | self.notebook.AddPage(self.html, "Html defaults") 72 | self.notebook.AddPage(self.fields, "Fields") 73 | 74 | self.save_menu = wx.Menu() 75 | self.save_locally = self.save_menu.Append( 76 | wx.ID_ANY, u"Locally", wx.EmptyString, wx.ITEM_NORMAL) 77 | self.save_globally = self.save_menu.Append( 78 | wx.ID_ANY, u"Globally", wx.EmptyString, wx.ITEM_NORMAL) 79 | 80 | self.Bind( 81 | wx.EVT_MENU, self.OnSaveLocally, id=self.save_locally.GetId()) 82 | self.Bind( 83 | wx.EVT_MENU, self.OnSaveGlobally, id=self.save_globally.GetId()) 84 | 85 | def OnExit(self, event): 86 | self.GetParent().EndModal(wx.ID_CANCEL) 87 | 88 | def OnGenerateBom(self, event): 89 | self.GetParent().EndModal(wx.ID_OK) 90 | 91 | def finish_init(self): 92 | self.html.OnBoardRotationSlider(None) 93 | 94 | def OnSave(self, event): 95 | # type: (wx.CommandEvent) -> None 96 | pos = wx.Point(0, event.GetEventObject().GetSize().y) 97 | self.saveSettingsBtn.PopupMenu(self.save_menu, pos) 98 | 99 | def OnSaveGlobally(self, event): 100 | self.config_save_func(self) 101 | 102 | def OnSaveLocally(self, event): 103 | self.config_save_func(self, locally=True) 104 | 105 | 106 | # Implementing HtmlSettingsPanelBase 107 | class HtmlSettingsPanel(dialog_base.HtmlSettingsPanelBase): 108 | def __init__(self, parent): 109 | dialog_base.HtmlSettingsPanelBase.__init__(self, parent) 110 | 111 | # Handlers for HtmlSettingsPanelBase events. 112 | def OnBoardRotationSlider(self, event): 113 | degrees = self.boardRotationSlider.Value * 5 114 | self.rotationDegreeLabel.LabelText = u"{}\u00B0".format(degrees) 115 | 116 | 117 | # Implementing GeneralSettingsPanelBase 118 | class GeneralSettingsPanel(dialog_base.GeneralSettingsPanelBase): 119 | 120 | def __init__(self, parent, file_name_format_hint): 121 | dialog_base.GeneralSettingsPanelBase.__init__(self, parent) 122 | 123 | self.file_name_format_hint = file_name_format_hint 124 | 125 | bmp_arrow_up = get_btn_bitmap("btn-arrow-up.png") 126 | bmp_arrow_down = get_btn_bitmap("btn-arrow-down.png") 127 | bmp_plus = get_btn_bitmap("btn-plus.png") 128 | bmp_minus = get_btn_bitmap("btn-minus.png") 129 | bmp_question = get_btn_bitmap("btn-question.png") 130 | 131 | self.m_btnSortUp.SetBitmap(bmp_arrow_up) 132 | self.m_btnSortDown.SetBitmap(bmp_arrow_down) 133 | self.m_btnSortAdd.SetBitmap(bmp_plus) 134 | self.m_btnSortRemove.SetBitmap(bmp_minus) 135 | self.m_btnNameHint.SetBitmap(bmp_question) 136 | self.m_btnBlacklistAdd.SetBitmap(bmp_plus) 137 | self.m_btnBlacklistRemove.SetBitmap(bmp_minus) 138 | 139 | self.Layout() 140 | 141 | # Handlers for GeneralSettingsPanelBase events. 142 | def OnComponentSortOrderUp(self, event): 143 | selection = self.componentSortOrderBox.Selection 144 | if selection != wx.NOT_FOUND and selection > 0: 145 | item = self.componentSortOrderBox.GetString(selection) 146 | self.componentSortOrderBox.Delete(selection) 147 | self.componentSortOrderBox.Insert(item, selection - 1) 148 | self.componentSortOrderBox.SetSelection(selection - 1) 149 | 150 | def OnComponentSortOrderDown(self, event): 151 | selection = self.componentSortOrderBox.Selection 152 | size = self.componentSortOrderBox.Count 153 | if selection != wx.NOT_FOUND and selection < size - 1: 154 | item = self.componentSortOrderBox.GetString(selection) 155 | self.componentSortOrderBox.Delete(selection) 156 | self.componentSortOrderBox.Insert(item, selection + 1) 157 | self.componentSortOrderBox.SetSelection(selection + 1) 158 | 159 | def OnComponentSortOrderAdd(self, event): 160 | item = wx.GetTextFromUser( 161 | "Characters other than A-Z will be ignored.", 162 | "Add sort order item") 163 | item = re.sub('[^A-Z]', '', item.upper()) 164 | if item == '': 165 | return 166 | found = self.componentSortOrderBox.FindString(item) 167 | if found != wx.NOT_FOUND: 168 | self.componentSortOrderBox.SetSelection(found) 169 | return 170 | self.componentSortOrderBox.Append(item) 171 | self.componentSortOrderBox.SetSelection( 172 | self.componentSortOrderBox.Count - 1) 173 | 174 | def OnComponentSortOrderRemove(self, event): 175 | selection = self.componentSortOrderBox.Selection 176 | if selection != wx.NOT_FOUND: 177 | item = self.componentSortOrderBox.GetString(selection) 178 | if item == '~': 179 | pop_error("You can not delete '~' item") 180 | return 181 | self.componentSortOrderBox.Delete(selection) 182 | if self.componentSortOrderBox.Count > 0: 183 | self.componentSortOrderBox.SetSelection(max(selection - 1, 0)) 184 | 185 | def OnComponentBlacklistAdd(self, event): 186 | item = wx.GetTextFromUser( 187 | "Characters other than A-Z 0-9 and * will be ignored.", 188 | "Add blacklist item") 189 | item = re.sub('[^A-Z0-9*]', '', item.upper()) 190 | if item == '': 191 | return 192 | found = self.blacklistBox.FindString(item) 193 | if found != wx.NOT_FOUND: 194 | self.blacklistBox.SetSelection(found) 195 | return 196 | self.blacklistBox.Append(item) 197 | self.blacklistBox.SetSelection(self.blacklistBox.Count - 1) 198 | 199 | def OnComponentBlacklistRemove(self, event): 200 | selection = self.blacklistBox.Selection 201 | if selection != wx.NOT_FOUND: 202 | self.blacklistBox.Delete(selection) 203 | if self.blacklistBox.Count > 0: 204 | self.blacklistBox.SetSelection(max(selection - 1, 0)) 205 | 206 | def OnNameFormatHintClick(self, event): 207 | wx.MessageBox(self.file_name_format_hint, 'File name format help', 208 | style=wx.ICON_NONE | wx.OK) 209 | 210 | def OnSize(self, event): 211 | # Trick the listCheckBox best size calculations 212 | tmp = self.componentSortOrderBox.GetStrings() 213 | self.componentSortOrderBox.SetItems([]) 214 | self.Layout() 215 | self.componentSortOrderBox.SetItems(tmp) 216 | 217 | 218 | # Implementing FieldsPanelBase 219 | class FieldsPanel(dialog_base.FieldsPanelBase): 220 | NONE_STRING = '' 221 | EMPTY_STRING = '' 222 | FIELDS_GRID_COLUMNS = 3 223 | 224 | def __init__(self, parent, extra_data_func, extra_data_wildcard): 225 | dialog_base.FieldsPanelBase.__init__(self, parent) 226 | self.show_fields = [] 227 | self.group_fields = [] 228 | 229 | self.extra_data_func = extra_data_func 230 | self.extra_field_data = None 231 | 232 | self.m_btnUp.SetBitmap(get_btn_bitmap("btn-arrow-up.png")) 233 | self.m_btnDown.SetBitmap(get_btn_bitmap("btn-arrow-down.png")) 234 | 235 | self.set_file_picker_wildcard(extra_data_wildcard) 236 | self._setFieldsList([]) 237 | for i in range(2): 238 | box = self.GetTextExtent(self.fieldsGrid.GetColLabelValue(i)) 239 | if hasattr(box, "x"): 240 | width = box.x 241 | else: 242 | width = box[0] 243 | width = int(width * 1.1 + 5) 244 | self.fieldsGrid.SetColMinimalWidth(i, width) 245 | self.fieldsGrid.SetColSize(i, width) 246 | 247 | self.Layout() 248 | 249 | def set_file_picker_wildcard(self, extra_data_wildcard): 250 | if extra_data_wildcard is None: 251 | self.extraDataFilePicker.Disable() 252 | return 253 | 254 | # wxFilePickerCtrl doesn't support changing wildcard at runtime 255 | # so we have to replace it 256 | picker_parent = self.extraDataFilePicker.GetParent() 257 | new_picker = wx.FilePickerCtrl( 258 | picker_parent, wx.ID_ANY, wx.EmptyString, 259 | u"Select a file", 260 | extra_data_wildcard, 261 | wx.DefaultPosition, wx.DefaultSize, 262 | (wx.FLP_DEFAULT_STYLE | wx.FLP_FILE_MUST_EXIST | wx.FLP_OPEN | 263 | wx.FLP_SMALL | wx.FLP_USE_TEXTCTRL | wx.BORDER_SIMPLE)) 264 | self.GetSizer().Replace(self.extraDataFilePicker, new_picker, 265 | recursive=True) 266 | self.extraDataFilePicker.Destroy() 267 | self.extraDataFilePicker = new_picker 268 | self.extraDataFilePicker.Bind( 269 | wx.EVT_FILEPICKER_CHANGED, self.OnExtraDataFileChanged) 270 | self.Layout() 271 | 272 | def _swapRows(self, a, b): 273 | for i in range(self.FIELDS_GRID_COLUMNS): 274 | va = self.fieldsGrid.GetCellValue(a, i) 275 | vb = self.fieldsGrid.GetCellValue(b, i) 276 | self.fieldsGrid.SetCellValue(a, i, vb) 277 | self.fieldsGrid.SetCellValue(b, i, va) 278 | 279 | # Handlers for FieldsPanelBase events. 280 | def OnGridCellClicked(self, event): 281 | self.fieldsGrid.ClearSelection() 282 | self.fieldsGrid.SelectRow(event.Row) 283 | if event.Col < 2: 284 | # toggle checkbox 285 | val = self.fieldsGrid.GetCellValue(event.Row, event.Col) 286 | val = "" if val else "1" 287 | self.fieldsGrid.SetCellValue(event.Row, event.Col, val) 288 | # group shouldn't be enabled without show 289 | if event.Col == 0 and val == "": 290 | self.fieldsGrid.SetCellValue(event.Row, 1, val) 291 | if event.Col == 1 and val == "1": 292 | self.fieldsGrid.SetCellValue(event.Row, 0, val) 293 | 294 | def OnFieldsUp(self, event): 295 | selection = self.fieldsGrid.SelectedRows 296 | if len(selection) == 1 and selection[0] > 0: 297 | self._swapRows(selection[0], selection[0] - 1) 298 | self.fieldsGrid.ClearSelection() 299 | self.fieldsGrid.SelectRow(selection[0] - 1) 300 | 301 | def OnFieldsDown(self, event): 302 | selection = self.fieldsGrid.SelectedRows 303 | size = self.fieldsGrid.NumberRows 304 | if len(selection) == 1 and selection[0] < size - 1: 305 | self._swapRows(selection[0], selection[0] + 1) 306 | self.fieldsGrid.ClearSelection() 307 | self.fieldsGrid.SelectRow(selection[0] + 1) 308 | 309 | def _setFieldsList(self, fields): 310 | if self.fieldsGrid.NumberRows: 311 | self.fieldsGrid.DeleteRows(0, self.fieldsGrid.NumberRows) 312 | self.fieldsGrid.AppendRows(len(fields)) 313 | row = 0 314 | for f in fields: 315 | self.fieldsGrid.SetCellValue(row, 0, "1") 316 | self.fieldsGrid.SetCellValue(row, 1, "1") 317 | self.fieldsGrid.SetCellRenderer( 318 | row, 0, wx.grid.GridCellBoolRenderer()) 319 | self.fieldsGrid.SetCellRenderer( 320 | row, 1, wx.grid.GridCellBoolRenderer()) 321 | self.fieldsGrid.SetCellValue(row, 2, f) 322 | self.fieldsGrid.SetCellAlignment( 323 | row, 2, wx.ALIGN_LEFT, wx.ALIGN_TOP) 324 | self.fieldsGrid.SetReadOnly(row, 2) 325 | row += 1 326 | 327 | def OnExtraDataFileChanged(self, event): 328 | extra_data_file = self.extraDataFilePicker.Path 329 | if not os.path.isfile(extra_data_file): 330 | return 331 | 332 | self.extra_field_data = None 333 | try: 334 | self.extra_field_data = self.extra_data_func( 335 | extra_data_file, self.normalizeCaseCheckbox.Value) 336 | except Exception as e: 337 | pop_error( 338 | "Failed to parse file %s\n\n%s" % (extra_data_file, e)) 339 | self.extraDataFilePicker.Path = '' 340 | 341 | if self.extra_field_data is not None: 342 | field_list = list(self.extra_field_data.fields) 343 | self._setFieldsList(["Value", "Footprint"] + field_list) 344 | self.SetCheckedFields() 345 | field_list.append(self.NONE_STRING) 346 | self.boardVariantFieldBox.SetItems(field_list) 347 | self.boardVariantFieldBox.SetStringSelection(self.NONE_STRING) 348 | self.boardVariantWhitelist.Clear() 349 | self.boardVariantBlacklist.Clear() 350 | self.dnpFieldBox.SetItems(field_list) 351 | self.dnpFieldBox.SetStringSelection(self.NONE_STRING) 352 | 353 | def OnBoardVariantFieldChange(self, event): 354 | selection = self.boardVariantFieldBox.Value 355 | if not selection or selection == self.NONE_STRING \ 356 | or self.extra_field_data is None: 357 | self.boardVariantWhitelist.Clear() 358 | self.boardVariantBlacklist.Clear() 359 | return 360 | variant_set = set() 361 | for _, field_dict in self.extra_field_data.fields_by_ref.items(): 362 | if selection in field_dict: 363 | v = field_dict[selection] 364 | if v == "": 365 | v = self.EMPTY_STRING 366 | variant_set.add(v) 367 | self.boardVariantWhitelist.SetItems(list(variant_set)) 368 | self.boardVariantBlacklist.SetItems(list(variant_set)) 369 | 370 | def OnSize(self, event): 371 | self.Layout() 372 | g = self.fieldsGrid 373 | g.SetColSize( 374 | 2, g.GetClientSize().x - g.GetColSize(0) - g.GetColSize(1) - 30) 375 | 376 | def GetShowFields(self): 377 | result = [] 378 | for row in range(self.fieldsGrid.NumberRows): 379 | if self.fieldsGrid.GetCellValue(row, 0) == "1": 380 | result.append(self.fieldsGrid.GetCellValue(row, 2)) 381 | return result 382 | 383 | def GetGroupFields(self): 384 | result = [] 385 | for row in range(self.fieldsGrid.NumberRows): 386 | if self.fieldsGrid.GetCellValue(row, 1) == "1": 387 | result.append(self.fieldsGrid.GetCellValue(row, 2)) 388 | return result 389 | 390 | def SetCheckedFields(self, show=None, group=None): 391 | self.show_fields = show or self.show_fields 392 | self.group_fields = group or self.group_fields 393 | self.group_fields = [ 394 | s for s in self.group_fields if s in self.show_fields 395 | ] 396 | current = [] 397 | for row in range(self.fieldsGrid.NumberRows): 398 | current.append(self.fieldsGrid.GetCellValue(row, 2)) 399 | new = [s for s in current if s not in self.show_fields] 400 | self._setFieldsList(self.show_fields + new) 401 | for row in range(self.fieldsGrid.NumberRows): 402 | field = self.fieldsGrid.GetCellValue(row, 2) 403 | self.fieldsGrid.SetCellValue( 404 | row, 0, "1" if field in self.show_fields else "") 405 | self.fieldsGrid.SetCellValue( 406 | row, 1, "1" if field in self.group_fields else "") 407 | -------------------------------------------------------------------------------- /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 | elif ext in ['.fbrd', '.brd']: 19 | return get_fusion_eagle_parser(file_name, config, logger) 20 | else: 21 | return None 22 | 23 | 24 | def get_kicad_parser(file_name, config, logger, board=None): 25 | from .kicad import PcbnewParser 26 | return PcbnewParser(file_name, config, logger, board) 27 | 28 | 29 | def get_easyeda_parser(file_name, config, logger): 30 | from .easyeda import EasyEdaParser 31 | return EasyEdaParser(file_name, config, logger) 32 | 33 | 34 | def get_generic_json_parser(file_name, config, logger): 35 | from .genericjson import GenericJsonParser 36 | return GenericJsonParser(file_name, config, logger) 37 | 38 | 39 | def get_fusion_eagle_parser(file_name, config, logger): 40 | from .fusion_eagle import FusionEagleParser 41 | return FusionEagleParser(file_name, config, logger) 42 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/common.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from .svgpath import parse_path 4 | 5 | 6 | class ExtraFieldData(object): 7 | def __init__(self, fields, fields_by_ref, fields_by_index=None): 8 | self.fields = fields 9 | self.fields_by_ref = fields_by_ref 10 | self.fields_by_index = fields_by_index 11 | 12 | 13 | class EcadParser(object): 14 | 15 | def __init__(self, file_name, config, logger): 16 | """ 17 | :param file_name: path to file that should be parsed. 18 | :param config: Config instance 19 | :param logger: logging object. 20 | """ 21 | self.file_name = file_name 22 | self.config = config 23 | self.logger = logger 24 | 25 | def parse(self): 26 | """ 27 | Abstract method that should be overridden in implementations. 28 | Performs all the parsing and returns a tuple of 29 | (pcbdata, components) 30 | pcbdata is described in DATAFORMAT.md 31 | components is list of Component objects 32 | :return: 33 | """ 34 | pass 35 | 36 | @staticmethod 37 | def normalize_field_names(data): 38 | # type: (ExtraFieldData) -> ExtraFieldData 39 | def remap(ref_fields): 40 | return {f.lower(): v for (f, v) in 41 | sorted(ref_fields.items(), reverse=True) if v} 42 | 43 | by_ref = {r: remap(d) for (r, d) in data.fields_by_ref.items()} 44 | if data.fields_by_index: 45 | by_index = {i: remap(d) for (i, d) in data.fields_by_index.items()} 46 | else: 47 | by_index = None 48 | 49 | field_map = {f.lower(): f for f in sorted(data.fields, reverse=True)} 50 | return ExtraFieldData(field_map.values(), by_ref, by_index) 51 | 52 | def get_extra_field_data(self, file_name): 53 | """ 54 | Abstract method that may be overridden in implementations that support 55 | extra field data. 56 | :return: ExtraFieldData 57 | """ 58 | return ExtraFieldData([], {}) 59 | 60 | def parse_extra_data(self, file_name, normalize_case): 61 | """ 62 | Parses the file and returns extra field data. 63 | :param file_name: path to file containing extra data 64 | :param normalize_case: if true, normalize case so that 65 | "mpn", "Mpn", "MPN" fields are combined 66 | :return: 67 | """ 68 | data = self.get_extra_field_data(file_name) 69 | if normalize_case: 70 | data = self.normalize_field_names(data) 71 | return ExtraFieldData( 72 | sorted(data.fields), data.fields_by_ref, data.fields_by_index) 73 | 74 | def latest_extra_data(self, extra_dirs=None): 75 | """ 76 | Abstract method that may be overridden in implementations that support 77 | extra field data. 78 | :param extra_dirs: List of extra directories to search. 79 | :return: File name of most recent file with extra field data. 80 | """ 81 | return None 82 | 83 | def extra_data_file_filter(self): 84 | """ 85 | Abstract method that may be overridden in implementations that support 86 | extra field data. 87 | :return: File open dialog filter string, eg: 88 | "Netlist and xml files (*.net; *.xml)|*.net;*.xml" 89 | """ 90 | return None 91 | 92 | def add_drawing_bounding_box(self, drawing, bbox): 93 | # type: (dict, BoundingBox) -> None 94 | 95 | def add_segment(): 96 | bbox.add_segment(drawing['start'][0], drawing['start'][1], 97 | drawing['end'][0], drawing['end'][1], 98 | drawing['width'] / 2) 99 | 100 | def add_circle(): 101 | bbox.add_circle(drawing['start'][0], drawing['start'][1], 102 | drawing['radius'] + drawing['width'] / 2) 103 | 104 | def add_svgpath(): 105 | width = drawing.get('width', 0) 106 | bbox.add_svgpath(drawing['svgpath'], width, self.logger) 107 | 108 | def add_polygon(): 109 | if 'polygons' not in drawing: 110 | add_svgpath() 111 | return 112 | polygon = drawing['polygons'][0] 113 | for point in polygon: 114 | bbox.add_point(point[0], point[1]) 115 | 116 | def add_arc(): 117 | if 'svgpath' in drawing: 118 | add_svgpath() 119 | else: 120 | width = drawing.get('width', 0) 121 | xc, yc = drawing['start'][:2] 122 | a1 = drawing['startangle'] 123 | a2 = drawing['endangle'] 124 | r = drawing['radius'] 125 | x1 = xc + r * math.cos(math.radians(a1)) 126 | y1 = yc + r * math.sin(math.radians(a1)) 127 | x2 = xc + r * math.cos(math.radians(a2)) 128 | y2 = yc + r * math.sin(math.radians(a2)) 129 | da = a2 - a1 if a2 > a1 else a2 + 360 - a1 130 | la = 1 if da > 180 else 0 131 | svgpath = 'M %s %s A %s %s 0 %s 1 %s %s' % \ 132 | (x1, y1, r, r, la, x2, y2) 133 | bbox.add_svgpath(svgpath, width, self.logger) 134 | 135 | { 136 | 'segment': add_segment, 137 | 'rect': add_segment, # bbox of a rect and segment are the same 138 | 'circle': add_circle, 139 | 'arc': add_arc, 140 | 'polygon': add_polygon, 141 | 'text': lambda: None, # text is not really needed for bounding box 142 | }.get(drawing['type'])() 143 | 144 | 145 | class Component(object): 146 | """Simple data object to store component data needed for bom table.""" 147 | 148 | def __init__(self, ref, val, footprint, layer, attr=None, extra_fields={}): 149 | self.ref = ref 150 | self.val = val 151 | self.footprint = footprint 152 | self.layer = layer 153 | self.attr = attr 154 | self.extra_fields = extra_fields 155 | 156 | 157 | class BoundingBox(object): 158 | """Geometry util to calculate and combine bounding box of simple shapes.""" 159 | 160 | def __init__(self): 161 | self._x0 = None 162 | self._y0 = None 163 | self._x1 = None 164 | self._y1 = None 165 | 166 | def to_dict(self): 167 | # type: () -> dict 168 | return { 169 | "minx": self._x0, 170 | "miny": self._y0, 171 | "maxx": self._x1, 172 | "maxy": self._y1, 173 | } 174 | 175 | def to_component_dict(self): 176 | # type: () -> dict 177 | return { 178 | "pos": [self._x0, self._y0], 179 | "relpos": [0, 0], 180 | "size": [self._x1 - self._x0, self._y1 - self._y0], 181 | "angle": 0, 182 | } 183 | 184 | def add(self, other): 185 | """Add another bounding box. 186 | :type other: BoundingBox 187 | """ 188 | if other._x0 is not None: 189 | self.add_point(other._x0, other._y0) 190 | self.add_point(other._x1, other._y1) 191 | return self 192 | 193 | @staticmethod 194 | def _rotate(x, y, rx, ry, angle): 195 | sin = math.sin(math.radians(angle)) 196 | cos = math.cos(math.radians(angle)) 197 | new_x = rx + (x - rx) * cos - (y - ry) * sin 198 | new_y = ry + (x - rx) * sin + (y - ry) * cos 199 | return new_x, new_y 200 | 201 | def add_point(self, x, y, rx=0, ry=0, angle=0): 202 | x, y = self._rotate(x, y, rx, ry, angle) 203 | if self._x0 is None: 204 | self._x0 = x 205 | self._y0 = y 206 | self._x1 = x 207 | self._y1 = y 208 | else: 209 | self._x0 = min(self._x0, x) 210 | self._y0 = min(self._y0, y) 211 | self._x1 = max(self._x1, x) 212 | self._y1 = max(self._y1, y) 213 | return self 214 | 215 | def add_segment(self, x0, y0, x1, y1, r): 216 | self.add_circle(x0, y0, r) 217 | self.add_circle(x1, y1, r) 218 | return self 219 | 220 | def add_rectangle(self, x, y, w, h, angle=0): 221 | self.add_point(x - w / 2, y - h / 2, x, y, angle) 222 | self.add_point(x + w / 2, y - h / 2, x, y, angle) 223 | self.add_point(x - w / 2, y + h / 2, x, y, angle) 224 | self.add_point(x + w / 2, y + h / 2, x, y, angle) 225 | return self 226 | 227 | def add_circle(self, x, y, r): 228 | self.add_point(x - r, y) 229 | self.add_point(x, y - r) 230 | self.add_point(x + r, y) 231 | self.add_point(x, y + r) 232 | return self 233 | 234 | def add_svgpath(self, svgpath, width, logger): 235 | w = width / 2 236 | for segment in parse_path(svgpath, logger): 237 | x0, x1, y0, y1 = segment.bbox() 238 | self.add_point(x0 - w, y0 - w) 239 | self.add_point(x1 + w, y1 + w) 240 | 241 | def pad(self, amount): 242 | """Add small padding to the box.""" 243 | if self._x0 is not None: 244 | self._x0 -= amount 245 | self._y0 -= amount 246 | self._x1 += amount 247 | self._y1 += amount 248 | 249 | def initialized(self): 250 | return self._x0 is not None 251 | -------------------------------------------------------------------------------- /InteractiveHtmlBom/ecad/easyeda.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import sys 4 | 5 | from .common import EcadParser, Component, BoundingBox, ExtraFieldData 6 | 7 | 8 | if sys.version_info >= (3, 0): 9 | string_types = str 10 | else: 11 | string_types = basestring # noqa F821: ignore undefined 12 | 13 | 14 | class EasyEdaParser(EcadParser): 15 | TOP_COPPER_LAYER = 1 16 | BOT_COPPER_LAYER = 2 17 | TOP_SILK_LAYER = 3 18 | BOT_SILK_LAYER = 4 19 | BOARD_OUTLINE_LAYER = 10 20 | TOP_ASSEMBLY_LAYER = 13 21 | BOT_ASSEMBLY_LAYER = 14 22 | ALL_LAYERS = 11 23 | 24 | def extra_data_file_filter(self): 25 | return "Json file ({f})|{f}".format(f=os.path.basename(self.file_name)) 26 | 27 | def latest_extra_data(self, extra_dirs=None): 28 | return self.file_name 29 | 30 | def get_extra_field_data(self, file_name): 31 | if os.path.abspath(file_name) != os.path.abspath(self.file_name): 32 | return None 33 | 34 | _, components = self.parse() 35 | field_set = set() 36 | comp_dict = {} 37 | 38 | for c in components: 39 | ref_fields = comp_dict.setdefault(c.ref, {}) 40 | 41 | for k, v in c.extra_fields.items(): 42 | field_set.add(k) 43 | ref_fields[k] = v 44 | 45 | by_index = { 46 | i: components[i].extra_fields for i in range(len(components)) 47 | } 48 | 49 | return ExtraFieldData(list(field_set), comp_dict, by_index) 50 | 51 | def get_easyeda_pcb(self): 52 | import json 53 | with io.open(self.file_name, 'r', encoding='utf-8') as f: 54 | return json.load(f) 55 | 56 | @staticmethod 57 | def tilda_split(s): 58 | # type: (str) -> list 59 | return s.split('~') 60 | 61 | @staticmethod 62 | def sharp_split(s): 63 | # type: (str) -> list 64 | return s.split('#@$') 65 | 66 | def _verify(self, pcb): 67 | """Spot check the pcb object.""" 68 | if 'head' not in pcb: 69 | self.logger.error('No head attribute.') 70 | return False 71 | head = pcb['head'] 72 | if len(head) < 2: 73 | self.logger.error('Incorrect head attribute ' + pcb['head']) 74 | return False 75 | if head['docType'] != '3': 76 | self.logger.error('Incorrect document type: ' + head['docType']) 77 | return False 78 | if 'canvas' not in pcb: 79 | self.logger.error('No canvas attribute.') 80 | return False 81 | canvas = self.tilda_split(pcb['canvas']) 82 | if len(canvas) < 18: 83 | self.logger.error('Incorrect canvas attribute ' + pcb['canvas']) 84 | return False 85 | self.logger.info('EasyEDA editor version ' + head['editorVersion']) 86 | return True 87 | 88 | @staticmethod 89 | def normalize(v): 90 | if isinstance(v, string_types): 91 | v = float(v) 92 | return v 93 | 94 | def parse_track(self, shape): 95 | shape = self.tilda_split(shape) 96 | assert len(shape) >= 5, 'Invalid track ' + str(shape) 97 | width = self.normalize(shape[0]) 98 | layer = int(shape[1]) 99 | points = [self.normalize(v) for v in shape[3].split(' ')] 100 | 101 | points_xy = [[points[i], points[i + 1]] for i in 102 | range(0, len(points), 2)] 103 | segments = [(points_xy[i], points_xy[i + 1]) for i in 104 | range(len(points_xy) - 1)] 105 | segments_json = [] 106 | for segment in segments: 107 | segments_json.append({ 108 | "type": "segment", 109 | "start": segment[0], 110 | "end": segment[1], 111 | "width": width, 112 | }) 113 | 114 | return layer, segments_json 115 | 116 | def parse_via(self, shape): 117 | shape = self.tilda_split(shape) 118 | assert len(shape) >= 5, 'Invalid via ' + str(shape) 119 | x, y = self.normalize(shape[0]), self.normalize(shape[1]) 120 | width = self.normalize(shape[2]) 121 | return self.TOP_COPPER_LAYER, [{ 122 | "type": "segment", 123 | "start": [x, y], 124 | "end": [x, y], 125 | "width": width 126 | }] 127 | 128 | def parse_rect(self, shape): 129 | shape = self.tilda_split(shape) 130 | assert len(shape) >= 9, 'Invalid rect ' + str(shape) 131 | x = self.normalize(shape[0]) 132 | y = self.normalize(shape[1]) 133 | width = self.normalize(shape[2]) 134 | height = self.normalize(shape[3]) 135 | layer = int(shape[4]) 136 | fill = shape[8] 137 | 138 | if fill == "none": 139 | thickness = self.normalize(shape[7]) 140 | return layer, [{ 141 | "type": "rect", 142 | "start": [x, y], 143 | "end": [x + width, y + height], 144 | "width": thickness, 145 | }] 146 | else: 147 | return layer, [{ 148 | "type": "polygon", 149 | "pos": [x, y], 150 | "angle": 0, 151 | "polygons": [ 152 | [[0, 0], [width, 0], [width, height], [0, height]] 153 | ] 154 | }] 155 | 156 | def parse_circle(self, shape): 157 | shape = self.tilda_split(shape) 158 | assert len(shape) >= 6, 'Invalid circle ' + str(shape) 159 | cx = self.normalize(shape[0]) 160 | cy = self.normalize(shape[1]) 161 | r = self.normalize(shape[2]) 162 | width = self.normalize(shape[3]) 163 | layer = int(shape[4]) 164 | 165 | return layer, [{ 166 | "type": "circle", 167 | "start": [cx, cy], 168 | "radius": r, 169 | "width": width 170 | }] 171 | 172 | def parse_solid_region(self, shape): 173 | shape = self.tilda_split(shape) 174 | assert len(shape) >= 5, 'Invalid solid region ' + str(shape) 175 | layer = int(shape[0]) 176 | svgpath = shape[2] 177 | 178 | return layer, [{ 179 | "type": "polygon", 180 | "svgpath": svgpath, 181 | }] 182 | 183 | def parse_text(self, shape): 184 | shape = self.tilda_split(shape) 185 | assert len(shape) >= 12, 'Invalid text ' + str(shape) 186 | text_type = shape[0] 187 | stroke_width = self.normalize(shape[3]) 188 | layer = int(shape[6]) 189 | text = shape[9] 190 | svgpath = shape[10] 191 | hide = shape[11] 192 | 193 | return layer, [{ 194 | "type": "text", 195 | "text": text, 196 | "thickness": stroke_width, 197 | "attr": [], 198 | "svgpath": svgpath, 199 | "hide": hide, 200 | "text_type": text_type, 201 | }] 202 | 203 | def parse_arc(self, shape): 204 | shape = self.tilda_split(shape) 205 | assert len(shape) >= 6, 'Invalid arc ' + str(shape) 206 | width = self.normalize(shape[0]) 207 | layer = int(shape[1]) 208 | svgpath = shape[3] 209 | 210 | return layer, [{ 211 | "type": "arc", 212 | "svgpath": svgpath, 213 | "width": width 214 | }] 215 | 216 | def parse_hole(self, shape): 217 | shape = self.tilda_split(shape) 218 | assert len(shape) >= 4, 'Invalid hole ' + str(shape) 219 | cx = self.normalize(shape[0]) 220 | cy = self.normalize(shape[1]) 221 | radius = self.normalize(shape[2]) 222 | 223 | return self.BOARD_OUTLINE_LAYER, [{ 224 | "type": "circle", 225 | "start": [cx, cy], 226 | "radius": radius, 227 | "width": 0.1, # 1 mil 228 | }] 229 | 230 | def parse_pad(self, shape): 231 | shape = self.tilda_split(shape) 232 | assert len(shape) >= 15, 'Invalid pad ' + str(shape) 233 | pad_shape = shape[0] 234 | x = self.normalize(shape[1]) 235 | y = self.normalize(shape[2]) 236 | width = self.normalize(shape[3]) 237 | height = self.normalize(shape[4]) 238 | layer = int(shape[5]) 239 | number = shape[7] 240 | hole_radius = self.normalize(shape[8]) 241 | if shape[9]: 242 | points = [self.normalize(v) for v in shape[9].split(' ')] 243 | else: 244 | points = [] 245 | angle = int(shape[10]) 246 | hole_length = self.normalize(shape[12]) if shape[12] else 0 247 | 248 | pad_layers = { 249 | self.TOP_COPPER_LAYER: ['F'], 250 | self.BOT_COPPER_LAYER: ['B'], 251 | self.ALL_LAYERS: ['F', 'B'] 252 | }.get(layer) 253 | pad_shape = { 254 | "ELLIPSE": "circle", 255 | "RECT": "rect", 256 | "OVAL": "oval", 257 | "POLYGON": "custom", 258 | }.get(pad_shape) 259 | pad_type = "smd" if len(pad_layers) == 1 else "th" 260 | 261 | json = { 262 | "layers": pad_layers, 263 | "pos": [x, y], 264 | "size": [width, height], 265 | "angle": angle, 266 | "shape": pad_shape, 267 | "type": pad_type, 268 | } 269 | if number == '1': 270 | json['pin1'] = 1 271 | if pad_shape == "custom": 272 | polygon = [(points[i], points[i + 1]) for i in 273 | range(0, len(points), 2)] 274 | # translate coordinates to be relative to footprint 275 | polygon = [(p[0] - x, p[1] - y) for p in polygon] 276 | json["polygons"] = [polygon] 277 | json["angle"] = 0 278 | if pad_type == "th": 279 | if hole_length > 1e-6: 280 | json["drillshape"] = "oblong" 281 | json["drillsize"] = [hole_radius * 2, hole_length] 282 | else: 283 | json["drillshape"] = "circle" 284 | json["drillsize"] = [hole_radius * 2, hole_radius * 2] 285 | 286 | return layer, [{ 287 | "type": "pad", 288 | "pad": json, 289 | }] 290 | 291 | @staticmethod 292 | def add_pad_bounding_box(pad, bbox): 293 | # type: (dict, BoundingBox) -> None 294 | 295 | def add_circle(): 296 | bbox.add_circle(pad['pos'][0], pad['pos'][1], pad['size'][0] / 2) 297 | 298 | def add_rect(): 299 | bbox.add_rectangle(pad['pos'][0], pad['pos'][1], 300 | pad['size'][0], pad['size'][1], 301 | pad['angle']) 302 | 303 | def add_custom(): 304 | x = pad['pos'][0] 305 | y = pad['pos'][1] 306 | polygon = pad['polygons'][0] 307 | for point in polygon: 308 | bbox.add_point(x + point[0], y + point[1]) 309 | 310 | { 311 | 'circle': add_circle, 312 | 'rect': add_rect, 313 | 'oval': add_rect, 314 | 'custom': add_custom, 315 | }.get(pad['shape'])() 316 | 317 | def parse_lib(self, shape): 318 | parts = self.sharp_split(shape) 319 | head = self.tilda_split(parts[0]) 320 | inner_shapes, _, _ = self.parse_shapes(parts[1:]) 321 | x = self.normalize(head[0]) 322 | y = self.normalize(head[1]) 323 | attr = head[2] 324 | fp_layer = int(head[6]) 325 | 326 | attr = attr.split('`') 327 | if len(attr) % 2 != 0: 328 | attr.pop() 329 | attr = {attr[i]: attr[i + 1] for i in range(0, len(attr), 2)} 330 | fp_layer = 'F' if fp_layer == self.TOP_COPPER_LAYER else 'B' 331 | val = '??' 332 | ref = '??' 333 | footprint = '??' 334 | if 'package' in attr: 335 | footprint = attr['package'] 336 | del attr['package'] 337 | 338 | pads = [] 339 | copper_drawings = [] 340 | extra_drawings = [] 341 | bbox = BoundingBox() 342 | for layer, shapes in inner_shapes.items(): 343 | for s in shapes: 344 | if s["type"] == "pad": 345 | pads.append(s["pad"]) 346 | continue 347 | if s["type"] == "text": 348 | if s["text_type"] == "N": 349 | val = s["text"] 350 | if s["text_type"] == "P": 351 | ref = s["text"] 352 | del s["text_type"] 353 | if s["hide"]: 354 | continue 355 | if layer in [self.TOP_COPPER_LAYER, self.BOT_COPPER_LAYER]: 356 | copper_drawings.append({ 357 | "layer": ( 358 | 'F' if layer == self.TOP_COPPER_LAYER else 'B'), 359 | "drawing": s, 360 | }) 361 | elif layer in [self.TOP_SILK_LAYER, 362 | self.BOT_SILK_LAYER, 363 | self.TOP_ASSEMBLY_LAYER, 364 | self.BOT_ASSEMBLY_LAYER, 365 | self.BOARD_OUTLINE_LAYER]: 366 | extra_drawings.append((layer, s)) 367 | 368 | for pad in pads: 369 | self.add_pad_bounding_box(pad, bbox) 370 | for drawing in copper_drawings: 371 | self.add_drawing_bounding_box(drawing['drawing'], bbox) 372 | for _, drawing in extra_drawings: 373 | self.add_drawing_bounding_box(drawing, bbox) 374 | bbox.pad(0.5) # pad by 5 mil 375 | if not bbox.initialized(): 376 | # if bounding box is not calculated yet 377 | # set it to 100x100 mil square 378 | bbox.add_rectangle(x, y, 10, 10, 0) 379 | 380 | footprint_json = { 381 | "ref": ref, 382 | "center": [x, y], 383 | "bbox": bbox.to_component_dict(), 384 | "pads": pads, 385 | "drawings": copper_drawings, 386 | "layer": fp_layer, 387 | } 388 | 389 | component = Component(ref, val, footprint, fp_layer, extra_fields=attr) 390 | 391 | return fp_layer, component, footprint_json, extra_drawings 392 | 393 | def parse_shapes(self, shapes): 394 | drawings = {} 395 | footprints = [] 396 | components = [] 397 | 398 | for shape_str in shapes: 399 | shape = shape_str.split('~', 1) 400 | parse_func = { 401 | 'TRACK': self.parse_track, 402 | 'VIA': self.parse_via, 403 | 'RECT': self.parse_rect, 404 | 'CIRCLE': self.parse_circle, 405 | 'SOLIDREGION': self.parse_solid_region, 406 | 'TEXT': self.parse_text, 407 | 'ARC': self.parse_arc, 408 | 'PAD': self.parse_pad, 409 | 'HOLE': self.parse_hole, 410 | }.get(shape[0], None) 411 | if parse_func: 412 | layer, json_list = parse_func(shape[1]) 413 | drawings.setdefault(layer, []).extend(json_list) 414 | if shape[0] == 'VIA': 415 | drawings.setdefault(self.BOT_COPPER_LAYER, []).extend(json_list) 416 | if shape[0] == 'LIB': 417 | layer, component, json, extras = self.parse_lib(shape[1]) 418 | for drawing_layer, drawing in extras: 419 | drawings.setdefault(drawing_layer, []).append(drawing) 420 | footprints.append(json) 421 | components.append(component) 422 | 423 | return drawings, footprints, components 424 | 425 | def get_metadata(self, pcb): 426 | if hasattr(pcb, 'metadata'): 427 | return pcb.metadata 428 | else: 429 | import os 430 | from datetime import datetime 431 | pcb_file_name = os.path.basename(self.file_name) 432 | title = os.path.splitext(pcb_file_name)[0] 433 | file_mtime = os.path.getmtime(self.file_name) 434 | file_date = datetime.fromtimestamp(file_mtime).strftime( 435 | '%Y-%m-%d %H:%M:%S') 436 | return { 437 | "title": title, 438 | "revision": "", 439 | "company": "", 440 | "date": file_date, 441 | } 442 | 443 | def parse(self): 444 | pcb = self.get_easyeda_pcb() 445 | if not self._verify(pcb): 446 | self.logger.error( 447 | 'File ' + self.file_name + 448 | ' does not appear to be valid EasyEDA json file.') 449 | return None, None 450 | 451 | drawings, footprints, components = self.parse_shapes(pcb['shape']) 452 | 453 | board_outline_bbox = BoundingBox() 454 | for drawing in drawings.get(self.BOARD_OUTLINE_LAYER, []): 455 | self.add_drawing_bounding_box(drawing, board_outline_bbox) 456 | if board_outline_bbox.initialized(): 457 | bbox = board_outline_bbox.to_dict() 458 | else: 459 | # if nothing is drawn on outline layer then rely on EasyEDA bbox 460 | x = self.normalize(pcb['BBox']['x']) 461 | y = self.normalize(pcb['BBox']['y']) 462 | bbox = { 463 | "minx": x, 464 | "miny": y, 465 | "maxx": x + self.normalize(pcb['BBox']['width']), 466 | "maxy": y + self.normalize(pcb['BBox']['height']) 467 | } 468 | 469 | pcbdata = { 470 | "edges_bbox": bbox, 471 | "edges": drawings.get(self.BOARD_OUTLINE_LAYER, []), 472 | "drawings": { 473 | "silkscreen": { 474 | 'F': drawings.get(self.TOP_SILK_LAYER, []), 475 | 'B': drawings.get(self.BOT_SILK_LAYER, []), 476 | }, 477 | "fabrication": { 478 | 'F': drawings.get(self.TOP_ASSEMBLY_LAYER, []), 479 | 'B': drawings.get(self.BOT_ASSEMBLY_LAYER, []), 480 | }, 481 | }, 482 | "footprints": footprints, 483 | "metadata": self.get_metadata(pcb), 484 | "bom": {}, 485 | "font_data": {} 486 | } 487 | 488 | if self.config.include_tracks: 489 | def filter_tracks(drawing_list, drawing_type, keys): 490 | result = [] 491 | for d in drawing_list: 492 | if d["type"] == drawing_type: 493 | r = {} 494 | for key in keys: 495 | r[key] = d[key] 496 | result.append(r) 497 | return result 498 | 499 | pcbdata["tracks"] = { 500 | 'F': filter_tracks(drawings.get(self.TOP_COPPER_LAYER, []), 501 | "segment", ["start", "end", "width"]), 502 | 'B': filter_tracks(drawings.get(self.BOT_COPPER_LAYER, []), 503 | "segment", ["start", "end", "width"]), 504 | } 505 | # zones are not supported 506 | pcbdata["zones"] = {'F': [], 'B': []} 507 | 508 | return pcbdata, components 509 | -------------------------------------------------------------------------------- /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, ExtraFieldData 7 | from ..core.fontparser import FontParser 8 | from ..errors import ParsingException 9 | 10 | 11 | class GenericJsonParser(EcadParser): 12 | COMPATIBLE_SPEC_VERSIONS = [1] 13 | 14 | def extra_data_file_filter(self): 15 | return "Json file ({f})|{f}".format(f=os.path.basename(self.file_name)) 16 | 17 | def latest_extra_data(self, extra_dirs=None): 18 | return self.file_name 19 | 20 | def get_extra_field_data(self, file_name): 21 | if os.path.abspath(file_name) != os.path.abspath(self.file_name): 22 | return None 23 | 24 | _, components = self._parse() 25 | field_set = set() 26 | comp_dict = {} 27 | 28 | for c in components: 29 | ref_fields = comp_dict.setdefault(c.ref, {}) 30 | 31 | for k, v in c.extra_fields.items(): 32 | field_set.add(k) 33 | ref_fields[k] = v 34 | 35 | by_index = { 36 | i: components[i].extra_fields for i in range(len(components)) 37 | } 38 | 39 | return ExtraFieldData(list(field_set), comp_dict, by_index) 40 | 41 | def get_generic_json_pcb(self): 42 | with io.open(self.file_name, 'r', encoding='utf-8') as f: 43 | pcb = json.load(f) 44 | 45 | if 'spec_version' not in pcb: 46 | raise ValidationError("'spec_version' is a required property") 47 | 48 | if pcb['spec_version'] not in self.COMPATIBLE_SPEC_VERSIONS: 49 | raise ValidationError("Unsupported spec_version ({})" 50 | .format(pcb['spec_version'])) 51 | 52 | schema_dir = os.path.join(os.path.dirname(__file__), 'schema') 53 | schema_file_name = os.path.join(schema_dir, 54 | 'genericjsonpcbdata_v{}.schema'.format( 55 | pcb['spec_version'])) 56 | 57 | with io.open(schema_file_name, 'r', encoding='utf-8') as f: 58 | schema = json.load(f) 59 | 60 | validate(instance=pcb, schema=schema) 61 | 62 | return pcb 63 | 64 | def _verify(self, pcb): 65 | """Spot check the pcb object.""" 66 | 67 | if len(pcb['pcbdata']['footprints']) != len(pcb['components']): 68 | self.logger.error("Length of components list doesn't match" 69 | " length of footprints list.") 70 | return False 71 | 72 | return True 73 | 74 | @staticmethod 75 | def _texts(pcbdata): 76 | for layer in pcbdata['drawings'].values(): 77 | for side in layer.values(): 78 | for dwg in side: 79 | if 'text' in dwg: 80 | yield dwg 81 | 82 | @staticmethod 83 | def _remove_control_codes(s): 84 | import unicodedata 85 | return ''.join(c for c in s if unicodedata.category(c)[0] != "C") 86 | 87 | def _parse_font_data(self, pcbdata): 88 | font_parser = FontParser() 89 | for dwg in self._texts(pcbdata): 90 | if 'svgpath' not in dwg: 91 | dwg['text'] = self._remove_control_codes(dwg['text']) 92 | font_parser.parse_font_for_string(dwg['text']) 93 | 94 | if font_parser.get_parsed_font(): 95 | pcbdata['font_data'] = font_parser.get_parsed_font() 96 | 97 | def _check_font_data(self, pcbdata): 98 | mc = set() 99 | for dwg in self._texts(pcbdata): 100 | dwg['text'] = self._remove_control_codes(dwg['text']) 101 | mc.update({c for c in dwg['text'] if 'svgpath' not in dwg and 102 | c not in pcbdata['font_data']}) 103 | 104 | if mc: 105 | s = ''.join(mc) 106 | self.logger.error('Provided font_data is missing character(s)' 107 | f' "{s}" that are present in text drawing' 108 | ' objects') 109 | return False 110 | else: 111 | return True 112 | 113 | def _parse(self): 114 | try: 115 | pcb = self.get_generic_json_pcb() 116 | except ValidationError as e: 117 | self.logger.error('File {f} does not comply with json schema. {m}' 118 | .format(f=self.file_name, m=e.message)) 119 | return None, None 120 | 121 | if not self._verify(pcb): 122 | self.logger.error('File {} does not appear to be valid generic' 123 | ' InteractiveHtmlBom json file.' 124 | .format(self.file_name)) 125 | return None, None 126 | 127 | pcbdata = pcb['pcbdata'] 128 | components = [Component(**c) for c in pcb['components']] 129 | 130 | if 'font_data' in pcbdata: 131 | if not self._check_font_data(pcbdata): 132 | raise ParsingException(f'Failed parsing {self.file_name}') 133 | else: 134 | self._parse_font_data(pcbdata) 135 | if 'font_data' in pcbdata: 136 | self.logger.info('No font_data provided in JSON, using ' 137 | 'newstroke font') 138 | 139 | self.logger.info('Successfully parsed {}'.format(self.file_name)) 140 | 141 | return pcbdata, components 142 | 143 | def parse(self): 144 | pcbdata, components = self._parse() 145 | 146 | # override board bounding box based on edges 147 | board_outline_bbox = BoundingBox() 148 | for drawing in pcbdata['edges']: 149 | self.add_drawing_bounding_box(drawing, board_outline_bbox) 150 | if board_outline_bbox.initialized(): 151 | pcbdata['edges_bbox'] = board_outline_bbox.to_dict() 152 | 153 | extra_fields = set(self.config.show_fields) 154 | extra_fields.discard("Footprint") 155 | extra_fields.discard("Value") 156 | if self.config.dnp_field: 157 | extra_fields.add(self.config.dnp_field) 158 | if self.config.board_variant_field: 159 | extra_fields.add(self.config.board_variant_field) 160 | if extra_fields: 161 | for c in components: 162 | c.extra_fields = { 163 | f: c.extra_fields.get(f, "") for f in extra_fields} 164 | 165 | self.config.kicad_text_formatting = False 166 | 167 | return pcbdata, components 168 | -------------------------------------------------------------------------------- /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 | dnp = False 25 | for f in c[1:]: 26 | if f[0] == 'ref': 27 | ref = f[1] 28 | if f[0] == 'fields': 29 | fields = f[1:] 30 | if f[0] == 'datasheet': 31 | datasheet = f[1] 32 | if f[0] == 'libsource': 33 | libsource = f[1:] 34 | if f[0] == 'property' and isinstance(f[1], list) and \ 35 | f[1][0] == 'name' and f[1][1] == 'dnp': 36 | dnp = True 37 | if ref is None: 38 | return None 39 | ref_fields = comp_dict.setdefault(ref, {}) 40 | if datasheet and datasheet != '~': 41 | field_set.add('Datasheet') 42 | ref_fields['Datasheet'] = datasheet 43 | if libsource is not None: 44 | for lib_field in libsource: 45 | if lib_field[0] == 'description': 46 | field_set.add('Description') 47 | ref_fields['Description'] = lib_field[1] 48 | if dnp: 49 | field_set.add('kicad_dnp') 50 | ref_fields['kicad_dnp'] = "DNP" 51 | if fields is None: 52 | continue 53 | for f in fields: 54 | if len(f) > 1: 55 | field_set.add(f[1][1]) 56 | if len(f) > 2: 57 | ref_fields[f[1][1]] = f[2] 58 | 59 | return list(field_set), comp_dict 60 | -------------------------------------------------------------------------------- /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 | for f in c.getElementsByTagName('property'): 38 | if f.attributes['name'].value == 'dnp': 39 | field_set.add('kicad_dnp') 40 | ref_fields['kicad_dnp'] = "DNP" 41 | 42 | return list(field_set), comp_dict 43 | -------------------------------------------------------------------------------- /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 | # Add ../ to the path 8 | # Works if this script is executed without installing the module 9 | script_dir = os.path.dirname(os.path.abspath(os.path.realpath(__file__))) 10 | sys.path.insert(0, os.path.dirname(script_dir)) 11 | # Pretend we are part of a module 12 | # Avoids: ImportError: attempted relative import with no known parent package 13 | __package__ = os.path.basename(script_dir) 14 | __import__(__package__) 15 | 16 | 17 | # python 2 and 3 compatibility hack 18 | def to_utf(s): 19 | if isinstance(s, bytes): 20 | return s.decode('utf-8') 21 | else: 22 | return s 23 | 24 | 25 | def main(): 26 | create_wx_app = 'INTERACTIVE_HTML_BOM_NO_DISPLAY' not in os.environ 27 | 28 | import wx 29 | 30 | if create_wx_app: 31 | app = wx.App() 32 | if hasattr(wx, "APP_ASSERT_SUPPRESS"): 33 | app.SetAssertMode(wx.APP_ASSERT_SUPPRESS) 34 | elif hasattr(wx, "DisableAsserts"): 35 | wx.DisableAsserts() 36 | 37 | from .core import ibom 38 | from .core.config import Config 39 | from .ecad import get_parser_by_extension 40 | from .version import version 41 | from .errors import (ExitCodes, ParsingException, exit_error) 42 | 43 | parser = argparse.ArgumentParser( 44 | description='KiCad InteractiveHtmlBom plugin CLI.', 45 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 46 | parser.add_argument('file', 47 | type=lambda s: to_utf(s), 48 | help="KiCad PCB file") 49 | 50 | Config.add_options(parser, version) 51 | args = parser.parse_args() 52 | logger = ibom.Logger(cli=True) 53 | 54 | if not os.path.isfile(args.file): 55 | exit_error(logger, ExitCodes.ERROR_FILE_NOT_FOUND, 56 | "File %s does not exist." % args.file) 57 | 58 | print("Loading %s" % args.file) 59 | 60 | config = Config(version, os.path.dirname(os.path.abspath(args.file))) 61 | 62 | parser = get_parser_by_extension( 63 | os.path.abspath(args.file), config, logger) 64 | 65 | if args.show_dialog: 66 | if not create_wx_app: 67 | exit_error(logger, ExitCodes.ERROR_NO_DISPLAY, 68 | "Can not show dialog when " 69 | "INTERACTIVE_HTML_BOM_NO_DISPLAY is set.") 70 | try: 71 | ibom.run_with_dialog(parser, config, logger) 72 | except ParsingException as e: 73 | exit_error(logger, ExitCodes.ERROR_PARSE, e) 74 | else: 75 | config.set_from_args(args) 76 | try: 77 | ibom.main(parser, config, logger) 78 | except ParsingException as e: 79 | exit_error(logger, ExitCodes.ERROR_PARSE, str(e)) 80 | return 0 81 | 82 | 83 | if __name__ == "__main__": 84 | main() 85 | -------------------------------------------------------------------------------- /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/openscopeproject/InteractiveHtmlBom/f42ee38fc93ecb7d619eb63db897cb2477d98423/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.10.0' 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 | stderr=subprocess.DEVNULL, 15 | cwd=plugin_path) 16 | if isinstance(git_version, bytes): 17 | return git_version.decode('utf-8').rstrip() 18 | else: 19 | return git_version.rstrip() 20 | except subprocess.CalledProcessError: 21 | # print('Git version check failed: ' + str(e)) 22 | pass 23 | except Exception: 24 | # print('Git process cannot be launched: ' + str(e)) 25 | pass 26 | return None 27 | 28 | 29 | version = _get_git_version() or LAST_TAG 30 | -------------------------------------------------------------------------------- /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 | 61 | 62 | 63 | 66 | 69 | 70 | 71 | 74 | 77 | 78 | 79 |
64 | Title 65 | 67 | Revision 68 |
72 | Company 73 | 75 | Date 76 |
80 |
81 |
82 | 181 |
182 | 185 | 188 | 191 |
192 |
193 | 195 | 197 | 199 |
200 |
201 | 203 | 205 | 207 |
208 | 253 | 291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 | 303 | 305 |
306 | 308 |
309 |
310 |
311 | 312 | 313 | 314 | 315 | 316 |
317 |
318 |
319 |
320 |
321 | 322 | 323 | 324 | 325 |
326 |
327 |
328 |
329 | 330 | 331 | 332 | 333 |
334 |
335 |
336 |
337 |
338 | ///USERFOOTER/// 339 | 340 | 341 | 342 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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, Fusion360 and Allegro PCB designer 3 | 4 | ![icon](https://i.imgur.com/js4kDOn.png) 5 | 6 | This plugin generates a convenient Bill of Materials (BOM) listing with the 7 | ability to visually correlate and easily search for components and their placements 8 | on the PCB. It is particularly useful when hand-soldering a prototype, as it allows 9 | users to quickly find locations of components groups on the board. It is also possible 10 | to reverse lookup the component group by clicking on a footprint on the board drawing. 11 | 12 | The plugin utilizes Pcbnew python API to read PCB data and render silkscreen, fab layer, 13 | footprint pads, text, and drawings. BOM table fields and grouping is fully configurable, 14 | additional columns, such as a manufacturer ID, can be added in Schematic editor and 15 | imported either through the netlist file, XML file generated by Eeschema's internal 16 | BOM tool, or from board file itself. 17 | 18 | There is an option to include tracks/zones data as well as netlist information allowing 19 | dynamic highlight of nets on the board. 20 | 21 | For full description of functionality see [wiki](https://github.com/openscopeproject/InteractiveHtmlBom/wiki). 22 | 23 | Generated html page is fully self contained, doesn't need internet connection to work 24 | and can be packaged with documentation of your project or hosted anywhere on the web. 25 | 26 | [A demo is worth a thousand words.](https://openscopeproject.org/InteractiveHtmlBomDemo/) 27 | 28 | ## Installation and Usage 29 | 30 | See [project wiki](https://github.com/openscopeproject/InteractiveHtmlBom/wiki/Installation) for instructions. 31 | 32 | ## License and credits 33 | 34 | Plugin code is licensed under MIT license, see `LICENSE` for more info. 35 | 36 | Html page uses [Split.js](https://github.com/nathancahill/Split.js), 37 | [PEP.js](https://github.com/jquery/PEP) and (stripped down) 38 | [lz-string.js](https://github.com/pieroxy/lz-string) libraries that get embedded into 39 | generated bom page. 40 | 41 | `units.py` is borrowed from [KiBom](https://github.com/SchrodingersGat/KiBoM) 42 | plugin (MIT license). 43 | 44 | `svgpath.py` is heavily based on 45 | [svgpathtools](https://github.com/mathandy/svgpathtools) module (MIT license). 46 | -------------------------------------------------------------------------------- /__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/openscopeproject/InteractiveHtmlBom/f42ee38fc93ecb7d619eb63db897cb2477d98423/icons/plugin_icon_big.png -------------------------------------------------------------------------------- /icons/stats-36px.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "InteractiveHtmlBom" 7 | dynamic = ["version"] 8 | description = 'Generate Interactive Html BOM for your electronics projects' 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = "MIT" 12 | keywords = ["ibom", "KiCad", "Eagle", "EasyEDA"] 13 | authors = [{ name = "qu1ck", email = "anlutsenko@gmail.com" }] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "Operating System :: OS Independent", 19 | "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)", 20 | "Topic :: Utilities", 21 | ] 22 | dependencies = [ 23 | "wxpython>=4.0", 24 | "jsonschema>=4.1", 25 | ] 26 | 27 | [project.scripts] 28 | generate_interactive_bom = "InteractiveHtmlBom.generate_interactive_bom:main" 29 | 30 | [project.urls] 31 | Documentation = "https://github.com/openscopeproject/InteractiveHtmlBom/wiki" 32 | Issues = "https://github.com/openscopeproject/InteractiveHtmlBom/issues" 33 | Source = "https://github.com/openscopeproject/InteractiveHtmlBom" 34 | 35 | [tool.hatch.version] 36 | path = "InteractiveHtmlBom/version.py" 37 | pattern = "LAST_TAG = 'v(?P[^']+)'" 38 | 39 | [tool.hatch.envs.default] 40 | system-packages = true 41 | dependencies = [ 42 | "coverage[toml]>=6.5", 43 | "pytest", 44 | "pytest-sugar" 45 | ] 46 | [tool.hatch.envs.default.scripts] 47 | test = "pytest {args:tests}" 48 | test-cov = "coverage run -m pytest {args:tests}" 49 | cov-report = ["- coverage combine", "coverage report"] 50 | cov = ["test-cov", "cov-report"] 51 | 52 | [[tool.hatch.envs.all.matrix]] 53 | python = ["3.8", "3.9", "3.10", "3.11", "3.12"] 54 | 55 | [tool.hatch.envs.types] 56 | dependencies = ["mypy>=1.0.0"] 57 | [tool.hatch.envs.types.scripts] 58 | check = "mypy --install-types --non-interactive {args:InteractiveHtmlBom}" 59 | 60 | [tool.coverage.run] 61 | source_pkgs = ["InteractiveHtmlBom", "tests"] 62 | branch = true 63 | parallel = true 64 | omit = ["src/InteractiveHtmlBom/__about__.py"] 65 | 66 | [tool.coverage.paths] 67 | InteractiveHtmlBom = [ 68 | "InteractiveHtmlBom", 69 | ] 70 | tests = ["tests", "*/InteractiveHtmlBom/tests"] 71 | 72 | [tool.coverage.report] 73 | exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] 74 | -------------------------------------------------------------------------------- /tests/test_module.py: -------------------------------------------------------------------------------- 1 | def test_module_import(): 2 | import InteractiveHtmlBom # noqa 3 | --------------------------------------------------------------------------------