draftwright 0.1.12__tar.gz → 0.1.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {draftwright-0.1.12 → draftwright-0.1.13}/CHANGELOG.md +28 -0
  2. {draftwright-0.1.12 → draftwright-0.1.13}/PKG-INFO +3 -4
  3. {draftwright-0.1.12 → draftwright-0.1.13}/README.md +1 -2
  4. {draftwright-0.1.12 → draftwright-0.1.13}/pyproject.toml +22 -6
  5. {draftwright-0.1.12 → draftwright-0.1.13}/src/draftwright/__init__.py +2 -3
  6. {draftwright-0.1.12 → draftwright-0.1.13}/src/draftwright/_core.py +166 -15
  7. draftwright-0.1.13/src/draftwright/analysis.py +479 -0
  8. draftwright-0.1.13/src/draftwright/annotate.py +13 -0
  9. draftwright-0.1.13/src/draftwright/annotations/__init__.py +6 -0
  10. draftwright-0.1.13/src/draftwright/annotations/_common.py +51 -0
  11. draftwright-0.1.13/src/draftwright/annotations/holes.py +1063 -0
  12. draftwright-0.1.13/src/draftwright/annotations/orchestrator.py +551 -0
  13. draftwright-0.1.13/src/draftwright/annotations/pmi.py +371 -0
  14. draftwright-0.1.13/src/draftwright/annotations/sections.py +466 -0
  15. draftwright-0.1.13/src/draftwright/annotations/turned.py +196 -0
  16. draftwright-0.1.13/src/draftwright/builder.py +705 -0
  17. draftwright-0.1.13/src/draftwright/drawing.py +1122 -0
  18. draftwright-0.1.13/src/draftwright/export.py +257 -0
  19. draftwright-0.1.13/src/draftwright/features.py +318 -0
  20. draftwright-0.1.13/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  21. draftwright-0.1.13/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  22. draftwright-0.1.13/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +93 -0
  23. draftwright-0.1.13/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +93 -0
  24. draftwright-0.1.13/src/draftwright/fonts/__init__.py +20 -0
  25. draftwright-0.1.13/src/draftwright/linting.py +268 -0
  26. draftwright-0.1.13/src/draftwright/make_drawing.py +17 -0
  27. {draftwright-0.1.12 → draftwright-0.1.13}/src/draftwright/pmi.py +3 -2
  28. draftwright-0.1.13/src/draftwright/projection.py +291 -0
  29. draftwright-0.1.13/src/draftwright/registry.py +126 -0
  30. draftwright-0.1.13/src/draftwright/repair.py +111 -0
  31. draftwright-0.1.13/src/draftwright/sheet.py +893 -0
  32. draftwright-0.1.13/tests/golden/ctc01.json +335 -0
  33. draftwright-0.1.13/tests/golden/cylinder.json +176 -0
  34. draftwright-0.1.13/tests/golden/plate.json +156 -0
  35. draftwright-0.1.13/tests/golden/stepped.json +148 -0
  36. {draftwright-0.1.12 → draftwright-0.1.13}/tests/test_e2e_standards.py +2 -1
  37. draftwright-0.1.13/tests/test_golden.py +309 -0
  38. {draftwright-0.1.12 → draftwright-0.1.13}/tests/test_layout.py +6 -2
  39. draftwright-0.1.13/tests/test_linting.py +36 -0
  40. {draftwright-0.1.12 → draftwright-0.1.13}/tests/test_make_drawing.py +357 -153
  41. draftwright-0.1.13/tests/test_registry.py +105 -0
  42. draftwright-0.1.12/src/draftwright/annotate.py +0 -2193
  43. draftwright-0.1.12/src/draftwright/make_drawing.py +0 -3907
  44. {draftwright-0.1.12 → draftwright-0.1.13}/.gitignore +0 -0
  45. {draftwright-0.1.12 → draftwright-0.1.13}/LICENSE +0 -0
  46. {draftwright-0.1.12 → draftwright-0.1.13}/skills/SKILL.md +0 -0
  47. {draftwright-0.1.12 → draftwright-0.1.13}/src/draftwright/layout.py +0 -0
  48. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  49. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  50. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  51. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  52. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  53. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  54. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  55. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  56. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  57. {draftwright-0.1.12 → draftwright-0.1.13}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  58. {draftwright-0.1.12 → draftwright-0.1.13}/tests/test_pmi.py +0 -0
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.1.13 — 2026-06-27
6
+
7
+ ### Changed
8
+
9
+ - **Requires `build123d-drafting-helpers>=0.13.0`; text pinned to bundled fonts**
10
+ (#149, ADR 0006). draftwright now vendors IBM Plex (OFL-1.1) and renders and
11
+ measures all text via `font_path` — IBM Plex Mono for dimensions/callouts/notes,
12
+ IBM Plex Sans Condensed for the title block — instead of resolving the system
13
+ font name `"Arial"`. Resolving a name substitutes a different font on Linux,
14
+ which shifted the whole sheet ~1 mm; pinning a bundled font file makes generated
15
+ layout **deterministic across Linux/macOS/Windows** and gives a consistent
16
+ typeface. **Drawing output changes**: positions shift slightly from prior
17
+ releases and labels render in IBM Plex (helpers #172).
18
+
19
+ ### Internal
20
+
21
+ - **Compiler-pipeline module split** (#138, ADR 0005). The two large modules
22
+ `make_drawing.py` (3,907 lines) and `annotate.py` (2,587) were decomposed into a
23
+ DAG of focused stage modules — `projection`, `sheet`, `analysis`, `drawing`,
24
+ `builder`, the `annotations/` subpackage (sections/turned/pmi/holes/orchestrator),
25
+ alongside the existing `registry`/`linting`/`repair`/`export`/`fonts`. Annotation
26
+ identity, the lint coverage signal, and the deterministic repair loop each gained a
27
+ single owner; `make_drawing.py` / `annotate.py` are now thin compat facades, so all
28
+ existing imports and the `draftwright` CLI entry point keep working. A golden-output
29
+ regression gate verified every step is behaviour-preserving (output byte-identical),
30
+ and mypy was tightened on the settled contracts. No public API or drawing-output
31
+ change. (Phases #160–#166.)
32
+
5
33
  ## v0.1.12 — 2026-06-21
6
34
 
7
35
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.1.12
3
+ Version: 0.1.13
4
4
  Summary: Automated technical-drawing generation for build123d
5
5
  Project-URL: Homepage, https://github.com/pzfreo/draftwright
6
6
  Project-URL: Repository, https://github.com/pzfreo/draftwright
@@ -680,7 +680,7 @@ Classifier: Programming Language :: Python :: 3.11
680
680
  Classifier: Programming Language :: Python :: 3.12
681
681
  Classifier: Topic :: Scientific/Engineering
682
682
  Requires-Python: <3.15,>=3.10
683
- Requires-Dist: build123d-drafting-helpers>=0.12.0
683
+ Requires-Dist: build123d-drafting-helpers>=0.13.0
684
684
  Requires-Dist: build123d>=0.9.0
685
685
  Requires-Dist: kiwisolver<2,>=1.4
686
686
  Requires-Dist: numpy>=1.24
@@ -756,8 +756,7 @@ make_drawing(part, out="drawing", title="My Part", number="DWG-001")
756
756
  # Composable: get a Drawing object to inspect or extend
757
757
  dwg = build_drawing(part, title="My Part")
758
758
  issues = dwg.lint() # list[LintIssue]
759
- dwg.export_svg("drawing.svg")
760
- dwg.export_dxf("drawing.dxf")
759
+ svg_path, dxf_path = dwg.export("drawing")
761
760
  ```
762
761
 
763
762
  ### From a STEP file
@@ -66,8 +66,7 @@ make_drawing(part, out="drawing", title="My Part", number="DWG-001")
66
66
  # Composable: get a Drawing object to inspect or extend
67
67
  dwg = build_drawing(part, title="My Part")
68
68
  issues = dwg.lint() # list[LintIssue]
69
- dwg.export_svg("drawing.svg")
70
- dwg.export_dxf("drawing.dxf")
69
+ svg_path, dxf_path = dwg.export("drawing")
71
70
  ```
72
71
 
73
72
  ### From a STEP file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.1.12"
7
+ version = "0.1.13"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -12,7 +12,7 @@ authors = [{ name = "Paul Fremantle", email = "pzfreo@gmail.com" }]
12
12
  requires-python = ">=3.10,<3.15"
13
13
  dependencies = [
14
14
  "build123d>=0.9.0",
15
- "build123d-drafting-helpers>=0.12.0",
15
+ "build123d-drafting-helpers>=0.13.0",
16
16
  "kiwisolver>=1.4,<2",
17
17
  "numpy>=1.24",
18
18
  ]
@@ -59,10 +59,17 @@ include = [
59
59
  testpaths = ["tests"]
60
60
  timeout = 300
61
61
  # `slow` covers the heavy end-to-end builds over the large NIST CTC fixtures
62
- # (minutes each). They are deselected by default for a fast normal run; run the
63
- # full suite with `-m ""` or just the slow tier with `-m slow` (CI runs both).
64
- markers = ["slow: heavy end-to-end CTC fixture builds (deselected by default)"]
65
- addopts = "--tb=short --cov=src/draftwright --cov-report=term-missing --cov-report=xml -m 'not slow'"
62
+ # (minutes each), deselected by default. `smoke` is a curated, build-light subset
63
+ # (~30 s) for a quick local "did I break something obvious" check run it with
64
+ # `uv run pytest -m smoke`. The default run is the full fast tier (`-m 'not slow'`,
65
+ # ~8 min); CI owns that plus the slow tier across the OS/Python matrix.
66
+ markers = [
67
+ "slow: heavy end-to-end CTC fixture builds (deselected by default)",
68
+ "smoke: curated build-light subset for a fast local check (~30 s)",
69
+ ]
70
+ # Coverage is a CI concern (it adds ~13% locally and instruments every line); the
71
+ # CI workflow passes the --cov flags explicitly. Keep the local default lean.
72
+ addopts = "--tb=short -m 'not slow'"
66
73
 
67
74
  [tool.coverage.run]
68
75
  source = ["src/draftwright"]
@@ -79,6 +86,14 @@ python_version = "3.10"
79
86
  ignore_missing_imports = true
80
87
  check_untyped_defs = true
81
88
  warn_unused_ignores = true
89
+ # Tightened on the settled module contracts (#138 / ADR 0005 P7). disallow_untyped_defs
90
+ # stays off (geometry helpers lean on untyped build123d returns); these flags catch
91
+ # real gaps without demanding annotations everywhere.
92
+ no_implicit_optional = true
93
+ warn_redundant_casts = true
94
+ strict_equality = true
95
+ warn_return_any = true
96
+ extra_checks = true
82
97
 
83
98
  [tool.ruff]
84
99
  line-length = 99
@@ -94,5 +109,6 @@ dev = [
94
109
  "pytest>=8",
95
110
  "pytest-cov>=5",
96
111
  "pytest-timeout>=2",
112
+ "pytest-xdist>=3",
97
113
  "ruff>=0.4",
98
114
  ]
@@ -11,19 +11,18 @@ Requires build123d-drafting-helpers for annotation primitives.
11
11
  Licensed under the GNU Affero General Public License v3 (AGPL-3.0).
12
12
  """
13
13
 
14
+ from draftwright.analysis import analyse_face_levels, dedup_diams
14
15
  from draftwright.make_drawing import (
15
16
  Drawing,
16
17
  FeatureInfo,
17
- analyse_face_levels,
18
18
  build_drawing,
19
- choose_scale,
20
- dedup_diams,
21
19
  fix_svg_page_size,
22
20
  generate_script,
23
21
  lint_feature_coverage,
24
22
  make_drawing,
25
23
  )
26
24
  from draftwright.pmi import PmiRecord, extract_pmi
25
+ from draftwright.sheet import choose_scale
27
26
 
28
27
  __all__ = [
29
28
  "Drawing",
@@ -10,21 +10,22 @@ the dimension/format helpers, and the layout constants). It imports only from
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import functools
13
14
  import logging
14
15
  import re
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
17
18
  from types import SimpleNamespace
18
19
 
19
- from build123d import BoundBox, Location, Shape
20
+ from build123d import Align, BoundBox, Location, Mode, Shape, Text
20
21
  from build123d_drafting.helpers import (
21
- _TB_COL_FRACTIONS, # private: title-block column widths — tight coupling, see CLAUDE.md
22
22
  Dimension,
23
23
  TitleBlock,
24
24
  draft_preset,
25
25
  format_drawing_scale,
26
26
  )
27
27
 
28
+ from draftwright.fonts import PLEX_MONO, PLEX_SANS_CONDENSED
28
29
  from draftwright.layout import _greedy_strip_1d, _solve_strip_1d
29
30
 
30
31
  _log = logging.getLogger(__name__)
@@ -64,6 +65,38 @@ def _tag_sequence(n):
64
65
 
65
66
  _DIAM_RE = re.compile(r"[øØ⌀]\s*(\d+(?:\.\d+)?)")
66
67
 
68
+ # A single-quoted label lifted from a lint message, e.g. "labels 'A' and 'B' …".
69
+ # Shared by the #29 lint suggestions (linting.py) and the #30 repair loop.
70
+ _QUOTED_RE = re.compile(r"'([^']*)'")
71
+
72
+
73
+ @functools.lru_cache(maxsize=512)
74
+ def _text_width(text: str, font_size: float, font_path: str = PLEX_MONO) -> float:
75
+ """Measured rendered width (page-mm) of *text* at *font_size*.
76
+
77
+ Uses build123d's ``Text`` — the same primitive ``Dimension``/``HoleCallout``
78
+ stroke their labels with — so callout-width estimates use real glyph metrics
79
+ instead of a character-count fudge (#31). Pinned to a vendored font **file**
80
+ (``font_path``), not a system font *name*: name resolution substitutes a
81
+ different font on Linux, which makes this estimate — and the layout it feeds —
82
+ platform-variant (#149). The default is the same face the annotations render
83
+ with, so estimate and render agree. Cached because the same numeric labels
84
+ recur across holes and the rasterisation is the costly part.
85
+ """
86
+ if not text:
87
+ return 0.0
88
+ return (
89
+ Text(
90
+ txt=text,
91
+ font_size=font_size,
92
+ font_path=font_path,
93
+ align=(Align.CENTER, Align.CENTER),
94
+ mode=Mode.PRIVATE,
95
+ )
96
+ .bounding_box()
97
+ .size.X
98
+ )
99
+
67
100
 
68
101
  def _axis_letter(obj) -> str:
69
102
  """Letter (``"x"``/``"y"``/``"z"``) of ``obj.axis``'s dominant component.
@@ -193,6 +226,12 @@ _SLOT_DIM_DEPTH = 2 * _FONT_SIZE + _PAD # sv_zones.below: overall depth dimensi
193
226
  _SLOT_DIM_HEIGHT = 2 * _FONT_SIZE + 2 * _PAD # fv_zones.right: overall height dim
194
227
 
195
228
 
229
+ _STRIP_SPACING = 4.0 # page-mm between successive annotations in a strip
230
+ _MIN_VIEW_MM = (
231
+ 10.0 # min projected view dimension; below it annotation geometry degenerates (#129)
232
+ )
233
+
234
+
196
235
  _SLOT_DIM_STEP = 4 * _FONT_SIZE + _PAD # fv_zones.right: step-height dimension
197
236
 
198
237
 
@@ -362,6 +401,7 @@ class Analysis:
362
401
  bbox_max: float
363
402
  holes: list
364
403
  patterns: list
404
+ slots: list
365
405
  z_diams: list[float]
366
406
  cross_diams: list[float]
367
407
  cyls: tuple[list, list]
@@ -433,29 +473,140 @@ def _add_title_block(dwg, a: Analysis):
433
473
  revision="A",
434
474
  legal_owner="",
435
475
  width=a.TB_W,
436
- draft=dwg.draft,
476
+ # Title block renders in condensed sans (the tight ISO 7200 cells), a
477
+ # different face from the monospace dimensions — so it carries its own
478
+ # pinned-font draft rather than reusing dwg.draft (#149).
479
+ draft=draft_preset(
480
+ font_size=dwg.draft.font_size,
481
+ decimal_precision=dwg.draft.decimal_precision,
482
+ font_path=PLEX_SANS_CONDENSED,
483
+ ),
437
484
  )
438
- # Drawn-by cell geometry, taken from the block itself rather than hardcoded,
439
- # so the hyperlink rect tracks any upstream TitleBlock layout change: the
440
- # two-row block's bottom row is half the block height, and the cell spans
441
- # from the first column divider (_TB_COL_FRACTIONS[0]) to the right edge.
442
- cell_h = tb.block_bbox["height"] / 2.0
485
+ # Drawn-by cell geometry, from the block's own public cell bbox (#139) rather
486
+ # than hardcoded column fractions, so the hyperlink rect tracks any upstream
487
+ # TitleBlock layout change. Build-frame bbox; translated to page space below.
488
+ cell = tb.drawn_by_cell_bbox()
443
489
  tb = tb.locate(Location((a.PAGE_W - a.TB_W - _TB_CLEAR, _TB_CLEAR, 0)))
444
490
  dwg.add(tb, "title_block")
445
491
 
446
492
  # Record that cell's page-space rectangle so export() can place a clickable
447
- # draftwright hyperlink over the "… / draftwright" author text. Keeps the
448
- # block at its standard two rows no extra height, no layout impact (a
449
- # third row would squeeze the dense-part hole table).
493
+ # draftwright hyperlink over the "… / draftwright" author text. The build-frame
494
+ # cell corners are offset by the block's page location (bx, _TB_CLEAR).
450
495
  bx = a.PAGE_W - a.TB_W - _TB_CLEAR
451
496
  dwg._draftwright_link_rect = (
452
- bx + _TB_COL_FRACTIONS[0] * a.TB_W,
453
- _TB_CLEAR,
454
- bx + a.TB_W,
455
- _TB_CLEAR + cell_h,
497
+ bx + cell["min_x"],
498
+ _TB_CLEAR + cell["min_y"],
499
+ bx + cell["max_x"],
500
+ _TB_CLEAR + cell["max_y"],
456
501
  )
457
502
 
458
503
 
459
504
  def _iso_bbox(dwg):
460
505
  """(min_x, min_y, max_x, max_y) of the placed iso view, hidden lines included."""
461
506
  return dwg.view_bounds("iso")
507
+
508
+
509
+ # --- page/scale selection + sheet-layout constants and helpers --------------
510
+ # Relocated from make_drawing for the sheet.py split (#162). Shared by sheet.py
511
+ # (choose_scale/_layout_geometry) and make_drawing's repack pass, so they live
512
+ # here in the shared base to keep the DAG acyclic.
513
+ _DIM_PAD = 18.0
514
+ _STRIP_GAP = 8.0
515
+
516
+ _PAGE_SIZES = {
517
+ "A4": (297.0, 210.0),
518
+ "A3": (420.0, 297.0),
519
+ "A2": (594.0, 420.0),
520
+ "A1": (841.0, 594.0),
521
+ "A0": (1189.0, 841.0),
522
+ }
523
+
524
+ _SCALES = [10.0, 5.0, 2.0, 1.0, 0.5, 0.2]
525
+
526
+ # Horizontal page budget to reserve for the isometric view during scale
527
+ # selection and view placement, as a fraction of bbox_max * scale. This is a
528
+ # deliberate *under-estimate*, not the true projected size (a cube's iso
529
+ # projection is ~1.63*bbox_max wide): the iso is the last column and is fitted
530
+ # to the actual largest-empty-rect afterwards by _fit_iso_view(), which shrinks
531
+ # it to whatever space is genuinely left. A true fit test here is circular —
532
+ # the empty rect depends on the very view positions this estimate feeds — so
533
+ # the budget stays a single, named factor rather than a recomputed fit (#31).
534
+ _ISO_WIDTH_BUDGET = 0.7
535
+
536
+ # Scale selection accepts a layout when the largest empty rectangle left for the
537
+ # iso view can hold a square of at least this fraction of the iso's natural size
538
+ # (bbox_max * scale * _ISO_WIDTH_BUDGET). Below 1.0 because _fit_iso_view scales
539
+ # the iso down to whatever space remains, so a modestly smaller rectangle still
540
+ # renders a legible iso — letting a long/short part enlarge onto a sheet (e.g.
541
+ # 2:1 on A3) where the strict row model would have under-scaled it.
542
+ _ISO_MIN_FIT_FRAC = 0.6
543
+
544
+
545
+ # Automatic scale/page preference ladder, first-fit. The enlargement/unity
546
+ # region is page-major: every standard scale on the smallest sheet (A4) is tried
547
+ # before moving to the next sheet, so a part lands on the smallest sheet it fits
548
+ # at the largest scale that sheet allows — e.g. a 20×15×10 part gets 2:1 on A4,
549
+ # not 5:1 on A3. Reductions (below 1:1) keep their legibility-vs-sheet balance
550
+ # (least reduction first) so a large part is not over-reduced onto a small sheet.
551
+ _LADDER = [
552
+ # A4 — smallest sheet first, largest scale first
553
+ (10.0, 297.0, 210.0, 120.0), # A4 10:1
554
+ (5.0, 297.0, 210.0, 120.0), # A4 5:1
555
+ (2.0, 297.0, 210.0, 120.0), # A4 2:1
556
+ (1.0, 297.0, 210.0, 120.0), # A4 1:1
557
+ # A3
558
+ (5.0, 420.0, 297.0, 150.0), # A3 5:1
559
+ (2.0, 420.0, 297.0, 150.0), # A3 2:1
560
+ (1.0, 420.0, 297.0, 150.0), # A3 1:1
561
+ # A2
562
+ (2.0, 594.0, 420.0, 150.0), # A2 2:1
563
+ (1.0, 594.0, 420.0, 150.0), # A2 1:1
564
+ # A1
565
+ (1.0, 841.0, 594.0, 150.0), # A1 1:1
566
+ # Reductions — least reduction first, so a too-big part is not crammed onto a
567
+ # small sheet at an illegible scale.
568
+ (0.5, 594.0, 420.0, 150.0), # A2 1:2
569
+ (0.2, 420.0, 297.0, 150.0), # A3 1:5
570
+ (0.2, 594.0, 420.0, 150.0), # A2 1:5
571
+ (0.5, 841.0, 594.0, 150.0), # A1 1:2
572
+ (0.2, 841.0, 594.0, 150.0), # A1 1:5
573
+ (0.5, 1189.0, 841.0, 150.0), # A0 1:2
574
+ (0.2, 1189.0, 841.0, 150.0), # A0 1:5
575
+ ]
576
+
577
+
578
+ def _tb_width(page_w: float) -> float:
579
+ """Title-block width for a page: 120 mm on A4, 150 mm on A3 and larger."""
580
+ return 120.0 if page_w <= 297.0 else 150.0
581
+
582
+
583
+ def _parse_page(page) -> tuple:
584
+ """Resolve a page spec to ``(PAGE_W, PAGE_H, TB_W)``.
585
+
586
+ Accepts an ISO name (``"A4"``…``"A0"``, case-insensitive), a
587
+ ``"WIDTHxHEIGHT"`` string in mm (e.g. ``"420x297"``), or a
588
+ ``(width, height)`` tuple in mm.
589
+ """
590
+ if isinstance(page, str):
591
+ name = page.strip().upper()
592
+ if name in _PAGE_SIZES:
593
+ pw, ph = _PAGE_SIZES[name]
594
+ else:
595
+ m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*[xX×]\s*(\d+(?:\.\d+)?)", page.strip())
596
+ if not m:
597
+ raise ValueError(
598
+ f"unknown page size {page!r} — expected one of "
599
+ f"{', '.join(_PAGE_SIZES)} or WIDTHxHEIGHT in mm (e.g. '420x297')"
600
+ )
601
+ pw, ph = float(m.group(1)), float(m.group(2))
602
+ else:
603
+ try:
604
+ pw, ph = float(page[0]), float(page[1])
605
+ except (TypeError, ValueError, IndexError):
606
+ raise ValueError(
607
+ f"invalid page size {page!r} — expected an ISO name, "
608
+ f"'WIDTHxHEIGHT', or a (width, height) tuple in mm"
609
+ ) from None
610
+ if pw <= 0 or ph <= 0:
611
+ raise ValueError(f"page dimensions must be positive, got {page!r}")
612
+ return pw, ph, _tb_width(pw)