Skip to content

Commit 80392a4

Browse files
DocAtPromptclaude
andcommitted
Cover sheet: a stroke-weight ladder, so a user sees how thin is thin (§ 8.8)
The cover already makes the one thing outside our control visible — a print driver scaling — with a 50 mm square and a 100 mm rule. This adds the other thing a definition cannot show on screen: how a given stroke weight actually renders on the user's own printer or e-ink panel. A fixed ladder of short lines from 0.1 to 1.0 pt, each labelled, sits between the rule and the summary. On a device each label also carries the pixel width — 0.15pt · 0.48px at 229 dpi — which ties the ladder to the media check (§ 12.1): that check says a hairline is too thin, the ladder shows how thin, and the pixel figure says why. On paper there is no pixel figure, because a format's assumed_dpi is a yardstick and not a resolution (§ 8.3.1). The weights are fixed numbers, a yardstick like the square and the rule, not a setting. The samples are shorter than the 100 mm rule, so they add no width constraint the rule has not. They do add height: the ladder is kept to seven weights so the cover still fits an A6 page (the imposition test's format), never scaled to fit — if it does not fit, the run is refused, exactly as before (§ 8.2). Verified on real cover sheets read back, on A4 paper and on the reMarkable Paper Pro. Spec § 8.8 gains the ladder as the cover's second figure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 13f6a91 commit 80392a4

3 files changed

Lines changed: 131 additions & 6 deletions

File tree

ctrlgrid/cover.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""The cover sheet (§ 8.8) — a calibration figure and a settings summary.
1+
"""The cover sheet (§ 8.8) — calibration figures, a weight ladder, a summary.
22
33
It is one extra first page and it is unlike every other page in three ways, all
44
of them from § 8.8: it is **not counted** in the numbering, it carries **no
@@ -41,6 +41,13 @@
4141
RULE_LENGTH = 100_000
4242
TICK = 3_000
4343

44+
#: A fixed ladder of stroke weights (§ 8.8), fine at the thin end where a stroke
45+
#: can round to nothing on a coarse device (§ 12.1). A yardstick like the square
46+
#: and the rule — fixed numbers, not settings the user picks.
47+
WEIGHTS_PT = (0.1, 0.15, 0.2, 0.3, 0.5, 0.7, 1.0)
48+
SAMPLE_LENGTH = 45_000 # shorter than the 100 mm rule, so it adds no width bound
49+
PT_TO_MM = 25.4 / 72
50+
4451
TITLE_SIZE = 4_233 # 12pt
4552
BODY_SIZE = 3_175 # 9pt
4653
#: The summary shrinks rather than overflowing, but only so far: below this it
@@ -99,6 +106,9 @@ def cover_marks(document: Document, *, q: WriterQuery) -> list[Mark]:
99106
marks += _rule(left, cursor, q=q)
100107
cursor -= TICK + _text_height(q, size=BODY_SIZE) + GAP // 2
101108

109+
cursor -= GAP
110+
cursor = _weight_ladder(marks, left, cursor, device=document.device, q=q)
111+
102112
cursor -= GAP
103113
cursor = _summary_block(marks, document, left, cursor, width=right - left, q=q)
104114

@@ -243,6 +253,59 @@ def _rule(left: Um, top: Um, *, q: WriterQuery) -> list[Mark]:
243253
]
244254

245255

256+
# ------------------------------------------------------------- the weight ladder
257+
258+
259+
def _weight_ladder(
260+
marks: list[Mark], left: Um, top: Um, *, device: object, q: WriterQuery
261+
) -> Um:
262+
"""Sample lines at rising weights, each labelled (§ 8.8).
263+
264+
So a user can see on their own printer or e-ink screen what a given stroke
265+
actually looks like: the media check (§ 12.1) says a hairline is too thin,
266+
this shows *how* thin. On a device each label also carries the pixel width,
267+
which ties the warning to the thing it warns about; on paper it does not,
268+
because a format's `assumed_dpi` is a yardstick, not a resolution (§ 8.3.1).
269+
270+
The lines are shorter than the 100 mm rule, so they never add a width
271+
constraint the rule has not already imposed.
272+
"""
273+
cursor = _line_of_text(
274+
marks, "line weights — how each renders on this medium",
275+
left, top, size=BODY_SIZE, family="sans", q=q,
276+
)
277+
labels = [_weight_label(weight, device) for weight in WEIGHTS_PT]
278+
label_width = max(q.text_width(label, family="mono", size=BODY_SIZE) for label in labels)
279+
line_x = left + label_width + GAP
280+
ascent, descent = q.text_metrics(family="mono", size=BODY_SIZE)
281+
for weight, label in zip(WEIGHTS_PT, labels, strict=True):
282+
baseline = cursor - ascent
283+
marks.append(
284+
Text(pos=Point(left, baseline), content=label, size=BODY_SIZE,
285+
family="mono", layer=Layer.PATTERN)
286+
)
287+
middle = baseline + ascent // 3 # the sample sits at the label's optical middle
288+
marks.append(
289+
Segment(
290+
start=Point(line_x, middle),
291+
end=Point(line_x + SAMPLE_LENGTH, middle),
292+
weight=weight * PT_TO_MM,
293+
layer=Layer.PATTERN,
294+
)
295+
)
296+
cursor = baseline - descent
297+
return cursor
298+
299+
300+
def _weight_label(weight: float, device: object) -> str:
301+
"""`0.15pt` on paper; `0.15pt 0.48px` on a device (§ 12.1)."""
302+
text = f"{weight}pt"
303+
density = getattr(device, "density", None)
304+
if density is not None:
305+
text += f" {weight * density / 72:.2f}px"
306+
return text
307+
308+
246309
# ------------------------------------------------------------------ the summary
247310

248311

pflichtenheft-vorlagengenerator.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,15 +1356,25 @@ kein Fehler, sondern der Normalfall — deshalb:
13561356
13571357
### 8.8 Deckblatt
13581358
1359-
`--cover` erzeugt **eine zusätzliche erste Seite**, die zwei Dinge trägt:
1359+
`--cover` erzeugt **eine zusätzliche erste Seite**, die drei Dinge trägt:
13601360
13611361
**1. Kalibrierquadrat.** Ein beschriftetes Quadrat von exakt 50 mm Kantenlänge
13621362
und eine 100-mm-Strecke, beide mit Sollmaß beschriftet. Der Nutzer legt ein
13631363
Lineal an; stimmt das Maß nicht, hat der Druckertreiber skaliert. Das ist die
13641364
einzig mögliche Antwort auf ein Problem außerhalb unserer Kontrolle (§ 8.2):
13651365
Wir können die Skalierung nicht verhindern, aber sichtbar machen.
13661366
1367-
**2. Einstellungszusammenfassung.** Generator, Format, Ränder, Basiswerte,
1367+
**2. Strichstärken-Leiter.** Kurze waagerechte Linien in einer festen,
1368+
aufsteigenden Folge von Strichstärken (0,1 bis 1,0 pt), jede beschriftet. So
1369+
sieht der Nutzer auf seinem *eigenen* Drucker oder E-Ink-Gerät, wie eine
1370+
gegebene Stärke wirklich aussieht — die Medienprüfung (§ 12.1) sagt nur, *dass*
1371+
eine Haarlinie zu dünn ist, die Leiter zeigt, *wie* dünn. Ist ein Gerät aktiv,
1372+
trägt jede Beschriftung zusätzlich die Pixelbreite (`0,15pt · 0,48px`) und
1373+
verbindet die Warnung mit dem, wovor sie warnt. Auf Papier nicht, weil ein
1374+
`assumed_dpi` ein Maßstab und keine Auflösung ist (§ 8.3.1). Feste Zahlen wie
1375+
Quadrat und Lineal, kein einstellbarer Wert.
1376+
1377+
**3. Einstellungszusammenfassung.** Generator, Format, Ränder, Basiswerte,
13681378
Zyklen, effektive Periode in Marken und Millimetern, Einrastmodus,
13691379
Werkzeugversion und Name bzw. Prüfsumme der zugrunde liegenden Def. Damit ist
13701380
ein gelungenes Blatt Jahre später reproduzierbar, ohne dass jemand die Def

tests/test_cover.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@
1717

1818
import pytest
1919

20-
from ctrlgrid.cover import RULE_LENGTH, SQUARE_SIDE, cover_marks, summary
20+
from ctrlgrid.cover import (
21+
PT_TO_MM,
22+
RULE_LENGTH,
23+
SAMPLE_LENGTH,
24+
SQUARE_SIDE,
25+
WEIGHTS_PT,
26+
cover_marks,
27+
summary,
28+
)
2129
from ctrlgrid.errors import DefinitionError
2230
from ctrlgrid.loader import loads
2331
from ctrlgrid.marks import Layer, Polygon, Segment, Text
@@ -103,6 +111,49 @@ def test_it_is_ticked_at_both_ends(self) -> None:
103111
assert abs(verticals[0].start.x - verticals[1].start.x) == RULE_LENGTH
104112

105113

114+
class TestLineWeightLadder:
115+
"""§ 8.8: a user sees how a stroke weight actually renders on their medium."""
116+
117+
def _samples(self, doc):
118+
return [
119+
m
120+
for m in marks(doc)
121+
if isinstance(m, Segment)
122+
and m.start.y == m.end.y
123+
and abs(m.end.x - m.start.x) == SAMPLE_LENGTH
124+
]
125+
126+
def test_it_samples_every_fixed_weight(self) -> None:
127+
samples = self._samples(document())
128+
assert len(samples) == len(WEIGHTS_PT)
129+
got = sorted(round(s.weight, 6) for s in samples)
130+
want = sorted(round(w * PT_TO_MM, 6) for w in WEIGHTS_PT)
131+
assert got == want
132+
133+
def test_each_sample_is_labelled_in_points(self) -> None:
134+
texts = " ".join(m.content for m in marks(document()) if isinstance(m, Text))
135+
assert "0.15pt" in texts and "0.5pt" in texts
136+
137+
def test_on_paper_there_is_no_pixel_figure(self) -> None:
138+
# A format's assumed_dpi is a yardstick, not a resolution (§ 8.3.1), so
139+
# a pixel width on paper would be a made-up number.
140+
texts = " ".join(m.content for m in marks(document()) if isinstance(m, Text))
141+
assert "px" not in texts
142+
143+
def test_on_a_device_each_weight_carries_its_pixel_width(self) -> None:
144+
# § 12.1 warns that a hairline is too thin; here the user sees how thin,
145+
# in the device's own pixels. 0.15pt at 229 dpi is 0.48 px.
146+
doc = loads(
147+
"version: 1\n"
148+
"page:\n device: remarkable-paper-pro\n"
149+
"generator: lines\n"
150+
"families:\n - direction: horizontal\n base_spacing: 10mm\n",
151+
source="test",
152+
)
153+
texts = " ".join(m.content for m in marks(doc) if isinstance(m, Text))
154+
assert "px" in texts and "0.48px" in texts
155+
156+
106157
class TestSummary:
107158
"""§ 8.8: enough to reproduce a sheet that worked, years later."""
108159

@@ -237,8 +288,9 @@ def test_it_carries_no_pattern(self, tmp_path: Path) -> None:
237288
path = tmp_path / "cover.pdf"
238289
build(document(blocks="pages:\n cover: true\n"), PdfWriter(path))
239290
horizontals = [line for line in pdfread.lines_um(path, 0) if line.is_horizontal]
240-
# Only the calibration rule, never a grid.
241-
assert len(horizontals) == 1
291+
# The calibration figures only — the 100 mm rule and the weight ladder —
292+
# never the generator's grid (which would be dozens of full-width lines).
293+
assert len(horizontals) == 1 + len(WEIGHTS_PT)
242294

243295
def test_the_square_measures_fifty_millimetres_in_the_finished_pdf(
244296
self, tmp_path: Path

0 commit comments

Comments
 (0)