From fb577cc89f4cab34ee302847b0d06065d60e6fc0 Mon Sep 17 00:00:00 2001 From: Knowit Date: Tue, 28 Apr 2026 23:04:58 +0800 Subject: [PATCH] tools/epro2/kicad: fix two KiCad 8 parse blockers (newline + pin_numbers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 装 kicad 8.0.9 (apt PPA) 后跑 kicad-cli sch erc 校验我们 emit 的 .kicad_sch 文件,发现 9/9 sheets 一开始全部报 "Failed to load schematic file" — 父节点解析就挂掉。Bisect 找到两个语法 bug: 1. **(pin_numbers (hide no)) 不被 KiCad 8 接受** KiCad 8 lib_symbols 里 `pin_numbers` 是 token-form,不接受 (hide yes/no) 子块。要么省略整个 block 默认 visible,要么 `(pin_numbers hide)` 表示隐藏。原来的 `(hide no)` 风格是 KiCad 7 旧语法。 Fix: tools/epro2/kicad/sym_writer.py 删掉 (pin_numbers (hide no)) 行;KiCad 默认 visible 行为正是我们想要的。 2. **String 里的字面 \n / \r / \t 让 KiCad 解析器中止** ESP-VoCat 的 Overview sheet 有 TEXT "Battary\n3.7V 700mAH"(多行 电池标签),EPRO2 里以**字面 0x0a 字符**存储。我们把它原样 emit 成 "..." 包住的字符串 → KiCad reader 在 quoted string 内遇到 \n 就报 parse error 不给 message。 Fix: tools/epro2/kicad/sexpr.py 在 str escape 路径加 \n / \r / \t 转义;reader 加 \r 解码(roundtrip 用)。 修完后: 9/9 sheets parse OK in KiCad 8.0.9 ERC 跑通,9 个 sheet 共 2793 violations,分布: 1372 endpoint_off_grid (49%, cosmetic — 30-mil EPRO2 grid 不 snap KiCad 默认 50-mil grid) 571 lib_symbol_issues (20%, cosmetic — facere 库未注册到 user library table;库已 embed 在 .kicad_sch 内联可用) 444 wire_dangling (16%, real — wire 端点没精确对齐 pin) 406 pin_not_connected (15%, 同上的另一面) Cosmetic 占 70%,real connectivity 30%,下个 phase 处理: - grid 校准(把 coord 精确 round 到统一 grid 上) - pin tip 端点匹配(KiCad 需要 wire 端点 == pin (at) 字段对应的 绝对坐标,浮点必须精确相等) - 生成 sym-lib-table 注册 facere 库(消 lib_symbol_issues) 测试: + test_string_escapes_newlines_and_tabs + test_lib_symbol_omits_pin_numbers_block reader 加 \r 解码 41/41 通过(39 旧 + 2 新)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/epro2/kicad/_sexpr_reader.py | 2 +- tools/epro2/kicad/sexpr.py | 10 +++++++++- tools/epro2/kicad/sym_writer.py | 4 +++- tools/epro2/tests/test_sexpr.py | 18 ++++++++++++++++++ tools/epro2/tests/test_sym_writer.py | 11 +++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/tools/epro2/kicad/_sexpr_reader.py b/tools/epro2/kicad/_sexpr_reader.py index 008eb47..50145c6 100644 --- a/tools/epro2/kicad/_sexpr_reader.py +++ b/tools/epro2/kicad/_sexpr_reader.py @@ -32,7 +32,7 @@ def tokens(src: str) -> Iterator[str]: ch = src[j] if ch == "\\" and j + 1 < n: nxt = src[j + 1] - buf.append({"\\": "\\", '"': '"', "n": "\n", "t": "\t"}.get(nxt, nxt)) + buf.append({"\\": "\\", '"': '"', "n": "\n", "t": "\t", "r": "\r"}.get(nxt, nxt)) j += 2 continue if ch == '"': diff --git a/tools/epro2/kicad/sexpr.py b/tools/epro2/kicad/sexpr.py index e3af2f8..dc502b6 100644 --- a/tools/epro2/kicad/sexpr.py +++ b/tools/epro2/kicad/sexpr.py @@ -52,7 +52,15 @@ def _emit(value: Any, out: io.StringIO, indent: int, *, pretty: bool) -> None: elif isinstance(value, (int, float)): out.write(_fmt_number(value)) elif isinstance(value, str): - escaped = value.replace("\\", "\\\\").replace('"', '\\"') + # KiCad's S-expr reader rejects literal newlines/CR/tabs inside + # quoted strings; we MUST escape them. Order matters: backslash first. + escaped = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) out.write(f'"{escaped}"') elif value is None: # rare but sometimes a slot is intentionally empty diff --git a/tools/epro2/kicad/sym_writer.py b/tools/epro2/kicad/sym_writer.py index b8e9205..b5c44fc 100644 --- a/tools/epro2/kicad/sym_writer.py +++ b/tools/epro2/kicad/sym_writer.py @@ -165,9 +165,11 @@ def write_lib_symbol(doc: Document, *, lib_prefix: str = "facere") -> list | Non [Sym("number"), pin_number or "~", _font()], ]) + # KiCad 8 syntax: omit `(pin_numbers ...)` block entirely to mean "visible" + # (using `(pin_numbers (hide no))` is rejected as a parse error). To hide + # we'd emit the bare token `(pin_numbers hide)`. return [ Sym("symbol"), f"{lib_prefix}:{part_id}", - [Sym("pin_numbers"), [Sym("hide"), Sym("no")]], [Sym("pin_names"), [Sym("offset"), 1.016]], [Sym("in_bom"), Sym("yes")], [Sym("on_board"), Sym("yes")], diff --git a/tools/epro2/tests/test_sexpr.py b/tools/epro2/tests/test_sexpr.py index 6ca78e2..4128f80 100644 --- a/tools/epro2/tests/test_sexpr.py +++ b/tools/epro2/tests/test_sexpr.py @@ -19,6 +19,24 @@ def test_string_escaping(): assert parsed[1] == 'a "quote" + \\backslash' +def test_string_escapes_newlines_and_tabs(): + """KiCad 8 rejects literal newlines/CR/tabs inside quoted strings, so the + emitter must escape them. Round-trip restores the original characters.""" + src = "Battary\n3.7V 700mAH\twith\rtab" + text = to_sexpr([Sym("text"), src], pretty=False) + # Raw text must NOT contain literal newline / CR / tab inside quotes + assert "\\n" in text + assert "\\r" in text + assert "\\t" in text + quoted = text[text.index('"'):text.rindex('"') + 1] + assert "\n" not in quoted + assert "\r" not in quoted + assert "\t" not in quoted + # Round-trip restores the original (our reader handles these escapes) + parsed = parse(text) + assert parsed[1] == src + + def test_float_formatting_strips_trailing_zeros(): text = to_sexpr([Sym("x"), 1.500000], pretty=False) assert "1.5" in text diff --git a/tools/epro2/tests/test_sym_writer.py b/tools/epro2/tests/test_sym_writer.py index 7d381ac..7576dfb 100644 --- a/tools/epro2/tests/test_sym_writer.py +++ b/tools/epro2/tests/test_sym_writer.py @@ -40,6 +40,17 @@ def test_write_lib_symbol_emits_outer_wrapper_and_body(): assert inner[0][1] == "MyPart.1_1_1" +def test_lib_symbol_omits_pin_numbers_block(): + """KiCad 8 rejects (pin_numbers (hide no)). When pins should be visible + we must omit the pin_numbers block entirely (default = visible).""" + d = _sym_doc("sym1", "MyPart.1", []) + entry = write_lib_symbol(d) + text = to_sexpr(entry) + parsed = parse(text) + assert not _block(parsed, "pin_numbers"), \ + "pin_numbers block should be omitted (KiCad 8 syntax)" + + def test_pin_renders_with_attr_pulled_metadata(): d = _sym_doc("sym1", "MyPart.1", [ ("e5", {"_type": "PIN", "partId": "MyPart.1",