fix: escape newlines in WireManager.add_text

The KiCad s-expression parser rejects raw newline and carriage-return
characters inside quoted string literals — a multi-line text annotation
written through `add_text` produced a `.kicad_sch` file that eeschema
silently tolerated but `kicad-cli sch ...` refused with "Failed to load
schematic." The escape pass only handled backslashes and double quotes.

Add `\\n` → `\\\\n` and `\\r` → `\\\\r` to the same escape chain. Order
matters: backslashes are escaped first so we don't double-escape our
own escapes.

A new regression test (`test_escapes_newlines_in_multiline_text`)
checks both that the resulting quoted string literal contains no raw
newline characters and that the file round-trips cleanly through the
sexpdata parser.

End-to-end smoke: a 4-line annotation written through the patched
add_text now passes `kicad-cli sch erc` (exit 0) where the previous
behaviour failed parse.

Note: the same escape gap exists in `_make_hierarchical_label_text`
and `_make_sheet_pin_text` for unescaped quotes/newlines in the user-
supplied text. Not fixed here to keep this PR scoped to the documented
add_text bug; happy to fold it in if a reviewer prefers.
This commit is contained in:
Matthew Runo
2026-05-01 10:03:49 -07:00
parent d3c01e20bd
commit 09ec6aaeb5
2 changed files with 42 additions and 1 deletions

View File

@@ -1045,7 +1045,15 @@ class WireManager:
) -> bool: ) -> bool:
"""Add a free-form text annotation (SCH_TEXT) to a KiCad schematic.""" """Add a free-form text annotation (SCH_TEXT) to a KiCad schematic."""
try: try:
text_escaped = text.replace("\\", "\\\\").replace('"', '\\"') # KiCad's parser rejects raw newlines inside quoted string literals,
# so escape them along with backslashes and quotes. Order matters:
# backslashes first, otherwise we double-escape our own escapes.
text_escaped = (
text.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
)
uid = str(uuid.uuid4()) uid = str(uuid.uuid4())
font_attrs = f"\n\t\t\t\t(size {font_size} {font_size})" font_attrs = f"\n\t\t\t\t(size {font_size} {font_size})"
if bold: if bold:

View File

@@ -136,6 +136,39 @@ class TestWireManagerAddText:
content = sch.read_text(encoding="utf-8") content = sch.read_text(encoding="utf-8")
assert r"He said \"hello\"" in content assert r"He said \"hello\"" in content
def test_escapes_newlines_in_multiline_text(self, tmp_path):
"""Raw newlines in quoted string literals break kicad-cli's parser
(silently in eeschema, but kicad-cli sch reports 'Failed to load
schematic'). They must be escaped to the two-character \\n sequence."""
from commands.wire_manager import WireManager
sch = tmp_path / "test.kicad_sch"
sch.write_text(_MINIMAL_SCH, encoding="utf-8")
WireManager.add_text(sch, "line one\nline two\r\nline three", [10.0, 10.0])
content = sch.read_text(encoding="utf-8")
# The text S-expression's quoted argument must not contain a literal
# newline. Find the (text "...") and check its first quoted arg.
text_start = content.index('(text "') + len('(text "')
# Find matching close-quote, respecting backslash escapes.
i = text_start
while i < len(content):
if content[i] == "\\":
i += 2
continue
if content[i] == '"':
break
i += 1
quoted = content[text_start:i]
assert (
"\n" not in quoted and "\r" not in quoted
), f"Quoted text still contains a raw newline: {quoted!r}"
assert "\\n" in quoted, "Newline should be escaped to \\n"
# And the file must round-trip through the s-expression parser cleanly.
sexpdata.loads(content)
def test_result_is_valid_sexp(self, tmp_path): def test_result_is_valid_sexp(self, tmp_path):
from commands.wire_manager import WireManager from commands.wire_manager import WireManager