From 2cc4008fe8863f0b94f4ed4c94099c83ec941ea2 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 15 Sep 2026 13:10:43 +0300 Subject: [PATCH 1/3] feat(blocks): shared validation for block_define primitive and ATTDEF specs Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/block_specs.py | 184 ++++++++++++++++++++++++++++++++++++++ tests/test_block_specs.py | 118 ++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 backends/block_specs.py create mode 100644 tests/test_block_specs.py diff --git a/backends/block_specs.py b/backends/block_specs.py new file mode 100644 index 0000000..75c352c --- /dev/null +++ b/backends/block_specs.py @@ -0,0 +1,184 @@ +# backends/block_specs.py +"""Typed primitive specs for ``block_define``, validated before any write. + +Both engines consume the same normalised specs, so a malformed request is +refused identically on COM and ezdxf and nothing is written on either. Every +error names the offending entry (``entities[i]`` / ``attdefs[i]``) and key. +""" + +from __future__ import annotations + +import math +import re + +ALLOWED_TYPES = ("line", "circle", "arc", "polyline", "text", "solid") +TEXT_ALIGNMENTS = ("left", "center", "right", "middle_center") +ATTDEF_TAG_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,30}$") + +_REQUIRED = { + "line": ("x1", "y1", "x2", "y2"), + "circle": ("cx", "cy", "r"), + "arc": ("cx", "cy", "r", "start_deg", "end_deg"), + "polyline": ("points",), + "text": ("text", "x", "y", "height"), + "solid": ("points",), +} +_POSITIVE = {"r", "height"} + + +def _scalar(value, where: str, *, positive: bool = False) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{where} must be a number, got {value!r}") + number = float(value) + if not math.isfinite(number): + raise TypeError(f"{where} must be finite") + if positive and number <= 0: + raise TypeError(f"{where} must be > 0, got {number}") + return number + + +def _number(spec: dict, key: str, where: str) -> float: + return _scalar(spec.get(key), f"{where}: {key!r}", positive=key in _POSITIVE) + + +def _points( + spec: dict, where: str, *, minimum: int, maximum: int | None = None +) -> list[tuple[float, float]]: + raw = spec.get("points") + if not isinstance(raw, (list, tuple)) or len(raw) < minimum: + raise TypeError(f"{where}: 'points' must be a list of at least {minimum} [x, y] pairs") + if maximum is not None and len(raw) > maximum: + raise TypeError(f"{where}: 'points' must have at most {maximum} pairs") + out: list[tuple[float, float]] = [] + for i, pt in enumerate(raw): + if not isinstance(pt, (list, tuple)) or len(pt) != 2: + raise TypeError(f"{where}: points[{i}] must be an [x, y] pair") + out.append( + ( + _scalar(pt[0], f"{where}: points[{i}].x"), + _scalar(pt[1], f"{where}: points[{i}].y"), + ) + ) + return out + + +def _align(spec: dict, where: str) -> str: + align = spec.get("align", "left") + if align not in TEXT_ALIGNMENTS: + raise TypeError(f"{where}: 'align' must be one of {TEXT_ALIGNMENTS}, got {align!r}") + return align + + +def _layer(spec: dict, where: str) -> str: + layer = spec.get("layer", "0") + if not isinstance(layer, str) or not layer.strip(): + raise TypeError(f"{where}: 'layer' must be a non-empty string") + return layer.strip() + + +def validate_entity_specs(entities) -> list[dict]: + """Normalise ``block_define`` primitive specs; refuse the whole list on the first defect.""" + if not isinstance(entities, (list, tuple)) or not entities: + raise ValueError( + "block_define: 'entities' is empty — a block that draws nothing is refused" + ) + out: list[dict] = [] + for i, spec in enumerate(entities): + where = f"entities[{i}]" + if not isinstance(spec, dict): + raise TypeError(f"{where}: must be an object") + kind = spec.get("type") + if kind not in ALLOWED_TYPES: + raise TypeError(f"{where}: 'type' must be one of {ALLOWED_TYPES}, got {kind!r}") + for key in _REQUIRED[kind]: + if key not in spec: + raise TypeError(f"{where}: {kind} requires {key!r}") + norm: dict = {"type": kind, "layer": _layer(spec, where)} + if kind == "line": + for key in ("x1", "y1", "x2", "y2"): + norm[key] = _number(spec, key, where) + elif kind == "circle": + for key in ("cx", "cy", "r"): + norm[key] = _number(spec, key, where) + elif kind == "arc": + for key in ("cx", "cy", "r", "start_deg", "end_deg"): + norm[key] = _number(spec, key, where) + elif kind == "polyline": + pts = _points(spec, where, minimum=2) + closed = spec.get("closed", False) + if not isinstance(closed, bool): + raise TypeError(f"{where}: 'closed' must be a boolean") + bulges = spec.get("bulges") + if bulges is not None: + if not isinstance(bulges, (list, tuple)) or len(bulges) != len(pts): + raise TypeError(f"{where}: 'bulges' must list one value per point") + bulges = [_scalar(b, f"{where}: bulges[{j}]") for j, b in enumerate(bulges)] + norm.update(points=pts, closed=closed, bulges=list(bulges) if bulges else None) + elif kind == "text": + text = spec.get("text") + if not isinstance(text, str): + raise TypeError(f"{where}: 'text' must be a string") + norm.update( + text=text, + x=_number(spec, "x", where), + y=_number(spec, "y", where), + height=_number(spec, "height", where), + rotation_deg=_scalar(spec.get("rotation_deg", 0.0), f"{where}: 'rotation_deg'"), + align=_align(spec, where), + ) + elif kind == "solid": + norm["points"] = _points(spec, where, minimum=3, maximum=4) + out.append(norm) + return out + + +def validate_attdef_specs(attdefs) -> list[dict]: + """Normalise ATTDEF specs: tag pattern, uniqueness, numeric fields, flags.""" + if attdefs is None: + return [] + if not isinstance(attdefs, (list, tuple)): + raise TypeError("block_define: 'attdefs' must be a list") + out: list[dict] = [] + seen: set[str] = set() + for i, spec in enumerate(attdefs): + where = f"attdefs[{i}]" + if not isinstance(spec, dict): + raise TypeError(f"{where}: must be an object") + tag = spec.get("tag") + if not isinstance(tag, str) or not ATTDEF_TAG_RE.match(tag): + raise TypeError(f"{where}: 'tag' must match {ATTDEF_TAG_RE.pattern}, got {tag!r}") + if tag in seen: + raise TypeError(f"{where}: duplicate tag {tag!r}") + seen.add(tag) + prompt = spec.get("prompt", tag) + default = spec.get("default", "") + invisible = spec.get("invisible", False) + if not isinstance(prompt, str): + raise TypeError(f"{where}: 'prompt' must be a string") + if not isinstance(default, str): + raise TypeError(f"{where}: 'default' must be a string") + if not isinstance(invisible, bool): + raise TypeError(f"{where}: 'invisible' must be a boolean") + out.append( + { + "tag": tag, + "prompt": prompt, + "default": default, + "x": _number(spec, "x", where), + "y": _number(spec, "y", where), + "height": _number(spec, "height", where), + "rotation_deg": _scalar(spec.get("rotation_deg", 0.0), f"{where}: 'rotation_deg'"), + "align": _align(spec, where), + "invisible": invisible, + } + ) + return out + + +def solid_vertices(points: list[tuple[float, float]]) -> list[tuple[float, float]]: + """DXF SOLID draws vtx0→vtx1→vtx3→vtx2, so a quad given in polygon order + (a, b, c, d) is stored as (a, b, d, c). Triangles are stored as given.""" + pts = [(float(x), float(y)) for x, y in points] + if len(pts) == 4: + return [pts[0], pts[1], pts[3], pts[2]] + return pts diff --git a/tests/test_block_specs.py b/tests/test_block_specs.py new file mode 100644 index 0000000..16cb4de --- /dev/null +++ b/tests/test_block_specs.py @@ -0,0 +1,118 @@ +# tests/test_block_specs.py +"""block_define request validation: refuse the whole request before any write.""" + +from __future__ import annotations + +import pytest + +from backends.block_specs import ( + ALLOWED_TYPES, + solid_vertices, + validate_attdef_specs, + validate_entity_specs, +) + + +def test_allowed_types_are_the_documented_six(): + assert ALLOWED_TYPES == ("line", "circle", "arc", "polyline", "text", "solid") + + +def test_empty_entities_is_refused(): + with pytest.raises(ValueError, match="empty"): + validate_entity_specs([]) + + +def test_each_type_normalises_to_floats_and_layer_zero(): + out = validate_entity_specs( + [ + {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}, + {"type": "circle", "cx": 0, "cy": 0, "r": 2}, + {"type": "arc", "cx": 0, "cy": 0, "r": 2, "start_deg": 0, "end_deg": 180}, + {"type": "polyline", "points": [[0, 0], [1, 0], [1, 1]], "closed": True}, + {"type": "text", "text": "M", "x": 0, "y": 0, "height": 2}, + {"type": "solid", "points": [[0, 0], [1, 0], [0, 1]]}, + ] + ) + assert [e["type"] for e in out] == list(ALLOWED_TYPES) + assert out[0] == {"type": "line", "layer": "0", "x1": 0.0, "y1": 0.0, "x2": 1.0, "y2": 0.0} + assert out[3]["points"] == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)] + assert out[3]["closed"] is True and out[3]["bulges"] is None + assert out[4]["align"] == "left" and out[4]["rotation_deg"] == 0.0 + assert out[5]["points"] == [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)] + + +@pytest.mark.parametrize( + "bad, fragment", + [ + ({"type": "square"}, "type"), + ({"type": "line", "x1": 0, "y1": 0, "x2": 1}, "y2"), + ({"type": "circle", "cx": 0, "cy": 0, "r": 0}, "r"), + ({"type": "circle", "cx": 0, "cy": 0, "r": float("nan")}, "finite"), + ({"type": "text", "text": "M", "x": 0, "y": 0, "height": -1}, "height"), + ({"type": "text", "text": 5, "x": 0, "y": 0, "height": 1}, "text"), + ({"type": "text", "text": "M", "x": 0, "y": 0, "height": 1, "align": "top"}, "align"), + ({"type": "polyline", "points": [[0, 0]]}, "points"), + ({"type": "polyline", "points": [[0, 0], [1]]}, "points[1]"), + ({"type": "polyline", "points": [[0, 0], [1, 1]], "bulges": [0.5]}, "bulges"), + ({"type": "polyline", "points": [[0, 0], [1, 1]], "closed": "yes"}, "closed"), + ({"type": "solid", "points": [[0, 0], [1, 0]]}, "points"), + ({"type": "solid", "points": [[0, 0], [1, 0], [1, 1], [0, 1], [2, 2]]}, "at most 4"), + ({"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0, "layer": ""}, "layer"), + ({"type": "line", "x1": True, "y1": 0, "x2": 1, "y2": 0}, "x1"), + ], +) +def test_bad_entity_names_the_index_and_key(bad, fragment): + good = {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0} + with pytest.raises(TypeError, match=r"entities\[1\]") as exc: + validate_entity_specs([good, bad]) + assert fragment in str(exc.value) + + +def test_attdefs_normalise_and_default(): + out = validate_attdef_specs([{"tag": "TAG", "x": 0, "y": 3, "height": 2.5}]) + assert out == [ + { + "tag": "TAG", + "prompt": "TAG", + "default": "", + "x": 0.0, + "y": 3.0, + "height": 2.5, + "rotation_deg": 0.0, + "align": "left", + "invisible": False, + } + ] + + +@pytest.mark.parametrize( + "bad, fragment", + [ + ({"tag": "tag", "x": 0, "y": 0, "height": 1}, "tag"), + ({"tag": "T-1", "x": 0, "y": 0, "height": 1}, "tag"), + ({"tag": "TAG", "x": 0, "y": 0, "height": 0}, "height"), + ({"tag": "TAG", "x": 0, "y": 0, "height": 1, "invisible": 1}, "invisible"), + ({"tag": "TAG", "x": 0, "y": 0, "height": 1, "default": 3}, "default"), + ], +) +def test_bad_attdef_names_the_index_and_key(bad, fragment): + with pytest.raises(TypeError, match=r"attdefs\[0\]") as exc: + validate_attdef_specs([bad]) + assert fragment in str(exc.value) + + +def test_duplicate_attdef_tags_are_refused(): + with pytest.raises(TypeError, match="duplicate"): + validate_attdef_specs( + [ + {"tag": "TAG", "x": 0, "y": 0, "height": 1}, + {"tag": "TAG", "x": 0, "y": 5, "height": 1}, + ] + ) + + +def test_solid_vertices_use_dxf_bowtie_order(): + quad = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + assert solid_vertices(quad) == [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] + tri = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)] + assert solid_vertices(tri) == tri From 41585757c9256a56d2523fe805dd6b536864af2b Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 15 Sep 2026 13:22:56 +0300 Subject: [PATCH 2/3] fix(task-2): close the trailing-newline tag hole and gate DXF-illegal layer names Two ways a malformed block_define request slipped past the pre-write validator and reached the engine: * `ATTDEF_TAG_RE.match` with a `$` anchor accepts 'TAG\n' because `$` matches before a trailing newline. ezdxf strips the newline on write, so 'TAG\n' + 'TAG' landed as two ATTDEFs with one tag, defeating the duplicate-tag refusal, and the tag-keyed attribute readers then dropped one silently. Now `fullmatch`. * `_layer` only required a non-empty string, so a DXF-illegal name such as 'BAD Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/block_specs.py | 17 +++++++++++++++-- security.py | 13 ++++++++++++- tests/test_block_specs.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/backends/block_specs.py b/backends/block_specs.py index 75c352c..629b20b 100644 --- a/backends/block_specs.py +++ b/backends/block_specs.py @@ -11,8 +11,12 @@ from __future__ import annotations import math import re +from security import illegal_symbol_name_chars + ALLOWED_TYPES = ("line", "circle", "arc", "polyline", "text", "solid") TEXT_ALIGNMENTS = ("left", "center", "right", "middle_center") +#: Checked with ``fullmatch`` — ``$`` alone would accept a trailing newline, which +#: ezdxf then strips on write, turning two "distinct" tags into one duplicate. ATTDEF_TAG_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,30}$") _REQUIRED = { @@ -73,7 +77,16 @@ def _layer(spec: dict, where: str) -> str: layer = spec.get("layer", "0") if not isinstance(layer, str) or not layer.strip(): raise TypeError(f"{where}: 'layer' must be a non-empty string") - return layer.strip() + layer = layer.strip() + # Gate the DXF name rule here rather than letting ezdxf raise DXFValueError + # on `add_*(dxfattribs={"layer": ...})` mid-loop, after the block exists and + # earlier primitives are already written (and, with overwrite, the old + # definition is already gone). + offenders = illegal_symbol_name_chars(layer) + if offenders: + shown = ", ".join(repr(c) for c in offenders) + raise TypeError(f"{where}: 'layer' is not a legal DXF layer name (contains {shown})") + return layer def validate_entity_specs(entities) -> list[dict]: @@ -145,7 +158,7 @@ def validate_attdef_specs(attdefs) -> list[dict]: if not isinstance(spec, dict): raise TypeError(f"{where}: must be an object") tag = spec.get("tag") - if not isinstance(tag, str) or not ATTDEF_TAG_RE.match(tag): + if not isinstance(tag, str) or not ATTDEF_TAG_RE.fullmatch(tag): raise TypeError(f"{where}: 'tag' must match {ATTDEF_TAG_RE.pattern}, got {tag!r}") if tag in seen: raise TypeError(f"{where}: duplicate tag {tag!r}") diff --git a/security.py b/security.py index 83819a3..453dd45 100644 --- a/security.py +++ b/security.py @@ -384,6 +384,17 @@ _FORBIDDEN_SYMBOL_CHARS = set('<>/\\":;?*|,=`') | set("\n\r\t") _MAX_SYMBOL_NAME = 255 +def illegal_symbol_name_chars(name: str) -> list[str]: + """The characters in `name` that DXF forbids in a symbol-table name, sorted. + + The one rule behind every symbol-name gate in this repo: `sanitize_symbol_name` + refuses on it for macro interpolation, and pre-write validators (block + primitive specs, for one) refuse on it so ezdxf's own `DXFValueError` never + fires mid-write after earlier entities have already landed. + """ + return sorted({c for c in name if c in _FORBIDDEN_SYMBOL_CHARS or ord(c) < 32}) + + def sanitize_symbol_name(name: str, *, kind: str = "symbol") -> str: """Return `name` if it is a legal DXF symbol-table name, else refuse. @@ -397,7 +408,7 @@ def sanitize_symbol_name(name: str, *, kind: str = "symbol") -> str: raise ToolError(f"{kind} name rejected: empty.") if len(candidate) > _MAX_SYMBOL_NAME: raise ToolError(f"{kind} name rejected: longer than {_MAX_SYMBOL_NAME} characters.") - offenders = sorted({c for c in candidate if c in _FORBIDDEN_SYMBOL_CHARS or ord(c) < 32}) + offenders = illegal_symbol_name_chars(candidate) if offenders: shown = ", ".join(repr(c) for c in offenders) raise ToolError( diff --git a/tests/test_block_specs.py b/tests/test_block_specs.py index 16cb4de..3cd6e88 100644 --- a/tests/test_block_specs.py +++ b/tests/test_block_specs.py @@ -58,6 +58,9 @@ def test_each_type_normalises_to_floats_and_layer_zero(): ({"type": "solid", "points": [[0, 0], [1, 0]]}, "points"), ({"type": "solid", "points": [[0, 0], [1, 0], [1, 1], [0, 1], [2, 2]]}, "at most 4"), ({"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0, "layer": ""}, "layer"), + ({"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0, "layer": "BAD Date: Tue, 15 Sep 2026 13:34:47 +0300 Subject: [PATCH 3/3] fix(task-2): refuse multi-line / trailing-caret TEXT and ATTDEF strings before write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `text`, ATTDEF `prompt` and `default` were only isinstance(str)-checked, so 'A\nB' and '10^' passed validation and ezdxf's DXFAttr fixer (`fix_one_line_text`) rewrote them on `add_text` / `add_attdef` at DEBUG log level — the declared ATTRIB value and the one the P&ID reader gets back differed silently, and COM (raw string) diverged from ezdxf. Same hole the tag check closed in 4158575, left open on its three siblings. A `_one_line(value, where)` helper now refuses '\n', '\r', any other C0 control, and a trailing '^' (ezdxf's own `is_valid_one_line_text` rule), naming `entities[i]: 'text'` / `attdefs[i]: 'prompt'|'default'`. Tests cover the refusals, the strings ezdxf keeps verbatim ('10^2', 'Ø20 H7', '%%C20'), and an ezdxf round-trip proving accepted values are stored unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/block_specs.py | 36 ++++++++++++++++------- tests/test_block_specs.py | 61 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/backends/block_specs.py b/backends/block_specs.py index 629b20b..3fdc85c 100644 --- a/backends/block_specs.py +++ b/backends/block_specs.py @@ -89,6 +89,29 @@ def _layer(spec: dict, where: str) -> str: return layer +def _one_line(value, where: str) -> str: + """A single-line DXF string (TEXT content, ATTDEF prompt/default). + + ezdxf's DXFAttr fixer (``fix_one_line_text``) silently strips line breaks + and a trailing ``^`` on write, logged at DEBUG only, so the declared value + and the value read back would differ; COM writes the raw string, so the two + engines would also diverge. Refuse it here, before any write, using ezdxf's + own ``is_valid_one_line_text`` rule plus the other C0 controls DXF text + cannot carry. + """ + if not isinstance(value, str): + raise TypeError(f"{where} must be a string") + controls = sorted({c for c in value if ord(c) < 32}) + if controls: + shown = ", ".join(repr(c) for c in controls) + raise TypeError( + f"{where} must be a single line without control characters (contains {shown})" + ) + if value.endswith("^"): + raise TypeError(f"{where} must not end with '^' (DXF strips a trailing caret)") + return value + + def validate_entity_specs(entities) -> list[dict]: """Normalise ``block_define`` primitive specs; refuse the whole list on the first defect.""" if not isinstance(entities, (list, tuple)) or not entities: @@ -128,11 +151,8 @@ def validate_entity_specs(entities) -> list[dict]: bulges = [_scalar(b, f"{where}: bulges[{j}]") for j, b in enumerate(bulges)] norm.update(points=pts, closed=closed, bulges=list(bulges) if bulges else None) elif kind == "text": - text = spec.get("text") - if not isinstance(text, str): - raise TypeError(f"{where}: 'text' must be a string") norm.update( - text=text, + text=_one_line(spec.get("text"), f"{where}: 'text'"), x=_number(spec, "x", where), y=_number(spec, "y", where), height=_number(spec, "height", where), @@ -163,13 +183,9 @@ def validate_attdef_specs(attdefs) -> list[dict]: if tag in seen: raise TypeError(f"{where}: duplicate tag {tag!r}") seen.add(tag) - prompt = spec.get("prompt", tag) - default = spec.get("default", "") + prompt = _one_line(spec.get("prompt", tag), f"{where}: 'prompt'") + default = _one_line(spec.get("default", ""), f"{where}: 'default'") invisible = spec.get("invisible", False) - if not isinstance(prompt, str): - raise TypeError(f"{where}: 'prompt' must be a string") - if not isinstance(default, str): - raise TypeError(f"{where}: 'default' must be a string") if not isinstance(invisible, bool): raise TypeError(f"{where}: 'invisible' must be a boolean") out.append( diff --git a/tests/test_block_specs.py b/tests/test_block_specs.py index 3cd6e88..ade8aa8 100644 --- a/tests/test_block_specs.py +++ b/tests/test_block_specs.py @@ -50,6 +50,10 @@ def test_each_type_normalises_to_floats_and_layer_zero(): ({"type": "circle", "cx": 0, "cy": 0, "r": float("nan")}, "finite"), ({"type": "text", "text": "M", "x": 0, "y": 0, "height": -1}, "height"), ({"type": "text", "text": 5, "x": 0, "y": 0, "height": 1}, "text"), + ({"type": "text", "text": "A\nB", "x": 0, "y": 0, "height": 1}, "'text'"), + ({"type": "text", "text": "A\rB", "x": 0, "y": 0, "height": 1}, "'text'"), + ({"type": "text", "text": "A\x01B", "x": 0, "y": 0, "height": 1}, "'text'"), + ({"type": "text", "text": "10^", "x": 0, "y": 0, "height": 1}, "'text'"), ({"type": "text", "text": "M", "x": 0, "y": 0, "height": 1, "align": "top"}, "align"), ({"type": "polyline", "points": [[0, 0]]}, "points"), ({"type": "polyline", "points": [[0, 0], [1]]}, "points[1]"), @@ -113,6 +117,11 @@ def test_attdefs_normalise_and_default(): ({"tag": "TAG", "x": 0, "y": 0, "height": 0}, "height"), ({"tag": "TAG", "x": 0, "y": 0, "height": 1, "invisible": 1}, "invisible"), ({"tag": "TAG", "x": 0, "y": 0, "height": 1, "default": 3}, "default"), + ({"tag": "TAG", "x": 0, "y": 0, "height": 1, "prompt": 3}, "prompt"), + ({"tag": "TAG", "x": 0, "y": 0, "height": 1, "prompt": "P\nQ"}, "'prompt'"), + ({"tag": "TAG", "x": 0, "y": 0, "height": 1, "default": "L-1\r\n"}, "'default'"), + ({"tag": "TAG", "x": 0, "y": 0, "height": 1, "default": "L-1^"}, "'default'"), + ({"tag": "TAG", "x": 0, "y": 0, "height": 1, "default": "L\x1f1"}, "'default'"), ], ) def test_bad_attdef_names_the_index_and_key(bad, fragment): @@ -144,6 +153,58 @@ def test_trailing_newline_cannot_smuggle_a_duplicate_tag(): ) +@pytest.mark.parametrize("value", ["A\nB", "A\rB", "10^", "A\tB", "L-1\r\n"]) +def test_one_line_strings_are_refused_before_ezdxf_would_rewrite_them(value): + """ezdxf's `fix_one_line_text` strips line breaks and a trailing caret on + `add_text` / `add_attdef` (DEBUG log only), so 'A\\nB' lands as 'AB' and the + ATTRIB value the P&ID reader gets back differs from the declared one; COM + writes the raw string, so the engines would diverge too. The gate is ours, + typed, and indexed — the same hole the tag check closed, on its siblings.""" + from ezdxf.lldxf.validator import is_valid_one_line_text + + # Either ezdxf's own rule rejects it or it is a C0 control DXF text cannot carry. + assert not is_valid_one_line_text(value) or any(ord(c) < 32 for c in value) + with pytest.raises(TypeError, match=r"entities\[0\]: 'text'"): + validate_entity_specs([{"type": "text", "text": value, "x": 0, "y": 0, "height": 2}]) + with pytest.raises(TypeError, match=r"attdefs\[0\]: 'prompt'"): + validate_attdef_specs([{"tag": "TAG", "x": 0, "y": 0, "height": 2, "prompt": value}]) + with pytest.raises(TypeError, match=r"attdefs\[0\]: 'default'"): + validate_attdef_specs([{"tag": "TAG", "x": 0, "y": 0, "height": 2, "default": value}]) + + +@pytest.mark.parametrize("value", ["10^2", "Ø20 H7", "%%C20", "L-101", ""]) +def test_one_line_strings_that_ezdxf_keeps_verbatim_pass(value): + """A caret inside the text (`10^2`), unicode and `%%C` survive both engines.""" + from ezdxf.lldxf.validator import is_valid_one_line_text + + assert is_valid_one_line_text(value) + out = validate_entity_specs([{"type": "text", "text": value, "x": 0, "y": 0, "height": 2}]) + assert out[0]["text"] == value + att = validate_attdef_specs( + [{"tag": "TAG", "x": 0, "y": 0, "height": 2, "prompt": value, "default": value}] + ) + assert att[0]["prompt"] == value and att[0]["default"] == value + + +def test_one_line_gate_writes_through_ezdxf_unchanged(): + """Round-trip proof: what the gate accepts, ezdxf stores verbatim (no + DXFAttr fixer rewrite), so declared == read back.""" + import ezdxf + + doc = ezdxf.new() + blk = doc.blocks.new("T") + [spec] = validate_entity_specs( + [{"type": "text", "text": "10^2 Ø", "x": 0, "y": 0, "height": 2}] + ) + [att] = validate_attdef_specs( + [{"tag": "TAG", "x": 0, "y": 0, "height": 2, "prompt": "P Q", "default": "L-101"}] + ) + t = blk.add_text(spec["text"]) + a = blk.add_attdef(att["tag"], text=att["default"], dxfattribs={"prompt": att["prompt"]}) + assert t.dxf.text == "10^2 Ø" + assert a.dxf.text == "L-101" and a.dxf.prompt == "P Q" + + def test_solid_vertices_use_dxf_bowtie_order(): quad = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] assert solid_vertices(quad) == [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]