fritzkit 0.0.0-stage → 0.1.1

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.
Files changed (319) hide show
  1. package/.claude-plugin/marketplace.json +12 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/CHANGELOG.md +8 -0
  4. package/LICENSE +21 -0
  5. package/README.md +429 -2
  6. package/dist/cli/commands/doctor.d.ts +4 -0
  7. package/dist/cli/commands/doctor.js +7 -0
  8. package/dist/cli/commands/doctor.js.map +1 -0
  9. package/dist/cli/commands/export.d.ts +17 -0
  10. package/dist/cli/commands/export.js +81 -0
  11. package/dist/cli/commands/export.js.map +1 -0
  12. package/dist/cli/commands/kicad.d.ts +10 -0
  13. package/dist/cli/commands/kicad.js +22 -0
  14. package/dist/cli/commands/kicad.js.map +1 -0
  15. package/dist/cli/commands/parts.d.ts +22 -0
  16. package/dist/cli/commands/parts.js +46 -0
  17. package/dist/cli/commands/parts.js.map +1 -0
  18. package/dist/cli/commands/prune.d.ts +14 -0
  19. package/dist/cli/commands/prune.js +67 -0
  20. package/dist/cli/commands/prune.js.map +1 -0
  21. package/dist/cli/commands/render.d.ts +24 -0
  22. package/dist/cli/commands/render.js +62 -0
  23. package/dist/cli/commands/render.js.map +1 -0
  24. package/dist/cli/commands/schema.d.ts +10 -0
  25. package/dist/cli/commands/schema.js +35 -0
  26. package/dist/cli/commands/schema.js.map +1 -0
  27. package/dist/cli/commands/ui.d.ts +14 -0
  28. package/dist/cli/commands/ui.js +67 -0
  29. package/dist/cli/commands/ui.js.map +1 -0
  30. package/dist/cli/commands/validate.d.ts +24 -0
  31. package/dist/cli/commands/validate.js +91 -0
  32. package/dist/cli/commands/validate.js.map +1 -0
  33. package/dist/cli/context.d.ts +81 -0
  34. package/dist/cli/context.js +139 -0
  35. package/dist/cli/context.js.map +1 -0
  36. package/dist/cli/main.d.ts +2 -0
  37. package/dist/cli/main.js +8 -0
  38. package/dist/cli/main.js.map +1 -0
  39. package/dist/cli/program.d.ts +21 -0
  40. package/dist/cli/program.js +158 -0
  41. package/dist/cli/program.js.map +1 -0
  42. package/dist/cli/raster.d.ts +4 -0
  43. package/dist/cli/raster.js +9 -0
  44. package/dist/cli/raster.js.map +1 -0
  45. package/dist/core/compile/breadboard-util.d.ts +12 -0
  46. package/dist/core/compile/breadboard-util.js +107 -0
  47. package/dist/core/compile/breadboard-util.js.map +1 -0
  48. package/dist/core/compile/breadboard.d.ts +60 -0
  49. package/dist/core/compile/breadboard.js +513 -0
  50. package/dist/core/compile/breadboard.js.map +1 -0
  51. package/dist/core/compile/fzp.d.ts +12 -0
  52. package/dist/core/compile/fzp.js +183 -0
  53. package/dist/core/compile/fzp.js.map +1 -0
  54. package/dist/core/compile/icon.d.ts +17 -0
  55. package/dist/core/compile/icon.js +27 -0
  56. package/dist/core/compile/icon.js.map +1 -0
  57. package/dist/core/compile/index.d.ts +4 -0
  58. package/dist/core/compile/index.js +62 -0
  59. package/dist/core/compile/index.js.map +1 -0
  60. package/dist/core/compile/pcb.d.ts +39 -0
  61. package/dist/core/compile/pcb.js +164 -0
  62. package/dist/core/compile/pcb.js.map +1 -0
  63. package/dist/core/compile/schematic.d.ts +69 -0
  64. package/dist/core/compile/schematic.js +240 -0
  65. package/dist/core/compile/schematic.js.map +1 -0
  66. package/dist/core/compile/style.d.ts +52 -0
  67. package/dist/core/compile/style.js +72 -0
  68. package/dist/core/compile/style.js.map +1 -0
  69. package/dist/core/compile/types.d.ts +50 -0
  70. package/dist/core/compile/types.js +15 -0
  71. package/dist/core/compile/types.js.map +1 -0
  72. package/dist/core/export/bin.d.ts +61 -0
  73. package/dist/core/export/bin.js +221 -0
  74. package/dist/core/export/bin.js.map +1 -0
  75. package/dist/core/export/fzpz.d.ts +37 -0
  76. package/dist/core/export/fzpz.js +121 -0
  77. package/dist/core/export/fzpz.js.map +1 -0
  78. package/dist/core/export/index.d.ts +5 -0
  79. package/dist/core/export/index.js +6 -0
  80. package/dist/core/export/index.js.map +1 -0
  81. package/dist/core/export/install.d.ts +77 -0
  82. package/dist/core/export/install.js +183 -0
  83. package/dist/core/export/install.js.map +1 -0
  84. package/dist/core/export/prune.d.ts +72 -0
  85. package/dist/core/export/prune.js +165 -0
  86. package/dist/core/export/prune.js.map +1 -0
  87. package/dist/core/fritzing.d.ts +22 -0
  88. package/dist/core/fritzing.js +77 -0
  89. package/dist/core/fritzing.js.map +1 -0
  90. package/dist/core/identity.d.ts +29 -0
  91. package/dist/core/identity.js +70 -0
  92. package/dist/core/identity.js.map +1 -0
  93. package/dist/core/image/bbox.d.ts +27 -0
  94. package/dist/core/image/bbox.js +24 -0
  95. package/dist/core/image/bbox.js.map +1 -0
  96. package/dist/core/image/index.d.ts +8 -0
  97. package/dist/core/image/index.js +9 -0
  98. package/dist/core/image/index.js.map +1 -0
  99. package/dist/core/image/key.d.ts +72 -0
  100. package/dist/core/image/key.js +317 -0
  101. package/dist/core/image/key.js.map +1 -0
  102. package/dist/core/image/prepare.d.ts +31 -0
  103. package/dist/core/image/prepare.js +98 -0
  104. package/dist/core/image/prepare.js.map +1 -0
  105. package/dist/core/image/remover.d.ts +17 -0
  106. package/dist/core/image/remover.js +51 -0
  107. package/dist/core/image/remover.js.map +1 -0
  108. package/dist/core/index.d.ts +16 -0
  109. package/dist/core/index.js +17 -0
  110. package/dist/core/index.js.map +1 -0
  111. package/dist/core/kicad/footprint.d.ts +104 -0
  112. package/dist/core/kicad/footprint.js +317 -0
  113. package/dist/core/kicad/footprint.js.map +1 -0
  114. package/dist/core/kicad/index.d.ts +25 -0
  115. package/dist/core/kicad/index.js +197 -0
  116. package/dist/core/kicad/index.js.map +1 -0
  117. package/dist/core/kicad/sexpr.d.ts +35 -0
  118. package/dist/core/kicad/sexpr.js +163 -0
  119. package/dist/core/kicad/sexpr.js.map +1 -0
  120. package/dist/core/resolve.d.ts +113 -0
  121. package/dist/core/resolve.js +301 -0
  122. package/dist/core/resolve.js.map +1 -0
  123. package/dist/core/schema/part.d.ts +995 -0
  124. package/dist/core/schema/part.js +278 -0
  125. package/dist/core/schema/part.js.map +1 -0
  126. package/dist/core/schema/part.schema.json +1122 -0
  127. package/dist/core/svg.d.ts +20 -0
  128. package/dist/core/svg.js +56 -0
  129. package/dist/core/svg.js.map +1 -0
  130. package/dist/core/units.d.ts +28 -0
  131. package/dist/core/units.js +42 -0
  132. package/dist/core/units.js.map +1 -0
  133. package/dist/core/validate/checker.d.ts +46 -0
  134. package/dist/core/validate/checker.js +142 -0
  135. package/dist/core/validate/checker.js.map +1 -0
  136. package/dist/core/validate/doctor.d.ts +25 -0
  137. package/dist/core/validate/doctor.js +156 -0
  138. package/dist/core/validate/doctor.js.map +1 -0
  139. package/dist/core/validate/explain.d.ts +10 -0
  140. package/dist/core/validate/explain.js +135 -0
  141. package/dist/core/validate/explain.js.map +1 -0
  142. package/dist/core/validate/index.d.ts +21 -0
  143. package/dist/core/validate/index.js +96 -0
  144. package/dist/core/validate/index.js.map +1 -0
  145. package/dist/core/validate/structural.d.ts +74 -0
  146. package/dist/core/validate/structural.js +664 -0
  147. package/dist/core/validate/structural.js.map +1 -0
  148. package/dist/core/workspace.d.ts +69 -0
  149. package/dist/core/workspace.js +213 -0
  150. package/dist/core/workspace.js.map +1 -0
  151. package/dist/mcp/jsonpatch.d.ts +30 -0
  152. package/dist/mcp/jsonpatch.js +145 -0
  153. package/dist/mcp/jsonpatch.js.map +1 -0
  154. package/dist/mcp/main.d.ts +2 -0
  155. package/dist/mcp/main.js +5 -0
  156. package/dist/mcp/main.js.map +1 -0
  157. package/dist/mcp/server.d.ts +28 -0
  158. package/dist/mcp/server.js +310 -0
  159. package/dist/mcp/server.js.map +1 -0
  160. package/dist/server/api.d.ts +72 -0
  161. package/dist/server/api.js +507 -0
  162. package/dist/server/api.js.map +1 -0
  163. package/dist/server/events.d.ts +28 -0
  164. package/dist/server/events.js +134 -0
  165. package/dist/server/events.js.map +1 -0
  166. package/dist/server/http.d.ts +36 -0
  167. package/dist/server/http.js +127 -0
  168. package/dist/server/http.js.map +1 -0
  169. package/dist/server/index.d.ts +65 -0
  170. package/dist/server/index.js +193 -0
  171. package/dist/server/index.js.map +1 -0
  172. package/dist/ui/assets/index-DLU8opyI.js +63 -0
  173. package/dist/ui/assets/index-GhUA-T3b.css +1 -0
  174. package/dist/ui/index.html +14 -0
  175. package/package.json +89 -4
  176. package/skills/fritzkit/SKILL.md +690 -0
  177. package/skills/fritzkit/reference/examples.md +275 -0
  178. package/skills/fritzkit/reference/part-json.md +333 -0
  179. package/skills/fritzkit/scripts/measure.py +197 -0
  180. package/tools/fzp-checker/.python-version +1 -0
  181. package/tools/fzp-checker/README.md +45 -0
  182. package/tools/fzp-checker/check_fzp.py +48 -0
  183. package/tools/fzp-checker/pyproject.toml +26 -0
  184. package/tools/fzp-checker/uv.lock +266 -0
  185. package/tools/fzp-checker/vendor/LICENSE.txt +8 -0
  186. package/tools/fzp-checker/vendor/UPSTREAM.md +25 -0
  187. package/tools/fzp-checker/vendor/fzp_checker.py +14 -0
  188. package/tools/fzp-checker/vendor/scripts/checks/__init__.py +26 -0
  189. package/tools/fzp-checker/vendor/scripts/checks/check_missing_leg_ids.py +44 -0
  190. package/tools/fzp-checker/vendor/scripts/checks/checkascii.py +27 -0
  191. package/tools/fzp-checker/vendor/scripts/checks/checkcase.py +156 -0
  192. package/tools/fzp-checker/vendor/scripts/checks/checkcopies.py +104 -0
  193. package/tools/fzp-checker/vendor/scripts/checks/connector_numbering.py +79 -0
  194. package/tools/fzp-checker/vendor/scripts/checks/connectors_misnumbered.py +228 -0
  195. package/tools/fzp-checker/vendor/scripts/checks/explain_errors.md +95 -0
  196. package/tools/fzp-checker/vendor/scripts/checks/fzp_checker_runner.py +717 -0
  197. package/tools/fzp-checker/vendor/scripts/checks/fzp_checkers.py +1069 -0
  198. package/tools/fzp-checker/vendor/scripts/checks/fzp_svg_checkers.py +62 -0
  199. package/tools/fzp-checker/vendor/scripts/checks/fzp_utils.py +222 -0
  200. package/tools/fzp-checker/vendor/scripts/checks/inspector_fc51.png +0 -0
  201. package/tools/fzp-checker/vendor/scripts/checks/invisibleconnectors.py +118 -0
  202. package/tools/fzp-checker/vendor/scripts/checks/nolayeridcheck.txt +758 -0
  203. package/tools/fzp-checker/vendor/scripts/checks/requirements.txt +3 -0
  204. package/tools/fzp-checker/vendor/scripts/checks/svg_checkers.py +953 -0
  205. package/tools/fzp-checker/vendor/scripts/checks/svg_utils.py +96 -0
  206. package/tools/fzp-checker/vendor/scripts/checks/test_checkers.py +839 -0
  207. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/author_missing.fzp.test +3 -0
  208. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/author_present.fzp.test +4 -0
  209. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/buses_missing_id.fzp.test +8 -0
  210. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/buses_missing_node_members.fzp.test +7 -0
  211. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/buses_present_valid.fzp.test +8 -0
  212. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_layers_missing_attributes.fzp.test +16 -0
  213. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_layers_present_valid.fzp.test +14 -0
  214. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_numbering_invalid.fzp.test +70 -0
  215. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_numbering_valid.fzp.test +70 -0
  216. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/connector_refs_valid.fzp.test +53 -0
  217. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_mixed.fzp.test +11 -0
  218. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_silkscreen_nested.fzp.test +11 -0
  219. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_style_attr.fzp.test +11 -0
  220. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_white_stroke.fzp.test +11 -0
  221. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_invalid_wrong_color.fzp.test +11 -0
  222. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_inherited.fzp.test +11 -0
  223. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_legacy.fzp.test +12 -0
  224. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_nested_copper.fzp.test +12 -0
  225. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_none.fzp.test +11 -0
  226. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_standard.fzp.test +12 -0
  227. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/copper_color_valid_uppercase.fzp.test +11 -0
  228. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/css_connector.fzp.test +100 -0
  229. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/description_missing.fzp.test +3 -0
  230. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/description_present.fzp.test +4 -0
  231. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/duplicate_ids.fzp.test +27 -0
  232. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/family_property_missing.fzp.test +6 -0
  233. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/family_property_present_valid.fzp.test +6 -0
  234. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/font_size.fzp.test +46 -0
  235. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/fritzing_version_invalid_format.fzp.test +4 -0
  236. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/fritzing_version_missing.fzp.test +4 -0
  237. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/fritzing_version_present_valid.fzp.test +4 -0
  238. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/gorn_absent.fzp.test +15 -0
  239. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/gorn_present.fzp.test +15 -0
  240. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/hybrid_connectors.fzp.test +232 -0
  241. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/invalid_xml.fzp.test +12 -0
  242. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/layer_ids_match.fzp.test +29 -0
  243. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/layer_ids_mismatch.fzp.test +30 -0
  244. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/layer_nesting_invalid.fzp.test +11 -0
  245. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/layer_nesting_valid.fzp.test +11 -0
  246. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/matrix_transform.fzp.test +8 -0
  247. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/missing_connector_refs.fzp.test +63 -0
  248. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/missing_leg_ids.fzp.test +34 -0
  249. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_missing.fzp.test +4 -0
  250. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_non_alnum.fzp.test +4 -0
  251. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_present.fzp.test +4 -0
  252. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_special_chars_absent.fzp.test +4 -0
  253. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_special_chars_present.fzp.test +4 -0
  254. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/module_id_too_short.fzp.test +4 -0
  255. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/no_layer_invalid.fzp.test +19 -0
  256. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/no_layer_namedview.fzp.test +19 -0
  257. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/no_layer_valid.fzp.test +19 -0
  258. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/pcb_only.fzp.test +48 -0
  259. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/property_fields_missing_name.fzp.test +6 -0
  260. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/property_fields_missing_value.fzp.test +6 -0
  261. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/property_fields_present.fzp.test +6 -0
  262. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/required_tags_missing.fzp.test +3 -0
  263. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/required_tags_present.fzp.test +11 -0
  264. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/stroke_test.fzp.test +60 -0
  265. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/template_svgs.fzp.test +35 -0
  266. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/title_missing.fzp.test +3 -0
  267. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/title_present.fzp.test +4 -0
  268. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/unique_ids.fzp.test +27 -0
  269. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/unique_property_names_duplicates.fzp.test +7 -0
  270. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/unique_property_names_unique.fzp.test +7 -0
  271. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/valid_xml.fzp.test +249 -0
  272. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/version_invalid_format.fzp.test +4 -0
  273. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/version_missing.fzp.test +3 -0
  274. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/version_present_valid.fzp.test +4 -0
  275. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/views_missing.fzp.test +3 -0
  276. package/tools/fzp-checker/vendor/scripts/checks/test_data/core/views_present.fzp.test +8 -0
  277. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/connector_refs_valid_breadboard.svg +7 -0
  278. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/duplicate_ids_breadboard.svg +13 -0
  279. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/gorn_absent_breadboard.svg +7 -0
  280. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/gorn_present_breadboard.svg +7 -0
  281. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/hybrid_connectors_breadboard.svg +102 -0
  282. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/layer_nesting_invalid.svg +13 -0
  283. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/layer_nesting_valid.svg +13 -0
  284. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/matrix_transform.svg +11 -0
  285. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/missing_connector_refs_breadboard.svg +8 -0
  286. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/missing_leg_ids_breadboard.svg +9 -0
  287. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/test_layer_ids_match_breadboard.svg +9 -0
  288. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/test_layer_ids_mismatch_breadboard.svg +9 -0
  289. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/breadboard/unique_ids_breadboard.svg +13 -0
  290. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/hybrid_connectors_icon.svg +78 -0
  291. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/no_layer_invalid.svg +4 -0
  292. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/no_layer_namedview.svg +9 -0
  293. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/no_layer_valid.svg +7 -0
  294. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/icon/plain_pcb.svg +10 -0
  295. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/connector_refs_valid_pcb.svg +10 -0
  296. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_mixed.svg +8 -0
  297. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_silkscreen_nested.svg +10 -0
  298. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_style_attr.svg +6 -0
  299. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_white_stroke.svg +6 -0
  300. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_invalid_wrong_color.svg +6 -0
  301. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_inherited.svg +7 -0
  302. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_legacy.svg +10 -0
  303. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_nested_copper.svg +9 -0
  304. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_none.svg +7 -0
  305. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_standard.svg +10 -0
  306. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/copper_color_valid_uppercase.svg +7 -0
  307. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/css_connector.svg +25 -0
  308. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/font_size.svg +54 -0
  309. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/hybrid_connectors_pcb.svg +35 -0
  310. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/missing_connector_refs_pcb.svg +14 -0
  311. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/pcb-arduino-shield.svg +25 -0
  312. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/stroke_test.svg +12 -0
  313. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/test_layer_ids_match_pcb.svg +12 -0
  314. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/pcb/test_layer_ids_mismatch_pcb.svg +12 -0
  315. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/schematic/connector_refs_valid_schematic.svg +7 -0
  316. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/schematic/hybrid_connectors_schematic.svg +59 -0
  317. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/schematic/missing_connector_refs_schematic.svg +8 -0
  318. package/tools/fzp-checker/vendor/scripts/checks/test_data/svg/core/schematic/test_layer_ids_match_schematic.svg +6 -0
  319. 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"