tools/epro2/kicad: fix two KiCad 8 parse blockers (newline + pin_numbers)
装 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) <noreply@anthropic.com>
This commit is contained in:
@@ -32,7 +32,7 @@ def tokens(src: str) -> Iterator[str]:
|
|||||||
ch = src[j]
|
ch = src[j]
|
||||||
if ch == "\\" and j + 1 < n:
|
if ch == "\\" and j + 1 < n:
|
||||||
nxt = src[j + 1]
|
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
|
j += 2
|
||||||
continue
|
continue
|
||||||
if ch == '"':
|
if ch == '"':
|
||||||
|
|||||||
@@ -52,7 +52,15 @@ def _emit(value: Any, out: io.StringIO, indent: int, *, pretty: bool) -> None:
|
|||||||
elif isinstance(value, (int, float)):
|
elif isinstance(value, (int, float)):
|
||||||
out.write(_fmt_number(value))
|
out.write(_fmt_number(value))
|
||||||
elif isinstance(value, str):
|
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}"')
|
out.write(f'"{escaped}"')
|
||||||
elif value is None:
|
elif value is None:
|
||||||
# rare but sometimes a slot is intentionally empty
|
# rare but sometimes a slot is intentionally empty
|
||||||
|
|||||||
@@ -165,9 +165,11 @@ def write_lib_symbol(doc: Document, *, lib_prefix: str = "facere") -> list | Non
|
|||||||
[Sym("number"), pin_number or "~", _font()],
|
[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 [
|
return [
|
||||||
Sym("symbol"), f"{lib_prefix}:{part_id}",
|
Sym("symbol"), f"{lib_prefix}:{part_id}",
|
||||||
[Sym("pin_numbers"), [Sym("hide"), Sym("no")]],
|
|
||||||
[Sym("pin_names"), [Sym("offset"), 1.016]],
|
[Sym("pin_names"), [Sym("offset"), 1.016]],
|
||||||
[Sym("in_bom"), Sym("yes")],
|
[Sym("in_bom"), Sym("yes")],
|
||||||
[Sym("on_board"), Sym("yes")],
|
[Sym("on_board"), Sym("yes")],
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ def test_string_escaping():
|
|||||||
assert parsed[1] == 'a "quote" + \\backslash'
|
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():
|
def test_float_formatting_strips_trailing_zeros():
|
||||||
text = to_sexpr([Sym("x"), 1.500000], pretty=False)
|
text = to_sexpr([Sym("x"), 1.500000], pretty=False)
|
||||||
assert "1.5" in text
|
assert "1.5" in text
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ def test_write_lib_symbol_emits_outer_wrapper_and_body():
|
|||||||
assert inner[0][1] == "MyPart.1_1_1"
|
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():
|
def test_pin_renders_with_attr_pulled_metadata():
|
||||||
d = _sym_doc("sym1", "MyPart.1", [
|
d = _sym_doc("sym1", "MyPart.1", [
|
||||||
("e5", {"_type": "PIN", "partId": "MyPart.1",
|
("e5", {"_type": "PIN", "partId": "MyPart.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user