├── .gitattributes ├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── README.md ├── Run.bat ├── __init__.py ├── cache └── .gitkeep ├── core ├── __init__.py ├── config.py ├── fontparser.py ├── ibom.py ├── lzstring.py ├── newstroke_font.py └── units.py ├── dialog ├── __init__.py ├── bitmaps │ ├── btn-arrow-down.png │ ├── btn-arrow-up.png │ ├── btn-minus.png │ ├── btn-plus.png │ └── btn-question.png ├── dialog_base.py └── settings_dialog.py ├── dialog_test.py ├── ecad ├── __init__.py ├── common.py ├── easyeda.py ├── genericjson.py ├── kicad.py ├── kicad_extra │ ├── __init__.py │ ├── netlistparser.py │ ├── parser_base.py │ ├── sexpressions.py │ └── xmlparser.py ├── schema │ └── genericjsonpcbdata_v1.schema └── svgpath.py ├── errors.py ├── generate_interactive_bom.py ├── i18n ├── language_en.bat └── language_zh.bat ├── icon.png ├── version.py └── web ├── ibom.css ├── ibom.html ├── ibom.js ├── lz-string.js ├── pep.js ├── render.js ├── split.js ├── table-util.js ├── user-file-examples ├── user.css ├── user.js ├── userfooter.html └── userheader.html └── util.js /.gitattributes: -------------------------------------------------------------------------------- 1 | *.bat eol=crlf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .idea 3 | .vscode 4 | *.iml 5 | *.bak 6 | test 7 | releases 8 | demo 9 | *config.ini 10 | /web/user* 11 | /cache/* 12 | !/cache/.gitkeep 13 | /python/ 14 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - deploy 3 | - package 4 | - release 5 | 6 | ######################### 7 | # Deploy # 8 | ######################### 9 | 10 | deploy: 11 | stage: deploy 12 | tags: 13 | - windowsserver2022powershell 14 | rules: 15 | - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH 16 | script: 17 | - Copy-Item "D:\wwwroot\downloadserver.soraharu.com\InteractiveHtmlBom\Python\python-3.9.12-embed-amd64.zip" ".\python.zip"; 18 | - PowerShell -Command "& {7z x .\python.zip -aoa}"; 19 | - if (${IHB_JSON_NAME}) { 20 | Remove-Item "D:\pipeline\vercel\InteractiveHtmlBom\${IHB_JSON_NAME}.html"; 21 | ${IHB_JSON_NAME_ARRAY} = ${IHB_JSON_NAME}; 22 | } else { 23 | Remove-Item "D:\pipeline\vercel\InteractiveHtmlBom\*.html"; 24 | } 25 | - foreach (${projectName} in ${IHB_JSON_NAME_ARRAY}.Split(" ")) { 26 | Invoke-WebRequest 27 | -Uri "${IHB_JSON_URL_LEFT}${projectName}${IHB_JSON_URL_RIGHT}" 28 | -OutFile ".\cache\${projectName}.json"; 29 | PowerShell -Command "& {.\python\python.exe .\generate_interactive_bom.py .\cache\${projectName}.json --dark-mode --no-browser}"; 30 | Rename-Item ".\cache\bom\ibom.html" -NewName "${projectName}.html"; 31 | Move-Item ".\cache\bom\${projectName}.html" "D:\pipeline\vercel\InteractiveHtmlBom\"; 32 | Remove-Item ".\cache\${projectName}.json"; 33 | } 34 | - Set-Location "D:\pipeline\vercel\InteractiveHtmlBom\"; 35 | - Copy-Item ".\index.html.bak" ".\index.html"; 36 | - PowerShell -Command "& {vercel --token ${VERCEL_TOKEN} --prod}"; 37 | 38 | ######################### 39 | # Package # 40 | ######################### 41 | 42 | package: 43 | stage: package 44 | tags: 45 | - windowsserver2022powershell 46 | variables: 47 | GIT_DEPTH: "1" 48 | rules: 49 | - if: $CI_COMMIT_TAG && $IHB_JSON_NAME == null 50 | script: 51 | - Copy-Item "D:\wwwroot\downloadserver.soraharu.com\InteractiveHtmlBom\Python\python-3.9.12-embed-amd64.zip" ".\python.zip"; 52 | - PowerShell -Command "& {7z x .\python.zip -aoa}"; 53 | - Remove-Item ".\python.zip"; 54 | - PowerShell -Command "& {7z a .\InteractiveHtmlBom-python-amd64.zip .\*}"; 55 | - ${downloadServerRootDir} = "D:\wwwroot\downloadserver.soraharu.com\InteractiveHtmlBom\${CI_COMMIT_TAG}\"; 56 | - if (Test-Path -Path "${downloadServerRootDir}") { 57 | Remove-Item "${downloadServerRootDir}*" -Recurse; 58 | } else { 59 | New-Item -Path "${downloadServerRootDir}" -ItemType Directory; 60 | } 61 | - Move-Item ".\InteractiveHtmlBom-python-amd64.zip" "${downloadServerRootDir}"; 62 | 63 | ######################### 64 | # Release # 65 | ######################### 66 | 67 | release: 68 | stage: release 69 | tags: 70 | - linuxdocker 71 | image: registry.gitlab.com/gitlab-org/release-cli:latest 72 | variables: 73 | GIT_STRATEGY: none 74 | rules: 75 | - if: $CI_COMMIT_TAG && $IHB_JSON_NAME == null 76 | dependencies: [] 77 | script: 78 | - | 79 | release-cli create --name "Release $CI_COMMIT_TAG" --tag-name $CI_COMMIT_TAG \ 80 | --assets-link "{\"name\":\"InteractiveHtmlBom-python-amd64.zip\",\"url\":\"https://downloadserver.soraharu.com:7000/InteractiveHtmlBom/$CI_COMMIT_TAG/InteractiveHtmlBom-python-amd64.zip\",\"link_type\":\"package\"}" 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 qu1ck 4 | Copyright (c) 2021 XiaoXi (Chinese Translator) 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # InteractiveHtmlBom 2 | 3 | ⭐ 简体中文版 Interactive HTML BOM,内置可嵌入式 Python 运行环境 ⭐ 4 | 5 | [![pipeline status](https://gitlab.soraharu.com/XiaoXi/InteractiveHtmlBom/badges/master/pipeline.svg)](https://gitlab.soraharu.com/XiaoXi/InteractiveHtmlBom/-/commits/master) [![Latest Release](https://gitlab.soraharu.com/XiaoXi/InteractiveHtmlBom/-/badges/release.svg)](https://gitlab.soraharu.com/XiaoXi/InteractiveHtmlBom/-/releases) [![vercel](https://vercelbadge.soraharu.com/?app=interactivehtmlbom)](https://interactivehtmlbom.soraharu.com/) [![OSCS Status](https://www.oscs1024.com/platform/badge/yanranxiaoxi/InteractiveHtmlBom.svg?size=small)](https://www.oscs1024.com/project/yanranxiaoxi/InteractiveHtmlBom?ref=badge_small) 6 | 7 | 🔗 [GitLab (Homepage)](https://gitlab.soraharu.com/XiaoXi/InteractiveHtmlBom) | 🔗 [GitHub](https://github.com/yanranxiaoxi/InteractiveHtmlBom) 8 | 9 | ## 📦 使用 10 | 11 | 1. 克隆本项目仓库到本地 12 | 13 | ```shell 14 | git clone --depth=1 https://gitlab.soraharu.com/XiaoXi/InteractiveHtmlBom.git 15 | ``` 16 | 17 | - 当然,你也可以直接下载本项目的发布版本 [GitLab-CE](https://gitlab.soraharu.com/XiaoXi/InteractiveHtmlBom/-/releases) | [GitHub](https://github.com/yanranxiaoxi/InteractiveHtmlBom/releases) 18 | 19 | 2. 如是使用 Git 克隆项目,请 [下载打包的 Python 运行环境](https://downloadserver.soraharu.com:7000/?InteractiveHtmlBom/Python) 并解压到项目根目录 20 | 3. 将你从 [嘉立创EDA](https://lceda.cn/) 导出的 PCB 源文件(文件后缀名为 `.json`)拖动至 `Run.bat` 21 | - 或者,你也可以双击打开 `Run.bat` 后再将文件拖入窗口 22 | 4. 图形化配置界面将会出现,你可以便捷地设置各参数 23 | 5. 享受你的一天~ 24 | 25 | ## 📜 开源许可 26 | 27 | 基于 [MIT License](https://choosealicense.com/licenses/mit/) 许可进行开源。 28 | 29 | ## 💕 感谢 30 | 31 | 本项目功能代码 99% 基于 [openscopeproject/InteractiveHtmlBom](https://github.com/openscopeproject/InteractiveHtmlBom) 32 | -------------------------------------------------------------------------------- /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 | %FilePath%\python\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 | -------------------------------------------------------------------------------- /__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 | os.path.basename(sys.argv[0]) != 'generate_interactive_bom.py'): 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 | -------------------------------------------------------------------------------- /cache/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanranxiaoxi/InteractiveHtmlBom/54355ec4541083fffa46e30e39d2aa4fdc126c79/cache/.gitkeep -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanranxiaoxi/InteractiveHtmlBom/54355ec4541083fffa46e30e39d2aa4fdc126c79/core/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /core/ibom.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import io 4 | import json 5 | import logging 6 | import os 7 | import re 8 | import sys 9 | from datetime import datetime 10 | 11 | import wx 12 | 13 | from . import units 14 | from .config import Config 15 | from ..dialog import SettingsDialog 16 | from ..ecad.common import EcadParser, Component 17 | from ..errors import ParsingException 18 | 19 | 20 | class Logger(object): 21 | 22 | def __init__(self, cli=False): 23 | self.cli = cli 24 | self.logger = logging.getLogger('InteractiveHtmlBom') 25 | self.logger.setLevel(logging.INFO) 26 | ch = logging.StreamHandler(sys.stdout) 27 | ch.setLevel(logging.INFO) 28 | formatter = logging.Formatter( 29 | "%(asctime)-15s %(levelname)s %(message)s") 30 | ch.setFormatter(formatter) 31 | self.logger.addHandler(ch) 32 | 33 | def info(self, *args): 34 | if self.cli: 35 | self.logger.info(*args) 36 | 37 | def error(self, msg): 38 | if self.cli: 39 | self.logger.error(msg) 40 | else: 41 | wx.MessageBox(msg) 42 | 43 | def warn(self, msg): 44 | if self.cli: 45 | self.logger.warning(msg) 46 | else: 47 | wx.LogWarning(msg) 48 | 49 | 50 | log = None 51 | 52 | 53 | def skip_component(m, config): 54 | # type: (Component, Config) -> bool 55 | # skip blacklisted components 56 | ref_prefix = re.findall('^[A-Z]*', m.ref)[0] 57 | if m.ref in config.component_blacklist: 58 | return True 59 | if ref_prefix + '*' in config.component_blacklist: 60 | return True 61 | 62 | if config.blacklist_empty_val and m.val in ['', '~']: 63 | return True 64 | 65 | # skip virtual components if needed 66 | if config.blacklist_virtual and m.attr == 'Virtual': 67 | return True 68 | 69 | # skip components with dnp field not empty 70 | if config.dnp_field \ 71 | and config.dnp_field in m.extra_fields \ 72 | and m.extra_fields[config.dnp_field]: 73 | return True 74 | 75 | # skip components with wrong variant field 76 | if config.board_variant_field and config.board_variant_whitelist: 77 | ref_variant = m.extra_fields.get(config.board_variant_field, '') 78 | if ref_variant not in config.board_variant_whitelist: 79 | return True 80 | 81 | if config.board_variant_field and config.board_variant_blacklist: 82 | ref_variant = m.extra_fields.get(config.board_variant_field, '') 83 | if ref_variant and ref_variant in config.board_variant_blacklist: 84 | return True 85 | 86 | return False 87 | 88 | 89 | def generate_bom(pcb_footprints, config): 90 | # type: (list, Config) -> dict 91 | """ 92 | Generate BOM from pcb layout. 93 | :param pcb_footprints: list of footprints on the pcb 94 | :param config: Config object 95 | :return: dict of BOM tables (qty, value, footprint, refs) 96 | and dnp components 97 | """ 98 | 99 | def convert(text): 100 | return int(text) if text.isdigit() else text.lower() 101 | 102 | def alphanum_key(key): 103 | return [convert(c) 104 | for c in re.split('([0-9]+)', key)] 105 | 106 | def natural_sort(lst): 107 | """ 108 | Natural sort for strings containing numbers 109 | """ 110 | 111 | return sorted(lst, key=lambda r: (alphanum_key(r[0]), r[1])) 112 | 113 | # build grouped part list 114 | skipped_components = [] 115 | part_groups = {} 116 | group_by = set(config.group_fields) 117 | index_to_fields = {} 118 | 119 | for i, f in enumerate(pcb_footprints): 120 | if skip_component(f, config): 121 | skipped_components.append(i) 122 | continue 123 | 124 | # group part refs by value and footprint 125 | fields = [] 126 | group_key = [] 127 | 128 | for field in config.show_fields: 129 | if field == "Value": 130 | fields.append(f.val) 131 | if "Value" in group_by: 132 | norm_value, unit = units.componentValue(f.val, f.ref) 133 | group_key.append(norm_value) 134 | group_key.append(unit) 135 | elif field == "Footprint": 136 | fields.append(f.footprint) 137 | if "Footprint" in group_by: 138 | group_key.append(f.footprint) 139 | group_key.append(f.attr) 140 | else: 141 | fields.append(f.extra_fields.get(field, '')) 142 | if field in group_by: 143 | group_key.append(f.extra_fields.get(field, '')) 144 | 145 | index_to_fields[i] = fields 146 | refs = part_groups.setdefault(tuple(group_key), []) 147 | refs.append((f.ref, i)) 148 | 149 | bom_table = [] 150 | 151 | # If some extra fields are just integers then convert the whole column 152 | # so that sorting will work naturally 153 | for i, field in enumerate(config.show_fields): 154 | if field in ["Value", "Footprint"]: 155 | continue 156 | all_num = True 157 | for f in index_to_fields.values(): 158 | if not f[i].isdigit() and len(f[i].strip()) > 0: 159 | all_num = False 160 | break 161 | if all_num: 162 | for f in index_to_fields.values(): 163 | if f[i].isdigit(): 164 | f[i] = int(f[i]) 165 | 166 | for _, refs in part_groups.items(): 167 | # Fixup values to normalized string 168 | if "Value" in group_by and "Value" in config.show_fields: 169 | index = config.show_fields.index("Value") 170 | value = index_to_fields[refs[0][1]][index] 171 | for ref in refs: 172 | index_to_fields[ref[1]][index] = value 173 | 174 | bom_table.append(natural_sort(refs)) 175 | 176 | # sort table by reference prefix and quantity 177 | def row_sort_key(element): 178 | prefix = re.findall('^[^0-9]*', element[0][0])[0] 179 | if prefix in config.component_sort_order: 180 | ref_ord = config.component_sort_order.index(prefix) 181 | else: 182 | ref_ord = config.component_sort_order.index('~') 183 | return ref_ord, -len(element), alphanum_key(element[0][0]) 184 | 185 | if '~' not in config.component_sort_order: 186 | config.component_sort_order.append('~') 187 | 188 | bom_table = sorted(bom_table, key=row_sort_key) 189 | 190 | result = { 191 | 'both': bom_table, 192 | 'skipped': skipped_components, 193 | 'fields': index_to_fields 194 | } 195 | 196 | for layer in ['F', 'B']: 197 | filtered_table = [] 198 | for row in bom_table: 199 | filtered_refs = [ref for ref in row 200 | if pcb_footprints[ref[1]].layer == layer] 201 | if filtered_refs: 202 | filtered_table.append(filtered_refs) 203 | 204 | result[layer] = sorted(filtered_table, key=row_sort_key) 205 | 206 | return result 207 | 208 | 209 | def open_file(filename): 210 | import subprocess 211 | try: 212 | if sys.platform.startswith('win'): 213 | os.startfile(filename) 214 | elif sys.platform.startswith('darwin'): 215 | subprocess.call(('open', filename)) 216 | elif sys.platform.startswith('linux'): 217 | subprocess.call(('xdg-open', filename)) 218 | except Exception as e: 219 | log.warn('Failed to open browser: {}'.format(e)) 220 | 221 | 222 | def process_substitutions(bom_name_format, pcb_file_name, metadata): 223 | # type: (str, str, dict)->str 224 | name = bom_name_format.replace('%f', os.path.splitext(pcb_file_name)[0]) 225 | name = name.replace('%p', metadata['title']) 226 | name = name.replace('%c', metadata['company']) 227 | name = name.replace('%r', metadata['revision']) 228 | name = name.replace('%d', metadata['date'].replace(':', '-')) 229 | now = datetime.now() 230 | name = name.replace('%D', now.strftime('%Y-%m-%d')) 231 | name = name.replace('%T', now.strftime('%H-%M-%S')) 232 | # sanitize the name to avoid characters illegal in file systems 233 | name = name.replace('\\', '/') 234 | name = re.sub(r'[?%*:|"<>]', '_', name) 235 | return name + '.html' 236 | 237 | 238 | def round_floats(o, precision): 239 | if isinstance(o, float): 240 | return round(o, precision) 241 | if isinstance(o, dict): 242 | return {k: round_floats(v, precision) for k, v in o.items()} 243 | if isinstance(o, (list, tuple)): 244 | return [round_floats(x, precision) for x in o] 245 | return o 246 | 247 | 248 | def get_pcbdata_javascript(pcbdata, compression): 249 | from .lzstring import LZString 250 | 251 | js = "var pcbdata = {}" 252 | pcbdata_str = json.dumps(round_floats(pcbdata, 6)) 253 | 254 | if compression: 255 | log.info("Compressing pcb data") 256 | pcbdata_str = json.dumps(LZString().compress_to_base64(pcbdata_str)) 257 | js = "var pcbdata = JSON.parse(LZString.decompressFromBase64({}))" 258 | 259 | return js.format(pcbdata_str) 260 | 261 | 262 | def generate_file(pcb_file_dir, pcb_file_name, pcbdata, config): 263 | def get_file_content(file_name): 264 | path = os.path.join(os.path.dirname(__file__), "..", "web", file_name) 265 | if not os.path.exists(path): 266 | return "" 267 | with io.open(path, 'r', encoding='utf-8') as f: 268 | return f.read() 269 | 270 | if os.path.isabs(config.bom_dest_dir): 271 | bom_file_dir = config.bom_dest_dir 272 | else: 273 | bom_file_dir = os.path.join(pcb_file_dir, config.bom_dest_dir) 274 | bom_file_name = process_substitutions( 275 | config.bom_name_format, pcb_file_name, pcbdata['metadata']) 276 | bom_file_name = os.path.join(bom_file_dir, bom_file_name) 277 | bom_file_dir = os.path.dirname(bom_file_name) 278 | if not os.path.isdir(bom_file_dir): 279 | os.makedirs(bom_file_dir) 280 | pcbdata_js = get_pcbdata_javascript(pcbdata, config.compression) 281 | log.info("Dumping pcb data") 282 | config_js = "var config = " + config.get_html_config() 283 | html = get_file_content("ibom.html") 284 | html = html.replace('///CSS///', get_file_content('ibom.css')) 285 | html = html.replace('///USERCSS///', get_file_content('user.css')) 286 | html = html.replace('///SPLITJS///', get_file_content('split.js')) 287 | html = html.replace('///LZ-STRING///', 288 | get_file_content('lz-string.js') 289 | if config.compression else '') 290 | html = html.replace('///POINTER_EVENTS_POLYFILL///', 291 | get_file_content('pep.js')) 292 | html = html.replace('///CONFIG///', config_js) 293 | html = html.replace('///UTILJS///', get_file_content('util.js')) 294 | html = html.replace('///RENDERJS///', get_file_content('render.js')) 295 | html = html.replace('///TABLEUTILJS///', get_file_content('table-util.js')) 296 | html = html.replace('///IBOMJS///', get_file_content('ibom.js')) 297 | html = html.replace('///USERJS///', get_file_content('user.js')) 298 | html = html.replace('///USERHEADER///', 299 | get_file_content('userheader.html')) 300 | html = html.replace('///USERFOOTER///', 301 | get_file_content('userfooter.html')) 302 | # Replace pcbdata last for better performance. 303 | html = html.replace('///PCBDATA///', pcbdata_js) 304 | 305 | with io.open(bom_file_name, 'wt', encoding='utf-8') as bom: 306 | bom.write(html) 307 | 308 | log.info("Created file %s", bom_file_name) 309 | return bom_file_name 310 | 311 | 312 | def main(parser, config, logger): 313 | # type: (EcadParser, Config, Logger) -> None 314 | global log 315 | log = logger 316 | pcb_file_name = os.path.basename(parser.file_name) 317 | pcb_file_dir = os.path.dirname(parser.file_name) 318 | 319 | pcbdata, components = parser.parse() 320 | if not pcbdata and not components: 321 | raise ParsingException('Parsing failed.') 322 | 323 | pcbdata["bom"] = generate_bom(components, config) 324 | pcbdata["ibom_version"] = config.version 325 | 326 | # build BOM 327 | bom_file = generate_file(pcb_file_dir, pcb_file_name, pcbdata, config) 328 | 329 | if config.open_browser: 330 | logger.info("Opening file in browser") 331 | open_file(bom_file) 332 | 333 | 334 | def run_with_dialog(parser, config, logger): 335 | # type: (EcadParser, Config, Logger) -> None 336 | def save_config(dialog_panel, locally=False): 337 | config.set_from_dialog(dialog_panel) 338 | config.save(locally) 339 | 340 | config.load_from_ini() 341 | dlg = SettingsDialog(extra_data_func=parser.parse_extra_data, 342 | extra_data_wildcard=parser.extra_data_file_filter(), 343 | config_save_func=save_config, 344 | file_name_format_hint=config.FILE_NAME_FORMAT_HINT, 345 | version=config.version) 346 | try: 347 | config.netlist_initial_directory = os.path.dirname(parser.file_name) 348 | extra_data_file = parser.latest_extra_data( 349 | extra_dirs=[config.bom_dest_dir]) 350 | if extra_data_file is not None: 351 | dlg.set_extra_data_path(extra_data_file) 352 | config.transfer_to_dialog(dlg.panel) 353 | if dlg.ShowModal() == wx.ID_OK: 354 | config.set_from_dialog(dlg.panel) 355 | main(parser, config, logger) 356 | finally: 357 | dlg.Destroy() 358 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | # 中文 Python 环境下报错 26 | # locale.setlocale(locale.LC_NUMERIC, current_locale) 27 | 28 | PREFIX_MICRO = [u"μ", u"µ", "u", "micro"] # first is \u03BC second is \u00B5 29 | PREFIX_MILLI = ["milli", "m"] 30 | PREFIX_NANO = ["nano", "n"] 31 | PREFIX_PICO = ["pico", "p"] 32 | PREFIX_KILO = ["kilo", "k"] 33 | PREFIX_MEGA = ["mega", "meg"] 34 | PREFIX_GIGA = ["giga", "g"] 35 | 36 | # All prefices 37 | PREFIX_ALL = PREFIX_PICO + PREFIX_NANO + PREFIX_MICRO + \ 38 | PREFIX_MILLI + PREFIX_KILO + PREFIX_MEGA + PREFIX_GIGA 39 | 40 | # Common methods of expressing component units 41 | UNIT_R = ["r", "ohms", "ohm", u"Ω", u"ω"] 42 | UNIT_C = ["farad", "f"] 43 | UNIT_L = ["henry", "h"] 44 | 45 | UNIT_ALL = UNIT_R + UNIT_C + UNIT_L 46 | 47 | VALUE_REGEX = re.compile( 48 | "^([0-9\\.]+)(" + "|".join(PREFIX_ALL) + ")*(" + "|".join( 49 | UNIT_ALL) + ")*(\\d*)$") 50 | 51 | REFERENCE_REGEX = re.compile("^(r|rv|c|l)(\\d+)$") 52 | 53 | 54 | def getUnit(unit): 55 | """ 56 | Return a simplified version of a units string, for comparison purposes 57 | """ 58 | if not unit: 59 | return None 60 | 61 | unit = unit.lower() 62 | 63 | if unit in UNIT_R: 64 | return "R" 65 | if unit in UNIT_C: 66 | return "F" 67 | if unit in UNIT_L: 68 | return "H" 69 | 70 | return None 71 | 72 | 73 | def getPrefix(prefix): 74 | """ 75 | Return the (numerical) value of a given prefix 76 | """ 77 | if not prefix: 78 | return 1 79 | 80 | prefix = prefix.lower() 81 | 82 | if prefix in PREFIX_PICO: 83 | return 1.0e-12 84 | if prefix in PREFIX_NANO: 85 | return 1.0e-9 86 | if prefix in PREFIX_MICRO: 87 | return 1.0e-6 88 | if prefix in PREFIX_MILLI: 89 | return 1.0e-3 90 | if prefix in PREFIX_KILO: 91 | return 1.0e3 92 | if prefix in PREFIX_MEGA: 93 | return 1.0e6 94 | if prefix in PREFIX_GIGA: 95 | return 1.0e9 96 | 97 | return 1 98 | 99 | 100 | def compMatch(component): 101 | """ 102 | Return a normalized value and units for a given component value string 103 | e.g. compMatch("10R2") returns (1000, R) 104 | e.g. compMatch("3.3mOhm") returns (0.0033, R) 105 | """ 106 | component = component.strip().lower() 107 | if decimal_separator == ',': 108 | # replace separator with dot 109 | component = component.replace(",", ".") 110 | else: 111 | # remove thousands separator 112 | component = component.replace(",", "") 113 | 114 | result = VALUE_REGEX.match(component) 115 | 116 | if not result: 117 | return None 118 | 119 | if not len(result.groups()) == 4: 120 | return None 121 | 122 | value, prefix, units, post = result.groups() 123 | 124 | # special case where units is in the middle of the string 125 | # e.g. "0R05" for 0.05Ohm 126 | # in this case, we will NOT have a decimal 127 | # we will also have a trailing number 128 | 129 | if post and "." not in value: 130 | try: 131 | value = float(int(value)) 132 | postValue = float(int(post)) / (10 ** len(post)) 133 | value = value * 1.0 + postValue 134 | except ValueError: 135 | return None 136 | 137 | try: 138 | val = float(value) 139 | except ValueError: 140 | return None 141 | 142 | val = "{0:.15f}".format(val * 1.0 * getPrefix(prefix)) 143 | 144 | return (val, getUnit(units)) 145 | 146 | 147 | def componentValue(valString, reference): 148 | # type: (str, str) -> tuple 149 | result = compMatch(valString) 150 | 151 | if not result: 152 | return valString, None # return the same string back with `None` unit 153 | 154 | if not len(result) == 2: # result length is incorrect 155 | return valString, None # return the same string back with `None` unit 156 | 157 | if result[1] is None: 158 | # try to infer unit from reference 159 | match = REFERENCE_REGEX.match(reference.lower()) 160 | if match and len(match.groups()) == 2: 161 | prefix, _ = match.groups() 162 | unit = None 163 | if prefix in ['r', 'rv']: 164 | unit = 'R' 165 | if prefix == 'c': 166 | unit = 'F' 167 | if prefix == 'l': 168 | unit = 'H' 169 | result = (result[0], unit) 170 | 171 | return result # (val,unit) 172 | 173 | 174 | def compareValues(c1, c2): 175 | r1 = compMatch(c1) 176 | r2 = compMatch(c2) 177 | 178 | if not r1 or not r2: 179 | return False 180 | 181 | (v1, u1) = r1 182 | (v2, u2) = r2 183 | 184 | if v1 == v2: 185 | # values match 186 | if u1 == u2: 187 | return True # units match 188 | if not u1: 189 | return True # no units for component 1 190 | if not u2: 191 | return True # no units for component 2 192 | 193 | return False 194 | -------------------------------------------------------------------------------- /dialog/__init__.py: -------------------------------------------------------------------------------- 1 | from .settings_dialog import SettingsDialog, GeneralSettingsPanel 2 | -------------------------------------------------------------------------------- /dialog/bitmaps/btn-arrow-down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanranxiaoxi/InteractiveHtmlBom/54355ec4541083fffa46e30e39d2aa4fdc126c79/dialog/bitmaps/btn-arrow-down.png -------------------------------------------------------------------------------- /dialog/bitmaps/btn-arrow-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanranxiaoxi/InteractiveHtmlBom/54355ec4541083fffa46e30e39d2aa4fdc126c79/dialog/bitmaps/btn-arrow-up.png -------------------------------------------------------------------------------- /dialog/bitmaps/btn-minus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanranxiaoxi/InteractiveHtmlBom/54355ec4541083fffa46e30e39d2aa4fdc126c79/dialog/bitmaps/btn-minus.png -------------------------------------------------------------------------------- /dialog/bitmaps/btn-plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanranxiaoxi/InteractiveHtmlBom/54355ec4541083fffa46e30e39d2aa4fdc126c79/dialog/bitmaps/btn-plus.png -------------------------------------------------------------------------------- /dialog/bitmaps/btn-question.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanranxiaoxi/InteractiveHtmlBom/54355ec4541083fffa46e30e39d2aa4fdc126c79/dialog/bitmaps/btn-question.png -------------------------------------------------------------------------------- /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, "常规") 71 | self.notebook.AddPage(self.html, "HTML 默认值") 72 | self.notebook.AddPage(self.fields, "字段") 73 | 74 | self.save_menu = wx.Menu() 75 | self.save_locally = self.save_menu.Append( 76 | wx.ID_ANY, u"本地", wx.EmptyString, wx.ITEM_NORMAL) 77 | self.save_globally = self.save_menu.Append( 78 | wx.ID_ANY, u"全局", 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 | "A-Z 以外的字符将被忽略。", 162 | "添加排序顺序项") 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("你不能删除 '~' 项") 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 | "A-Z 0-9 和 * 以外的字符将被忽略。", 188 | "添加黑名单项") 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, '文件名称格式帮助', 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 | FIELDS_GRID_COLUMNS = 3 222 | 223 | def __init__(self, parent, extra_data_func, extra_data_wildcard): 224 | dialog_base.FieldsPanelBase.__init__(self, parent) 225 | self.extra_data_func = extra_data_func 226 | self.extra_field_data = None 227 | 228 | self.m_btnUp.SetBitmap(get_btn_bitmap("btn-arrow-up.png")) 229 | self.m_btnDown.SetBitmap(get_btn_bitmap("btn-arrow-down.png")) 230 | 231 | self.set_file_picker_wildcard(extra_data_wildcard) 232 | self._setFieldsList([]) 233 | for i in range(2): 234 | box = self.GetTextExtent(self.fieldsGrid.GetColLabelValue(i)) 235 | if hasattr(box, "x"): 236 | width = box.x 237 | else: 238 | width = box[0] 239 | width = int(width * 1.1 + 5) 240 | self.fieldsGrid.SetColMinimalWidth(i, width) 241 | self.fieldsGrid.SetColSize(i, width) 242 | 243 | self.Layout() 244 | 245 | def set_file_picker_wildcard(self, extra_data_wildcard): 246 | if extra_data_wildcard is None: 247 | self.extraDataFilePicker.Disable() 248 | return 249 | 250 | # wxFilePickerCtrl doesn't support changing wildcard at runtime 251 | # so we have to replace it 252 | picker_parent = self.extraDataFilePicker.GetParent() 253 | new_picker = wx.FilePickerCtrl( 254 | picker_parent, wx.ID_ANY, wx.EmptyString, 255 | u"选择文件", 256 | extra_data_wildcard, 257 | wx.DefaultPosition, wx.DefaultSize, 258 | (wx.FLP_DEFAULT_STYLE | wx.FLP_FILE_MUST_EXIST | wx.FLP_OPEN | 259 | wx.FLP_SMALL | wx.FLP_USE_TEXTCTRL | wx.BORDER_SIMPLE)) 260 | self.GetSizer().Replace(self.extraDataFilePicker, new_picker, 261 | recursive=True) 262 | self.extraDataFilePicker.Destroy() 263 | self.extraDataFilePicker = new_picker 264 | self.Layout() 265 | 266 | def _swapRows(self, a, b): 267 | for i in range(self.FIELDS_GRID_COLUMNS): 268 | va = self.fieldsGrid.GetCellValue(a, i) 269 | vb = self.fieldsGrid.GetCellValue(b, i) 270 | self.fieldsGrid.SetCellValue(a, i, vb) 271 | self.fieldsGrid.SetCellValue(b, i, va) 272 | 273 | # Handlers for FieldsPanelBase events. 274 | def OnGridCellClicked(self, event): 275 | self.fieldsGrid.ClearSelection() 276 | self.fieldsGrid.SelectRow(event.Row) 277 | if event.Col < 2: 278 | # toggle checkbox 279 | val = self.fieldsGrid.GetCellValue(event.Row, event.Col) 280 | val = "" if val else "1" 281 | self.fieldsGrid.SetCellValue(event.Row, event.Col, val) 282 | # group shouldn't be enabled without show 283 | if event.Col == 0 and val == "": 284 | self.fieldsGrid.SetCellValue(event.Row, 1, val) 285 | if event.Col == 1 and val == "1": 286 | self.fieldsGrid.SetCellValue(event.Row, 0, val) 287 | 288 | def OnFieldsUp(self, event): 289 | selection = self.fieldsGrid.SelectedRows 290 | if len(selection) == 1 and selection[0] > 0: 291 | self._swapRows(selection[0], selection[0] - 1) 292 | self.fieldsGrid.ClearSelection() 293 | self.fieldsGrid.SelectRow(selection[0] - 1) 294 | 295 | def OnFieldsDown(self, event): 296 | selection = self.fieldsGrid.SelectedRows 297 | size = self.fieldsGrid.NumberRows 298 | if len(selection) == 1 and selection[0] < size - 1: 299 | self._swapRows(selection[0], selection[0] + 1) 300 | self.fieldsGrid.ClearSelection() 301 | self.fieldsGrid.SelectRow(selection[0] + 1) 302 | 303 | def _setFieldsList(self, fields): 304 | if self.fieldsGrid.NumberRows: 305 | self.fieldsGrid.DeleteRows(0, self.fieldsGrid.NumberRows) 306 | self.fieldsGrid.AppendRows(len(fields)) 307 | row = 0 308 | for f in fields: 309 | self.fieldsGrid.SetCellValue(row, 0, "1") 310 | self.fieldsGrid.SetCellValue(row, 1, "1") 311 | self.fieldsGrid.SetCellRenderer( 312 | row, 0, wx.grid.GridCellBoolRenderer()) 313 | self.fieldsGrid.SetCellRenderer( 314 | row, 1, wx.grid.GridCellBoolRenderer()) 315 | self.fieldsGrid.SetCellValue(row, 2, f) 316 | self.fieldsGrid.SetCellAlignment( 317 | row, 2, wx.ALIGN_LEFT, wx.ALIGN_TOP) 318 | self.fieldsGrid.SetReadOnly(row, 2) 319 | row += 1 320 | 321 | def OnExtraDataFileChanged(self, event): 322 | extra_data_file = self.extraDataFilePicker.Path 323 | if not os.path.isfile(extra_data_file): 324 | return 325 | 326 | self.extra_field_data = None 327 | try: 328 | self.extra_field_data = self.extra_data_func( 329 | extra_data_file, self.normalizeCaseCheckbox.Value) 330 | except Exception as e: 331 | pop_error( 332 | "未能解析文件 %s\n\n%s" % (extra_data_file, e)) 333 | self.extraDataFilePicker.Path = '' 334 | 335 | if self.extra_field_data is not None: 336 | field_list = list(self.extra_field_data[0]) 337 | self._setFieldsList(["Value", "Footprint"] + field_list) 338 | field_list.append(self.NONE_STRING) 339 | self.boardVariantFieldBox.SetItems(field_list) 340 | self.boardVariantFieldBox.SetStringSelection(self.NONE_STRING) 341 | self.boardVariantWhitelist.Clear() 342 | self.boardVariantBlacklist.Clear() 343 | self.dnpFieldBox.SetItems(field_list) 344 | self.dnpFieldBox.SetStringSelection(self.NONE_STRING) 345 | 346 | def OnBoardVariantFieldChange(self, event): 347 | selection = self.boardVariantFieldBox.Value 348 | if not selection or selection == self.NONE_STRING \ 349 | or self.extra_field_data is None: 350 | self.boardVariantWhitelist.Clear() 351 | self.boardVariantBlacklist.Clear() 352 | return 353 | variant_set = set() 354 | for _, field_dict in self.extra_field_data[1].items(): 355 | if selection in field_dict: 356 | variant_set.add(field_dict[selection]) 357 | self.boardVariantWhitelist.SetItems(list(variant_set)) 358 | self.boardVariantBlacklist.SetItems(list(variant_set)) 359 | 360 | def OnSize(self, event): 361 | self.Layout() 362 | g = self.fieldsGrid 363 | g.SetColSize( 364 | 2, g.GetClientSize().x - g.GetColSize(0) - g.GetColSize(1) - 30) 365 | 366 | def GetShowFields(self): 367 | result = [] 368 | for row in range(self.fieldsGrid.NumberRows): 369 | if self.fieldsGrid.GetCellValue(row, 0) == "1": 370 | result.append(self.fieldsGrid.GetCellValue(row, 2)) 371 | return result 372 | 373 | def GetGroupFields(self): 374 | result = [] 375 | for row in range(self.fieldsGrid.NumberRows): 376 | if self.fieldsGrid.GetCellValue(row, 1) == "1": 377 | result.append(self.fieldsGrid.GetCellValue(row, 2)) 378 | return result 379 | 380 | def SetCheckedFields(self, show, group): 381 | group = [s for s in group if s in show] 382 | current = [] 383 | for row in range(self.fieldsGrid.NumberRows): 384 | current.append(self.fieldsGrid.GetCellValue(row, 2)) 385 | new = [s for s in current if s not in show] 386 | self._setFieldsList(show + new) 387 | for row in range(self.fieldsGrid.NumberRows): 388 | field = self.fieldsGrid.GetCellValue(row, 2) 389 | self.fieldsGrid.SetCellValue(row, 0, "1" if field in show else "") 390 | self.fieldsGrid.SetCellValue(row, 1, "1" if field in group else "") 391 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ecad/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def get_parser_by_extension(file_name, config, logger): 5 | ext = os.path.splitext(file_name)[1] 6 | if ext == '.kicad_pcb': 7 | return get_kicad_parser(file_name, config, logger) 8 | elif ext == '.json': 9 | """.json file may be from EasyEDA or a generic json format""" 10 | import io 11 | import json 12 | with io.open(file_name, 'r', encoding='utf-8') as f: 13 | obj = json.load(f) 14 | if 'pcbdata' in obj: 15 | return get_generic_json_parser(file_name, config, logger) 16 | else: 17 | return get_easyeda_parser(file_name, config, logger) 18 | else: 19 | return None 20 | 21 | 22 | def get_kicad_parser(file_name, config, logger, board=None): 23 | from .kicad import PcbnewParser 24 | return PcbnewParser(file_name, config, logger, board) 25 | 26 | 27 | def get_easyeda_parser(file_name, config, logger): 28 | from .easyeda import EasyEdaParser 29 | return EasyEdaParser(file_name, config, logger) 30 | 31 | 32 | def get_generic_json_parser(file_name, config, logger): 33 | from .genericjson import GenericJsonParser 34 | return GenericJsonParser(file_name, config, logger) 35 | -------------------------------------------------------------------------------- /ecad/common.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from .svgpath import parse_path 4 | 5 | 6 | class EcadParser(object): 7 | 8 | def __init__(self, file_name, config, logger): 9 | """ 10 | :param file_name: path to file that should be parsed. 11 | :param config: Config instance 12 | :param logger: logging object. 13 | """ 14 | self.file_name = file_name 15 | self.config = config 16 | self.logger = logger 17 | 18 | def parse(self): 19 | """ 20 | Abstract method that should be overridden in implementations. 21 | Performs all the parsing and returns a tuple of 22 | (pcbdata, components) 23 | pcbdata is described in DATAFORMAT.md 24 | components is list of Component objects 25 | :return: 26 | """ 27 | pass 28 | 29 | @staticmethod 30 | def normalize_field_names(data): 31 | field_map = {f.lower(): f for f in reversed(data[0])} 32 | 33 | def remap(ref_fields): 34 | return {field_map[f.lower()]: v for (f, v) in 35 | sorted(ref_fields.items(), reverse=True)} 36 | 37 | field_data = {r: remap(d) for (r, d) in data[1].items()} 38 | return field_map.values(), field_data 39 | 40 | def get_extra_field_data(self, file_name): 41 | """ 42 | Abstract method that may be overridden in implementations that support 43 | extra field data. 44 | :return: tuple of the format 45 | ( 46 | [field_name1, field_name2,... ], 47 | { 48 | ref1: { 49 | field_name1: field_value1, 50 | field_name2: field_value2, 51 | ... 52 | ], 53 | ref2: ... 54 | } 55 | ) 56 | """ 57 | return [], {} 58 | 59 | def parse_extra_data(self, file_name, normalize_case): 60 | """ 61 | Parses the file and returns extra field data. 62 | :param file_name: path to file containing extra data 63 | :param normalize_case: if true, normalize case so that 64 | "mpn", "Mpn", "MPN" fields are combined 65 | :return: 66 | """ 67 | data = self.get_extra_field_data(file_name) 68 | if normalize_case: 69 | data = self.normalize_field_names(data) 70 | return sorted(data[0]), data[1] 71 | 72 | def latest_extra_data(self, extra_dirs=None): 73 | """ 74 | Abstract method that may be overridden in implementations that support 75 | extra field data. 76 | :param extra_dirs: List of extra directories to search. 77 | :return: File name of most recent file with extra field data. 78 | """ 79 | return None 80 | 81 | def extra_data_file_filter(self): 82 | """ 83 | Abstract method that may be overridden in implementations that support 84 | extra field data. 85 | :return: File open dialog filter string, eg: 86 | "Netlist and xml files (*.net; *.xml)|*.net;*.xml" 87 | """ 88 | return None 89 | 90 | def add_drawing_bounding_box(self, drawing, bbox): 91 | # type: (dict, BoundingBox) -> None 92 | 93 | def add_segment(): 94 | bbox.add_segment(drawing['start'][0], drawing['start'][1], 95 | drawing['end'][0], drawing['end'][1], 96 | drawing['width'] / 2) 97 | 98 | def add_circle(): 99 | bbox.add_circle(drawing['start'][0], drawing['start'][1], 100 | drawing['radius'] + drawing['width'] / 2) 101 | 102 | def add_svgpath(): 103 | width = drawing.get('width', 0) 104 | bbox.add_svgpath(drawing['svgpath'], width, self.logger) 105 | 106 | def add_polygon(): 107 | if 'polygons' not in drawing: 108 | add_svgpath() 109 | return 110 | polygon = drawing['polygons'][0] 111 | for point in polygon: 112 | bbox.add_point(point[0], point[1]) 113 | 114 | { 115 | 'segment': add_segment, 116 | 'rect': add_segment, # bbox of a rect and segment are the same 117 | 'circle': add_circle, 118 | 'arc': add_svgpath, 119 | 'polygon': add_polygon, 120 | 'text': lambda: None, # text is not really needed for bounding box 121 | }.get(drawing['type'])() 122 | 123 | 124 | class Component(object): 125 | """Simple data object to store component data needed for bom table.""" 126 | 127 | def __init__(self, ref, val, footprint, layer, attr=None, extra_fields={}): 128 | self.ref = ref 129 | self.val = val 130 | self.footprint = footprint 131 | self.layer = layer 132 | self.attr = attr 133 | self.extra_fields = extra_fields 134 | 135 | 136 | class BoundingBox(object): 137 | """Geometry util to calculate and compound bounding box of simple shapes.""" 138 | 139 | def __init__(self): 140 | self._x0 = None 141 | self._y0 = None 142 | self._x1 = None 143 | self._y1 = None 144 | 145 | def to_dict(self): 146 | # type: () -> dict 147 | return { 148 | "minx": self._x0, 149 | "miny": self._y0, 150 | "maxx": self._x1, 151 | "maxy": self._y1, 152 | } 153 | 154 | def to_component_dict(self): 155 | # type: () -> dict 156 | return { 157 | "pos": [self._x0, self._y0], 158 | "relpos": [0, 0], 159 | "size": [self._x1 - self._x0, self._y1 - self._y0], 160 | "angle": 0, 161 | } 162 | 163 | def add(self, other): 164 | """Add another bounding box. 165 | :type other: BoundingBox 166 | """ 167 | if other._x0 is not None: 168 | self.add_point(other._x0, other._y0) 169 | self.add_point(other._x1, other._y1) 170 | return self 171 | 172 | @staticmethod 173 | def _rotate(x, y, rx, ry, angle): 174 | sin = math.sin(math.radians(angle)) 175 | cos = math.cos(math.radians(angle)) 176 | new_x = rx + (x - rx) * cos - (y - ry) * sin 177 | new_y = ry + (x - rx) * sin + (y - ry) * cos 178 | return new_x, new_y 179 | 180 | def add_point(self, x, y, rx=0, ry=0, angle=0): 181 | x, y = self._rotate(x, y, rx, ry, angle) 182 | if self._x0 is None: 183 | self._x0 = x 184 | self._y0 = y 185 | self._x1 = x 186 | self._y1 = y 187 | else: 188 | self._x0 = min(self._x0, x) 189 | self._y0 = min(self._y0, y) 190 | self._x1 = max(self._x1, x) 191 | self._y1 = max(self._y1, y) 192 | return self 193 | 194 | def add_segment(self, x0, y0, x1, y1, r): 195 | self.add_circle(x0, y0, r) 196 | self.add_circle(x1, y1, r) 197 | return self 198 | 199 | def add_rectangle(self, x, y, w, h, angle=0): 200 | self.add_point(x - w / 2, y - h / 2, x, y, angle) 201 | self.add_point(x + w / 2, y - h / 2, x, y, angle) 202 | self.add_point(x - w / 2, y + h / 2, x, y, angle) 203 | self.add_point(x + w / 2, y + h / 2, x, y, angle) 204 | return self 205 | 206 | def add_circle(self, x, y, r): 207 | self.add_point(x - r, y) 208 | self.add_point(x, y - r) 209 | self.add_point(x + r, y) 210 | self.add_point(x, y + r) 211 | return self 212 | 213 | def add_svgpath(self, svgpath, width, logger): 214 | w = width / 2 215 | for segment in parse_path(svgpath, logger): 216 | x0, x1, y0, y1 = segment.bbox() 217 | self.add_point(x0 - w, y0 - w) 218 | self.add_point(x1 + w, y1 + w) 219 | 220 | def pad(self, amount): 221 | """Add small padding to the box.""" 222 | if self._x0 is not None: 223 | self._x0 -= amount 224 | self._y0 -= amount 225 | self._x1 += amount 226 | self._y1 += amount 227 | 228 | def initialized(self): 229 | return self._x0 is not None 230 | -------------------------------------------------------------------------------- /ecad/easyeda.py: -------------------------------------------------------------------------------- 1 | import io 2 | import sys 3 | 4 | from .common import EcadParser, Component, BoundingBox 5 | 6 | 7 | if sys.version_info >= (3, 0): 8 | string_types = str 9 | else: 10 | string_types = basestring # noqa F821: ignore undefined 11 | 12 | 13 | class EasyEdaParser(EcadParser): 14 | TOP_COPPER_LAYER = 1 15 | BOT_COPPER_LAYER = 2 16 | TOP_SILK_LAYER = 3 17 | BOT_SILK_LAYER = 4 18 | BOARD_OUTLINE_LAYER = 10 19 | TOP_ASSEMBLY_LAYER = 13 20 | BOT_ASSEMBLY_LAYER = 14 21 | ALL_LAYERS = 11 22 | 23 | def get_easyeda_pcb(self): 24 | import json 25 | with io.open(self.file_name, 'r', encoding='utf-8') as f: 26 | return json.load(f) 27 | 28 | @staticmethod 29 | def tilda_split(s): 30 | # type: (str) -> list 31 | return s.split('~') 32 | 33 | @staticmethod 34 | def sharp_split(s): 35 | # type: (str) -> list 36 | return s.split('#@$') 37 | 38 | def _verify(self, pcb): 39 | """Spot check the pcb object.""" 40 | if 'head' not in pcb: 41 | self.logger.error('No head attribute.') 42 | return False 43 | head = pcb['head'] 44 | if len(head) < 2: 45 | self.logger.error('Incorrect head attribute ' + pcb['head']) 46 | return False 47 | if head['docType'] != '3': 48 | self.logger.error('Incorrect document type: ' + head['docType']) 49 | return False 50 | if 'canvas' not in pcb: 51 | self.logger.error('No canvas attribute.') 52 | return False 53 | canvas = self.tilda_split(pcb['canvas']) 54 | if len(canvas) < 18: 55 | self.logger.error('Incorrect canvas attribute ' + pcb['canvas']) 56 | return False 57 | self.logger.info('EasyEDA editor version ' + head['editorVersion']) 58 | return True 59 | 60 | @staticmethod 61 | def normalize(v): 62 | if isinstance(v, string_types): 63 | v = float(v) 64 | return v 65 | 66 | def parse_track(self, shape): 67 | shape = self.tilda_split(shape) 68 | assert len(shape) >= 5, 'Invalid track ' + str(shape) 69 | width = self.normalize(shape[0]) 70 | layer = int(shape[1]) 71 | points = [self.normalize(v) for v in shape[3].split(' ')] 72 | 73 | points_xy = [[points[i], points[i + 1]] for i in 74 | range(0, len(points), 2)] 75 | segments = [(points_xy[i], points_xy[i + 1]) for i in 76 | range(len(points_xy) - 1)] 77 | segments_json = [] 78 | for segment in segments: 79 | segments_json.append({ 80 | "type": "segment", 81 | "start": segment[0], 82 | "end": segment[1], 83 | "width": width, 84 | }) 85 | 86 | return layer, segments_json 87 | 88 | def parse_rect(self, shape): 89 | shape = self.tilda_split(shape) 90 | assert len(shape) >= 9, 'Invalid rect ' + str(shape) 91 | x = self.normalize(shape[0]) 92 | y = self.normalize(shape[1]) 93 | width = self.normalize(shape[2]) 94 | height = self.normalize(shape[3]) 95 | layer = int(shape[4]) 96 | fill = shape[8] 97 | 98 | if fill == "none": 99 | thickness = self.normalize(shape[7]) 100 | return layer, [{ 101 | "type": "rect", 102 | "start": [x, y], 103 | "end": [x + width, y + height], 104 | "width": thickness, 105 | }] 106 | else: 107 | return layer, [{ 108 | "type": "polygon", 109 | "pos": [x, y], 110 | "angle": 0, 111 | "polygons": [ 112 | [[0, 0], [width, 0], [width, height], [0, height]] 113 | ] 114 | }] 115 | 116 | def parse_circle(self, shape): 117 | shape = self.tilda_split(shape) 118 | assert len(shape) >= 6, 'Invalid circle ' + str(shape) 119 | cx = self.normalize(shape[0]) 120 | cy = self.normalize(shape[1]) 121 | r = self.normalize(shape[2]) 122 | width = self.normalize(shape[3]) 123 | layer = int(shape[4]) 124 | 125 | return layer, [{ 126 | "type": "circle", 127 | "start": [cx, cy], 128 | "radius": r, 129 | "width": width 130 | }] 131 | 132 | def parse_solid_region(self, shape): 133 | shape = self.tilda_split(shape) 134 | assert len(shape) >= 5, 'Invalid solid region ' + str(shape) 135 | layer = int(shape[0]) 136 | svgpath = shape[2] 137 | 138 | return layer, [{ 139 | "type": "polygon", 140 | "svgpath": svgpath, 141 | }] 142 | 143 | def parse_text(self, shape): 144 | shape = self.tilda_split(shape) 145 | assert len(shape) >= 12, 'Invalid text ' + str(shape) 146 | text_type = shape[0] 147 | stroke_width = self.normalize(shape[3]) 148 | layer = int(shape[6]) 149 | text = shape[9] 150 | svgpath = shape[10] 151 | hide = shape[11] 152 | 153 | return layer, [{ 154 | "type": "text", 155 | "text": text, 156 | "thickness": stroke_width, 157 | "attr": [], 158 | "svgpath": svgpath, 159 | "hide": hide, 160 | "text_type": text_type, 161 | }] 162 | 163 | def parse_arc(self, shape): 164 | shape = self.tilda_split(shape) 165 | assert len(shape) >= 6, 'Invalid arc ' + str(shape) 166 | width = self.normalize(shape[0]) 167 | layer = int(shape[1]) 168 | svgpath = shape[3] 169 | 170 | return layer, [{ 171 | "type": "arc", 172 | "svgpath": svgpath, 173 | "width": width 174 | }] 175 | 176 | def parse_hole(self, shape): 177 | shape = self.tilda_split(shape) 178 | assert len(shape) >= 4, 'Invalid hole ' + str(shape) 179 | cx = self.normalize(shape[0]) 180 | cy = self.normalize(shape[1]) 181 | radius = self.normalize(shape[2]) 182 | 183 | return self.BOARD_OUTLINE_LAYER, [{ 184 | "type": "circle", 185 | "start": [cx, cy], 186 | "radius": radius, 187 | "width": 0.1, # 1 mil 188 | }] 189 | 190 | def parse_pad(self, shape): 191 | shape = self.tilda_split(shape) 192 | assert len(shape) >= 15, 'Invalid pad ' + str(shape) 193 | pad_shape = shape[0] 194 | x = self.normalize(shape[1]) 195 | y = self.normalize(shape[2]) 196 | width = self.normalize(shape[3]) 197 | height = self.normalize(shape[4]) 198 | layer = int(shape[5]) 199 | number = shape[7] 200 | hole_radius = self.normalize(shape[8]) 201 | if shape[9]: 202 | points = [self.normalize(v) for v in shape[9].split(' ')] 203 | else: 204 | points = [] 205 | angle = int(shape[10]) 206 | hole_length = self.normalize(shape[12]) if shape[12] else 0 207 | 208 | pad_layers = { 209 | self.TOP_COPPER_LAYER: ['F'], 210 | self.BOT_COPPER_LAYER: ['B'], 211 | self.ALL_LAYERS: ['F', 'B'] 212 | }.get(layer) 213 | pad_shape = { 214 | "ELLIPSE": "circle", 215 | "RECT": "rect", 216 | "OVAL": "oval", 217 | "POLYGON": "custom", 218 | }.get(pad_shape) 219 | pad_type = "smd" if len(pad_layers) == 1 else "th" 220 | 221 | json = { 222 | "layers": pad_layers, 223 | "pos": [x, y], 224 | "size": [width, height], 225 | "angle": angle, 226 | "shape": pad_shape, 227 | "type": pad_type, 228 | } 229 | if number == '1': 230 | json['pin1'] = 1 231 | if pad_shape == "custom": 232 | polygon = [(points[i], points[i + 1]) for i in 233 | range(0, len(points), 2)] 234 | # translate coordinates to be relative to footprint 235 | polygon = [(p[0] - x, p[1] - y) for p in polygon] 236 | json["polygons"] = [polygon] 237 | json["angle"] = 0 238 | if pad_type == "th": 239 | if hole_length > 1e-6: 240 | json["drillshape"] = "oblong" 241 | json["drillsize"] = [hole_radius * 2, hole_length] 242 | else: 243 | json["drillshape"] = "circle" 244 | json["drillsize"] = [hole_radius * 2, hole_radius * 2] 245 | 246 | return layer, [{ 247 | "type": "pad", 248 | "pad": json, 249 | }] 250 | 251 | @staticmethod 252 | def add_pad_bounding_box(pad, bbox): 253 | # type: (dict, BoundingBox) -> None 254 | 255 | def add_circle(): 256 | bbox.add_circle(pad['pos'][0], pad['pos'][1], pad['size'][0] / 2) 257 | 258 | def add_rect(): 259 | bbox.add_rectangle(pad['pos'][0], pad['pos'][1], 260 | pad['size'][0], pad['size'][1], 261 | pad['angle']) 262 | 263 | def add_custom(): 264 | x = pad['pos'][0] 265 | y = pad['pos'][1] 266 | polygon = pad['polygons'][0] 267 | for point in polygon: 268 | bbox.add_point(x + point[0], y + point[1]) 269 | 270 | { 271 | 'circle': add_circle, 272 | 'rect': add_rect, 273 | 'oval': add_rect, 274 | 'custom': add_custom, 275 | }.get(pad['shape'])() 276 | 277 | def parse_lib(self, shape): 278 | parts = self.sharp_split(shape) 279 | head = self.tilda_split(parts[0]) 280 | inner_shapes, _, _ = self.parse_shapes(parts[1:]) 281 | x = self.normalize(head[0]) 282 | y = self.normalize(head[1]) 283 | attr = head[2] 284 | fp_layer = int(head[6]) 285 | 286 | attr = attr.split('`') 287 | if len(attr) % 2 != 0: 288 | attr.pop() 289 | attr = {attr[i]: attr[i + 1] for i in range(0, len(attr), 2)} 290 | fp_layer = 'F' if fp_layer == self.TOP_COPPER_LAYER else 'B' 291 | val = '??' 292 | ref = '??' 293 | footprint = attr.get('package', '??') 294 | 295 | pads = [] 296 | copper_drawings = [] 297 | extra_drawings = [] 298 | bbox = BoundingBox() 299 | for layer, shapes in inner_shapes.items(): 300 | for s in shapes: 301 | if s["type"] == "pad": 302 | pads.append(s["pad"]) 303 | continue 304 | if s["type"] == "text": 305 | if s["text_type"] == "N": 306 | val = s["text"] 307 | if s["text_type"] == "P": 308 | ref = s["text"] 309 | del s["text_type"] 310 | if s["hide"]: 311 | continue 312 | if layer in [self.TOP_COPPER_LAYER, self.BOT_COPPER_LAYER]: 313 | copper_drawings.append({ 314 | "layer": ( 315 | 'F' if layer == self.TOP_COPPER_LAYER else 'B'), 316 | "drawing": s, 317 | }) 318 | elif layer in [self.TOP_SILK_LAYER, 319 | self.BOT_SILK_LAYER, 320 | self.TOP_ASSEMBLY_LAYER, 321 | self.BOT_ASSEMBLY_LAYER, 322 | self.BOARD_OUTLINE_LAYER]: 323 | extra_drawings.append((layer, s)) 324 | 325 | for pad in pads: 326 | self.add_pad_bounding_box(pad, bbox) 327 | for drawing in copper_drawings: 328 | self.add_drawing_bounding_box(drawing['drawing'], bbox) 329 | for _, drawing in extra_drawings: 330 | self.add_drawing_bounding_box(drawing, bbox) 331 | bbox.pad(0.5) # pad by 5 mil 332 | if not bbox.initialized(): 333 | # if bounding box is not calculated yet 334 | # set it to 100x100 mil square 335 | bbox.add_rectangle(x, y, 10, 10, 0) 336 | 337 | footprint_json = { 338 | "ref": ref, 339 | "center": [x, y], 340 | "bbox": bbox.to_component_dict(), 341 | "pads": pads, 342 | "drawings": copper_drawings, 343 | "layer": fp_layer, 344 | } 345 | 346 | component = Component(ref, val, footprint, fp_layer) 347 | 348 | return fp_layer, component, footprint_json, extra_drawings 349 | 350 | def parse_shapes(self, shapes): 351 | drawings = {} 352 | footprints = [] 353 | components = [] 354 | 355 | for shape_str in shapes: 356 | shape = shape_str.split('~', 1) 357 | parse_func = { 358 | 'TRACK': self.parse_track, 359 | 'RECT': self.parse_rect, 360 | 'CIRCLE': self.parse_circle, 361 | 'SOLIDREGION': self.parse_solid_region, 362 | 'TEXT': self.parse_text, 363 | 'ARC': self.parse_arc, 364 | 'PAD': self.parse_pad, 365 | 'HOLE': self.parse_hole, 366 | }.get(shape[0], None) 367 | if parse_func: 368 | layer, json_list = parse_func(shape[1]) 369 | drawings.setdefault(layer, []).extend(json_list) 370 | if shape[0] == 'LIB': 371 | layer, component, json, extras = self.parse_lib(shape[1]) 372 | for drawing_layer, drawing in extras: 373 | drawings.setdefault(drawing_layer, []).append(drawing) 374 | footprints.append(json) 375 | components.append(component) 376 | 377 | return drawings, footprints, components 378 | 379 | def get_metadata(self, pcb): 380 | if hasattr(pcb, 'metadata'): 381 | return pcb.metadata 382 | else: 383 | import os 384 | from datetime import datetime 385 | pcb_file_name = os.path.basename(self.file_name) 386 | title = os.path.splitext(pcb_file_name)[0] 387 | file_mtime = os.path.getmtime(self.file_name) 388 | file_date = datetime.fromtimestamp(file_mtime).strftime( 389 | '%Y-%m-%d %H:%M:%S') 390 | return { 391 | "title": title, 392 | "revision": "", 393 | "company": "", 394 | "date": file_date, 395 | } 396 | 397 | def parse(self): 398 | pcb = self.get_easyeda_pcb() 399 | if not self._verify(pcb): 400 | self.logger.error( 401 | 'File ' + self.file_name + 402 | ' does not appear to be valid EasyEDA json file.') 403 | return None, None 404 | 405 | drawings, footprints, components = self.parse_shapes(pcb['shape']) 406 | 407 | board_outline_bbox = BoundingBox() 408 | for drawing in drawings.get(self.BOARD_OUTLINE_LAYER, []): 409 | self.add_drawing_bounding_box(drawing, board_outline_bbox) 410 | if board_outline_bbox.initialized(): 411 | bbox = board_outline_bbox.to_dict() 412 | else: 413 | # if nothing is drawn on outline layer then rely on EasyEDA bbox 414 | x = self.normalize(pcb['BBox']['x']) 415 | y = self.normalize(pcb['BBox']['y']) 416 | bbox = { 417 | "minx": x, 418 | "miny": y, 419 | "maxx": x + self.normalize(pcb['BBox']['width']), 420 | "maxy": y + self.normalize(pcb['BBox']['height']) 421 | } 422 | 423 | pcbdata = { 424 | "edges_bbox": bbox, 425 | "edges": drawings.get(self.BOARD_OUTLINE_LAYER, []), 426 | "drawings": { 427 | "silkscreen": { 428 | 'F': drawings.get(self.TOP_SILK_LAYER, []), 429 | 'B': drawings.get(self.BOT_SILK_LAYER, []), 430 | }, 431 | "fabrication": { 432 | 'F': drawings.get(self.TOP_ASSEMBLY_LAYER, []), 433 | 'B': drawings.get(self.BOT_ASSEMBLY_LAYER, []), 434 | }, 435 | }, 436 | "footprints": footprints, 437 | "metadata": self.get_metadata(pcb), 438 | "bom": {}, 439 | "font_data": {} 440 | } 441 | 442 | if self.config.include_tracks: 443 | def filter_tracks(drawing_list, drawing_type, keys): 444 | result = [] 445 | for d in drawing_list: 446 | if d["type"] == drawing_type: 447 | r = {} 448 | for key in keys: 449 | r[key] = d[key] 450 | result.append(r) 451 | return result 452 | 453 | pcbdata["tracks"] = { 454 | 'F': filter_tracks(drawings.get(self.TOP_COPPER_LAYER, []), 455 | "segment", ["start", "end", "width"]), 456 | 'B': filter_tracks(drawings.get(self.BOT_COPPER_LAYER, []), 457 | "segment", ["start", "end", "width"]), 458 | } 459 | # zones are not supported 460 | pcbdata["zones"] = {'F': [], 'B': []} 461 | 462 | return pcbdata, components 463 | -------------------------------------------------------------------------------- /ecad/genericjson.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import os.path 4 | from jsonschema import validate, ValidationError 5 | 6 | from .common import EcadParser, Component, BoundingBox 7 | 8 | 9 | class GenericJsonParser(EcadParser): 10 | COMPATIBLE_SPEC_VERSIONS = [1] 11 | 12 | def extra_data_file_filter(self): 13 | return "Json file ({f})|{f}".format(f=os.path.basename(self.file_name)) 14 | 15 | def latest_extra_data(self, extra_dirs=None): 16 | return self.file_name 17 | 18 | def get_extra_field_data(self, file_name): 19 | if os.path.abspath(file_name) != os.path.abspath(self.file_name): 20 | return None 21 | 22 | _, components = self._parse() 23 | field_set = set() 24 | comp_dict = {} 25 | 26 | for c in components: 27 | ref_fields = comp_dict.setdefault(c.ref, {}) 28 | 29 | for k, v in c.extra_fields.items(): 30 | field_set.add(k) 31 | ref_fields[k] = v 32 | 33 | return list(field_set), comp_dict 34 | 35 | def get_generic_json_pcb(self): 36 | with io.open(self.file_name, 'r', encoding='utf-8') as f: 37 | pcb = json.load(f) 38 | 39 | if 'spec_version' not in pcb: 40 | raise ValidationError("'spec_version' is a required property") 41 | 42 | if pcb['spec_version'] not in self.COMPATIBLE_SPEC_VERSIONS: 43 | raise ValidationError("Unsupported spec_version ({})" 44 | .format(pcb['spec_version'])) 45 | 46 | schema_dir = os.path.join(os.path.dirname(__file__), 'schema') 47 | schema_file_name = os.path.join(schema_dir, 48 | 'genericjsonpcbdata_v{}.schema'.format( 49 | pcb['spec_version'])) 50 | 51 | with io.open(schema_file_name, 'r', encoding='utf-8') as f: 52 | schema = json.load(f) 53 | 54 | validate(instance=pcb, schema=schema) 55 | 56 | return pcb 57 | 58 | def _verify(self, pcb): 59 | """Spot check the pcb object.""" 60 | 61 | if len(pcb['pcbdata']['footprints']) != len(pcb['components']): 62 | self.logger.error("Length of components list doesn't match" 63 | " length of footprints list.") 64 | return False 65 | 66 | return True 67 | 68 | def _parse(self): 69 | try: 70 | pcb = self.get_generic_json_pcb() 71 | except ValidationError as e: 72 | self.logger.error('File {f} does not comply with json schema. {m}' 73 | .format(f=self.file_name, m=e.message)) 74 | return None, None 75 | 76 | if not self._verify(pcb): 77 | self.logger.error('File {} does not appear to be valid generic' 78 | ' InteractiveHtmlBom json file.' 79 | .format(self.file_name)) 80 | return None, None 81 | 82 | pcbdata = pcb['pcbdata'] 83 | components = [Component(**c) for c in pcb['components']] 84 | 85 | self.logger.info('Successfully parsed {}'.format(self.file_name)) 86 | 87 | return pcbdata, components 88 | 89 | def parse(self): 90 | pcbdata, components = self._parse() 91 | 92 | # override board bounding box based on edges 93 | board_outline_bbox = BoundingBox() 94 | for drawing in pcbdata['edges']: 95 | self.add_drawing_bounding_box(drawing, board_outline_bbox) 96 | if board_outline_bbox.initialized(): 97 | pcbdata['edges_bbox'] = board_outline_bbox.to_dict() 98 | 99 | extra_fields = set(self.config.show_fields) 100 | extra_fields.discard("Footprint") 101 | extra_fields.discard("Value") 102 | if self.config.dnp_field: 103 | extra_fields.add(self.config.dnp_field) 104 | if self.config.board_variant_field: 105 | extra_fields.add(self.config.board_variant_field) 106 | if extra_fields: 107 | for c in components: 108 | c.extra_fields = { 109 | f: c.extra_fields.get(f, "") for f in extra_fields} 110 | 111 | return pcbdata, components 112 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ecad/kicad_extra/netlistparser.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from .parser_base import ParserBase 4 | from .sexpressions import parse_sexpression 5 | 6 | 7 | class NetlistParser(ParserBase): 8 | def get_extra_field_data(self): 9 | with io.open(self.file_name, 'r', encoding='utf-8') as f: 10 | sexpression = parse_sexpression(f.read()) 11 | components = None 12 | for s in sexpression: 13 | if s[0] == 'components': 14 | components = s[1:] 15 | if components is None: 16 | return None 17 | field_set = set() 18 | comp_dict = {} 19 | for c in components: 20 | ref = None 21 | fields = None 22 | datasheet = None 23 | libsource = None 24 | for f in c[1:]: 25 | if f[0] == 'ref': 26 | ref = f[1] 27 | if f[0] == 'fields': 28 | fields = f[1:] 29 | if f[0] == 'datasheet': 30 | datasheet = f[1] 31 | if f[0] == 'libsource': 32 | libsource = f[1:] 33 | if ref is None: 34 | return None 35 | ref_fields = comp_dict.setdefault(ref, {}) 36 | if datasheet and datasheet != '~': 37 | field_set.add('Datasheet') 38 | ref_fields['Datasheet'] = datasheet 39 | if libsource is not None: 40 | for lib_field in libsource: 41 | if lib_field[0] == 'description': 42 | field_set.add('Description') 43 | ref_fields['Description'] = lib_field[1] 44 | if fields is None: 45 | continue 46 | for f in fields: 47 | if len(f) > 1: 48 | field_set.add(f[1][1]) 49 | if len(f) > 2: 50 | ref_fields[f[1][1]] = f[2] 51 | 52 | return list(field_set), comp_dict 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ecad/kicad_extra/xmlparser.py: -------------------------------------------------------------------------------- 1 | from xml.dom import minidom 2 | 3 | from .parser_base import ParserBase 4 | 5 | 6 | class XmlParser(ParserBase): 7 | @staticmethod 8 | def get_text(nodelist): 9 | rc = [] 10 | for node in nodelist: 11 | if node.nodeType == node.TEXT_NODE: 12 | rc.append(node.data) 13 | return ''.join(rc) 14 | 15 | def get_extra_field_data(self): 16 | xml = minidom.parse(self.file_name) 17 | components = xml.getElementsByTagName('comp') 18 | field_set = set() 19 | comp_dict = {} 20 | for c in components: 21 | ref_fields = comp_dict.setdefault(c.attributes['ref'].value, {}) 22 | datasheet = c.getElementsByTagName('datasheet') 23 | if datasheet: 24 | datasheet = self.get_text(datasheet[0].childNodes) 25 | if datasheet != '~': 26 | field_set.add('Datasheet') 27 | ref_fields['Datasheet'] = datasheet 28 | libsource = c.getElementsByTagName('libsource') 29 | if libsource and libsource[0].hasAttribute('description'): 30 | field_set.add('Description') 31 | attr = libsource[0].attributes['description'] 32 | ref_fields['Description'] = attr.value 33 | for f in c.getElementsByTagName('field'): 34 | name = f.attributes['name'].value 35 | field_set.add(name) 36 | ref_fields[name] = self.get_text(f.childNodes) 37 | 38 | return list(field_set), comp_dict 39 | -------------------------------------------------------------------------------- /ecad/schema/genericjsonpcbdata_v1.schema: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$ref": "#/definitions/GenericJSONPCBData", 4 | "definitions": { 5 | "GenericJSONPCBData": { 6 | "type": "object", 7 | "additionalProperties": false, 8 | "properties": { 9 | "spec_version": { 10 | "type": "integer" 11 | }, 12 | "pcbdata": { 13 | "$ref": "#/definitions/Pcbdata" 14 | }, 15 | "components": { 16 | "type": "array", 17 | "items": { 18 | "$ref": "#/definitions/Component" 19 | } 20 | } 21 | }, 22 | "required": [ 23 | "spec_version", 24 | "pcbdata", 25 | "components" 26 | ], 27 | "title": "GenericJSONPCBData" 28 | }, 29 | "Component": { 30 | "type": "object", 31 | "additionalProperties": false, 32 | "properties": { 33 | "attr": { 34 | "type": "string" 35 | }, 36 | "footprint": { 37 | "type": "string" 38 | }, 39 | "layer": { 40 | "$ref": "#/definitions/Layer" 41 | }, 42 | "ref": { 43 | "type": "string" 44 | }, 45 | "val": { 46 | "type": "string" 47 | }, 48 | "extra_fields": { 49 | "$ref": "#/definitions/ExtraData" 50 | } 51 | }, 52 | "required": [ 53 | "footprint", 54 | "layer", 55 | "ref", 56 | "val" 57 | ], 58 | "title": "Component" 59 | }, 60 | "Pcbdata": { 61 | "type": "object", 62 | "additionalProperties": false, 63 | "properties": { 64 | "edges_bbox": { 65 | "$ref": "#/definitions/EdgesBbox" 66 | }, 67 | "edges": { 68 | "$ref": "#/definitions/DrawingArray" 69 | }, 70 | "drawings": { 71 | "$ref": "#/definitions/LayerDrawings" 72 | }, 73 | "footprints": { 74 | "type": "array", 75 | "items": { 76 | "$ref": "#/definitions/Footprint" 77 | } 78 | }, 79 | "metadata": { 80 | "$ref": "#/definitions/Metadata" 81 | }, 82 | "tracks": { 83 | "$ref": "#/definitions/Tracks" 84 | }, 85 | "zones": { 86 | "$ref": "#/definitions/Zones" 87 | }, 88 | "nets": { 89 | "type": "array", 90 | "items": { "type": "string" } 91 | } 92 | }, 93 | "required": [ 94 | "edges_bbox", 95 | "edges", 96 | "drawings", 97 | "footprints", 98 | "metadata" 99 | ], 100 | "dependencies": { 101 | "tracks": { "required": ["zones"] }, 102 | "zones": { "required": ["tracks"] } 103 | }, 104 | "title": "Pcbdata" 105 | }, 106 | "EdgesBbox": { 107 | "type": "object", 108 | "additionalProperties": false, 109 | "properties": { 110 | "minx": { 111 | "type": "number" 112 | }, 113 | "miny": { 114 | "type": "number" 115 | }, 116 | "maxx": { 117 | "type": "number" 118 | }, 119 | "maxy": { 120 | "type": "number" 121 | } 122 | }, 123 | "required": ["minx", "miny", "maxx", "maxy"], 124 | "title": "EdgesBbox" 125 | }, 126 | "DrawingSet": { 127 | "type": "object", 128 | "additionalProperties": false, 129 | "properties": { 130 | "F": { 131 | "$ref": "#/definitions/DrawingArray" 132 | }, 133 | "B": { 134 | "$ref": "#/definitions/DrawingArray" 135 | } 136 | }, 137 | "required": ["F", "B"], 138 | "title": "DrawingSet" 139 | }, 140 | "Footprint": { 141 | "type": "object", 142 | "additionalProperties": false, 143 | "properties": { 144 | "ref": { 145 | "type": "string" 146 | }, 147 | "center": { 148 | "$ref": "#/definitions/Coordinates" 149 | }, 150 | "bbox": { 151 | "$ref": "#/definitions/Bbox" 152 | }, 153 | "pads": { 154 | "type": "array", 155 | "items": { 156 | "$ref": "#/definitions/Pad" 157 | } 158 | }, 159 | "drawings": { 160 | "type": "array", 161 | "items": { 162 | "type": "object", 163 | "additionalProperties": false, 164 | "properties": { 165 | "layer": { "$ref": "#/definitions/Layer" }, 166 | "drawing": { "$ref": "#/definitions/Drawing" } 167 | }, 168 | "required": [ "layer", "drawing" ] 169 | } 170 | }, 171 | "layer": { 172 | "$ref": "#/definitions/Layer" 173 | } 174 | }, 175 | "required": ["ref", "center", "bbox", "pads", "drawings", "layer"], 176 | "title": "Footprint" 177 | }, 178 | "Bbox": { 179 | "type": "object", 180 | "additionalProperties": false, 181 | "properties": { 182 | "pos": { 183 | "$ref": "#/definitions/Coordinates" 184 | }, 185 | "relpos": { 186 | "$ref": "#/definitions/Coordinates" 187 | }, 188 | "size": { 189 | "$ref": "#/definitions/Coordinates" 190 | }, 191 | "angle": { 192 | "type": "number" 193 | } 194 | }, 195 | "required": ["pos", "relpos", "size", "angle"], 196 | "title": "Bbox" 197 | }, 198 | "Pad": { 199 | "type": "object", 200 | "additionalProperties": false, 201 | "properties": { 202 | "layers": { 203 | "type": "array", 204 | "items": { 205 | "$ref": "#/definitions/Layer" 206 | }, 207 | "minItems": 1, 208 | "maxItems": 2 209 | }, 210 | "pos": { 211 | "$ref": "#/definitions/Coordinates" 212 | }, 213 | "size": { 214 | "$ref": "#/definitions/Coordinates" 215 | }, 216 | "angle": { 217 | "type": "number" 218 | }, 219 | "shape": { 220 | "$ref": "#/definitions/Shape" 221 | }, 222 | "svgpath": { "type": "string" }, 223 | "polygons": { "$ref": "#/definitions/Polygons" }, 224 | "radius": { "type": "number" }, 225 | "chamfpos": { "type": "integer" }, 226 | "chamfratio": { "type": "number" }, 227 | "type": { 228 | "$ref": "#/definitions/PadType" 229 | }, 230 | "pin1": { 231 | "type": "integer", "const": 1 232 | }, 233 | "drillshape": { 234 | "$ref": "#/definitions/Drillshape" 235 | }, 236 | "drillsize": { 237 | "$ref": "#/definitions/Coordinates" 238 | }, 239 | "offset": { 240 | "$ref": "#/definitions/Coordinates" 241 | }, 242 | "net": { "type": "string" } 243 | }, 244 | "required": [ 245 | "layers", 246 | "pos", 247 | "size", 248 | "shape", 249 | "type" 250 | ], 251 | "allOf": [ 252 | { 253 | "if": { 254 | "properties": { "shape": { "const": "custom" } } 255 | }, 256 | "then": { 257 | "anyOf": [ 258 | { "required": [ "svgpath" ] }, 259 | { "required": [ "pos", "angle", "polygons" ] } 260 | ] 261 | } 262 | }, 263 | { 264 | "if": { 265 | "properties": { "shape": { "const": "roundrect" } } 266 | }, 267 | "then": { 268 | "required": [ "radius" ] 269 | } 270 | }, 271 | { 272 | "if": { 273 | "properties": { "shape": { "const": "chamfrect" } } 274 | }, 275 | "then": { 276 | "required": [ "radius", "chamfpos", "chamfratio" ] 277 | } 278 | }, 279 | { 280 | "if": { 281 | "properties": { "type": { "const": "th" } } 282 | }, 283 | "then": { 284 | "required": [ "drillshape", "drillsize" ] 285 | } 286 | } 287 | ], 288 | "title": "Pad" 289 | }, 290 | "Metadata": { 291 | "type": "object", 292 | "additionalProperties": false, 293 | "properties": { 294 | "title": { 295 | "type": "string" 296 | }, 297 | "revision": { 298 | "type": "string" 299 | }, 300 | "company": { 301 | "type": "string" 302 | }, 303 | "date": { 304 | "type": "string" 305 | } 306 | }, 307 | "required": ["title", "revision", "company", "date"], 308 | "title": "Metadata" 309 | }, 310 | "LayerDrawings": { 311 | "type": "object", 312 | "items": { 313 | "silkscreen": { 314 | "$ref": "#/definitions/DrawingSet" 315 | }, 316 | "fabrication": { 317 | "$ref": "#/definitions/DrawingSet" 318 | } 319 | } 320 | }, 321 | "DrawingArray": { 322 | "type": "array", 323 | "items": { 324 | "$ref": "#/definitions/Drawing" 325 | } 326 | }, 327 | "Drawing": { 328 | "type": "object", 329 | "oneOf": [ 330 | { "$ref": "#/definitions/DrawingSegment" }, 331 | { "$ref": "#/definitions/DrawingRect" }, 332 | { "$ref": "#/definitions/DrawingCircle" }, 333 | { "$ref": "#/definitions/DrawingArc" }, 334 | { "$ref": "#/definitions/DrawingCurve" }, 335 | { "$ref": "#/definitions/DrawingPolygon" }, 336 | { "$ref": "#/definitions/DrawingText" } 337 | ] 338 | }, 339 | "DrawingSegment": { 340 | "type": "object", 341 | "additionalProperties": false, 342 | "properties": { 343 | "type": { "type": "string", "const": "segment" }, 344 | "start": { "$ref": "#/definitions/Coordinates" }, 345 | "end": { "$ref": "#/definitions/Coordinates" }, 346 | "width": { "type": "number" } 347 | }, 348 | "required": ["type", "start", "end", "width"], 349 | "title": "DrawingSegment" 350 | }, 351 | "DrawingRect": { 352 | "type": "object", 353 | "additionalProperties": false, 354 | "properties": { 355 | "type": { "const": "rect" }, 356 | "start": { "$ref": "#/definitions/Coordinates" }, 357 | "end": { "$ref": "#/definitions/Coordinates" }, 358 | "width": { "type": "number" } 359 | }, 360 | "required": ["type", "start", "end", "width"], 361 | "title": "DrawingRect" 362 | }, 363 | "DrawingCircle": { 364 | "type": "object", 365 | "additionalProperties": false, 366 | "properties": { 367 | "type": { "const": "circle" }, 368 | "start": { "$ref": "#/definitions/Coordinates" }, 369 | "radius": { "type": "number" }, 370 | "filled": { "type": "integer" }, 371 | "width": { "type": "number" } 372 | }, 373 | "required": ["type", "start", "radius", "width"], 374 | "title": "DrawingCircle" 375 | }, 376 | "DrawingArc": { 377 | "type": "object", 378 | "additionalProperties": false, 379 | "properties": { 380 | "type": { "const": "arc" }, 381 | "width": { "type": "number" }, 382 | "svgpath": { "type": "string" }, 383 | "start": { "$ref": "#/definitions/Coordinates" }, 384 | "radius": { "type": "number" }, 385 | "startangle": { "type": "number" }, 386 | "endangle": { "type": "number" } 387 | }, 388 | "required": [ 389 | "type", 390 | "width" 391 | ], 392 | "anyOf": [ 393 | { "required": ["svgpath"] }, 394 | { "required": ["start", "radius", "startangle", "endangle"] } 395 | ], 396 | "title": "DrawingArc" 397 | }, 398 | "DrawingCurve": { 399 | "type": "object", 400 | "additionalProperties": false, 401 | "properties": { 402 | "type": { "const": "curve" }, 403 | "start": { "$ref": "#/definitions/Coordinates" }, 404 | "end": { "$ref": "#/definitions/Coordinates" }, 405 | "cpa": { "$ref": "#/definitions/Coordinates" }, 406 | "cpb": { "$ref": "#/definitions/Coordinates" }, 407 | "width": { "type": "number" } 408 | }, 409 | "required": ["type", "start", "end", "cpa", "cpb", "width"], 410 | "title": "DrawingCurve" 411 | }, 412 | "DrawingPolygon": { 413 | "type": "object", 414 | "additionalProperties": false, 415 | "properties": { 416 | "type": { "const": "polygon" }, 417 | "filled": { "type": "integer" }, 418 | "width": { "type": "number" }, 419 | "svgpath": { "type": "string" }, 420 | "pos": { "$ref": "#/definitions/Coordinates" }, 421 | "angle": { "type": "number" }, 422 | "polygons": { 423 | "type": "array", 424 | "items": { 425 | "type": "array", 426 | "items": { "$ref": "#/definitions/Coordinates" } 427 | } 428 | } 429 | }, 430 | "required": ["type"], 431 | "anyOf": [ 432 | { "required": ["svgpath"] }, 433 | { "required": ["pos", "angle", "polygons"] } 434 | ], 435 | "title": "DrawingPolygon" 436 | }, 437 | "DrawingText": { 438 | "type": "object", 439 | "additionalProperties": false, 440 | "properties": { 441 | "svgpath": { "type": "string" }, 442 | "thickness": { "type": "number" }, 443 | "ref": { "type": "integer" , "const": 1 }, 444 | "val": { "type": "integer" , "const": 1 } 445 | }, 446 | "required": [ 447 | "svgpath", 448 | "thickness" 449 | ], 450 | "title": "DrawingText" 451 | }, 452 | "Coordinates": { 453 | "type": "array", 454 | "items": { "type": "number" }, 455 | "minItems": 2, 456 | "maxItems": 2 457 | }, 458 | "Drillshape": { 459 | "type": "string", 460 | "enum": [ 461 | "circle", 462 | "oblong" 463 | ], 464 | "title": "Drillshape" 465 | }, 466 | "Layer": { 467 | "type": "string", 468 | "enum": [ 469 | "B", 470 | "F" 471 | ], 472 | "title": "Layer" 473 | }, 474 | "Shape": { 475 | "type": "string", 476 | "enum": [ 477 | "rect", 478 | "circle", 479 | "oval", 480 | "roundrect", 481 | "chamfrect", 482 | "custom" 483 | ], 484 | "title": "Shape" 485 | }, 486 | "PadType": { 487 | "type": "string", 488 | "enum": [ 489 | "smd", 490 | "th" 491 | ], 492 | "title": "PadType" 493 | }, 494 | "Tracks": { 495 | "type": "object", 496 | "additionalProperties": false, 497 | "properties": { 498 | "F": { 499 | "type": "array", 500 | "items": { "$ref": "#/definitions/Track" } 501 | }, 502 | "B": { 503 | "type": "array", 504 | "items": { "$ref": "#/definitions/Track" } 505 | } 506 | }, 507 | "required": [ "F", "B" ], 508 | "title": "Tracks" 509 | }, 510 | "Track": { 511 | "type": "object", 512 | "oneOf":[ 513 | { 514 | "additionalProperties": false, 515 | "properties": { 516 | "start": { "$ref": "#/definitions/Coordinates" }, 517 | "end": { "$ref": "#/definitions/Coordinates" }, 518 | "width": { "type": "number" }, 519 | "net": { "type": "string" } 520 | }, 521 | "required": [ 522 | "start", 523 | "end", 524 | "width" 525 | ] 526 | }, 527 | { 528 | "additionalProperties": false, 529 | "properties": { 530 | "center": { "$ref": "#/definitions/Coordinates" }, 531 | "startangle": { "type": "number" }, 532 | "endangle": { "type": "number" }, 533 | "radius": { "type": "number" }, 534 | "width": { "type": "number" }, 535 | "net": { "type": "string" } 536 | }, 537 | "required": [ 538 | "center", 539 | "startangle", 540 | "endangle", 541 | "radius", 542 | "width" 543 | ] 544 | } 545 | ] 546 | }, 547 | "Zones": { 548 | "type": "object", 549 | "additionalProperties": false, 550 | "properties": { 551 | "F": { 552 | "type": "array", 553 | "items": { "$ref": "#/definitions/Zone" } 554 | }, 555 | "B": { 556 | "type": "array", 557 | "items": { "$ref": "#/definitions/Zone" } 558 | } 559 | }, 560 | "required": [ "F", "B" ], 561 | "title": "Zones" 562 | }, 563 | "Zone": { 564 | "type": "object", 565 | "additionalProperties": false, 566 | "properties": { 567 | "svgpath": { "type": "string" }, 568 | "polygons": { 569 | "$ref": "#/definitions/Polygons" 570 | }, 571 | "net": { "type": "string" } 572 | }, 573 | "anyOf": [ 574 | { "required": [ "svgpath" ] }, 575 | { "required": [ "polygons" ] } 576 | ], 577 | "title": "Zone" 578 | }, 579 | "Polygons": { 580 | "type": "array", 581 | "items": { 582 | "type": "array", 583 | "items": { 584 | "$ref": "#/definitions/Coordinates" 585 | } 586 | } 587 | }, 588 | "ReferenceSet": { 589 | "type": "array", 590 | "items": { 591 | "type": "array", 592 | "items": [ 593 | { "type": "string" }, 594 | { "type": "integer" } 595 | ], 596 | "additionalItems": false 597 | } 598 | }, 599 | "ExtraData": { 600 | "type": "object", 601 | "additionalProperties": true, 602 | "properties": { 603 | }, 604 | "title": "ExtraData" 605 | } 606 | } 607 | } 608 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | if create_wx_app: 29 | import wx 30 | 31 | app = wx.App() 32 | app.SetAssertMode(wx.APP_ASSERT_SUPPRESS) 33 | 34 | from .core import ibom 35 | from .core.config import Config 36 | from .ecad import get_parser_by_extension 37 | from .version import version 38 | from .errors import (ExitCodes, ParsingException, exit_error) 39 | 40 | parser = argparse.ArgumentParser( 41 | description='KiCad InteractiveHtmlBom plugin CLI.', 42 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 43 | parser.add_argument('file', 44 | type=lambda s: to_utf(s), 45 | help="KiCad PCB file") 46 | 47 | Config.add_options(parser, version) 48 | args = parser.parse_args() 49 | logger = ibom.Logger(cli=True) 50 | 51 | if not os.path.isfile(args.file): 52 | exit_error(logger, ExitCodes.ERROR_FILE_NOT_FOUND, 53 | "File %s does not exist." % args.file) 54 | 55 | print("Loading %s" % args.file) 56 | 57 | config = Config(version, os.path.dirname(os.path.abspath(args.file))) 58 | 59 | parser = get_parser_by_extension( 60 | os.path.abspath(args.file), config, logger) 61 | 62 | if args.show_dialog: 63 | if not create_wx_app: 64 | exit_error(logger, ExitCodes.ERROR_NO_DISPLAY, 65 | "Can not show dialog when " 66 | "INTERACTIVE_HTML_BOM_NO_DISPLAY is set.") 67 | try: 68 | ibom.run_with_dialog(parser, config, logger) 69 | except ParsingException as e: 70 | exit_error(logger, ExitCodes.ERROR_PARSE, e) 71 | else: 72 | config.set_from_args(args) 73 | try: 74 | ibom.main(parser, config, logger) 75 | except ParsingException as e: 76 | exit_error(logger, ExitCodes.ERROR_PARSE, str(e)) 77 | return 0 78 | 79 | 80 | if __name__ == "__main__": 81 | main() 82 | -------------------------------------------------------------------------------- /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! -------------------------------------------------------------------------------- /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://gitlab.soraharu.com/XiaoXi/InteractiveHtmlBom 8 | set i18n_batScar= Bat 文件由 XiaoXi(admin@soraharu.com) 汉化 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 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yanranxiaoxi/InteractiveHtmlBom/54355ec4541083fffa46e30e39d2aa4fdc126c79/icon.png -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | # Update this when new version is tagged. 2 | import os 3 | import subprocess 4 | 5 | 6 | LAST_TAG = 'v1.1.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 | cwd=plugin_path) 15 | if isinstance(git_version, bytes): 16 | return git_version.decode('utf-8').rstrip() 17 | else: 18 | return git_version.rstrip() 19 | except subprocess.CalledProcessError: 20 | # print('Git 版本检查失败:' + str(e)) 21 | pass 22 | except Exception: 23 | # print('无法启动 Git 进程:' + str(e)) 24 | pass 25 | return None 26 | 27 | 28 | version = _get_git_version() or LAST_TAG 29 | -------------------------------------------------------------------------------- /web/ibom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --pcb-edge-color: black; 3 | --pad-color: #878787; 4 | --pad-hole-color: #CCCCCC; 5 | --pad-color-highlight: #D04040; 6 | --pad-color-highlight-both: #D0D040; 7 | --pad-color-highlight-marked: #44a344; 8 | --pin1-outline-color: #ffb629; 9 | --pin1-outline-color-highlight: #ffb629; 10 | --pin1-outline-color-highlight-both: #fcbb39; 11 | --pin1-outline-color-highlight-marked: #fdbe41; 12 | --silkscreen-edge-color: #aa4; 13 | --silkscreen-polygon-color: #4aa; 14 | --silkscreen-text-color: #4aa; 15 | --fabrication-edge-color: #907651; 16 | --fabrication-polygon-color: #907651; 17 | --fabrication-text-color: #a27c24; 18 | --track-color: #def5f1; 19 | --track-color-highlight: #D04040; 20 | --zone-color: #def5f1; 21 | --zone-color-highlight: #d0404080; 22 | } 23 | 24 | html, 25 | body { 26 | margin: 0px; 27 | height: 100%; 28 | font-family: Verdana, sans-serif; 29 | } 30 | 31 | .dark.topmostdiv { 32 | --pcb-edge-color: #eee; 33 | --pad-color: #808080; 34 | --pin1-outline-color: #ffa800; 35 | --pin1-outline-color-highlight: #ccff00; 36 | --track-color: #42524f; 37 | --zone-color: #42524f; 38 | background-color: #252c30; 39 | color: #eee; 40 | } 41 | 42 | button { 43 | background-color: #eee; 44 | border: 1px solid #888; 45 | color: black; 46 | height: 44px; 47 | width: 44px; 48 | text-align: center; 49 | text-decoration: none; 50 | display: inline-block; 51 | font-size: 14px; 52 | font-weight: bolder; 53 | } 54 | 55 | .dark button { 56 | /* This will be inverted */ 57 | background-color: #c3b7b5; 58 | } 59 | 60 | button.depressed { 61 | background-color: #0a0; 62 | color: white; 63 | } 64 | 65 | .dark button.depressed { 66 | /* This will be inverted */ 67 | background-color: #b3b; 68 | } 69 | 70 | button:focus { 71 | outline: 0; 72 | } 73 | 74 | button#tb-btn { 75 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8.47 8.47'%3E%3Crect transform='translate(0 -288.53)' ry='1.17' y='288.8' x='.27' height='7.94' width='7.94' fill='%23f9f9f9'/%3E%3Cg transform='translate(0 -288.53)'%3E%3Crect width='7.94' height='7.94' x='.27' y='288.8' ry='1.17' fill='none' stroke='%23000' stroke-width='.4' stroke-linejoin='round'/%3E%3Cpath d='M1.32 290.12h5.82M1.32 291.45h5.82' fill='none' stroke='%23000' stroke-width='.4'/%3E%3Cpath d='M4.37 292.5v4.23M.26 292.63H8.2' fill='none' stroke='%23000' stroke-width='.3'/%3E%3Ctext font-weight='700' font-size='3.17' font-family='sans-serif'%3E%3Ctspan x='1.35' y='295.73'%3EF%3C/tspan%3E%3Ctspan x='5.03' y='295.68'%3EB%3C/tspan%3E%3C/text%3E%3C/g%3E%3C/svg%3E%0A"); 76 | } 77 | 78 | button#lr-btn { 79 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8.47 8.47'%3E%3Crect transform='translate(0 -288.53)' ry='1.17' y='288.8' x='.27' height='7.94' width='7.94' fill='%23f9f9f9'/%3E%3Cg transform='translate(0 -288.53)'%3E%3Crect width='7.94' height='7.94' x='.27' y='288.8' ry='1.17' fill='none' stroke='%23000' stroke-width='.4' stroke-linejoin='round'/%3E%3Cpath d='M1.06 290.12H3.7m-2.64 1.33H3.7m-2.64 1.32H3.7m-2.64 1.3H3.7m-2.64 1.33H3.7' fill='none' stroke='%23000' stroke-width='.4'/%3E%3Cpath d='M4.37 288.8v7.94m0-4.11h3.96' fill='none' stroke='%23000' stroke-width='.3'/%3E%3Ctext font-weight='700' font-size='3.17' font-family='sans-serif'%3E%3Ctspan x='5.11' y='291.96'%3EF%3C/tspan%3E%3Ctspan x='5.03' y='295.68'%3EB%3C/tspan%3E%3C/text%3E%3C/g%3E%3C/svg%3E%0A"); 80 | } 81 | 82 | button#bom-btn { 83 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8.47 8.47'%3E%3Crect transform='translate(0 -288.53)' ry='1.17' y='288.8' x='.27' height='7.94' width='7.94' fill='%23f9f9f9'/%3E%3Cg transform='translate(0 -288.53)' fill='none' stroke='%23000' stroke-width='.4'%3E%3Crect width='7.94' height='7.94' x='.27' y='288.8' ry='1.17' stroke-linejoin='round'/%3E%3Cpath d='M1.59 290.12h5.29M1.59 291.45h5.33M1.59 292.75h5.33M1.59 294.09h5.33M1.59 295.41h5.33'/%3E%3C/g%3E%3C/svg%3E"); 84 | } 85 | 86 | button#bom-grouped-btn { 87 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Cg stroke='%23000' stroke-linejoin='round' class='layer'%3E%3Crect width='29' height='29' x='1.5' y='1.5' stroke-width='2' fill='%23fff' rx='5' ry='5'/%3E%3Cpath stroke-linecap='square' stroke-width='2' d='M6 10h4m4 0h5m4 0h3M6.1 22h3m3.9 0h5m4 0h4m-16-8h4m4 0h4'/%3E%3Cpath stroke-linecap='null' d='M5 17.5h22M5 26.6h22M5 5.5h22'/%3E%3C/g%3E%3C/svg%3E"); 88 | } 89 | 90 | button#bom-ungrouped-btn { 91 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Cg stroke='%23000' stroke-linejoin='round' class='layer'%3E%3Crect width='29' height='29' x='1.5' y='1.5' stroke-width='2' fill='%23fff' rx='5' ry='5'/%3E%3Cpath stroke-linecap='square' stroke-width='2' d='M6 10h4m-4 8h3m-3 8h4'/%3E%3Cpath stroke-linecap='null' d='M5 13.5h22m-22 8h22M5 5.5h22'/%3E%3C/g%3E%3C/svg%3E"); 92 | } 93 | 94 | button#bom-netlist-btn { 95 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Cg fill='none' stroke='%23000' class='layer'%3E%3Crect width='29' height='29' x='1.5' y='1.5' stroke-width='2' fill='%23fff' rx='5' ry='5'/%3E%3Cpath stroke-width='2' d='M6 26l6-6v-8m13.8-6.3l-6 6v8'/%3E%3Ccircle cx='11.8' cy='9.5' r='2.8' stroke-width='2'/%3E%3Ccircle cx='19.8' cy='22.8' r='2.8' stroke-width='2'/%3E%3C/g%3E%3C/svg%3E"); 96 | } 97 | 98 | button#copy { 99 | background-image: url("data:image/svg+xml,%3Csvg height='48' viewBox='0 0 48 48' width='48' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0h48v48h-48z' fill='none'/%3E%3Cpath d='M32 2h-24c-2.21 0-4 1.79-4 4v28h4v-28h24v-4zm6 8h-22c-2.21 0-4 1.79-4 4v28c0 2.21 1.79 4 4 4h22c2.21 0 4-1.79 4-4v-28c0-2.21-1.79-4-4-4zm0 32h-22v-28h22v28z'/%3E%3C/svg%3E"); 100 | background-position: 6px 6px; 101 | background-repeat: no-repeat; 102 | background-size: 26px 26px; 103 | border-radius: 6px; 104 | height: 40px; 105 | width: 40px; 106 | margin: 10px 5px; 107 | } 108 | 109 | button#copy:active { 110 | box-shadow: inset 0px 0px 5px #6c6c6c; 111 | } 112 | 113 | textarea.clipboard-temp { 114 | position: fixed; 115 | top: 0; 116 | left: 0; 117 | width: 2em; 118 | height: 2em; 119 | padding: 0; 120 | border: None; 121 | outline: None; 122 | box-shadow: None; 123 | background: transparent; 124 | } 125 | 126 | .left-most-button { 127 | border-right: 0; 128 | border-top-left-radius: 6px; 129 | border-bottom-left-radius: 6px; 130 | } 131 | 132 | .middle-button { 133 | border-right: 0; 134 | } 135 | 136 | .right-most-button { 137 | border-top-right-radius: 6px; 138 | border-bottom-right-radius: 6px; 139 | } 140 | 141 | .button-container { 142 | font-size: 0; 143 | margin: 10px 10px 10px 0px; 144 | } 145 | 146 | .dark .button-container { 147 | filter: invert(1); 148 | } 149 | 150 | .button-container button { 151 | background-size: 32px 32px; 152 | background-position: 5px 5px; 153 | background-repeat: no-repeat; 154 | } 155 | 156 | @media print { 157 | .hideonprint { 158 | display: none; 159 | } 160 | } 161 | 162 | canvas { 163 | cursor: crosshair; 164 | } 165 | 166 | canvas:active { 167 | cursor: grabbing; 168 | } 169 | 170 | .fileinfo { 171 | width: 100%; 172 | max-width: 1000px; 173 | border: none; 174 | padding: 5px; 175 | } 176 | 177 | .fileinfo .title { 178 | font-size: 20pt; 179 | font-weight: bold; 180 | } 181 | 182 | .fileinfo td { 183 | overflow: hidden; 184 | white-space: nowrap; 185 | max-width: 1px; 186 | width: 50%; 187 | text-overflow: ellipsis; 188 | } 189 | 190 | .bom { 191 | border-collapse: collapse; 192 | font-family: Consolas, "DejaVu Sans Mono", Monaco, monospace; 193 | font-size: 10pt; 194 | table-layout: fixed; 195 | width: 100%; 196 | margin-top: 1px; 197 | position: relative; 198 | } 199 | 200 | .bom th, 201 | .bom td { 202 | border: 1px solid black; 203 | padding: 5px; 204 | word-wrap: break-word; 205 | text-align: center; 206 | position: relative; 207 | } 208 | 209 | .dark .bom th, 210 | .dark .bom td { 211 | border: 1px solid #777; 212 | } 213 | 214 | .bom th { 215 | background-color: #CCCCCC; 216 | background-clip: padding-box; 217 | } 218 | 219 | .dark .bom th { 220 | background-color: #3b4749; 221 | } 222 | 223 | .bom tr.highlighted:nth-child(n) { 224 | background-color: #cfc; 225 | } 226 | 227 | .dark .bom tr.highlighted:nth-child(n) { 228 | background-color: #226022; 229 | } 230 | 231 | .bom tr:nth-child(even) { 232 | background-color: #f2f2f2; 233 | } 234 | 235 | .dark .bom tr:nth-child(even) { 236 | background-color: #313b40; 237 | } 238 | 239 | .bom tr.checked { 240 | color: #1cb53d; 241 | } 242 | 243 | .dark .bom tr.checked { 244 | color: #2cce54; 245 | } 246 | 247 | .bom tr { 248 | transition: background-color 0.2s; 249 | } 250 | 251 | .bom .numCol { 252 | width: 30px; 253 | } 254 | 255 | .bom .value { 256 | width: 15%; 257 | } 258 | 259 | .bom .quantity { 260 | width: 65px; 261 | } 262 | 263 | .bom th .sortmark { 264 | position: absolute; 265 | right: 1px; 266 | top: 1px; 267 | margin-top: -5px; 268 | border-width: 5px; 269 | border-style: solid; 270 | border-color: transparent transparent #221 transparent; 271 | transform-origin: 50% 85%; 272 | transition: opacity 0.2s, transform 0.4s; 273 | } 274 | 275 | .dark .bom th .sortmark { 276 | filter: invert(1); 277 | } 278 | 279 | .bom th .sortmark.none { 280 | opacity: 0; 281 | } 282 | 283 | .bom th .sortmark.desc { 284 | transform: rotate(180deg); 285 | } 286 | 287 | .bom th:hover .sortmark.none { 288 | opacity: 0.5; 289 | } 290 | 291 | .bom .bom-checkbox { 292 | width: 30px; 293 | position: relative; 294 | user-select: none; 295 | -moz-user-select: none; 296 | } 297 | 298 | .bom .bom-checkbox:before { 299 | content: ""; 300 | position: absolute; 301 | border-width: 15px; 302 | border-style: solid; 303 | border-color: #51829f transparent transparent transparent; 304 | visibility: hidden; 305 | top: -15px; 306 | } 307 | 308 | .bom .bom-checkbox:after { 309 | content: "Double click to set/unset all"; 310 | position: absolute; 311 | color: white; 312 | top: -35px; 313 | left: -26px; 314 | background: #51829f; 315 | padding: 5px 15px; 316 | border-radius: 8px; 317 | white-space: nowrap; 318 | visibility: hidden; 319 | } 320 | 321 | .bom .bom-checkbox:hover:before, 322 | .bom .bom-checkbox:hover:after { 323 | visibility: visible; 324 | transition: visibility 0.2s linear 1s; 325 | } 326 | 327 | .split { 328 | -webkit-box-sizing: border-box; 329 | -moz-box-sizing: border-box; 330 | box-sizing: border-box; 331 | overflow-y: auto; 332 | overflow-x: hidden; 333 | background-color: inherit; 334 | } 335 | 336 | .split.split-horizontal, 337 | .gutter.gutter-horizontal { 338 | height: 100%; 339 | float: left; 340 | } 341 | 342 | .gutter { 343 | background-color: #ddd; 344 | background-repeat: no-repeat; 345 | background-position: 50%; 346 | transition: background-color 0.3s; 347 | } 348 | 349 | .dark .gutter { 350 | background-color: #777; 351 | } 352 | 353 | .gutter.gutter-horizontal { 354 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg=='); 355 | cursor: ew-resize; 356 | width: 5px; 357 | } 358 | 359 | .gutter.gutter-vertical { 360 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII='); 361 | cursor: ns-resize; 362 | height: 5px; 363 | } 364 | 365 | .searchbox { 366 | float: left; 367 | height: 40px; 368 | margin: 10px 5px; 369 | padding: 12px 32px; 370 | font-family: Consolas, "DejaVu Sans Mono", Monaco, monospace; 371 | font-size: 18px; 372 | box-sizing: border-box; 373 | border: 1px solid #888; 374 | border-radius: 6px; 375 | outline: none; 376 | background-color: #eee; 377 | transition: background-color 0.2s, border 0.2s; 378 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABNklEQVQ4T8XSMUvDQBQH8P/LElFa/AIZHcTBQSz0I/gFstTBRR2KUC4ldDxw7h0Bl3RRUATxi4iiODgoiLNrbQYp5J6cpJJqomkX33Z37/14d/dIa33MzDuYI4johOI4XhyNRteO46zNYjDzAxE1yBZprVeZ+QbAUhXEGJMA2Ox2u4+fQIa0mPmsCgCgJYQ4t7lfgF0opQYAdv9ABkKI/UnOFCClXKjX61cA1osQY8x9kiRNKeV7IWA3oyhaSdP0FkAtjxhj3hzH2RBCPOf3pzqYHCilfAAX+URm9oMguPzeWSGQvUcMYC8rOBJCHBRdqxTo9/vbRHRqi8bj8XKv1xvODbiuW2u32/bvf0SlDv4XYOY7z/Mavu+nM1+BmQ+NMc0wDF/LprP0DbTWW0T00ul0nn4b7Q87+X4Qmfiq2wAAAABJRU5ErkJggg=='); 379 | background-position: 10px 10px; 380 | background-repeat: no-repeat; 381 | } 382 | 383 | .dark .searchbox { 384 | background-color: #111; 385 | color: #eee; 386 | } 387 | 388 | .searchbox::placeholder { 389 | color: #ccc; 390 | } 391 | 392 | .dark .searchbox::placeholder { 393 | color: #666; 394 | } 395 | 396 | .filter { 397 | width: calc(60% - 64px); 398 | } 399 | 400 | .reflookup { 401 | width: calc(40% - 10px); 402 | } 403 | 404 | input[type=text]:focus { 405 | background-color: white; 406 | border: 1px solid #333; 407 | } 408 | 409 | .dark input[type=text]:focus { 410 | background-color: #333; 411 | border: 1px solid #ccc; 412 | } 413 | 414 | mark.highlight { 415 | background-color: #5050ff; 416 | color: #fff; 417 | padding: 2px; 418 | border-radius: 6px; 419 | } 420 | 421 | .dark mark.highlight { 422 | background-color: #76a6da; 423 | color: #111; 424 | } 425 | 426 | .menubtn { 427 | background-color: white; 428 | border: none; 429 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 20 20'%3E%3Cpath fill='none' d='M0 0h20v20H0V0z'/%3E%3Cpath d='M15.95 10.78c.03-.25.05-.51.05-.78s-.02-.53-.06-.78l1.69-1.32c.15-.12.19-.34.1-.51l-1.6-2.77c-.1-.18-.31-.24-.49-.18l-1.99.8c-.42-.32-.86-.58-1.35-.78L12 2.34c-.03-.2-.2-.34-.4-.34H8.4c-.2 0-.36.14-.39.34l-.3 2.12c-.49.2-.94.47-1.35.78l-1.99-.8c-.18-.07-.39 0-.49.18l-1.6 2.77c-.1.18-.06.39.1.51l1.69 1.32c-.04.25-.07.52-.07.78s.02.53.06.78L2.37 12.1c-.15.12-.19.34-.1.51l1.6 2.77c.1.18.31.24.49.18l1.99-.8c.42.32.86.58 1.35.78l.3 2.12c.04.2.2.34.4.34h3.2c.2 0 .37-.14.39-.34l.3-2.12c.49-.2.94-.47 1.35-.78l1.99.8c.18.07.39 0 .49-.18l1.6-2.77c.1-.18.06-.39-.1-.51l-1.67-1.32zM10 13c-1.65 0-3-1.35-3-3s1.35-3 3-3 3 1.35 3 3-1.35 3-3 3z'/%3E%3C/svg%3E%0A"); 430 | background-position: center; 431 | background-repeat: no-repeat; 432 | } 433 | 434 | .statsbtn { 435 | background-color: white; 436 | border: none; 437 | background-image: url("data:image/svg+xml,%3Csvg width='36' height='36' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 6h28v24H4V6zm0 8h28v8H4m9-16v24h10V5.8' fill='none' stroke='%23000' stroke-width='2'/%3E%3C/svg%3E"); 438 | background-position: center; 439 | background-repeat: no-repeat; 440 | } 441 | 442 | .iobtn { 443 | background-color: white; 444 | border: none; 445 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='36' height='36'%3E%3Cpath fill='none' stroke='%23000' stroke-width='2' d='M3 33v-7l6.8-7h16.5l6.7 7v7H3zM3.2 26H33M21 9l5-5.9 5 6h-2.5V15h-5V9H21zm-4.9 0l-5 6-5-6h2.5V3h5v6h2.5z'/%3E%3Cpath fill='none' stroke='%23000' d='M6.1 29.5H10'/%3E%3C/svg%3E"); 446 | background-position: center; 447 | background-repeat: no-repeat; 448 | } 449 | 450 | .visbtn { 451 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath fill='none' stroke='%23333' d='M2.5 4.5h5v15h-5zM9.5 4.5h5v15h-5zM16.5 4.5h5v15h-5z'/%3E%3C/svg%3E"); 452 | background-position: center; 453 | background-repeat: no-repeat; 454 | padding: 15px; 455 | } 456 | 457 | #vismenu-content { 458 | left: 0px; 459 | font-family: Verdana, sans-serif; 460 | } 461 | 462 | .dark .statsbtn, 463 | .dark .savebtn, 464 | .dark .menubtn, 465 | .dark .iobtn, 466 | .dark .visbtn { 467 | filter: invert(1); 468 | } 469 | 470 | .flexbox { 471 | display: flex; 472 | align-items: center; 473 | justify-content: space-between; 474 | width: 100%; 475 | } 476 | 477 | .savebtn { 478 | background-color: #d6d6d6; 479 | width: auto; 480 | height: 30px; 481 | flex-grow: 1; 482 | margin: 5px; 483 | border-radius: 4px; 484 | } 485 | 486 | .savebtn:active { 487 | background-color: #0a0; 488 | color: white; 489 | } 490 | 491 | .dark .savebtn:active { 492 | /* This will be inverted */ 493 | background-color: #b3b; 494 | } 495 | 496 | .stats { 497 | border-collapse: collapse; 498 | font-size: 12pt; 499 | table-layout: fixed; 500 | width: 100%; 501 | min-width: 450px; 502 | } 503 | 504 | .dark .stats td { 505 | border: 1px solid #bbb; 506 | } 507 | 508 | .stats td { 509 | border: 1px solid black; 510 | padding: 5px; 511 | word-wrap: break-word; 512 | text-align: center; 513 | position: relative; 514 | } 515 | 516 | #checkbox-stats div { 517 | position: absolute; 518 | left: 0; 519 | top: 0; 520 | height: 100%; 521 | width: 100%; 522 | display: flex; 523 | align-items: center; 524 | justify-content: center; 525 | } 526 | 527 | #checkbox-stats .bar { 528 | background-color: rgba(28, 251, 0, 0.6); 529 | } 530 | 531 | .menu { 532 | position: relative; 533 | display: inline-block; 534 | margin: 10px 10px 10px 0px; 535 | } 536 | 537 | .menu-content { 538 | font-size: 12pt !important; 539 | text-align: left !important; 540 | font-weight: normal !important; 541 | display: none; 542 | position: absolute; 543 | background-color: white; 544 | right: 0; 545 | min-width: 300px; 546 | box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); 547 | z-index: 100; 548 | padding: 8px; 549 | } 550 | 551 | .dark .menu-content { 552 | background-color: #111; 553 | } 554 | 555 | .menu:hover .menu-content { 556 | display: block; 557 | } 558 | 559 | .menu:hover .menubtn, 560 | .menu:hover .iobtn, 561 | .menu:hover .statsbtn { 562 | background-color: #eee; 563 | } 564 | 565 | .menu-label { 566 | display: inline-block; 567 | padding: 8px; 568 | border: 1px solid #ccc; 569 | border-top: 0; 570 | width: calc(100% - 18px); 571 | } 572 | 573 | .menu-label-top { 574 | border-top: 1px solid #ccc; 575 | } 576 | 577 | .menu-textbox { 578 | float: left; 579 | height: 24px; 580 | margin: 10px 5px; 581 | padding: 5px 5px; 582 | font-family: Consolas, "DejaVu Sans Mono", Monaco, monospace; 583 | font-size: 14px; 584 | box-sizing: border-box; 585 | border: 1px solid #888; 586 | border-radius: 4px; 587 | outline: none; 588 | background-color: #eee; 589 | transition: background-color 0.2s, border 0.2s; 590 | width: calc(100% - 10px); 591 | } 592 | 593 | .menu-textbox.invalid, 594 | .dark .menu-textbox.invalid { 595 | color: red; 596 | } 597 | 598 | .dark .menu-textbox { 599 | background-color: #222; 600 | color: #eee; 601 | } 602 | 603 | .radio-container { 604 | margin: 4px; 605 | } 606 | 607 | .topmostdiv { 608 | width: 100%; 609 | height: 100%; 610 | background-color: white; 611 | transition: background-color 0.3s; 612 | } 613 | 614 | #top { 615 | height: 78px; 616 | border-bottom: 2px solid black; 617 | } 618 | 619 | .dark #top { 620 | border-bottom: 2px solid #ccc; 621 | } 622 | 623 | #dbg { 624 | display: block; 625 | } 626 | 627 | ::-webkit-scrollbar { 628 | width: 8px; 629 | } 630 | 631 | ::-webkit-scrollbar-track { 632 | background: #aaa; 633 | } 634 | 635 | ::-webkit-scrollbar-thumb { 636 | background: #666; 637 | border-radius: 3px; 638 | } 639 | 640 | ::-webkit-scrollbar-thumb:hover { 641 | background: #555; 642 | } 643 | 644 | .slider { 645 | -webkit-appearance: none; 646 | width: 100%; 647 | margin: 3px 0; 648 | padding: 0; 649 | outline: none; 650 | opacity: 0.7; 651 | -webkit-transition: .2s; 652 | transition: opacity .2s; 653 | border-radius: 3px; 654 | } 655 | 656 | .slider:hover { 657 | opacity: 1; 658 | } 659 | 660 | .slider:focus { 661 | outline: none; 662 | } 663 | 664 | .slider::-webkit-slider-runnable-track { 665 | -webkit-appearance: none; 666 | width: 100%; 667 | height: 8px; 668 | background: #d3d3d3; 669 | border-radius: 3px; 670 | border: none; 671 | } 672 | 673 | .slider::-webkit-slider-thumb { 674 | -webkit-appearance: none; 675 | width: 15px; 676 | height: 15px; 677 | border-radius: 50%; 678 | background: #0a0; 679 | cursor: pointer; 680 | margin-top: -4px; 681 | } 682 | 683 | .dark .slider::-webkit-slider-thumb { 684 | background: #3d3; 685 | } 686 | 687 | .slider::-moz-range-thumb { 688 | width: 15px; 689 | height: 15px; 690 | border-radius: 50%; 691 | background: #0a0; 692 | cursor: pointer; 693 | } 694 | 695 | .slider::-moz-range-track { 696 | height: 8px; 697 | background: #d3d3d3; 698 | border-radius: 3px; 699 | } 700 | 701 | .dark .slider::-moz-range-thumb { 702 | background: #3d3; 703 | } 704 | 705 | .slider::-ms-track { 706 | width: 100%; 707 | height: 8px; 708 | border-width: 3px 0; 709 | background: transparent; 710 | border-color: transparent; 711 | color: transparent; 712 | transition: opacity .2s; 713 | } 714 | 715 | .slider::-ms-fill-lower { 716 | background: #d3d3d3; 717 | border: none; 718 | border-radius: 3px; 719 | } 720 | 721 | .slider::-ms-fill-upper { 722 | background: #d3d3d3; 723 | border: none; 724 | border-radius: 3px; 725 | } 726 | 727 | .slider::-ms-thumb { 728 | width: 15px; 729 | height: 15px; 730 | border-radius: 50%; 731 | background: #0a0; 732 | cursor: pointer; 733 | margin: 0; 734 | } 735 | 736 | .shameless-plug { 737 | font-size: 0.8em; 738 | text-align: center; 739 | display: block; 740 | } 741 | 742 | a { 743 | color: #0278a4; 744 | } 745 | 746 | .dark a { 747 | color: #00b9fd; 748 | } 749 | 750 | #frontcanvas, 751 | #backcanvas { 752 | touch-action: none; 753 | } 754 | 755 | .placeholder { 756 | border: 1px dashed #9f9fda !important; 757 | background-color: #edf2f7 !important; 758 | } 759 | 760 | .dragging { 761 | z-index: 999; 762 | } 763 | 764 | .dark .dragging>table>tbody>tr { 765 | background-color: #252c30; 766 | } 767 | 768 | .dark .placeholder { 769 | filter: invert(1); 770 | } 771 | 772 | .column-spacer { 773 | top: 0; 774 | left: 0; 775 | width: calc(100% - 4px); 776 | position: absolute; 777 | cursor: pointer; 778 | user-select: none; 779 | height: 100%; 780 | } 781 | 782 | .column-width-handle { 783 | top: 0; 784 | right: 0; 785 | width: 4px; 786 | position: absolute; 787 | cursor: col-resize; 788 | user-select: none; 789 | height: 100%; 790 | } 791 | 792 | .column-width-handle:hover { 793 | background-color: #4f99bd; 794 | } 795 | 796 | .help-link { 797 | border: 1px solid #0278a4; 798 | padding-inline: 0.3rem; 799 | border-radius: 3px; 800 | cursor: pointer; 801 | } 802 | 803 | .dark .help-link { 804 | border: 1px solid #00b9fd; 805 | } 806 | -------------------------------------------------------------------------------- /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 | 137 |
139 | 142 | 145 | 148 |
149 |
151 | 153 | 155 | 157 |
158 |
160 | 162 | 164 | 166 |
167 | 212 | 249 |
250 |
251 | 252 | 253 | 254 | 257 | 260 | 261 | 262 | 265 | 268 | 269 | 270 |
255 | 标题 256 | 258 | 版本 259 |
263 | 公司 264 | 266 | 日期 267 |
271 |
272 |
273 |
274 |
275 |
276 | 278 | 280 |
281 | 283 |
284 |
285 |
286 | 287 | 288 | 289 | 290 | 291 |
292 |
293 |
294 |
295 |
296 | 297 | 298 | 299 | 300 |
301 |
302 |
303 |
304 | 305 | 306 | 307 | 308 |
309 |
310 |
311 |
312 |
313 | ///USERFOOTER/// 314 | 315 | 316 | 317 | -------------------------------------------------------------------------------- /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}); -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /web/user-file-examples/user.css: -------------------------------------------------------------------------------- 1 | /* Add custom css styles and overrides here. */ 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /web/user-file-examples/userfooter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | Hello footer 5 |
6 | -------------------------------------------------------------------------------- /web/user-file-examples/userheader.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | Hello header 4 |
5 |
6 | -------------------------------------------------------------------------------- /web/util.js: -------------------------------------------------------------------------------- 1 | /* Utility functions */ 2 | 3 | var storagePrefix = 'KiCad_HTML_BOM__' + pcbdata.metadata.title + '__' + 4 | pcbdata.metadata.revision + '__#'; 5 | var storage; 6 | 7 | function initStorage(key) { 8 | try { 9 | window.localStorage.getItem("blank"); 10 | storage = window.localStorage; 11 | } catch (e) { 12 | // localStorage not available 13 | } 14 | if (!storage) { 15 | try { 16 | window.sessionStorage.getItem("blank"); 17 | storage = window.sessionStorage; 18 | } catch (e) { 19 | // sessionStorage also not available 20 | } 21 | } 22 | } 23 | 24 | function readStorage(key) { 25 | if (storage) { 26 | return storage.getItem(storagePrefix + key); 27 | } else { 28 | return null; 29 | } 30 | } 31 | 32 | function writeStorage(key, value) { 33 | if (storage) { 34 | storage.setItem(storagePrefix + key, value); 35 | } 36 | } 37 | 38 | function fancyDblClickHandler(el, onsingle, ondouble) { 39 | return function() { 40 | if (el.getAttribute("data-dblclick") == null) { 41 | el.setAttribute("data-dblclick", 1); 42 | setTimeout(function() { 43 | if (el.getAttribute("data-dblclick") == 1) { 44 | onsingle(); 45 | } 46 | el.removeAttribute("data-dblclick"); 47 | }, 200); 48 | } else { 49 | el.removeAttribute("data-dblclick"); 50 | ondouble(); 51 | } 52 | } 53 | } 54 | 55 | function smoothScrollToRow(rowid) { 56 | document.getElementById(rowid).scrollIntoView({ 57 | behavior: "smooth", 58 | block: "center", 59 | inline: "nearest" 60 | }); 61 | } 62 | 63 | function focusInputField(input) { 64 | input.scrollIntoView(false); 65 | input.focus(); 66 | input.select(); 67 | } 68 | 69 | function saveBomTable(output) { 70 | var text = ''; 71 | for (var node of bomhead.childNodes[0].childNodes) { 72 | if (node.firstChild) { 73 | text += (output == 'csv' ? `"${node.firstChild.nodeValue}"` : node.firstChild.nodeValue); 74 | } 75 | if (node != bomhead.childNodes[0].lastChild) { 76 | text += (output == 'csv' ? ',' : '\t'); 77 | } 78 | } 79 | text += '\n'; 80 | for (var row of bombody.childNodes) { 81 | for (var cell of row.childNodes) { 82 | let val = ''; 83 | for (var node of cell.childNodes) { 84 | if (node.nodeName == "INPUT") { 85 | if (node.checked) { 86 | val += '✓'; 87 | } 88 | } else if (node.nodeName == "MARK") { 89 | val += node.firstChild.nodeValue; 90 | } else { 91 | val += node.nodeValue; 92 | } 93 | } 94 | if (output == 'csv') { 95 | val = val.replace(/\"/g, '\"\"'); // pair of double-quote characters 96 | if (isNumeric(val)) { 97 | val = +val; // use number 98 | } else { 99 | val = `"${val}"`; // enclosed within double-quote 100 | } 101 | } 102 | text += val; 103 | if (cell != row.lastChild) { 104 | text += (output == 'csv' ? ',' : '\t'); 105 | } 106 | } 107 | text += '\n'; 108 | } 109 | 110 | if (output != 'clipboard') { 111 | // To file: csv or txt 112 | var blob = new Blob([text], { 113 | type: `text/${output}` 114 | }); 115 | saveFile(`${pcbdata.metadata.title}.${output}`, blob); 116 | } else { 117 | // To clipboard 118 | var textArea = document.createElement("textarea"); 119 | textArea.classList.add('clipboard-temp'); 120 | textArea.value = text; 121 | 122 | document.body.appendChild(textArea); 123 | textArea.focus(); 124 | textArea.select(); 125 | 126 | try { 127 | if (document.execCommand('copy')) { 128 | console.log('Bom copied to clipboard.'); 129 | } 130 | } catch (err) { 131 | console.log('Can not copy to clipboard.'); 132 | } 133 | 134 | document.body.removeChild(textArea); 135 | } 136 | } 137 | 138 | function isNumeric(str) { 139 | /* https://stackoverflow.com/a/175787 */ 140 | return (typeof str != "string" ? false : !isNaN(str) && !isNaN(parseFloat(str))); 141 | } 142 | 143 | function removeGutterNode(node) { 144 | for (var i = 0; i < node.childNodes.length; i++) { 145 | if (node.childNodes[i].classList && 146 | node.childNodes[i].classList.contains("gutter")) { 147 | node.removeChild(node.childNodes[i]); 148 | break; 149 | } 150 | } 151 | } 152 | 153 | function cleanGutters() { 154 | removeGutterNode(document.getElementById("bot")); 155 | removeGutterNode(document.getElementById("canvasdiv")); 156 | } 157 | 158 | var units = { 159 | prefixes: { 160 | giga: ["G", "g", "giga", "Giga", "GIGA"], 161 | mega: ["M", "mega", "Mega", "MEGA"], 162 | kilo: ["K", "k", "kilo", "Kilo", "KILO"], 163 | milli: ["m", "milli", "Milli", "MILLI"], 164 | micro: ["U", "u", "micro", "Micro", "MICRO", "μ", "µ"], // different utf8 μ 165 | nano: ["N", "n", "nano", "Nano", "NANO"], 166 | pico: ["P", "p", "pico", "Pico", "PICO"], 167 | }, 168 | unitsShort: ["R", "r", "Ω", "F", "f", "H", "h"], 169 | unitsLong: [ 170 | "OHM", "Ohm", "ohm", "ohms", 171 | "FARAD", "Farad", "farad", 172 | "HENRY", "Henry", "henry" 173 | ], 174 | getMultiplier: function(s) { 175 | if (this.prefixes.giga.includes(s)) return 1e9; 176 | if (this.prefixes.mega.includes(s)) return 1e6; 177 | if (this.prefixes.kilo.includes(s)) return 1e3; 178 | if (this.prefixes.milli.includes(s)) return 1e-3; 179 | if (this.prefixes.micro.includes(s)) return 1e-6; 180 | if (this.prefixes.nano.includes(s)) return 1e-9; 181 | if (this.prefixes.pico.includes(s)) return 1e-12; 182 | return 1; 183 | }, 184 | valueRegex: null, 185 | } 186 | 187 | function initUtils() { 188 | var allPrefixes = units.prefixes.giga 189 | .concat(units.prefixes.mega) 190 | .concat(units.prefixes.kilo) 191 | .concat(units.prefixes.milli) 192 | .concat(units.prefixes.micro) 193 | .concat(units.prefixes.nano) 194 | .concat(units.prefixes.pico); 195 | var allUnits = units.unitsShort.concat(units.unitsLong); 196 | units.valueRegex = new RegExp("^([0-9\.]+)" + 197 | "\\s*(" + allPrefixes.join("|") + ")?" + 198 | "(" + allUnits.join("|") + ")?" + 199 | "(\\b.*)?$", ""); 200 | units.valueAltRegex = new RegExp("^([0-9]*)" + 201 | "(" + units.unitsShort.join("|") + ")?" + 202 | "([GgMmKkUuNnPp])?" + 203 | "([0-9]*)" + 204 | "(\\b.*)?$", ""); 205 | if (config.fields.includes("Value")) { 206 | var index = config.fields.indexOf("Value"); 207 | pcbdata.bom["parsedValues"] = {}; 208 | for (var id in pcbdata.bom.fields) { 209 | pcbdata.bom.parsedValues[id] = parseValue(pcbdata.bom.fields[id][index]) 210 | } 211 | } 212 | } 213 | 214 | function parseValue(val, ref) { 215 | var inferUnit = (unit, ref) => { 216 | if (unit) { 217 | unit = unit.toLowerCase(); 218 | if (unit == 'Ω' || unit == "ohm" || unit == "ohms") { 219 | unit = 'r'; 220 | } 221 | unit = unit[0]; 222 | } else { 223 | ref = /^([a-z]+)\d+$/i.exec(ref); 224 | if (ref) { 225 | ref = ref[1].toLowerCase(); 226 | if (ref == "c") unit = 'f'; 227 | else if (ref == "l") unit = 'h'; 228 | else if (ref == "r" || ref == "rv") unit = 'r'; 229 | else unit = null; 230 | } 231 | } 232 | return unit; 233 | }; 234 | val = val.replace(/,/g, ""); 235 | var match = units.valueRegex.exec(val); 236 | var unit; 237 | if (match) { 238 | val = parseFloat(match[1]); 239 | if (match[2]) { 240 | val = val * units.getMultiplier(match[2]); 241 | } 242 | unit = inferUnit(match[3], ref); 243 | if (!unit) return null; 244 | else return { 245 | val: val, 246 | unit: unit, 247 | extra: match[4], 248 | } 249 | } 250 | match = units.valueAltRegex.exec(val); 251 | if (match && (match[1] || match[4])) { 252 | val = parseFloat(match[1] + "." + match[4]); 253 | if (match[3]) { 254 | val = val * units.getMultiplier(match[3]); 255 | } 256 | unit = inferUnit(match[2], ref); 257 | if (!unit) return null; 258 | else return { 259 | val: val, 260 | unit: unit, 261 | extra: match[5], 262 | } 263 | } 264 | return null; 265 | } 266 | 267 | function valueCompare(a, b, stra, strb) { 268 | if (a === null && b === null) { 269 | // Failed to parse both values, compare them as strings. 270 | if (stra != strb) return stra > strb ? 1 : -1; 271 | else return 0; 272 | } else if (a === null) { 273 | return 1; 274 | } else if (b === null) { 275 | return -1; 276 | } else { 277 | if (a.unit != b.unit) return a.unit > b.unit ? 1 : -1; 278 | else if (a.val != b.val) return a.val > b.val ? 1 : -1; 279 | else if (a.extra != b.extra) return a.extra > b.extra ? 1 : -1; 280 | else return 0; 281 | } 282 | } 283 | 284 | function validateSaveImgDimension(element) { 285 | var valid = false; 286 | var intValue = 0; 287 | if (/^[1-9]\d*$/.test(element.value)) { 288 | intValue = parseInt(element.value); 289 | if (intValue <= 16000) { 290 | valid = true; 291 | } 292 | } 293 | if (valid) { 294 | element.classList.remove("invalid"); 295 | } else { 296 | element.classList.add("invalid"); 297 | } 298 | return intValue; 299 | } 300 | 301 | function saveImage(layer) { 302 | var width = validateSaveImgDimension(document.getElementById("render-save-width")); 303 | var height = validateSaveImgDimension(document.getElementById("render-save-height")); 304 | var bgcolor = null; 305 | if (!document.getElementById("render-save-transparent").checked) { 306 | var style = getComputedStyle(topmostdiv); 307 | bgcolor = style.getPropertyValue("background-color"); 308 | } 309 | if (!width || !height) return; 310 | 311 | // Prepare image 312 | var canvas = document.createElement("canvas"); 313 | var layerdict = { 314 | transform: { 315 | x: 0, 316 | y: 0, 317 | s: 1, 318 | panx: 0, 319 | pany: 0, 320 | zoom: 1, 321 | }, 322 | bg: canvas, 323 | fab: canvas, 324 | silk: canvas, 325 | highlight: canvas, 326 | layer: layer, 327 | } 328 | // Do the rendering 329 | recalcLayerScale(layerdict, width, height); 330 | prepareLayer(layerdict); 331 | clearCanvas(canvas, bgcolor); 332 | drawBackground(layerdict, false); 333 | drawHighlightsOnLayer(layerdict, false); 334 | 335 | // Save image 336 | var imgdata = canvas.toDataURL("image/png"); 337 | 338 | var filename = pcbdata.metadata.title; 339 | if (pcbdata.metadata.revision) { 340 | filename += `.${pcbdata.metadata.revision}`; 341 | } 342 | filename += `.${layer}.png`; 343 | saveFile(filename, dataURLtoBlob(imgdata)); 344 | } 345 | 346 | function saveSettings() { 347 | var data = { 348 | type: "InteractiveHtmlBom settings", 349 | version: 1, 350 | pcbmetadata: pcbdata.metadata, 351 | settings: settings, 352 | } 353 | var blob = new Blob([JSON.stringify(data, null, 4)], { 354 | type: "application/json" 355 | }); 356 | saveFile(`${pcbdata.metadata.title}.settings.json`, blob); 357 | } 358 | 359 | function loadSettings() { 360 | var input = document.createElement("input"); 361 | input.type = "file"; 362 | input.accept = ".settings.json"; 363 | input.onchange = function(e) { 364 | var file = e.target.files[0]; 365 | var reader = new FileReader(); 366 | reader.onload = readerEvent => { 367 | var content = readerEvent.target.result; 368 | var newSettings; 369 | try { 370 | newSettings = JSON.parse(content); 371 | } catch (e) { 372 | alert("Selected file is not InteractiveHtmlBom settings file."); 373 | return; 374 | } 375 | if (newSettings.type != "InteractiveHtmlBom settings") { 376 | alert("Selected file is not InteractiveHtmlBom settings file."); 377 | return; 378 | } 379 | var metadataMatches = newSettings.hasOwnProperty("pcbmetadata"); 380 | if (metadataMatches) { 381 | for (var k in pcbdata.metadata) { 382 | if (!newSettings.pcbmetadata.hasOwnProperty(k) || newSettings.pcbmetadata[k] != pcbdata.metadata[k]) { 383 | metadataMatches = false; 384 | } 385 | } 386 | } 387 | if (!metadataMatches) { 388 | var currentMetadata = JSON.stringify(pcbdata.metadata, null, 4); 389 | var fileMetadata = JSON.stringify(newSettings.pcbmetadata, null, 4); 390 | if (!confirm( 391 | `Settins file metadata does not match current metadata.\n\n` + 392 | `Page metadata:\n${currentMetadata}\n\n` + 393 | `Settings file metadata:\n${fileMetadata}\n\n` + 394 | `Press OK if you would like to import settings anyway.`)) { 395 | return; 396 | } 397 | } 398 | overwriteSettings(newSettings.settings); 399 | } 400 | reader.readAsText(file, 'UTF-8'); 401 | } 402 | input.click(); 403 | } 404 | 405 | function overwriteSettings(newSettings) { 406 | initDone = false; 407 | Object.assign(settings, newSettings); 408 | writeStorage("bomlayout", settings.bomlayout); 409 | writeStorage("bommode", settings.bommode); 410 | writeStorage("canvaslayout", settings.canvaslayout); 411 | writeStorage("bomCheckboxes", settings.checkboxes.join(",")); 412 | document.getElementById("bomCheckboxes").value = settings.checkboxes.join(","); 413 | for (var checkbox of settings.checkboxes) { 414 | writeStorage("checkbox_" + checkbox, settings.checkboxStoredRefs[checkbox]); 415 | } 416 | writeStorage("markWhenChecked", settings.markWhenChecked); 417 | padsVisible(settings.renderPads); 418 | document.getElementById("padsCheckbox").checked = settings.renderPads; 419 | fabricationVisible(settings.renderFabrication); 420 | document.getElementById("fabricationCheckbox").checked = settings.renderFabrication; 421 | silkscreenVisible(settings.renderSilkscreen); 422 | document.getElementById("silkscreenCheckbox").checked = settings.renderSilkscreen; 423 | referencesVisible(settings.renderReferences); 424 | document.getElementById("referencesCheckbox").checked = settings.renderReferences; 425 | valuesVisible(settings.renderValues); 426 | document.getElementById("valuesCheckbox").checked = settings.renderValues; 427 | tracksVisible(settings.renderTracks); 428 | document.getElementById("tracksCheckbox").checked = settings.renderTracks; 429 | zonesVisible(settings.renderZones); 430 | document.getElementById("zonesCheckbox").checked = settings.renderZones; 431 | dnpOutline(settings.renderDnpOutline); 432 | document.getElementById("dnpOutlineCheckbox").checked = settings.renderDnpOutline; 433 | setRedrawOnDrag(settings.redrawOnDrag); 434 | document.getElementById("dragCheckbox").checked = settings.redrawOnDrag; 435 | setDarkMode(settings.darkMode); 436 | document.getElementById("darkmodeCheckbox").checked = settings.darkMode; 437 | setHighlightPin1(settings.highlightpin1); 438 | document.getElementById("highlightpin1Checkbox").checked = settings.highlightpin1; 439 | showFootprints(settings.show_footprints); 440 | writeStorage("boardRotation", settings.boardRotation); 441 | document.getElementById("boardRotation").value = settings.boardRotation / 5; 442 | document.getElementById("rotationDegree").textContent = settings.boardRotation; 443 | initDone = true; 444 | prepCheckboxes(); 445 | changeBomLayout(settings.bomlayout); 446 | } 447 | 448 | function saveFile(filename, blob) { 449 | var link = document.createElement("a"); 450 | var objurl = URL.createObjectURL(blob); 451 | link.download = filename; 452 | link.href = objurl; 453 | link.click(); 454 | } 455 | 456 | function dataURLtoBlob(dataurl) { 457 | var arr = dataurl.split(','), 458 | mime = arr[0].match(/:(.*?);/)[1], 459 | bstr = atob(arr[1]), 460 | n = bstr.length, 461 | u8arr = new Uint8Array(n); 462 | while (n--) { 463 | u8arr[n] = bstr.charCodeAt(n); 464 | } 465 | return new Blob([u8arr], { 466 | type: mime 467 | }); 468 | } 469 | 470 | var settings = { 471 | canvaslayout: "default", 472 | bomlayout: "default", 473 | bommode: "grouped", 474 | checkboxes: [], 475 | checkboxStoredRefs: {}, 476 | darkMode: false, 477 | highlightpin1: false, 478 | redrawOnDrag: true, 479 | boardRotation: 0, 480 | renderPads: true, 481 | renderReferences: true, 482 | renderValues: true, 483 | renderSilkscreen: true, 484 | renderFabrication: true, 485 | renderDnpOutline: false, 486 | renderTracks: true, 487 | renderZones: true, 488 | columnOrder: [], 489 | hiddenColumns: [], 490 | } 491 | 492 | function initDefaults() { 493 | settings.bomlayout = readStorage("bomlayout"); 494 | if (settings.bomlayout === null) { 495 | settings.bomlayout = config.bom_view; 496 | } 497 | if (!['bom-only', 'left-right', 'top-bottom'].includes(settings.bomlayout)) { 498 | settings.bomlayout = config.bom_view; 499 | } 500 | settings.bommode = readStorage("bommode"); 501 | if (settings.bommode === null) { 502 | settings.bommode = "grouped"; 503 | } 504 | if (!["grouped", "ungrouped", "netlist"].includes(settings.bommode)) { 505 | settings.bommode = "grouped"; 506 | } 507 | settings.canvaslayout = readStorage("canvaslayout"); 508 | if (settings.canvaslayout === null) { 509 | settings.canvaslayout = config.layer_view; 510 | } 511 | var bomCheckboxes = readStorage("bomCheckboxes"); 512 | if (bomCheckboxes === null) { 513 | bomCheckboxes = config.checkboxes; 514 | } 515 | settings.checkboxes = bomCheckboxes.split(",").filter((e) => e); 516 | document.getElementById("bomCheckboxes").value = bomCheckboxes; 517 | 518 | settings.markWhenChecked = readStorage("markWhenChecked") || ""; 519 | populateMarkWhenCheckedOptions(); 520 | 521 | function initBooleanSetting(storageString, def, elementId, func) { 522 | var b = readStorage(storageString); 523 | if (b === null) { 524 | b = def; 525 | } else { 526 | b = (b == "true"); 527 | } 528 | document.getElementById(elementId).checked = b; 529 | func(b); 530 | } 531 | 532 | initBooleanSetting("padsVisible", config.show_pads, "padsCheckbox", padsVisible); 533 | initBooleanSetting("fabricationVisible", config.show_fabrication, "fabricationCheckbox", fabricationVisible); 534 | initBooleanSetting("silkscreenVisible", config.show_silkscreen, "silkscreenCheckbox", silkscreenVisible); 535 | initBooleanSetting("referencesVisible", true, "referencesCheckbox", referencesVisible); 536 | initBooleanSetting("valuesVisible", true, "valuesCheckbox", valuesVisible); 537 | if ("tracks" in pcbdata) { 538 | initBooleanSetting("tracksVisible", true, "tracksCheckbox", tracksVisible); 539 | initBooleanSetting("zonesVisible", true, "zonesCheckbox", zonesVisible); 540 | } else { 541 | document.getElementById("tracksAndZonesCheckboxes").style.display = "none"; 542 | tracksVisible(false); 543 | zonesVisible(false); 544 | } 545 | initBooleanSetting("dnpOutline", false, "dnpOutlineCheckbox", dnpOutline); 546 | initBooleanSetting("redrawOnDrag", config.redraw_on_drag, "dragCheckbox", setRedrawOnDrag); 547 | initBooleanSetting("darkmode", config.dark_mode, "darkmodeCheckbox", setDarkMode); 548 | initBooleanSetting("highlightpin1", config.highlight_pin1, "highlightpin1Checkbox", setHighlightPin1); 549 | 550 | var fields = ["checkboxes", "References"].concat(config.fields).concat(["Quantity"]); 551 | var hcols = JSON.parse(readStorage("hiddenColumns")); 552 | if (hcols === null) { 553 | hcols = []; 554 | } 555 | settings.hiddenColumns = hcols.filter(e => fields.includes(e)); 556 | 557 | var cord = JSON.parse(readStorage("columnOrder")); 558 | if (cord === null) { 559 | cord = fields; 560 | } else { 561 | cord = cord.filter(e => fields.includes(e)); 562 | if (cord.length != fields.length) 563 | cord = fields; 564 | } 565 | settings.columnOrder = cord; 566 | 567 | settings.boardRotation = readStorage("boardRotation"); 568 | if (settings.boardRotation === null) { 569 | settings.boardRotation = config.board_rotation * 5; 570 | } else { 571 | settings.boardRotation = parseInt(settings.boardRotation); 572 | } 573 | document.getElementById("boardRotation").value = settings.boardRotation / 5; 574 | document.getElementById("rotationDegree").textContent = settings.boardRotation; 575 | } 576 | 577 | // Helper classes for user js callbacks. 578 | 579 | const IBOM_EVENT_TYPES = { 580 | ALL: "all", 581 | HIGHLIGHT_EVENT: "highlightEvent", 582 | CHECKBOX_CHANGE_EVENT: "checkboxChangeEvent", 583 | BOM_BODY_CHANGE_EVENT: "bomBodyChangeEvent", 584 | } 585 | 586 | const EventHandler = { 587 | callbacks: {}, 588 | init: function() { 589 | for (eventType of Object.values(IBOM_EVENT_TYPES)) 590 | this.callbacks[eventType] = []; 591 | }, 592 | registerCallback: function(eventType, callback) { 593 | this.callbacks[eventType].push(callback); 594 | }, 595 | emitEvent: function(eventType, eventArgs) { 596 | event = { 597 | eventType: eventType, 598 | args: eventArgs, 599 | } 600 | var callback; 601 | for (callback of this.callbacks[eventType]) 602 | callback(event); 603 | for (callback of this.callbacks[IBOM_EVENT_TYPES.ALL]) 604 | callback(event); 605 | } 606 | } 607 | EventHandler.init(); 608 | --------------------------------------------------------------------------------