fritzkit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +13 -0
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +429 -2
- package/dist/cli/commands/doctor.d.ts +4 -0
- package/dist/cli/commands/doctor.js +7 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/export.d.ts +17 -0
- package/dist/cli/commands/export.js +81 -0
- package/dist/cli/commands/export.js.map +1 -0
- package/dist/cli/commands/kicad.d.ts +10 -0
- package/dist/cli/commands/kicad.js +22 -0
- package/dist/cli/commands/kicad.js.map +1 -0
- package/dist/cli/commands/parts.d.ts +22 -0
- package/dist/cli/commands/parts.js +46 -0
- package/dist/cli/commands/parts.js.map +1 -0
- package/dist/cli/commands/prune.d.ts +14 -0
- package/dist/cli/commands/prune.js +67 -0
- package/dist/cli/commands/prune.js.map +1 -0
- package/dist/cli/commands/render.d.ts +24 -0
- package/dist/cli/commands/render.js +62 -0
- package/dist/cli/commands/render.js.map +1 -0
- package/dist/cli/commands/schema.d.ts +10 -0
- package/dist/cli/commands/schema.js +35 -0
- package/dist/cli/commands/schema.js.map +1 -0
- package/dist/cli/commands/ui.d.ts +14 -0
- package/dist/cli/commands/ui.js +67 -0
- package/dist/cli/commands/ui.js.map +1 -0
- package/dist/cli/commands/validate.d.ts +24 -0
- package/dist/cli/commands/validate.js +91 -0
- package/dist/cli/commands/validate.js.map +1 -0
- package/dist/cli/context.d.ts +81 -0
- package/dist/cli/context.js +139 -0
- package/dist/cli/context.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +8 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/program.d.ts +21 -0
- package/dist/cli/program.js +158 -0
- package/dist/cli/program.js.map +1 -0
- package/dist/cli/raster.d.ts +4 -0
- package/dist/cli/raster.js +9 -0
- package/dist/cli/raster.js.map +1 -0
- package/dist/core/compile/breadboard-util.d.ts +12 -0
- package/dist/core/compile/breadboard-util.js +107 -0
- package/dist/core/compile/breadboard-util.js.map +1 -0
- package/dist/core/compile/breadboard.d.ts +60 -0
- package/dist/core/compile/breadboard.js +513 -0
- package/dist/core/compile/breadboard.js.map +1 -0
- package/dist/core/compile/fzp.d.ts +12 -0
- package/dist/core/compile/fzp.js +183 -0
- package/dist/core/compile/fzp.js.map +1 -0
- package/dist/core/compile/icon.d.ts +17 -0
- package/dist/core/compile/icon.js +27 -0
- package/dist/core/compile/icon.js.map +1 -0
- package/dist/core/compile/index.d.ts +4 -0
- package/dist/core/compile/index.js +62 -0
- package/dist/core/compile/index.js.map +1 -0
- package/dist/core/compile/pcb.d.ts +39 -0
- package/dist/core/compile/pcb.js +164 -0
- package/dist/core/compile/pcb.js.map +1 -0
- package/dist/core/compile/schematic.d.ts +69 -0
- package/dist/core/compile/schematic.js +240 -0
- package/dist/core/compile/schematic.js.map +1 -0
- package/dist/core/compile/style.d.ts +52 -0
- package/dist/core/compile/style.js +72 -0
- package/dist/core/compile/style.js.map +1 -0
- package/dist/core/compile/types.d.ts +50 -0
- package/dist/core/compile/types.js +15 -0
- package/dist/core/compile/types.js.map +1 -0
- package/dist/core/export/bin.d.ts +61 -0
- package/dist/core/export/bin.js +221 -0
- package/dist/core/export/bin.js.map +1 -0
- package/dist/core/export/fzpz.d.ts +37 -0
- package/dist/core/export/fzpz.js +121 -0
- package/dist/core/export/fzpz.js.map +1 -0
- package/dist/core/export/index.d.ts +5 -0
- package/dist/core/export/index.js +6 -0
- package/dist/core/export/index.js.map +1 -0
- package/dist/core/export/install.d.ts +77 -0
- package/dist/core/export/install.js +183 -0
- package/dist/core/export/install.js.map +1 -0
- package/dist/core/export/prune.d.ts +72 -0
- package/dist/core/export/prune.js +165 -0
- package/dist/core/export/prune.js.map +1 -0
- package/dist/core/fritzing.d.ts +22 -0
- package/dist/core/fritzing.js +77 -0
- package/dist/core/fritzing.js.map +1 -0
- package/dist/core/identity.d.ts +29 -0
- package/dist/core/identity.js +70 -0
- package/dist/core/identity.js.map +1 -0
- package/dist/core/image/bbox.d.ts +27 -0
- package/dist/core/image/bbox.js +24 -0
- package/dist/core/image/bbox.js.map +1 -0
- package/dist/core/image/index.d.ts +8 -0
- package/dist/core/image/index.js +9 -0
- package/dist/core/image/index.js.map +1 -0
- package/dist/core/image/key.d.ts +72 -0
- package/dist/core/image/key.js +317 -0
- package/dist/core/image/key.js.map +1 -0
- package/dist/core/image/prepare.d.ts +31 -0
- package/dist/core/image/prepare.js +98 -0
- package/dist/core/image/prepare.js.map +1 -0
- package/dist/core/image/remover.d.ts +17 -0
- package/dist/core/image/remover.js +51 -0
- package/dist/core/image/remover.js.map +1 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +17 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/kicad/footprint.d.ts +104 -0
- package/dist/core/kicad/footprint.js +317 -0
- package/dist/core/kicad/footprint.js.map +1 -0
- package/dist/core/kicad/index.d.ts +25 -0
- package/dist/core/kicad/index.js +197 -0
- package/dist/core/kicad/index.js.map +1 -0
- package/dist/core/kicad/sexpr.d.ts +35 -0
- package/dist/core/kicad/sexpr.js +163 -0
- package/dist/core/kicad/sexpr.js.map +1 -0
- package/dist/core/resolve.d.ts +113 -0
- package/dist/core/resolve.js +301 -0
- package/dist/core/resolve.js.map +1 -0
- package/dist/core/schema/part.d.ts +995 -0
- package/dist/core/schema/part.js +278 -0
- package/dist/core/schema/part.js.map +1 -0
- package/dist/core/schema/part.schema.json +1122 -0
- package/dist/core/svg.d.ts +20 -0
- package/dist/core/svg.js +56 -0
- package/dist/core/svg.js.map +1 -0
- package/dist/core/units.d.ts +28 -0
- package/dist/core/units.js +42 -0
- package/dist/core/units.js.map +1 -0
- package/dist/core/validate/checker.d.ts +46 -0
- package/dist/core/validate/checker.js +142 -0
- package/dist/core/validate/checker.js.map +1 -0
- package/dist/core/validate/doctor.d.ts +25 -0
- package/dist/core/validate/doctor.js +156 -0
- package/dist/core/validate/doctor.js.map +1 -0
- package/dist/core/validate/explain.d.ts +10 -0
- package/dist/core/validate/explain.js +135 -0
- package/dist/core/validate/explain.js.map +1 -0
- package/dist/core/validate/index.d.ts +21 -0
- package/dist/core/validate/index.js +96 -0
- package/dist/core/validate/index.js.map +1 -0
- package/dist/core/validate/structural.d.ts +74 -0
- package/dist/core/validate/structural.js +664 -0
- package/dist/core/validate/structural.js.map +1 -0
- package/dist/core/workspace.d.ts +69 -0
- package/dist/core/workspace.js +213 -0
- package/dist/core/workspace.js.map +1 -0
- package/dist/mcp/jsonpatch.d.ts +30 -0
- package/dist/mcp/jsonpatch.js +145 -0
- package/dist/mcp/jsonpatch.js.map +1 -0
- package/dist/mcp/main.d.ts +2 -0
- package/dist/mcp/main.js +5 -0
- package/dist/mcp/main.js.map +1 -0
- package/dist/mcp/server.d.ts +28 -0
- package/dist/mcp/server.js +310 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/server/api.d.ts +72 -0
- package/dist/server/api.js +507 -0
- package/dist/server/api.js.map +1 -0
- package/dist/server/events.d.ts +28 -0
- package/dist/server/events.js +134 -0
- package/dist/server/events.js.map +1 -0
- package/dist/server/http.d.ts +36 -0
- package/dist/server/http.js +127 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server/index.d.ts +65 -0
- package/dist/server/index.js +193 -0
- package/dist/server/index.js.map +1 -0
- package/dist/ui/assets/index-DLU8opyI.js +63 -0
- package/dist/ui/assets/index-GhUA-T3b.css +1 -0
- package/dist/ui/index.html +14 -0
- package/package.json +89 -4
- package/skills/fritzkit/SKILL.md +690 -0
- package/skills/fritzkit/reference/examples.md +275 -0
- package/skills/fritzkit/reference/part-json.md +333 -0
- package/skills/fritzkit/scripts/measure.py +197 -0
- package/tools/fzp-checker/.python-version +1 -0
- package/tools/fzp-checker/README.md +45 -0
- package/tools/fzp-checker/check_fzp.py +48 -0
- package/tools/fzp-checker/pyproject.toml +26 -0
- package/tools/fzp-checker/uv.lock +266 -0
- package/tools/fzp-checker/vendor/LICENSE.txt +8 -0
- package/tools/fzp-checker/vendor/UPSTREAM.md +25 -0
- package/tools/fzp-checker/vendor/fzp_checker.py +14 -0
- package/tools/fzp-checker/vendor/scripts/checks/__init__.py +26 -0
- package/tools/fzp-checker/vendor/scripts/checks/__pycache__/__init__.cpython-313.pyc +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/__pycache__/check_missing_leg_ids.cpython-313.pyc +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/__pycache__/connector_numbering.cpython-313.pyc +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/__pycache__/fzp_checker_runner.cpython-313.pyc +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/__pycache__/fzp_checkers.cpython-313.pyc +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/__pycache__/fzp_svg_checkers.cpython-313.pyc +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/__pycache__/fzp_utils.cpython-313.pyc +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/__pycache__/svg_checkers.cpython-313.pyc +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/__pycache__/svg_utils.cpython-313.pyc +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/check_missing_leg_ids.py +44 -0
- package/tools/fzp-checker/vendor/scripts/checks/checkascii.py +27 -0
- package/tools/fzp-checker/vendor/scripts/checks/checkcase.py +156 -0
- package/tools/fzp-checker/vendor/scripts/checks/checkcopies.py +104 -0
- package/tools/fzp-checker/vendor/scripts/checks/connector_numbering.py +79 -0
- package/tools/fzp-checker/vendor/scripts/checks/connectors_misnumbered.py +228 -0
- package/tools/fzp-checker/vendor/scripts/checks/explain_errors.md +95 -0
- package/tools/fzp-checker/vendor/scripts/checks/fzp_checker_runner.py +717 -0
- package/tools/fzp-checker/vendor/scripts/checks/fzp_checkers.py +1069 -0
- package/tools/fzp-checker/vendor/scripts/checks/fzp_svg_checkers.py +62 -0
- package/tools/fzp-checker/vendor/scripts/checks/fzp_utils.py +222 -0
- package/tools/fzp-checker/vendor/scripts/checks/inspector_fc51.png +0 -0
- package/tools/fzp-checker/vendor/scripts/checks/invisibleconnectors.py +118 -0
- package/tools/fzp-checker/vendor/scripts/checks/nolayeridcheck.txt +758 -0
- package/tools/fzp-checker/vendor/scripts/checks/requirements.txt +3 -0
- package/tools/fzp-checker/vendor/scripts/checks/svg_checkers.py +953 -0
- package/tools/fzp-checker/vendor/scripts/checks/svg_utils.py +96 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_checkers.py +839 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/author_missing.fzp.test +3 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/author_present.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/buses_missing_id.fzp.test +8 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/buses_missing_node_members.fzp.test +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/buses_present_valid.fzp.test +8 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_layers_missing_attributes.fzp.test +16 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_layers_present_valid.fzp.test +14 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_numbering_invalid.fzp.test +70 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_numbering_valid.fzp.test +70 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_refs_valid.fzp.test +53 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_mixed.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_silkscreen_nested.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_style_attr.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_white_stroke.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_wrong_color.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_inherited.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_legacy.fzp.test +12 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_nested_copper.fzp.test +12 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_none.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_standard.fzp.test +12 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_uppercase.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/css_connector.fzp.test +100 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/description_missing.fzp.test +3 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/description_present.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/duplicate_ids.fzp.test +27 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/family_property_missing.fzp.test +6 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/family_property_present_valid.fzp.test +6 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/font_size.fzp.test +46 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/fritzing_version_invalid_format.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/fritzing_version_missing.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/fritzing_version_present_valid.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/gorn_absent.fzp.test +15 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/gorn_present.fzp.test +15 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/hybrid_connectors.fzp.test +232 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/invalid_xml.fzp.test +12 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/layer_ids_match.fzp.test +29 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/layer_ids_mismatch.fzp.test +30 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/layer_nesting_invalid.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/layer_nesting_valid.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/matrix_transform.fzp.test +8 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/missing_connector_refs.fzp.test +63 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/missing_leg_ids.fzp.test +34 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_missing.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_non_alnum.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_present.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_special_chars_absent.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_special_chars_present.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_too_short.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/no_layer_invalid.fzp.test +19 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/no_layer_namedview.fzp.test +19 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/no_layer_valid.fzp.test +19 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/pcb_only.fzp.test +48 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/property_fields_missing_name.fzp.test +6 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/property_fields_missing_value.fzp.test +6 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/property_fields_present.fzp.test +6 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/required_tags_missing.fzp.test +3 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/required_tags_present.fzp.test +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/stroke_test.fzp.test +60 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/template_svgs.fzp.test +35 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/title_missing.fzp.test +3 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/title_present.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/unique_ids.fzp.test +27 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/unique_property_names_duplicates.fzp.test +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/unique_property_names_unique.fzp.test +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/valid_xml.fzp.test +249 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/version_invalid_format.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/version_missing.fzp.test +3 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/version_present_valid.fzp.test +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/views_missing.fzp.test +3 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/core/views_present.fzp.test +8 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/connector_refs_valid_breadboard.svg +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/duplicate_ids_breadboard.svg +13 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/gorn_absent_breadboard.svg +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/gorn_present_breadboard.svg +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/hybrid_connectors_breadboard.svg +102 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/layer_nesting_invalid.svg +13 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/layer_nesting_valid.svg +13 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/matrix_transform.svg +11 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/missing_connector_refs_breadboard.svg +8 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/missing_leg_ids_breadboard.svg +9 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/test_layer_ids_match_breadboard.svg +9 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/test_layer_ids_mismatch_breadboard.svg +9 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/unique_ids_breadboard.svg +13 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/hybrid_connectors_icon.svg +78 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/no_layer_invalid.svg +4 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/no_layer_namedview.svg +9 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/no_layer_valid.svg +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/plain_pcb.svg +10 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/connector_refs_valid_pcb.svg +10 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_mixed.svg +8 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_silkscreen_nested.svg +10 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_style_attr.svg +6 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_white_stroke.svg +6 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_wrong_color.svg +6 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_inherited.svg +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_legacy.svg +10 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_nested_copper.svg +9 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_none.svg +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_standard.svg +10 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_uppercase.svg +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/css_connector.svg +25 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/font_size.svg +54 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/hybrid_connectors_pcb.svg +35 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/missing_connector_refs_pcb.svg +14 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/pcb-arduino-shield.svg +25 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/stroke_test.svg +12 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/test_layer_ids_match_pcb.svg +12 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/test_layer_ids_mismatch_pcb.svg +12 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/schematic/connector_refs_valid_schematic.svg +7 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/schematic/hybrid_connectors_schematic.svg +59 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/schematic/missing_connector_refs_schematic.svg +8 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/schematic/test_layer_ids_match_schematic.svg +6 -0
- package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/schematic/test_layer_ids_mismatch_schematic.svg +6 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
# Filename: svg_checkers.py
|
|
2
|
+
from lxml import etree
|
|
3
|
+
import re
|
|
4
|
+
import os
|
|
5
|
+
import logging
|
|
6
|
+
from .svg_utils import SVGUtils
|
|
7
|
+
from .fzp_checkers import ValidationIssue, FixResult
|
|
8
|
+
|
|
9
|
+
class SVGChecker:
|
|
10
|
+
def __init__(self, svg_doc, layer_ids):
|
|
11
|
+
self.svg_doc = svg_doc
|
|
12
|
+
self.layer_ids = layer_ids
|
|
13
|
+
self.errors = 0
|
|
14
|
+
self.warnings = 0
|
|
15
|
+
self.issues = []
|
|
16
|
+
self.fixes = []
|
|
17
|
+
self.logger = logging.getLogger(self.__class__.__name__)
|
|
18
|
+
|
|
19
|
+
def add_error(self, message, node=None):
|
|
20
|
+
issue = ValidationIssue(message, severity='error', node=node)
|
|
21
|
+
self.issues.append(issue)
|
|
22
|
+
self.logger.debug(f"Error: {message}")
|
|
23
|
+
self.errors += 1
|
|
24
|
+
|
|
25
|
+
def add_warning(self, message, node=None):
|
|
26
|
+
issue = ValidationIssue(message, severity='warning', node=node)
|
|
27
|
+
self.issues.append(issue)
|
|
28
|
+
self.logger.debug(f"Warning: {message}")
|
|
29
|
+
self.warnings += 1
|
|
30
|
+
|
|
31
|
+
def add_fix(self, message, node=None, line_number=None):
|
|
32
|
+
fix = FixResult(message, node=node, line_number=line_number)
|
|
33
|
+
self.fixes.append(fix)
|
|
34
|
+
self.logger.debug(f"Fixed: {message}")
|
|
35
|
+
|
|
36
|
+
def get_result(self):
|
|
37
|
+
return self.errors, self.warnings
|
|
38
|
+
|
|
39
|
+
def get_fixes_count(self):
|
|
40
|
+
return len(self.fixes)
|
|
41
|
+
|
|
42
|
+
def check(self):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def get_name():
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def get_description():
|
|
51
|
+
raise NotImplementedError
|
|
52
|
+
|
|
53
|
+
class SVGFontSizeChecker(SVGChecker):
|
|
54
|
+
|
|
55
|
+
def getChildXML(self, elem):
|
|
56
|
+
out = ""
|
|
57
|
+
if elem.text:
|
|
58
|
+
out += elem.text
|
|
59
|
+
for c in elem.iterchildren():
|
|
60
|
+
if len(c) == 0:
|
|
61
|
+
out += f"<{c.tag}/>"
|
|
62
|
+
else:
|
|
63
|
+
out += f"<{c.tag}>{self.getChildXML(c)}</{c.tag}>"
|
|
64
|
+
if c.tail:
|
|
65
|
+
out += c.tail
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_font_size(self, element):
|
|
70
|
+
font_size = SVGUtils.get_inherited_attribute(element, "font-size")
|
|
71
|
+
if font_size is None:
|
|
72
|
+
if element.tag.endswith("text"):
|
|
73
|
+
for child in element.iterchildren():
|
|
74
|
+
if child.tag.endswith("tspan"):
|
|
75
|
+
return self.check_font_size(child)
|
|
76
|
+
content = self.getChildXML(element)
|
|
77
|
+
self.add_error(f"No font size found for element [{content}]", node=element)
|
|
78
|
+
return
|
|
79
|
+
if not re.match(r"^\d+(\.\d+)?$", font_size):
|
|
80
|
+
content = self.getChildXML(element)
|
|
81
|
+
self.add_error(f"Invalid font size {font_size} unit in element: [{content}]", node=element)
|
|
82
|
+
|
|
83
|
+
def check(self):
|
|
84
|
+
text_elements = self.svg_doc.xpath("//*[local-name()='text' or local-name()='tspan']")
|
|
85
|
+
for element in text_elements:
|
|
86
|
+
self.check_font_size(element)
|
|
87
|
+
return self.get_result()
|
|
88
|
+
|
|
89
|
+
def fix(self, filename):
|
|
90
|
+
"""Strip unit suffixes (px, mm, pt, etc.) from font-size values.
|
|
91
|
+
|
|
92
|
+
Handles both XML attributes (font-size="5px") and CSS style
|
|
93
|
+
properties (style="font-size:5px;...").
|
|
94
|
+
"""
|
|
95
|
+
with open(filename, 'r', encoding='utf-8') as f:
|
|
96
|
+
content = f.read()
|
|
97
|
+
|
|
98
|
+
original_content = content
|
|
99
|
+
units = r'(?:px|mm|pt|in|cm|em|ex|rem|%)'
|
|
100
|
+
|
|
101
|
+
# Fix font-size XML attributes: font-size="5px" -> font-size="5"
|
|
102
|
+
attr_pattern = r'(font-size\s*=\s*(["\']))(\d+(?:\.\d+)?)\s*' + units + r'(\2)'
|
|
103
|
+
|
|
104
|
+
def replace_attr(match):
|
|
105
|
+
prefix = match.group(1) # font-size=" (including quote)
|
|
106
|
+
number = match.group(3)
|
|
107
|
+
closing = match.group(4) # closing quote
|
|
108
|
+
old_val = match.group(0)
|
|
109
|
+
self.add_fix(f"Removed unit from font-size attribute: {old_val} -> {prefix}{number}{closing}")
|
|
110
|
+
return f'{prefix}{number}{closing}'
|
|
111
|
+
|
|
112
|
+
content = re.sub(attr_pattern, replace_attr, content)
|
|
113
|
+
|
|
114
|
+
# Fix font-size inside style attributes: font-size:5px -> font-size:5
|
|
115
|
+
style_pattern = r'(font-size\s*:\s*)(\d+(?:\.\d+)?)\s*' + units + r'(\s*[;"\'])'
|
|
116
|
+
|
|
117
|
+
def replace_style(match):
|
|
118
|
+
prefix = match.group(1) # font-size:
|
|
119
|
+
number = match.group(2)
|
|
120
|
+
suffix = match.group(3) # ; or closing quote
|
|
121
|
+
old_val = match.group(0)
|
|
122
|
+
self.add_fix(f"Removed unit from font-size in style: {old_val} -> {prefix}{number}{suffix}")
|
|
123
|
+
return f'{prefix}{number}{suffix}'
|
|
124
|
+
|
|
125
|
+
content = re.sub(style_pattern, replace_style, content)
|
|
126
|
+
|
|
127
|
+
if content != original_content:
|
|
128
|
+
with open(filename, 'w', encoding='utf-8') as f:
|
|
129
|
+
f.write(content)
|
|
130
|
+
|
|
131
|
+
return self.fixes
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def get_name():
|
|
135
|
+
return "font_size"
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def get_description():
|
|
139
|
+
return "Check that the font-size attribute of each text element is a valid number"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SVGFontTypeChecker(SVGChecker):
|
|
143
|
+
VALID_FONTS = {
|
|
144
|
+
'Noto Sans',
|
|
145
|
+
'OCR-Fritzing-mono',
|
|
146
|
+
'Droid Sans', # deprecated, use Noto Sans instead
|
|
147
|
+
'Droid Sans Mono', # deprecated, use Noto Sans instead
|
|
148
|
+
'OCRA',
|
|
149
|
+
'Segment16C'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
FONT_REPLACEMENTS = {
|
|
153
|
+
'Segment16C Bold.ttf': 'Segment16C',
|
|
154
|
+
'DroidSans-Bold': 'Noto Sans',
|
|
155
|
+
'NotoSans-Regular': 'Noto Sans',
|
|
156
|
+
'OCRAStd': 'OCR-Fritzing-mono',
|
|
157
|
+
'OCRATributeW01 - Regular': 'OCR-Fritzing-mono',
|
|
158
|
+
'ocra10': 'OCR-Fritzing-mono',
|
|
159
|
+
'OCRATributeW01-Regular': 'OCR-Fritzing-mono',
|
|
160
|
+
'OpenSans': 'Noto Sans',
|
|
161
|
+
'ArialMT': 'default',
|
|
162
|
+
'MyriadPro - Regular': 'default',
|
|
163
|
+
'MyriadPro-Regular': 'default',
|
|
164
|
+
'HelveticaNeueLTStd-Roman': 'default',
|
|
165
|
+
'DroidSans - Bold': 'Noto Sans',
|
|
166
|
+
"'DroidSans'": 'Noto Sans',
|
|
167
|
+
'DroidSans': 'Noto Sans',
|
|
168
|
+
"'Droid Sans'": 'Noto Sans',
|
|
169
|
+
"Droid": "Noto Sans",
|
|
170
|
+
'Droid Sans Mono': 'default',
|
|
171
|
+
'DroidSansMono': 'default',
|
|
172
|
+
"'DroidSans, 'Droid Sans'": 'Noto Sans',
|
|
173
|
+
"DroidSans, 'Droid Sans'": 'Noto Sans',
|
|
174
|
+
'Arial-BoldMT': 'Noto Sans',
|
|
175
|
+
'EurostileLTStd': 'Noto Sans',
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
def __init__(self, svg_doc, layer_ids):
|
|
179
|
+
super().__init__(svg_doc, layer_ids)
|
|
180
|
+
self.is_pcb_view = 'copper' in layer_ids or 'silkscreen' in layer_ids
|
|
181
|
+
self.default_font = 'OCR-Fritzing-mono' if self.is_pcb_view else 'Noto Sans'
|
|
182
|
+
|
|
183
|
+
def has_inherited_style(self, element):
|
|
184
|
+
"""Check if element has an inherited style attribute"""
|
|
185
|
+
return SVGUtils.get_inherited_attribute(element, "style") is not None
|
|
186
|
+
|
|
187
|
+
def fix(self, filename):
|
|
188
|
+
"""
|
|
189
|
+
Fixes invalid or missing font families in the SVG document using regex
|
|
190
|
+
to preserve original formatting and make minimal changes.
|
|
191
|
+
Always uses double quotes for consistency.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
filename: Path to the SVG file to write fixes to
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
# Read the original file
|
|
198
|
+
with open(filename, 'r', encoding='utf-8') as file:
|
|
199
|
+
content = file.read()
|
|
200
|
+
|
|
201
|
+
modified = False
|
|
202
|
+
original_content = content
|
|
203
|
+
|
|
204
|
+
# Pattern to match font-family with any quote style, including nested quotes
|
|
205
|
+
# Handles cases like font-family="'DroidSans, 'Droid Sans'"
|
|
206
|
+
pattern = r'font-family\s*=\s*(["\'])([^\1]*?)\1'
|
|
207
|
+
|
|
208
|
+
def replace_font(match):
|
|
209
|
+
nonlocal modified
|
|
210
|
+
quote_char = match.group(1) # The quote character used
|
|
211
|
+
font = match.group(2) # The font name content
|
|
212
|
+
if font in self.FONT_REPLACEMENTS:
|
|
213
|
+
new_font = self.FONT_REPLACEMENTS[font]
|
|
214
|
+
if new_font == 'default':
|
|
215
|
+
new_font = self.default_font
|
|
216
|
+
modified = True
|
|
217
|
+
self.add_fix(f"Replaced font '{font}' with '{new_font}' in {filename}")
|
|
218
|
+
# Always use double quotes for consistency
|
|
219
|
+
return f'font-family="{new_font}"'
|
|
220
|
+
return match.group(0)
|
|
221
|
+
|
|
222
|
+
# Debug: Show all matches found
|
|
223
|
+
matches = re.findall(pattern, content)
|
|
224
|
+
self.logger.debug(f"Font-family matches found: {matches}")
|
|
225
|
+
|
|
226
|
+
# Make replacements
|
|
227
|
+
content = re.sub(pattern, replace_font, content)
|
|
228
|
+
|
|
229
|
+
if modified:
|
|
230
|
+
# Create backup if it doesn't exist
|
|
231
|
+
backup_path = filename + ".bak"
|
|
232
|
+
if not os.path.exists(backup_path):
|
|
233
|
+
with open(backup_path, 'w', encoding='utf-8') as file:
|
|
234
|
+
file.write(original_content)
|
|
235
|
+
self.logger.debug(f"Backup created at '{backup_path}'")
|
|
236
|
+
|
|
237
|
+
# Write modified content only if changes were made
|
|
238
|
+
with open(filename, 'w', encoding='utf-8') as file:
|
|
239
|
+
file.write(content)
|
|
240
|
+
self.logger.debug(f"SVG file '{filename}' has been updated successfully")
|
|
241
|
+
return self.fixes
|
|
242
|
+
else:
|
|
243
|
+
self.logger.debug("No fonts found to replace. No changes made.")
|
|
244
|
+
return self.fixes
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def check_font_type(self, element):
|
|
249
|
+
font_family = SVGUtils.get_inherited_attribute(element, "font-family")
|
|
250
|
+
if font_family is None:
|
|
251
|
+
if element.tag.endswith("text"):
|
|
252
|
+
for child in element.iterchildren():
|
|
253
|
+
if child.tag.endswith("tspan"):
|
|
254
|
+
return self.check_font_type(child)
|
|
255
|
+
content = self.getChildXML(element)
|
|
256
|
+
self.add_error(f"No font family found for element [{content}]", node=element)
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
# Remove quotes if present
|
|
260
|
+
font_family = font_family.strip('"\'')
|
|
261
|
+
|
|
262
|
+
if font_family not in self.VALID_FONTS:
|
|
263
|
+
content = self.getChildXML(element)
|
|
264
|
+
self.add_error(f"Invalid font family '{font_family}' in element: [{content}]", node=element)
|
|
265
|
+
|
|
266
|
+
def getChildXML(self, elem):
|
|
267
|
+
out = ""
|
|
268
|
+
if elem.text:
|
|
269
|
+
out += elem.text
|
|
270
|
+
for c in elem.iterchildren():
|
|
271
|
+
if len(c) == 0:
|
|
272
|
+
out += f"<{c.tag}/>"
|
|
273
|
+
else:
|
|
274
|
+
out += f"<{c.tag}>{self.getChildXML(c)}</{c.tag}>"
|
|
275
|
+
if c.tail:
|
|
276
|
+
out += c.tail
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
def check(self):
|
|
280
|
+
text_elements = self.svg_doc.xpath("//*[local-name()='text' or local-name()='tspan']")
|
|
281
|
+
for element in text_elements:
|
|
282
|
+
self.check_font_type(element)
|
|
283
|
+
return self.get_result()
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def get_name():
|
|
287
|
+
return "font_type"
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def get_description():
|
|
291
|
+
return "Check that font-family attributes use only allowed fonts (Noto Sans, OCR-Fritzing-mono, DroidSans, OCRA)"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class SVGViewBoxChecker(SVGChecker):
|
|
295
|
+
def check(self):
|
|
296
|
+
# For icons, we don't really need a viewBox
|
|
297
|
+
if self.layer_ids == ['icon']:
|
|
298
|
+
return self.get_result()
|
|
299
|
+
|
|
300
|
+
root_element = self.svg_doc.getroot()
|
|
301
|
+
if "viewBox" in root_element.attrib:
|
|
302
|
+
viewbox = root_element.attrib["viewBox"]
|
|
303
|
+
if not re.match(r"^-?\d+(\.\d+)?( -?\d+(\.\d+)?){3}$", viewbox):
|
|
304
|
+
self.add_error(f"Invalid viewBox attribute: {viewbox}", node=root_element)
|
|
305
|
+
else:
|
|
306
|
+
self.add_error("Missing viewBox attribute", node=root_element)
|
|
307
|
+
return self.get_result()
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def get_name():
|
|
311
|
+
return "viewbox"
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def get_description():
|
|
315
|
+
return "Check that the viewBox attribute is valid"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class SVGIdsChecker(SVGChecker):
|
|
319
|
+
def check(self):
|
|
320
|
+
id_occurrences = {}
|
|
321
|
+
elements_with_id = self.svg_doc.xpath("//*[@id]")
|
|
322
|
+
|
|
323
|
+
# First pass: collect all occurrences and check for empty IDs
|
|
324
|
+
for element in elements_with_id:
|
|
325
|
+
element_id = element.attrib["id"]
|
|
326
|
+
|
|
327
|
+
# Check for empty or whitespace-only IDs
|
|
328
|
+
if not element_id or element_id.isspace():
|
|
329
|
+
self.add_warning(f"Element has empty or whitespace-only id attribute - should be removed", node=element)
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
if element_id not in id_occurrences:
|
|
333
|
+
id_occurrences[element_id] = []
|
|
334
|
+
id_occurrences[element_id].append(element)
|
|
335
|
+
|
|
336
|
+
# Second pass: report duplicates with enumeration
|
|
337
|
+
for element_id, elements in id_occurrences.items():
|
|
338
|
+
if len(elements) > 1:
|
|
339
|
+
for i, element in enumerate(elements, 1):
|
|
340
|
+
self.add_error(f"Duplicate id attribute: {element_id} (occurrence {i} of {len(elements)})", node=element)
|
|
341
|
+
|
|
342
|
+
return self.get_result()
|
|
343
|
+
|
|
344
|
+
def fix(self, filename):
|
|
345
|
+
"""Fix SVG ID issues: 1) Remove empty IDs, 2) Fix duplicate 'label' IDs by combining consecutive text elements"""
|
|
346
|
+
import re
|
|
347
|
+
|
|
348
|
+
# Use the provided filename
|
|
349
|
+
svg_path = filename
|
|
350
|
+
if not svg_path:
|
|
351
|
+
self.logger.debug("Cannot fix SVG IDs - file path not found")
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
fixes_applied = False
|
|
355
|
+
|
|
356
|
+
# Read the original file for string-based operations
|
|
357
|
+
with open(svg_path, 'r', encoding='utf-8') as file:
|
|
358
|
+
content = file.read()
|
|
359
|
+
original_content = content
|
|
360
|
+
|
|
361
|
+
# First: Remove empty or whitespace-only IDs using regex
|
|
362
|
+
empty_id_pattern = r'\s+id\s*=\s*["\']["\']' # matches id="" or id=''
|
|
363
|
+
whitespace_id_pattern = r'\s+id\s*=\s*["\'][\s]*["\']' # matches id=" " etc
|
|
364
|
+
|
|
365
|
+
empty_matches = len(re.findall(empty_id_pattern, content))
|
|
366
|
+
whitespace_matches = len(re.findall(whitespace_id_pattern, content))
|
|
367
|
+
|
|
368
|
+
if empty_matches > 0 or whitespace_matches > 0:
|
|
369
|
+
content = re.sub(empty_id_pattern, '', content)
|
|
370
|
+
content = re.sub(whitespace_id_pattern, '', content)
|
|
371
|
+
total_removed = empty_matches + whitespace_matches
|
|
372
|
+
self.add_fix(f"Removed {total_removed} empty/whitespace id attributes in {svg_path}")
|
|
373
|
+
fixes_applied = True
|
|
374
|
+
|
|
375
|
+
# Second: Handle duplicate label IDs
|
|
376
|
+
label_elements = self.svg_doc.xpath("//*[local-name()='text' and @id='label']")
|
|
377
|
+
|
|
378
|
+
if len(label_elements) <= 1:
|
|
379
|
+
if not fixes_applied:
|
|
380
|
+
self.logger.debug(f"No duplicate text elements with id='label' to fix in {svg_path}")
|
|
381
|
+
return self.fixes
|
|
382
|
+
|
|
383
|
+
self.logger.debug(f"Found {len(label_elements)} text elements with id='label' in {svg_path}")
|
|
384
|
+
|
|
385
|
+
# Group consecutive elements
|
|
386
|
+
consecutive_groups = self._find_consecutive_groups(label_elements)
|
|
387
|
+
|
|
388
|
+
# Check if there are actually groups to fix
|
|
389
|
+
groups_to_fix = [group for group in consecutive_groups if len(group) > 1]
|
|
390
|
+
|
|
391
|
+
# Count total elements that could be fixed vs those in consecutive groups
|
|
392
|
+
total_fixable_elements = sum(len(group) for group in groups_to_fix)
|
|
393
|
+
|
|
394
|
+
if not groups_to_fix:
|
|
395
|
+
# We have duplicates but no consecutive groups - this can't be auto-fixed
|
|
396
|
+
self.add_error(f"Found {len(label_elements)} duplicate label IDs that are not consecutive and cannot be automatically fixed in {svg_path}")
|
|
397
|
+
return self.fixes
|
|
398
|
+
elif total_fixable_elements < len(label_elements):
|
|
399
|
+
# We have some consecutive groups but also some non-consecutive duplicates
|
|
400
|
+
non_consecutive_count = len(label_elements) - total_fixable_elements
|
|
401
|
+
self.add_error(f"Found {non_consecutive_count} non-consecutive duplicate label IDs that cannot be automatically fixed in {svg_path}")
|
|
402
|
+
|
|
403
|
+
self.logger.debug(f"Found {len(groups_to_fix)} groups of consecutive label elements to fix")
|
|
404
|
+
|
|
405
|
+
# Process each group (in reverse order to maintain positions)
|
|
406
|
+
for i, group in enumerate(reversed(groups_to_fix)):
|
|
407
|
+
self.logger.debug(f"Creating group {i+1} with {len(group)} text elements")
|
|
408
|
+
content = self._replace_label_group_in_content(content, group)
|
|
409
|
+
self.add_fix(f"Successfully fixed {len(groups_to_fix)} groups of duplicate label IDs in {svg_path}")
|
|
410
|
+
fixes_applied = True
|
|
411
|
+
|
|
412
|
+
# Write the modified content back if any fixes were applied
|
|
413
|
+
if fixes_applied:
|
|
414
|
+
with open(svg_path, 'w', encoding='utf-8') as file:
|
|
415
|
+
file.write(content)
|
|
416
|
+
|
|
417
|
+
return self.fixes
|
|
418
|
+
|
|
419
|
+
def _replace_label_group_in_content(self, content, text_elements):
|
|
420
|
+
"""Replace consecutive text elements with id='label' with a single text element containing tspan children"""
|
|
421
|
+
import re
|
|
422
|
+
|
|
423
|
+
# Use a simpler approach - find all text elements with id="label" in the content
|
|
424
|
+
# and match them by position and content
|
|
425
|
+
label_pattern = r'<text[^>]*id="label"[^>]*>.*?</text>'
|
|
426
|
+
label_matches = []
|
|
427
|
+
|
|
428
|
+
for match in re.finditer(label_pattern, content, re.DOTALL):
|
|
429
|
+
# Extract the full element text
|
|
430
|
+
element_text = match.group(0)
|
|
431
|
+
|
|
432
|
+
# Check if this matches any of our target elements by x, y, and text content
|
|
433
|
+
for elem in text_elements:
|
|
434
|
+
x_val = elem.get('x', '')
|
|
435
|
+
y_val = elem.get('y', '')
|
|
436
|
+
text_content = elem.text or ""
|
|
437
|
+
|
|
438
|
+
# Check if this element matches by looking for x, y, and text content
|
|
439
|
+
if (f'x="{x_val}"' in element_text and
|
|
440
|
+
f'y="{y_val}"' in element_text and
|
|
441
|
+
text_content in element_text):
|
|
442
|
+
label_matches.append((match.start(), match.end(), element_text))
|
|
443
|
+
break
|
|
444
|
+
|
|
445
|
+
found_elements = label_matches
|
|
446
|
+
|
|
447
|
+
if len(found_elements) < 2:
|
|
448
|
+
return content # Not enough elements to group
|
|
449
|
+
|
|
450
|
+
# Sort by position in file
|
|
451
|
+
found_elements.sort()
|
|
452
|
+
|
|
453
|
+
# Get the original indentation from the first element
|
|
454
|
+
first_start = found_elements[0][0]
|
|
455
|
+
line_start = content.rfind('\n', 0, first_start) + 1
|
|
456
|
+
original_indent = content[line_start:first_start]
|
|
457
|
+
|
|
458
|
+
# Create the text element with tspan children replacement with proper indentation
|
|
459
|
+
# Extract attributes from the first text element
|
|
460
|
+
first_element = found_elements[0][2]
|
|
461
|
+
|
|
462
|
+
# Extract common attributes from first element (x, y, fill, font-family, font-size, text-anchor)
|
|
463
|
+
import re
|
|
464
|
+
x_match = re.search(r'\bx="([^"]*)"', first_element)
|
|
465
|
+
y_match = re.search(r'\by="([^"]*)"', first_element)
|
|
466
|
+
fill_match = re.search(r'\bfill="([^"]*)"', first_element)
|
|
467
|
+
font_family_match = re.search(r'\bfont-family="([^"]*)"', first_element)
|
|
468
|
+
font_size_match = re.search(r'\bfont-size="([^"]*)"', first_element)
|
|
469
|
+
text_anchor_match = re.search(r'\btext-anchor="([^"]*)"', first_element)
|
|
470
|
+
|
|
471
|
+
x_val = x_match.group(1) if x_match else "0"
|
|
472
|
+
y_val = y_match.group(1) if y_match else "0"
|
|
473
|
+
fill_val = fill_match.group(1) if fill_match else "#000000"
|
|
474
|
+
font_family_val = font_family_match.group(1) if font_family_match else "Noto Sans"
|
|
475
|
+
font_size_val = font_size_match.group(1) if font_size_match else "3.5"
|
|
476
|
+
text_anchor_val = text_anchor_match.group(1) if text_anchor_match else "middle"
|
|
477
|
+
|
|
478
|
+
# Create text element with tspan children
|
|
479
|
+
group_content = original_indent + f'<text id="label" x="{x_val}" y="{y_val}" fill="{fill_val}" font-family="{font_family_val}" font-size="{font_size_val}" text-anchor="{text_anchor_val}">\n'
|
|
480
|
+
|
|
481
|
+
for i, (_, _, element_str) in enumerate(found_elements):
|
|
482
|
+
# Extract text content from each element
|
|
483
|
+
text_match = re.search(r'<text[^>]*>([^<]*)</text>', element_str)
|
|
484
|
+
text_content = text_match.group(1) if text_match else ""
|
|
485
|
+
# Extract y position from this specific element
|
|
486
|
+
y_match = re.search(r'\by="([^"]*)"', element_str)
|
|
487
|
+
element_y = y_match.group(1) if y_match else y_val
|
|
488
|
+
|
|
489
|
+
# Create tspan with x and y attributes, no dx/dy as requested
|
|
490
|
+
group_content += original_indent + f' <tspan x="{x_val}" y="{element_y}">{text_content}</tspan>\n'
|
|
491
|
+
|
|
492
|
+
group_content += original_indent + '</text>'
|
|
493
|
+
|
|
494
|
+
# Replace all the individual elements with the group
|
|
495
|
+
# Remove from end to start to preserve positions
|
|
496
|
+
for start, end, _ in reversed(found_elements):
|
|
497
|
+
if start == found_elements[0][0]: # First element - replace with group
|
|
498
|
+
# Replace including the original indentation (which is already in group_content)
|
|
499
|
+
line_start = content.rfind('\n', 0, start) + 1
|
|
500
|
+
content = content[:line_start] + group_content + content[end:]
|
|
501
|
+
else: # Other elements - remove including their line and indentation
|
|
502
|
+
# Find the start of the line (including indentation)
|
|
503
|
+
line_start = content.rfind('\n', 0, start) + 1
|
|
504
|
+
# Check if there's a newline after the element to remove it too
|
|
505
|
+
end_pos = end
|
|
506
|
+
if end_pos < len(content) and content[end_pos] == '\n':
|
|
507
|
+
end_pos += 1
|
|
508
|
+
content = content[:line_start] + content[end_pos:]
|
|
509
|
+
|
|
510
|
+
return content
|
|
511
|
+
|
|
512
|
+
def _find_consecutive_groups(self, elements):
|
|
513
|
+
"""Find groups of consecutive text elements in document order"""
|
|
514
|
+
if not elements:
|
|
515
|
+
return []
|
|
516
|
+
|
|
517
|
+
# Get all elements in the document to determine order
|
|
518
|
+
all_elements = self.svg_doc.xpath("//*")
|
|
519
|
+
element_positions = {elem: i for i, elem in enumerate(all_elements)}
|
|
520
|
+
|
|
521
|
+
# Sort label elements by their document position
|
|
522
|
+
sorted_elements = sorted(elements, key=lambda x: element_positions.get(x, float('inf')))
|
|
523
|
+
|
|
524
|
+
# Group consecutive elements
|
|
525
|
+
groups = []
|
|
526
|
+
current_group = [sorted_elements[0]]
|
|
527
|
+
|
|
528
|
+
for i in range(1, len(sorted_elements)):
|
|
529
|
+
curr_pos = element_positions.get(sorted_elements[i], float('inf'))
|
|
530
|
+
prev_pos = element_positions.get(sorted_elements[i-1], float('inf'))
|
|
531
|
+
|
|
532
|
+
# Check if elements are consecutive in document order
|
|
533
|
+
consecutive = True
|
|
534
|
+
for pos in range(prev_pos + 1, curr_pos):
|
|
535
|
+
if pos < len(all_elements):
|
|
536
|
+
between_elem = all_elements[pos]
|
|
537
|
+
# If there's a text element with id="label" between them, they're not consecutive
|
|
538
|
+
if between_elem.tag == "text" and between_elem.get("id") == "label":
|
|
539
|
+
consecutive = False
|
|
540
|
+
break
|
|
541
|
+
|
|
542
|
+
if consecutive:
|
|
543
|
+
current_group.append(sorted_elements[i])
|
|
544
|
+
else:
|
|
545
|
+
groups.append(current_group)
|
|
546
|
+
current_group = [sorted_elements[i]]
|
|
547
|
+
|
|
548
|
+
groups.append(current_group)
|
|
549
|
+
return groups
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@staticmethod
|
|
553
|
+
def get_name():
|
|
554
|
+
return "ids"
|
|
555
|
+
|
|
556
|
+
@staticmethod
|
|
557
|
+
def get_description():
|
|
558
|
+
return "Check that all id attributes are unique"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class SVGMatrixChecker(SVGChecker):
|
|
562
|
+
@staticmethod
|
|
563
|
+
def get_name():
|
|
564
|
+
return "matrix"
|
|
565
|
+
|
|
566
|
+
@staticmethod
|
|
567
|
+
def get_description():
|
|
568
|
+
return "Checks for malformed matrix transformations in SVG files"
|
|
569
|
+
|
|
570
|
+
def check(self):
|
|
571
|
+
elements = self.svg_doc.xpath("//*[@transform]")
|
|
572
|
+
|
|
573
|
+
# SVG standard allows numbers with optional leading dot (.5),
|
|
574
|
+
# but requires decimal point to be followed by digit if present
|
|
575
|
+
# See: https://www.w3.org/TR/SVGTiny12/types.html#DataTypeNumber
|
|
576
|
+
float_regex = re.compile(r'^-?(\d+|\d*\.\d+)([eE][-+]?\d+)?$')
|
|
577
|
+
|
|
578
|
+
for element in elements:
|
|
579
|
+
transform = element.get("transform")
|
|
580
|
+
if "matrix" in transform:
|
|
581
|
+
try:
|
|
582
|
+
# Extract values between parentheses
|
|
583
|
+
matrix_values = transform.split("(")[1].split(")")[0]
|
|
584
|
+
values = re.split(r'[,\s]+', matrix_values.strip())
|
|
585
|
+
|
|
586
|
+
# Matrix should have exactly 6 values
|
|
587
|
+
if len(values) != 6:
|
|
588
|
+
self.add_error(f"Invalid matrix transform (wrong number of values) in element {element.get('id')}: {transform}", node=element)
|
|
589
|
+
continue
|
|
590
|
+
|
|
591
|
+
# Check for empty values and validate float format
|
|
592
|
+
if any(not v or not float_regex.match(v) for v in values):
|
|
593
|
+
self.add_error(f"Invalid matrix transform (invalid value) in element {element.get('id')}: {transform}", node=element)
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
except IndexError:
|
|
597
|
+
self.add_error(f"Malformed matrix transform in element {element.get('id')}: {transform}", node=element)
|
|
598
|
+
|
|
599
|
+
return self.get_result()
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
class SVGLayerNestingChecker(SVGChecker):
|
|
603
|
+
def check(self):
|
|
604
|
+
root_element = self.svg_doc.getroot()
|
|
605
|
+
svg_path = self.svg_doc.docinfo.URL
|
|
606
|
+
|
|
607
|
+
# Layer groups that shouldn't be nested in certain other layers
|
|
608
|
+
invalid_nesting = {
|
|
609
|
+
'breadboard': ['schematic', 'icon', 'silkscreen', 'silkscreen0', 'copper0', 'copper1'],
|
|
610
|
+
'schematic': ['breadboard', 'icon', 'silkscreen', 'silkscreen0', 'copper0', 'copper1'],
|
|
611
|
+
'icon': ['silkscreen', 'silkscreen0', 'copper0', 'copper1', 'breadboard', 'schematic'],
|
|
612
|
+
'silkscreen': ['breadboard', 'schematic', 'icon', 'copper0', 'copper1'],
|
|
613
|
+
'silkscreen0': ['breadboard', 'schematic', 'icon', 'copper0', 'copper1'],
|
|
614
|
+
'copper0': ['breadboard', 'schematic', 'icon', 'silkscreen', 'silkscreen0'],
|
|
615
|
+
'copper1': ['breadboard', 'schematic', 'icon', 'silkscreen', 'silkscreen0'],
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
# Check each main layer group
|
|
619
|
+
for parent_layer, invalid_children in invalid_nesting.items():
|
|
620
|
+
parent_groups = root_element.xpath(f"//*[@id='{parent_layer}']")
|
|
621
|
+
for parent_group in parent_groups:
|
|
622
|
+
# Check for invalid child layers
|
|
623
|
+
for invalid_child in invalid_children:
|
|
624
|
+
child_elements = parent_group.xpath(f".//*[@id='{invalid_child}']")
|
|
625
|
+
for element in child_elements:
|
|
626
|
+
# icon inside breadboard (or vice versa) is unsupported but not fatal
|
|
627
|
+
pair = {parent_layer, invalid_child}
|
|
628
|
+
if pair == {'breadboard', 'icon'}:
|
|
629
|
+
self.add_warning(f"Found '{invalid_child}' layer nested inside '{parent_layer}' group, which is unsupported. File: {svg_path}", node=element)
|
|
630
|
+
else:
|
|
631
|
+
self.add_error(f"Found '{invalid_child}' layer nested inside '{parent_layer}' group, which is invalid. File: {svg_path}", node=element)
|
|
632
|
+
|
|
633
|
+
return self.get_result()
|
|
634
|
+
|
|
635
|
+
@staticmethod
|
|
636
|
+
def get_name():
|
|
637
|
+
return "layer_nesting"
|
|
638
|
+
|
|
639
|
+
@staticmethod
|
|
640
|
+
def get_description():
|
|
641
|
+
return "Check that layer groups are not incorrectly nested (e.g. silkscreen within breadboard)"
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class SVGCopperLayerContentChecker(SVGChecker):
|
|
645
|
+
"""
|
|
646
|
+
Validates that copper layer groups (copper0, copper1) only contain
|
|
647
|
+
copper-colored elements and no non-copper colors.
|
|
648
|
+
|
|
649
|
+
This prevents the common mistake of using silkscreen colors (white, gray)
|
|
650
|
+
or other non-copper colors within copper layer groups, which causes
|
|
651
|
+
rendering issues.
|
|
652
|
+
"""
|
|
653
|
+
|
|
654
|
+
# RGB threshold for color similarity (Euclidean distance)
|
|
655
|
+
# Set high enough to catch all copperish/golden/orange colors
|
|
656
|
+
# but low enough to reject silkscreen colors (white ~227, black ~311)
|
|
657
|
+
RGB_DISTANCE_THRESHOLD = 75.0
|
|
658
|
+
STANDARD_COPPER_RGB = (247, 189, 19) # #f7bd13
|
|
659
|
+
|
|
660
|
+
def check(self):
|
|
661
|
+
"""Check copper0 and copper1 layers for invalid content"""
|
|
662
|
+
# Skip if not a PCB view
|
|
663
|
+
if 'copper0' not in self.layer_ids and 'copper1' not in self.layer_ids:
|
|
664
|
+
return self.get_result()
|
|
665
|
+
|
|
666
|
+
# Check both copper layers
|
|
667
|
+
for layer in ['copper0', 'copper1']:
|
|
668
|
+
layer_groups = self.svg_doc.xpath(f"//*[@id='{layer}']")
|
|
669
|
+
for group in layer_groups:
|
|
670
|
+
self._check_copper_layer_colors(group, layer)
|
|
671
|
+
|
|
672
|
+
return self.get_result()
|
|
673
|
+
|
|
674
|
+
def _check_copper_layer_colors(self, layer_group, layer_id):
|
|
675
|
+
"""Check colors of all elements within a copper layer group"""
|
|
676
|
+
# Get all descendant elements
|
|
677
|
+
descendants = layer_group.xpath(".//*")
|
|
678
|
+
|
|
679
|
+
for element in descendants:
|
|
680
|
+
# Skip copper layer groups themselves (copper0 in copper1 is valid)
|
|
681
|
+
if element.get('id') in ['copper0', 'copper1']:
|
|
682
|
+
continue
|
|
683
|
+
|
|
684
|
+
self._check_element_colors(element, layer_id)
|
|
685
|
+
|
|
686
|
+
def _check_element_colors(self, element, layer_id):
|
|
687
|
+
"""Check fill and stroke colors of an element"""
|
|
688
|
+
# Get attributes (may be overridden by style)
|
|
689
|
+
fill = SVGUtils.get_inherited_attribute(element, 'fill')
|
|
690
|
+
stroke = SVGUtils.get_inherited_attribute(element, 'stroke')
|
|
691
|
+
stroke_width = SVGUtils.get_inherited_attribute(element, 'stroke-width')
|
|
692
|
+
style = SVGUtils.get_inherited_attribute(element, 'style')
|
|
693
|
+
|
|
694
|
+
# Parse style attribute if present (style overrides attributes)
|
|
695
|
+
if style:
|
|
696
|
+
style_attrs = style.split(";")
|
|
697
|
+
for attr in style_attrs:
|
|
698
|
+
if attr:
|
|
699
|
+
parts = attr.split(":")
|
|
700
|
+
if len(parts) == 2:
|
|
701
|
+
key = parts[0].strip()
|
|
702
|
+
value = parts[1].strip()
|
|
703
|
+
if key == "fill":
|
|
704
|
+
fill = value
|
|
705
|
+
elif key == "stroke":
|
|
706
|
+
stroke = value
|
|
707
|
+
elif key == "stroke-width":
|
|
708
|
+
stroke_width = value
|
|
709
|
+
|
|
710
|
+
# Check fill color
|
|
711
|
+
if fill and fill.lower() != 'none':
|
|
712
|
+
result = self._is_valid_copper_color(fill, element, layer_id, 'fill')
|
|
713
|
+
if not result:
|
|
714
|
+
self._report_invalid_color(element, layer_id, fill, 'fill')
|
|
715
|
+
|
|
716
|
+
# Check stroke color (only if stroke is visible)
|
|
717
|
+
if stroke and stroke.lower() != 'none':
|
|
718
|
+
# Only check stroke if it's visible (has non-zero width)
|
|
719
|
+
if stroke_width and stroke_width != '0':
|
|
720
|
+
result = self._is_valid_copper_color(stroke, element, layer_id, 'stroke')
|
|
721
|
+
if not result:
|
|
722
|
+
self._report_invalid_color(element, layer_id, stroke, 'stroke')
|
|
723
|
+
|
|
724
|
+
def _is_valid_copper_color(self, color, element, layer_id, attr_type):
|
|
725
|
+
"""Check if color is a valid copper color
|
|
726
|
+
|
|
727
|
+
Only #f7bd13 passes without warning.
|
|
728
|
+
Near colors (within threshold) generate a warning.
|
|
729
|
+
Far colors generate an error (by returning False).
|
|
730
|
+
"""
|
|
731
|
+
if not color:
|
|
732
|
+
return True
|
|
733
|
+
|
|
734
|
+
# Convert color to RGB for comparison
|
|
735
|
+
try:
|
|
736
|
+
rgb = self._color_to_rgb(color)
|
|
737
|
+
if not rgb:
|
|
738
|
+
return False
|
|
739
|
+
|
|
740
|
+
# Check if exact match with standard copper color
|
|
741
|
+
if rgb == self.STANDARD_COPPER_RGB:
|
|
742
|
+
return True
|
|
743
|
+
|
|
744
|
+
# Check RGB distance from standard copper color
|
|
745
|
+
distance = self._rgb_distance(rgb, self.STANDARD_COPPER_RGB)
|
|
746
|
+
|
|
747
|
+
if distance <= self.RGB_DISTANCE_THRESHOLD:
|
|
748
|
+
# Near color - issue warning but don't fail
|
|
749
|
+
element_id = element.get('id', '(no id)')
|
|
750
|
+
element_tag = element.tag.split('}')[-1] if '}' in element.tag else element.tag
|
|
751
|
+
self.add_warning(
|
|
752
|
+
f"Non-standard copper color '{color}' found in copper layer '{layer_id}'. "
|
|
753
|
+
f"Expected exact color (#f7bd13). "
|
|
754
|
+
f"Element: {element_tag} with id='{element_id}' ({attr_type} attribute)",
|
|
755
|
+
node=element
|
|
756
|
+
)
|
|
757
|
+
return True
|
|
758
|
+
|
|
759
|
+
# Far color - will trigger error
|
|
760
|
+
return False
|
|
761
|
+
except:
|
|
762
|
+
pass
|
|
763
|
+
|
|
764
|
+
return False
|
|
765
|
+
|
|
766
|
+
def _color_to_rgb(self, color):
|
|
767
|
+
"""Convert color (hex or rgb notation) to RGB tuple
|
|
768
|
+
|
|
769
|
+
Handles normalization (lowercase, whitespace removal) internally.
|
|
770
|
+
"""
|
|
771
|
+
# Normalize color (lowercase, remove whitespace)
|
|
772
|
+
color = color.strip().lower()
|
|
773
|
+
|
|
774
|
+
# Handle rgb(r, g, b) notation
|
|
775
|
+
if color.startswith('rgb(') and color.endswith(')'):
|
|
776
|
+
try:
|
|
777
|
+
rgb_values = color[4:-1].split(',')
|
|
778
|
+
return tuple(int(v.strip()) for v in rgb_values)
|
|
779
|
+
except (ValueError, IndexError):
|
|
780
|
+
return None
|
|
781
|
+
|
|
782
|
+
# Handle hex notation
|
|
783
|
+
hex_color = color.lstrip('#')
|
|
784
|
+
if len(hex_color) != 6:
|
|
785
|
+
return None
|
|
786
|
+
try:
|
|
787
|
+
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
|
788
|
+
except ValueError:
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
def _rgb_distance(self, rgb1, rgb2):
|
|
792
|
+
"""Calculate Euclidean distance between two RGB colors"""
|
|
793
|
+
return sum((a - b) ** 2 for a, b in zip(rgb1, rgb2)) ** 0.5
|
|
794
|
+
|
|
795
|
+
def _report_invalid_color(self, element, layer_id, color, attr_type):
|
|
796
|
+
"""Report an invalid color error"""
|
|
797
|
+
element_id = element.get('id', '(no id)')
|
|
798
|
+
element_tag = element.tag.split('}')[-1] if '}' in element.tag else element.tag
|
|
799
|
+
|
|
800
|
+
self.add_error(
|
|
801
|
+
f"Invalid color '{color}' found in copper layer '{layer_id}'. "
|
|
802
|
+
f"Expected copper color (#f7bd13). "
|
|
803
|
+
f"Element: {element_tag} with id='{element_id}' ({attr_type} attribute)",
|
|
804
|
+
node=element
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
@staticmethod
|
|
808
|
+
def get_name():
|
|
809
|
+
return "copper_layer_content"
|
|
810
|
+
|
|
811
|
+
@staticmethod
|
|
812
|
+
def get_description():
|
|
813
|
+
return "Check that copper layers only contain copper-colored elements (no silkscreen or other layer content)"
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
class SVGNoLayerChecker(SVGChecker):
|
|
817
|
+
"""Check that SVG child elements are inside proper Fritzing layer groups."""
|
|
818
|
+
|
|
819
|
+
VALID_LAYERS = [
|
|
820
|
+
"icon", "breadboardbreadboard", "breadboard", "breadboardWire",
|
|
821
|
+
"breadboardLabel", "breadboardNote", "breadboardRuler",
|
|
822
|
+
"schematic", "schematicWire", "schematicTrace", "schematicLabel",
|
|
823
|
+
"schematicRuler", "board", "ratsnest", "silkscreen",
|
|
824
|
+
"silkscreenLabel", "groundplane", "copper0", "copper0trace",
|
|
825
|
+
"groundplane1", "copper1", "copper1trace", "silkscreen0",
|
|
826
|
+
"silkscreen0Label", "soldermask", "outline", "keepout",
|
|
827
|
+
"partimage", "pcbNote", "pcbRuler"
|
|
828
|
+
]
|
|
829
|
+
|
|
830
|
+
SKIP_LOCAL_TAGS = {"metadata", "title", "desc", "defs", "namedview"}
|
|
831
|
+
|
|
832
|
+
def _check_element(self, element):
|
|
833
|
+
"""Walk direct children of element, checking for valid layer groups."""
|
|
834
|
+
for child in element:
|
|
835
|
+
tag = etree.QName(child.tag).localname if '}' in child.tag else child.tag
|
|
836
|
+
|
|
837
|
+
if tag in self.SKIP_LOCAL_TAGS:
|
|
838
|
+
continue
|
|
839
|
+
|
|
840
|
+
child_id = child.get("id", "")
|
|
841
|
+
if child_id in self.VALID_LAYERS:
|
|
842
|
+
continue
|
|
843
|
+
|
|
844
|
+
if tag == "g":
|
|
845
|
+
result = self._check_element(child)
|
|
846
|
+
if result:
|
|
847
|
+
return result
|
|
848
|
+
continue
|
|
849
|
+
|
|
850
|
+
return tag
|
|
851
|
+
|
|
852
|
+
return None
|
|
853
|
+
|
|
854
|
+
def check(self):
|
|
855
|
+
root = self.svg_doc.getroot()
|
|
856
|
+
|
|
857
|
+
# Check that root <svg> doesn't have a layer ID (Fritzing bug)
|
|
858
|
+
root_id = root.get("id", "")
|
|
859
|
+
if root_id in self.VALID_LAYERS:
|
|
860
|
+
self.add_error(
|
|
861
|
+
f"Root <svg> element contains layer id '{root_id}' — "
|
|
862
|
+
f"Fritzing has problems with layer ids in the root element",
|
|
863
|
+
node=root
|
|
864
|
+
)
|
|
865
|
+
return self.get_result()
|
|
866
|
+
|
|
867
|
+
bad_tag = self._check_element(root)
|
|
868
|
+
if bad_tag:
|
|
869
|
+
self.add_error(
|
|
870
|
+
f"Child element '{bad_tag}' is not inside a valid Fritzing layer group "
|
|
871
|
+
f"(expected a <g> with id matching a known layer)",
|
|
872
|
+
node=root
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
return self.get_result()
|
|
876
|
+
|
|
877
|
+
@staticmethod
|
|
878
|
+
def get_name():
|
|
879
|
+
return "no_layer"
|
|
880
|
+
|
|
881
|
+
@staticmethod
|
|
882
|
+
def get_description():
|
|
883
|
+
return "Check that SVG child elements are inside proper Fritzing layer groups (e.g. <g id='breadboard'>)"
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
class SVGGornChecker(SVGChecker):
|
|
887
|
+
"""Check for gorn attributes in SVG files"""
|
|
888
|
+
|
|
889
|
+
def check(self):
|
|
890
|
+
"""Check for gorn attributes in the SVG document"""
|
|
891
|
+
self.errors = 0
|
|
892
|
+
self.warnings = 0
|
|
893
|
+
|
|
894
|
+
# Search for all elements with gorn attributes
|
|
895
|
+
gorn_elements = self.svg_doc.xpath("//*[@gorn]")
|
|
896
|
+
|
|
897
|
+
for element in gorn_elements:
|
|
898
|
+
gorn_value = element.get("gorn")
|
|
899
|
+
self.add_error(f"Found gorn attribute with value '{gorn_value}' on element '{element.tag}'", element)
|
|
900
|
+
|
|
901
|
+
return self.errors, self.warnings
|
|
902
|
+
|
|
903
|
+
def fix(self, svg_path):
|
|
904
|
+
"""Remove gorn attributes from the SVG file"""
|
|
905
|
+
if self.errors == 0:
|
|
906
|
+
self.logger.debug(f"No gorns to fix for {svg_path}")
|
|
907
|
+
return self.fixes
|
|
908
|
+
else:
|
|
909
|
+
self.logger.debug(f"{self.errors} gorns to fix for {svg_path}")
|
|
910
|
+
|
|
911
|
+
# Read the file content
|
|
912
|
+
with open(svg_path, 'r', encoding='utf-8') as f:
|
|
913
|
+
content = f.read()
|
|
914
|
+
|
|
915
|
+
# Find all gorn attributes with their details before removing them
|
|
916
|
+
gorn_pattern = r'\s*gorn="([\.\d]*)"\s*'
|
|
917
|
+
gorn_matches = []
|
|
918
|
+
|
|
919
|
+
# Split content into lines for line number tracking
|
|
920
|
+
lines = content.split('\n')
|
|
921
|
+
for line_num, line in enumerate(lines, 1):
|
|
922
|
+
for match in re.finditer(gorn_pattern, line):
|
|
923
|
+
gorn_value = match.group(1)
|
|
924
|
+
gorn_matches.append({
|
|
925
|
+
'value': gorn_value,
|
|
926
|
+
'line_number': line_num,
|
|
927
|
+
'full_match': match.group(0).strip()
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
# Remove gorn attributes using the same pattern
|
|
931
|
+
updated_content, count = re.subn(gorn_pattern, ' ', content, flags=re.MULTILINE)
|
|
932
|
+
|
|
933
|
+
if count > 0:
|
|
934
|
+
# Write the updated content back
|
|
935
|
+
with open(svg_path, 'w', encoding='utf-8') as f:
|
|
936
|
+
f.write(updated_content)
|
|
937
|
+
|
|
938
|
+
# Add one detailed fix entry per gorn attribute removed
|
|
939
|
+
for gorn_info in gorn_matches:
|
|
940
|
+
self.add_fix(
|
|
941
|
+
f"Removed gorn attribute '{gorn_info['full_match']}' (value: {gorn_info['value']}) from line {gorn_info['line_number']} in {svg_path}",
|
|
942
|
+
line_number=gorn_info['line_number']
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
return self.fixes
|
|
946
|
+
|
|
947
|
+
@staticmethod
|
|
948
|
+
def get_name():
|
|
949
|
+
return "svg-gorn"
|
|
950
|
+
|
|
951
|
+
@staticmethod
|
|
952
|
+
def get_description():
|
|
953
|
+
return "Check for unwanted gorn attributes left by the Fritzing parts editor"
|