diff --git a/engineering/standards/dimstyles.py b/engineering/standards/dimstyles.py index f7ce151..4a63abc 100644 --- a/engineering/standards/dimstyles.py +++ b/engineering/standards/dimstyles.py @@ -12,9 +12,13 @@ Every value in ``PRESETS`` is pinned by name in lettering, .125 in arrowheads) in metric values: 3 mm text and arrowheads, text centred in a gap and horizontal, decimal point. -``DIMDSEP`` is a one-character *string* here. DXF stores the character code and -ezdxf raises on a string in the header, so the translation belongs to the -backends, not to the data. +``DIMDSEP`` is a one-character *string* here. DXF stores the character code +(group 278, a 16-bit value) and ezdxf raises on a string in the header, so the +translation belongs to the backends, not to the data — but the data is where +the refusal starts: a control character (``\\n`` splits the dimension text over +two lines, NUL reads back as a comma) or a code point above ``0x7FFF`` +(``ezdxf`` writes it, AutoCAD refuses it as "not a single character") is +refused here, before ``ord()`` runs in a backend. Pure data plus validation: no I/O, no ezdxf, no COM, so the backends, the tools and the provenance tests all read one copy. @@ -270,6 +274,15 @@ def ezdxf_arrowhead(canonical: str) -> str: return canonical +DIMDSEP_MAX_CODE = 0x7FFF +"""The largest character code ``DIMDSEP`` accepts. + +DXF group 278 is a 16-bit integer and the DWG field a signed short; AutoCAD +refuses anything above it as "not a single character", while ezdxf would +write it verbatim (measured: ``'\\U0001F600'`` reached the file as 128512). +""" + + def _number(key: str, value: Any) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise TypeError(f"{key}: expected a number, got {type(value).__name__}") @@ -324,6 +337,16 @@ def check_dim_value(name: str, value: Any) -> Any: raise TypeError(f"{key}: expected a string, got {type(value).__name__}") if len(value) != 1: # "char" raise ValueError(f"{key}: {value!r} must be exactly one character") + code = ord(value) + if code < 32 or code == 127: + raise ValueError( + f"{key}: {value!r} is a control character, which a DXF cannot hold as a separator" + ) + if code > DIMDSEP_MAX_CODE: + raise ValueError( + f"{key}: {value!r} (U+{code:04X}) is outside the 16-bit character code DXF group " + f"278 stores (at most U+{DIMDSEP_MAX_CODE:04X})" + ) return value diff --git a/tests/test_standards_presets.py b/tests/test_standards_presets.py index 61def1a..95dcd10 100644 --- a/tests/test_standards_presets.py +++ b/tests/test_standards_presets.py @@ -200,6 +200,35 @@ def test_unknown_preset_is_refused(): resolve_dimstyle("iso-25", [("DIMTXT", 2.5)]) +@pytest.mark.parametrize( + "separator", + ["\n", "\t", "\x00", "\x7f", chr(0x1F600), chr(0x8000), chr(0xFFFF)], + ids=["newline", "tab", "nul", "del", "non-bmp", "first-above-signed-short", "u-ffff"], +) +def test_dimdsep_refuses_what_a_dxf_character_code_cannot_hold(separator): + """DIMDSEP is the one variable stored as a character code (group 278, a + 16-bit value); ``len == 1`` alone let these through, and the headless + engine then wrote ``ord()`` unguarded — measured: ``'\\n'`` split the + dimension text over two lines, NUL read back as a comma, and the emoji + reached the file as 128512, which AutoCAD refuses as "not a single + character". ezdxf's audit reports none of it.""" + with pytest.raises(ValueError, match="DIMDSEP"): + check_dim_value("DIMDSEP", separator) + with pytest.raises(ValueError, match="DIMDSEP"): + resolve_dimstyle("iso-25", {"DIMDSEP": separator}) + + +@pytest.mark.parametrize( + "separator", + [",", ".", " ", chr(0xB7), chr(0x66B), chr(0x7FFF)], + ids=["comma", "point", "space", "middle-dot", "arabic-decimal", "last-signed-short"], +) +def test_dimdsep_takes_any_printable_separator_a_dxf_can_hold(separator): + """The comma, the point, the middle dot, the Arabic decimal separator, and + the last code a signed short holds all pass; the guard is not an ASCII gate.""" + assert check_dim_value("DIMDSEP", separator) == separator + + def test_validate_overrides_alone_is_what_modify_uses(): assert validate_overrides(None) == {} assert validate_overrides({"dimgap": -1}) == {"DIMGAP": -1.0}, "negative gap boxes the text" diff --git a/tests/test_styles.py b/tests/test_styles.py index 171e22e..cd73c70 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -134,6 +134,14 @@ async def test_refusals_happen_before_anything_is_written(backend): with pytest.raises(ValueError, match="does not exist"): await backend.dimstyle_set_current("NOPE") assert backend._doc.dimstyles.get("ISO-25").dxf.dimdec == 2 + # DIMDSEP is stored as ``ord(value)``: a control character or a code point + # above the 16-bit group 278 must stop here, not reach the table. + with pytest.raises(ValueError, match="DIMDSEP"): + await backend.dimstyle_create("Z", {"DIMDSEP": "\n"}) + assert not backend._doc.dimstyles.has_entry("Z") + with pytest.raises(ValueError, match="DIMDSEP"): + await backend.dimstyle_modify("ISO-25", {"DIMDSEP": "\U0001f600"}) + assert backend._doc.dimstyles.get("ISO-25").dxf.dimdsep == 44 async def test_builtin_arrowheads_render_save_and_report_one_spelling(backend, tmp_path):