diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 47d7a75..c847fd0 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -1045,7 +1045,15 @@ class WireManager: ) -> bool: """Add a free-form text annotation (SCH_TEXT) to a KiCad schematic.""" 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()) font_attrs = f"\n\t\t\t\t(size {font_size} {font_size})" if bold: diff --git a/tests/test_add_schematic_text.py b/tests/test_add_schematic_text.py index 9370716..1ab68b2 100644 --- a/tests/test_add_schematic_text.py +++ b/tests/test_add_schematic_text.py @@ -136,6 +136,39 @@ class TestWireManagerAddText: content = sch.read_text(encoding="utf-8") 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): from commands.wire_manager import WireManager