fix(task-17): the headless page setup writes the scale AutoCAD plots by, and reads the code 75 AutoCAD writes

Two measured root causes on the headless engine, both silent:

1. page_setup_apply wrote PLOTSETTINGS group 147 (unit_factor) as 1.0 for
   every scale. Under USE_STANDARD_SCALE AutoCAD's plot engine scales by
   group 147 alone (a 1:10 sheet with code 21 and 147 = 1.0 plotted a
   100-unit line at 99.99 mm while the row, `applied` and 142/143 said
   1:10; patching 147 to 0.1 alone gave 9.99 mm). AutoCAD writes 147 as
   numerator / denominator (measured live on AutoCAD 2026 for fit, 1:1,
   1:2, 1:10, 1:50, 2:1, 10:1: 1.0, 1.0, 0.5, 0.1, 0.02, 2.0, 10.0). The
   writer now stores 75, 142/143 and 147 from the one resolved ratio on
   every route, so no reader can disagree with the plot.

2. DXF_STANDARD_SCALE_TYPE and _plot_scale followed ezdxf's STD_SCALES,
   which has no 19 = 1:5 and numbers everything above 18 one lower than
   the ObjectARX StdScaleType enum AutoCAD actually writes (1:10 = 21,
   1:20 = 23, 1:50 = 26, 1:100 = 27, 2:1 = 28, 10:1 = 31 — measured via COM
   SaveAs DXF). An AutoCAD-authored 1:50 sheet listed as "1:100" and a
   1:10 sheet rendered at 1:16. papers.py now authors the full file enum
   (DXF_STD_SCALES, codes 1-33 with AutoCAD's imperial names), the writer
   uses the same codes ActiveX uses, and nothing goes through ezdxf's
   table. 1:5 stays on the custom route on both engines (the live setter
   refuses 19) while the reader knows code 19 for files AutoCAD wrote.

Tests: the plan's `1:50 -> 25` assertion was the stale numbering and now
pins 26; a pure-data test pins the enum against the measured codes and the
ActiveX numbering; a parametrised writer test checks 75/142/143/147 for
every catalogue scale; a reader test stores the scale exactly as AutoCAD
saves it (including codes 19 and 22 the writer never emits) and checks
page_setup_list and _paper_frame report and render it at its own scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-17 15:29:47 +03:00
co-authored by Claude Opus 5
parent f2386a9fdd
commit aeb27c252b
3 changed files with 281 additions and 39 deletions
+120 -21
View File
@@ -3,18 +3,40 @@
Sources: ISO 216 (A series), ASME/ANSI Y14.1 (A–E, metric millimetre values as
AutoCAD's ``ANSI_*`` media measure, inch values as the media are *named*),
the ctb files AutoCAD ships in its Plot Styles folder, and the standard-scale
tables of two enums that do not agree with each other:
enum — one numbering, shared by the file and by ActiveX:
* DXF group code 75 (``standard_scale_type``, what ezdxf writes): 16=1:1,
17=1:2, 20=1:10, 22=1:20, 25=1:50, 26=1:100, 27=2:1, 30=10:1 — no 1:5.
* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib):
ac1_1=16, ac1_2=17, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, ac2_1=28,
ac10_1=31, acScaleToFit=0. The typelib also declares ``ac1_5=19``, but
AutoCAD 2026 refuses ``Layout.StandardScale = 19`` with "Invalid input" in
every condition tried (PaperUnits mm and inches, UseStandardScale True and
False; measured live 2026-09-17) — its standard-scale list has no 1:5, the
same gap as DXF code 75 — so 1:5 is *not* in the table and takes the custom
route on the live engine too (``SetCustomScale(1, 5)`` reads back ``1:5``).
* DXF group code 75 (``standard_scale_type``) is the ObjectARX
``AcDbPlotSettings::StdScaleType`` enum: 0=fit, 1..15 the imperial
architectural scales, 16=1:1, 17=1:2, 18=1:4, 19=1:5, 20=1:8, 21=1:10,
22=1:16, 23=1:20, 24=1:30, 25=1:40, 26=1:50, 27=1:100, 28=2:1, 29=4:1,
30=8:1, 31=10:1, 32=100:1, 33=1000:1 (``DXF_STD_SCALES``). Measured live
on AutoCAD 2026 (COM ``SaveAs(..., 61)`` after ``StandardScale = n``):
1:10 saves as 21, 1:50 as 26, 2:1 as 28, 10:1 as 31. ezdxf's
``STD_SCALES`` table (and the DXF Reference page it follows) has no 19=1:5
and numbers everything above 18 one lower (20=1:10, 25=1:50, 30=10:1) —
reading an AutoCAD-authored 1:50 sheet through it reports 1:100, and a
1:10 sheet renders at 1:16 — so nothing here goes through that table.
* DXF group code 147 (``unit_factor`` in ezdxf; "a floating point scale
factor that represents the standard scale value specified in code 75" in
the DXF Reference — the Reference is right) is the factor AutoCAD's plot
engine *uses* under ``USE_STANDARD_SCALE`` (bit 16): AutoCAD writes it as
numerator / denominator (0.1 for 1:10, 2.0 for 2:1, 1.0 for fit and 1:1),
and a file with code 21 but 147 = 1.0 plots at 1:1 while claiming 1:10
(measured: a 100-unit line spans 99.99 mm of paper). Under the custom
route (bit 16 cleared) AutoCAD honours 142/143 and leaves 75 and 147
stale. The headless writer therefore writes 147 alongside 142/143 on
every route.
* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib)
is the same numbering (ac1_1=16, ac1_2=17, ac1_10=21, ac1_20=23,
ac1_50=26, ac1_100=27, ac2_1=28, ac10_1=31, acScaleToFit=0). The typelib
declares ``ac1_5=19`` too, but AutoCAD 2026 refuses
``Layout.StandardScale = 19`` with "Invalid input" in every condition tried
(PaperUnits mm and inches, UseStandardScale True and False; measured live
2026-09-17), so 1:5 is not in ``ACTIVEX_PLOT_SCALE`` and takes the custom
route on the live engine (``SetCustomScale(1, 5)`` reads back ``1:5``).
The headless writer mirrors that — 1:5 is not in ``DXF_STANDARD_SCALE_TYPE``
either, so both engines store the same 1:5 (custom, 142/143 = 1/5) — while
the reader knows code 19 for files AutoCAD wrote.
A scale without a code on an engine goes through the custom numerator /
denominator on that engine, and the read-back reports the same label either way.
@@ -39,6 +61,7 @@ __all__ = [
"ACTIVEX_PLOT_SCALE",
"CTB_CATALOG",
"DXF_STANDARD_SCALE_TYPE",
"DXF_STD_SCALES",
"ORIENTATIONS",
"PAPER_INCHES",
"PAPER_SIZES",
@@ -126,19 +149,79 @@ SCALES: dict[str, float | None] = {
"10:1": 10.0,
}
#: DXF group code 75. Labels missing here use the custom numerator/denominator.
#: DXF group code 75 → (numerator, denominator): the ObjectARX
#: ``AcDbPlotSettings::StdScaleType`` enum, which is what AutoCAD writes and
#: reads (module docstring — not ezdxf's ``STD_SCALES``, which is one off
#: above 18). Code 0 is *scaled to fit* and has no ratio.
DXF_STD_SCALES: dict[int, tuple[float, float]] = {
1: (1.0 / 128.0, 12.0),
2: (1.0 / 64.0, 12.0),
3: (1.0 / 32.0, 12.0),
4: (1.0 / 16.0, 12.0),
5: (3.0 / 32.0, 12.0),
6: (1.0 / 8.0, 12.0),
7: (3.0 / 16.0, 12.0),
8: (1.0 / 4.0, 12.0),
9: (3.0 / 8.0, 12.0),
10: (1.0 / 2.0, 12.0),
11: (3.0 / 4.0, 12.0),
12: (1.0, 12.0),
13: (3.0, 12.0),
14: (6.0, 12.0),
15: (12.0, 12.0),
16: (1.0, 1.0),
17: (1.0, 2.0),
18: (1.0, 4.0),
19: (1.0, 5.0),
20: (1.0, 8.0),
21: (1.0, 10.0),
22: (1.0, 16.0),
23: (1.0, 20.0),
24: (1.0, 30.0),
25: (1.0, 40.0),
26: (1.0, 50.0),
27: (1.0, 100.0),
28: (2.0, 1.0),
29: (4.0, 1.0),
30: (8.0, 1.0),
31: (10.0, 1.0),
32: (100.0, 1.0),
33: (1000.0, 1.0),
}
#: AutoCAD's names for the imperial codes (inches of paper = feet of drawing).
_DXF_IMPERIAL_LABELS: dict[int, str] = {
1: "1/128\"=1'",
2: "1/64\"=1'",
3: "1/32\"=1'",
4: "1/16\"=1'",
5: "3/32\"=1'",
6: "1/8\"=1'",
7: "3/16\"=1'",
8: "1/4\"=1'",
9: "3/8\"=1'",
10: "1/2\"=1'",
11: "3/4\"=1'",
12: "1\"=1'",
13: "3\"=1'",
14: "6\"=1'",
15: "1'=1'",
}
#: DXF group code 75 the headless *writer* uses per catalogue label. Labels
#: missing here use the custom numerator/denominator: ``1:5`` (code 19 exists
#: in the file enum, but the live engine cannot set it, so both engines store
#: 1:5 the same custom way) and ``5:1`` (no code anywhere).
DXF_STANDARD_SCALE_TYPE: dict[str, int] = {
"fit": 0,
"1:1": 16,
"1:2": 17,
"1:10": 20,
"1:20": 22,
"1:50": 25,
"1:100": 26,
"2:1": 27,
"10:1": 30,
"1:10": 21,
"1:20": 23,
"1:50": 26,
"1:100": 27,
"2:1": 28,
"10:1": 31,
}
_DXF_SCALE_LABEL = {code: label for label, code in DXF_STANDARD_SCALE_TYPE.items()}
#: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale.
#: No ``"1:5"``: the typelib's ``ac1_5 = 19`` is refused by AutoCAD 2026 (module docstring).
@@ -253,9 +336,25 @@ def scale_label(numerator, denominator) -> str:
return f"{num:g}:{den:g}"
_DXF_SCALE_LABEL: dict[int, str] = {
0: "fit",
**_DXF_IMPERIAL_LABELS,
**{
code: scale_label(num, den)
for code, (num, den) in DXF_STD_SCALES.items()
if code not in _DXF_IMPERIAL_LABELS
},
}
def dxf_scale_label(code, numerator, denominator) -> str:
"""Read-back for the headless engine: the code-75 label, else the custom ratio."""
return _DXF_SCALE_LABEL.get(int(code)) or scale_label(numerator, denominator)
"""Read-back for the headless engine: the code-75 label (``fit`` for 0,
the ratio ``DXF_STD_SCALES`` gives the code, an AutoCAD-style name for an
imperial code), else the custom ratio."""
label = _DXF_SCALE_LABEL.get(int(code))
if label is not None:
return label
return scale_label(numerator, denominator)
def activex_scale_label(code) -> str: