Where the specification
(pflichtenheft-vorlagengenerator.md)
was genuinely silent or internally ambiguous, and a decision had to be made
to write the code. Each entry names the section it belongs to, what was chosen,
and why.
This file is not a place to record disagreement with the specification. Where the specification decides something, it decides it — say so and name the section instead. These are the gaps.
Order is roughly the order they came up.
§ 7.1, § 7.6. The YAML comment in § 7.1 calls extent "quer zur
Laufrichtung", which alone could mean either axis.
Two other passages settle it. § 7.6 says "quer zur Laufrichtung der Linien"
when it introduces radial_extent for the case that runs the other way, and
§ 7.1's slanted-families paragraph says offset and extent are measured
"senkrecht zur Linienrichtung". Both mean perpendicular to the line — the
same axis offset and base_spacing live on.
So an extent selects which lines are drawn. Nothing shortens a line, which also keeps § 2 intact: there is still no way to put a stroke of chosen length at a chosen coordinate.
§ 8.3, § 8.5, § 3.6. Both are settings of the handle's pattern: block, but
the numbers they need belong to the blade: with a cycle like [1, 1, 2] the
leftover does not follow from the base spacing.
The alternative was to pass the pattern block into generate(). Rejected: § 8.3
makes snap an error for staves, grid, maze, tiling, form and
polar, so six of the eight blades would receive a parameter they are required
to reject.
Instead the blade answers a question — periodic_axes(), returning AxisPeriod
objects that can say how much of a given extent the family actually uses — and
the handle shrinks the pattern area and shifts the origin, exactly as § 8.3
describes it ("kann der Musterbereich verkleinert werden"). generate() is
unchanged and blades still know nothing of page geometry (§ 3.3).
§ 8.5. The specification lists end | center | whole_cycles with end
first, which elsewhere marks the default — but the sketch in § 5.2 shows
{x: center, y: center}, and § 8.3 speaks of centring as the ordinary case
("bei center gleichmäßig auf beide Seiten").
Chosen: center. On a 5 mm grid over a 287 mm area the 2 mm leftover reads as a
mistake at one edge and as breathing room split across two. It does not affect
millimeter-a4, where a 1 mm grid divides both A4 dimensions exactly.
Note the asymmetry with snap, which defaults to none for a reason
§ 8.3 states outright: snapping changes the geometry § 8.1 computed. remainder
only moves what is left over after that computation.
§ 8.3. The specification says that with several families on one axis, one
must be marked governing, otherwise it is an error naming the families.
Read literally against a center default (decision 3), every two-family axis
would fail out of the box — and two families on one axis is ordinary, not
exotic.
Chosen: a marked family wins; families that agree on their period need no mark, because there is nothing to disambiguate; only real disagreement is an error, and it names both base spacings. Two marked families on one axis is also an error.
§ 8.3, § 8.5. § 8.3's principle is that whoever writes a setting down
expects an effect, which is why snap on a non-periodic generator is an error
rather than silent ineffectiveness.
Applied to axes: remainder: {x: center} on a definition with only horizontal
families is an error. The scalar shorthand remainder: center names no axis and
stays quiet where there is nothing to place — it means "both, wherever there is
something".
This is why AxisPair carries an explicit flag: the distinction is between
the user naming an axis and the user naming a value for both.
§ 7.1, § 8.3, § 8.5. A family with count or an extent does not fill the
pattern area, so it has no say in how that area's leftover is placed, and
periodic_axes() leaves it out. Snapping the sheet to the single red margin
rule of an exercise book would be absurd.
§ 8.5. The specification says the cut cycle "entfällt" but not where the
space it frees goes. Since the three modes are one enum, whole_cycles cannot
also be centred.
Chosen: it behaves like end for placement — pattern at the origin, space at
the far end.
§ 8.3.1, § 9.1. § 8.3.1 allows it only with a device profile, and those
arrive with M5 — so "arrives with M5" alone would be misleading. § 9.1 is
explicit that assumed_dpi is a yardstick for the media check and nothing else,
and geometry must never rest on a guessed number.
The message says both halves: it needs a device profile, and device profiles arrive with M5.
§ 5.2, § 6, § 3.6. A page background must be painted under everything, but
§ 6 fixes the vocabulary at three layers — pattern, frame, overlay — and
the vocabulary is contract, not an ascending scale.
Since the writer draws in the order marks arrive and never sorts (§ 3.6), being
emitted first is what "underneath" means. So the background is a Polygon on
Layer.PATTERN, emitted before the blade's marks. The page loop reads as the
stacking order itself: background, pattern, border, hole marks, bands, stamp.
§ 9.4. The two modes are "data-driven, one sheet per entry — the default
with --names" and "fixed count, entries repeated or cut". The specification
does not say how to tell them apart when pages.count has a default.
Testing the value would be wrong: count defaults to 1, so a 27-name list
would produce a single sheet. The check therefore asks whether the key was
actually set (model_fields_set), which makes the bare
ctrlgrid millimeter-a4 --names class3b.txt do the obvious thing while
--pages 3 or a pages.count: in the definition takes the count back over.
§ 9.4. Not stated. Every editor leaves a trailing newline, and a nameless sheet is not something anyone means, so blank lines go — including ones left in the middle.
§ 8.8, § 8.2. § 8.8 fixes the calibration figures at 50 mm and 100 mm and says nothing about formats too small to hold them. A6 between its margins leaves 95 mm, so the question is unavoidable.
Shrinking the figures is the one answer that is out of the question: a square
labelled "50 mm" that measures 38 mm would produce exactly the wrong verdict
in the one measurement the page exists for. The run is therefore refused, with
both numbers in the message, and the refusal happens in preflight so that
check reports it and nothing is written (§ 12 point 13).
The summary is treated differently, because it is written by the tool rather than by the user: it shrinks to fit the width and, at the smallest readable size, truncates with the ellipsis § 8.9 makes mandatory. § 8.9's refusal is right for a user's own text — nobody can act on "the tool's own summary line is 2 mm too wide".
§ 8.8, § 11. The flag is a wish for one run. There is no --no-cover: the
absent flag means "the definition decides", and a third state would need a
spelling for "off, whatever the definition says" that nobody would remember.
The same holds for --stamp, which also has no eraser.
§ 8.8. The specification asks for "name or checksum". It gets both, in one
line: a preset copied with ctrlgrid show … > mine.yaml and then bent keeps a
name that says nothing about what changed. Twelve hex digits of SHA-256 over
the definition text — enough for a human comparing two sheets, and explicitly
not a signature.
The cover is otherwise stable input to stable output, so § 10.1's byte-identical promise survives it: no clock, no randomness, and the version string only changes when the version does. That is also why § 8.8 excludes the page from golden comparisons (§ 13.2).
§ 10.3, § 6, § 3.6. Stage 2 has to tell the writer which file to set text
in, and the obvious move — a second field on the Text mark — is the one § 6
forbids: the mark vocabulary is contract, and it does not grow because a
feature arrived.
It does not need to. family was always a string the writer resolves; stage
1's serif | sans | mono are three spellings of it and file:/absolute/path
is a fourth. FontSpec.token produces it, writers/pdf.py resolves it, and
every other writer inherits the same convention for free. The resolved path
goes in, not the raw text: ~ is expanded once, at the seam, so two
definitions naming the same file in different ways embed one font and not two.
§ 10.3, § 12, § 3.3. § 10.3 requires refusing a font whose fsType forbids
embedding, and § 12 requires the refusal to point at the line that named it —
which the writer, three seams away from the YAML, cannot do.
So loader opens every named font file while it still has the document tree,
and attaches field and line; fonts.py parses and judges; writers/pdf.py
only embeds. This also satisfies § 12 point 13 without further work: the file
is opened before any page exists, so check reports a bad font and a run
aborts completely rather than half way through.
fontTools is confined to fonts.py by tests/test_architecture.py, on the
same reasoning as reportlab: a second writer should inherit the licence check
rather than reimplement it. Reportlab could not do the job anyway — it parses
OS/2 for metrics and never reads fsType.
A font with no OS/2 table at all is treated as installable. Refusing every
font from before the table existed would be absurd, and it is what every other
PDF producer does.
§ 5.3, § 7.1. § 5.3 defines a cycle as a list applied "positionsweise auf
die laufenden Marken" and then lists dash: [2, 1] in the same table as
weight: [1, 1, 2]. Taken literally that would mean line 0 is dashed 2 mm,
line 1 is dashed 1 mm — which is not what the row is called: it is called a
Strichel-/Punktmuster, a dash pattern, and a pattern belongs to one mark.
So dash describes one mark and applies to every mark of the family, while
every other cycle steps along the marks. Two consequences follow, and both are
tested: the pattern is identical on every line of a family, and the dash cycle
does not enter the effective period, which counts marks.
style supplies the numbers when none are written: dashed is [3, 2] and
dotted is [0, 2], both against a base_dash of 1 mm. A zero-length
on-segment with a round cap is a dot — the same trick § 10.1 prescribes for
the dots blade, which is why cap is in the mark vocabulary at all. Writing
dash: or base_dash: next to style: solid is an error rather than a
no-op (§ 5.1).
§ 9.1. The format table and the device profiles are stored portrait, "one
single convention for both data files", and orientation turns them. A free
size in a definition is not one of those files, and applying the same
normalisation would break § 9.1's own example: 210x99mm is a wide strip, and
standing it upright hands back a sheet nobody asked for.
So a free size is width first, exactly as written, and orientation: landscape
swaps whatever it finds — which is the same sentence for both sources, even
though "portrait" means the table's convention in one case and "as you wrote
it" in the other.
The unit may stand once at the end (210x99mm, the form § 9.1 uses) or on both
sides (210mmx99mm), because that is what people type. Everything else goes
through the ordinary unit parser, so px still refuses by naming device
profiles (§ 9.2) rather than by looking like a typo.
§ 5.2. The example is image: "logo.png", and nothing says what that is
relative to. The working directory is the wrong answer: a definition and its
logo travel together, and where the shell happens to stand when the command
runs is not a property of the sheet. So paths resolve against the directory of
the definition file, and a definition that arrived as text — a preset — falls
back to the working directory, because it has no directory of its own.
The file is opened in the loader, like a font (decision 16), so the message can name the line and § 12 point 13 holds without further work.
PNG is decided by the signature, not the extension (§ 13). A .png that is
really a GIF is named as such, and a .svg gets a message about SVG rather
than about a broken file — § 13 rules SVG out for a reason, and "unknown file
type" would hide it.
§ 8.9, § 8.4. § 8.9 lays out the three fields and their widths for text,
then adds one sentence about images: the same, only without truncation. So an
image takes part in the width split of § 8.9 with the width its own
proportions give it at the height that was asked for, and cut: true does not
reach it — a logo fits or the run is refused.
The height is checked against the band the same way a font size is (§ 8.4, § 12 point 13) and never derived from the picture, or page 1 with a tall logo would have a different pattern area than page 7.
layout_band therefore returns Mark rather than Text. Nothing else in the
seam moved: Image has been in the vocabulary since M1 (§ 6), which is what
made this a change to two modules instead of five.
§ 3.6, § 7.6, § 12 point 13. § 7.6 requires segment labels to be measured
before rendering, and only the blade can measure them — but only the handle
knows the pattern area, which is what they have to fit into. The obvious move
is to let generate raise when it meets a label that does not fit. That
breaks the rule the whole pre-flight exists for: generate runs while pages
are being written, so the file would already be half there.
So the blade got check(cfg, area, q), called once by preflight against the
area it will be handed. generate is unchanged, as it was through snap,
remainder and describe — four queries now, and the one method that
produces marks has never moved.
This bit for real during M3: the ring-label count mismatch was raised from
generate and only surfaced when a real sheet was built, not by any test.
Both the check and a test that nothing is written now sit in the pre-flight.
§ 8.3, § 7.6. § 8.3 lists the blades for which snapping is an error —
polar among them. That could have been inferred from periodic_axes
returning nothing, and it would have been wrong: "no periodic family along x"
and "snapping does not apply to this pattern" are different sentences, and only
the second may be answered with a refusal that names the generator.
So the blade declares supports_snap, and the handle refuses before it
computes any geometry — no half-fitting answer is produced on the way.
§ 5.3, § 7.6. Mark 0 of a family sits on the origin (§ 3.5), and in polar coordinates the origin is the centre point: a circle of radius 0 is not a mark. The question the specification does not answer is what happens to the weight and colour cycles then.
They count drawn rings. weight: [1, 1, 1, 2] makes every fourth visible
ring heavy, which is what it says; counting the invisible circle at the centre
would put every cycle one step out of phase with what is on the paper.
Two smaller placements go with it, both from what a real sheet looked like: a segment label belongs to the wedge between two spokes, not to a spoke — § 7.6's twelve 30° spokes carry 1 … 12 — and ring labels run up the bisector of the segment that contains straight up, because straight up is where a spoke runs on every family that divides 90°, and a number printed over a line is unreadable.
§ 7.3 against § 6. § 7.3 originally wanted clef glyphs "as stored vector
paths", which would make the PDF font-free. § 6 fixes the mark vocabulary at six
primitives and calls it contract rather than a build stage — and none of the six
is a curve path. Arc and Polygon cannot draw a treble clef; they can draw
something that looks nearly like one, which is exactly the "almost right" sheet
§ 5.1 calls the worst failure there is.
Resolved in § 15.3: a clef becomes a Text mark in an embedded music
font, not a seventh primitive. § 7.3's real goal was not "font-free" for its own
sake but self-contained, and fonts stage 2 (§ 10.3) already embeds and subsets
— so the PDF carries only the few clef glyphs and stays self-contained, with the
six-primitive vocabulary and § 15.2 (no free paths) both untouched. The two
rejected options are named there: a seventh path primitive (against § 6 / § 15.2,
and work for every future writer) and dropping clefs (§ 7.3 names them, and
pre-printed clefs are genuinely wanted). A small OFL font ships, overridable via
font: {file:}.
Built in M9. The shipped font is Bravura subset to the four clef glyphs and
converted CFF→TrueType (reportlab embeds no CFF outlines), renamed off its
reserved font name as the OFL requires. Placement is pure SMuFL — one em is four
stave spaces, so size = 4 × stave_space, and the glyph origin sits on the
reference line, so the text baseline goes there with no per-glyph nudging.
Clefs are refused on anything but a five-line staff (tablature has no line for
them), and — via the capability oracle — on the PNG writer, which has no text.
§ 7.3. Nothing says where a short block of systems sits in a taller pattern area. Music is read from the top down and a page of manuscript normally keeps its spare room at the foot, so the first system starts at the top edge of the pattern area.
Not remainder (§ 8.5): that places the leftover of a periodic family, and
a stack of systems is a group with a gap rule of its own — the same reason
§ 8.3 refuses snapping here.
§ 7.5. The YAML sketch shows min_path_factor: 0.5, and § 7.5 argues well
for the feature: a naive generator regularly produces mazes whose solution is
laughably short behind a complicated-looking picture. What it does not say is
what the default should be — and 0.5 turns out to be unreachable for two of
the three algorithms it also prescribes.
Measured over thirty carvings each, as a share of the cells:
| algorithm | 10x10 | 20x20 |
|---|---|---|
backtracker |
0.44 | 0.36 |
kruskal |
0.23 | 0.14 |
prim |
0.19 | 0.10 |
A single built-in number would therefore be toothless for a backtracker and unreachable for prim, and it falls further as the grid grows. So the demand is the user's to make: the default is 0, the presets set a real one, and the refusal quotes this table so the number can be chosen rather than guessed.
The retry bound is 200. § 7.5 asks for regeneration on a shortfall; it does not ask for a hang, and a factor nobody can reach has to end in an error that says how far it got.
§ 7.5, § 3. solution: separate_page doubles the sheets, makes odd ones
puzzles and even ones solutions, doubles {page_count}, and gives each entry
of a name list two sheets that share a name. All four are properties of the
page loop, which is the handle's (§ 3) — but only the blade knows that they
apply.
So the blade returns a SheetPlan (per_item, and which sub-sheets are
mirrored) and the handle does the rest: it multiplies the count, keeps the
numbering running across every sheet, indexes names by item rather than by
sheet, and mirrors the pattern layer where the plan says so. Default is one
sheet per item, so no other blade has to say anything at all.
Mirroring lives with the handle for a second reason: § 7.5 mirrors about the
sheet's vertical centre, not the pattern area's, because the reference is
the physical turning edge — and a blade never learns where the sheet is
(§ 3.3). mirror_x sits beside translate in marks.py, the one other place
where coordinate systems cross, and it deliberately does not reflect text:
mirrored writing on the back is the one thing nobody wants.
§ 7.8. The table lists them separately: rest is "the remaining space",
an omitted measure is "an even split of the rest among all without one". Those
are the same sentence twice, so they are one implementation: every entry that
is rest or absent takes an equal share of what the fixed and percentage
entries leave.
Keeping both spellings is still right. The omitted one is the commonest case
by far — three equal fields are written by saying nothing — and rest is the
written form for when one field among several sized ones should take what is
left, where silence would read like an oversight.
§ 7.8, § 12 point 13. form is the first blade whose layout depends on
text metrics: a title decides how much room is left below it for the writing
area. That makes the pre-flight more than a check — it has to lay the whole
form out.
So check() runs _place() in full and measures every title, every option
row and every fixed line count against the cells that will actually be drawn,
and generate() calls the same _place(). The two cannot disagree, and a
form that does not fit is refused before the file exists rather than half way
through it.
§ 9.2, § 5.1. px is unlike sp: sp is generator-local and resolved
late, once stave_space is known, so it travels as a wrapper through the whole
seam. px is document-global and its resolver — the device density — is known
at load time, before any section is validated. So it needs no wrapper: the
loader settles the medium first, puts the density in pydantic's validation
context, and the same _as_length that handles every other unit resolves
px against it. On paper the context is empty and px stays an error, because
a format's assumed_dpi is a yardstick and not a resolution (§ 8.3.1).
The medium therefore has to be known before the sections are validated, which
is why _resolve_device peeks the device id from the raw value rather than
from the model — the model is exactly what the density is needed to build.
§ 9.2. § 9.2 provides quirks deliberately, with a concrete example: a
Boox Note drops roughly every 16th pixel row in one view, and a generator
would have to shift lines by ±1 px to survive it. But no shipped profile is a
Boox, and inventing the dead-row pattern for a device nobody here can test is
exactly the guessed number § 9.2 says is worse than no profile at all.
So DeviceProfile carries quirks as a validated, empty tuple, and the field
exists for the first verified contribution to fill — not for me to guess.
§ 12.1. § 12.1's colour finding is "two colours that yield the same grey
value", and its worked example is #7799bb and #4466aa — the millimetre
preset's own grid and accent. But those are 48 luminance levels apart (Rec.
709), not the same: read strictly, the finding would never fire on the case the
spec chose to illustrate it.
So the check flags two colours whose greys are within a threshold, set at 48 so the documented pair fires. The wording is honest about it — "48 grey levels apart, they read as one tone" — rather than claiming they are identical. The consequence for a definition is a clear guideline: on a grayscale screen an accent needs more than ~1/5 of the range between it and the base to survive.
§ 12.1. § 12.1 lists its findings without splitting them by medium, but two of them only make sense on a low-resolution screen. The 1–2px stepped line is about weak e-ink antialiasing — a 600dpi printer renders 1.9px cleanly. The uneven-cells finding (45.08px drawn as 45/46) is visible on a 229dpi screen and imperceptible at 600dpi, where the ±1px swing is 1/600 inch.
Both are therefore gated on a device being active. The sub-1px and under-3px-merge findings stay general: a stroke that rounds away and a spacing that flows into a grey area are as real on paper as on a pad.
§ 8.3.1, § 3.3. snap: pixel is unlike the other two snap modes: they
shrink the pattern area (handle-side) while the blade fills it with its own
unchanged spacing, but pixel snapping changes the step — it rounds every gap
to a whole pixel — so it has to reach where positions are computed, inside the
blade's call to Cycle.positions.
That needs the device to reach the blade, which § 3.3 keeps geometry out of.
The resolution: PageContext grows a pixel_snap field carrying, per axis,
the device density in dpi. A density is a property of the medium, not of the
page's layout — it says nothing about margins or where the pattern sits — so
§ 3.3 holds. The handle computes it in place_pattern, the blade reads it as
page.pixel_of(axis), and only lines and dots (the snap-capable periodic
blades) consult it. generate's signature still has not changed.
§ 8.3.1. Two traps, both from the arithmetic. First, § 8.3.1 says round
every step, not the accumulated position — because rounding accumulated
micrometre positions leaves a uniform 45.08px grid alternating 45/46px, the
very unevenness the mode removes. Cycle._walk_pixels therefore keeps a
running total in whole pixels and turns only the emitted position into µm.
Second, the pixel size must stay exact — 25400 / dpi, not pre-rounded to a
whole micrometre. Rounding 25400/229 to 111µm and multiplying by 45 gives
4.995mm; the true 45-pixel measure is 4.991mm, which is what § 8.3.1 quotes and
what the device actually draws. So the density is carried, not a rounded pixel,
and the exact size is used in the one final rounding to µm.
§ 14, § 3.3. § 14 says imposition "works on finished pages", which reads
like a post-process on the output PDF — and a pypdf-based re-imposition would
be the obvious route. It is not taken, for two reasons. It would make a
PDF-manipulation library a runtime dependency, and it would put page geometry
in a second place outside pages.py.
Instead the page loop renders each logical page to its own coordinates as
before, collects the marks, and the imposed writer translates each whole page
onto its cell of the larger sheet — the same translate that already places
the pattern origin, one level up. "Finished pages" is honoured exactly: the
pattern is untouched and complete before anything decides where it lands. The
writer never learns imposition happened; it draws translated marks like any
others, so a second writer (PNG, M7) gets N-up for free.
Two consequences the specification names are carried here. The cover sheet keeps the page size and its own sheet (§ 8.8) — its 50 mm square must stay 50 mm — and per-page bookmarks are dropped under imposition (§ 14 notes it destroys links): a bookmark would point at a whole imposed sheet rather than the page, which is worse than none.
§ 14. § 14 asks for "optional crop marks" and the § 11.1 flag table does
not name the switch, so this adds --crop-marks, opt-in — most of the time the
pages butt together and the marks would have nowhere to go anyway.
They are cut guides, not a frame: short ticks reaching in from the sheet edge towards each cut line, in the margin strip, never crossing the pages. Where the block fills the sheet in one direction — a 2×2 of A6 on A4 leaves 0.5 mm top and bottom — there is no room, and rather than draw a mark over a page the tool simply omits it on that pair of edges. Every length is clamped to the margin actually available, so a tight sheet gets shorter marks instead of none.
§ 10.2, § 3.6. Every measurement in the pre-flight — band layout, label fitting, the media check, the cover — needs font metrics, and § 3.6 makes the writer the only source of them. That held while PDF was the only writer. The PNG writer breaks it: it has no font file, so it cannot answer a metric.
The fix keeps the seam honest. Metrics are data — the fixed widths of the
standard PDF fonts — not rendering, so the pre-flight measures with a metrics
oracle (a PdfWriter that never opens a file), and consults the real
writer only for its capabilities(). For a PDF run the two are the same
object; for PNG the oracle stands in for measuring while the PNG writer's
missing text capability is what the run is checked against.
§ 10.2, § 14. § 10.2 says the vocabulary is complete from M1 and a writer grows into it, with the pre-flight refusing the missing feature by name. Until M7 nothing exercised this — the PDF writer does everything. The PNG writer is the first that does less (no text), so the check finally had to exist.
It samples one page's marks with the oracle and maps each to the capability it needs, rather than reading the config — so it is right for every blade without knowing any of them. Text has three extra sources the sample would miss because they are the handle's, not the blade's: header and footer fields, the stamp, and the cover's calibration labels, each added explicitly. The message names the missing capability and, for text, the three ways out: a font file, no text, or PDF.
§ 8.8, § 15 point 5. The spec named the feature ("die vollständige Def als Dateianhang") and its purpose (the document carries its own source) but not the mechanics. Four calls were made building it:
- What is embedded is the raw source text, not the resolved model. "Seinen
eigenen Quelltext" is the source the user wrote — comments, anchors and all —
and it is the same bytes the cover's checksum runs over, so the attachment and
the checksum can never disagree. CLI overrides (
--pages, …) are deliberately not folded in; the cover's settings summary already records effective settings, and the two are complementary (§ 8.8). - Its own opt-in, one-way like
--cover.pages.embed_def/--embed-def, independent of the cover — a user may want the attachment without a cover page or the reverse. The flag only ever switches it on (§ 11), the same reasoning as--cover: there is no--no-embed-def, because it would be a second spelling of "the definition decides". - The objects are built by hand, and two obvious keys are left off. reportlab
has no filespec support ("skipping filespecs" in its own source), so the writer
emits an
EmbeddedFilestream, aFilespec, and the catalog's/Names /EmbeddedFilesname tree itself. No/Subtype: reportlab'sPDFNameleaves the MIME slash raw (/application/yaml), which a strict parser reads as two name tokens and rejects — verified with pypdf — and Subtype is optional, so the.yamlfilename carries the type instead. No/AF(the PDF 2.0 association array): reportlab'sPDFCatalogserialises only a fixed key set and silently drops it — verified with pikepdf — and the name tree is what viewers actually read on PDF 1.4, which is the version this writer targets. - PNG refuses by name. Carrying a file is a writer capability like text
(§ 10.2). PDF declares
attachment, PNG does not, and the pre-flight refusesembed_defon PNG with the way out (drop it, or output PDF) rather than dropping the attachment silently.
The filename comes from the source's basename, with .yaml appended when it has
no extension — a preset name like millimeter-a4 is not a filename until it does.
§ 7.11. Making mandala more varied added three families (petals,
beads, and a widened rosette). Three calls were made where § 7.11 was silent:
- A motif ring is single or a list.
rosette,petals,beads,scallopsandpinwheeleach accept one mapping or a list of them (Rosette | tuple[Rosette, ...]), so a band can be stacked at several radii. A single mapping still validates, so the old example and preset are untouched — backward compatibility over a second plural key.polygonsstays a plain list, as it already was. A_as_ringshelper normalises to a tuple sogenerate/check/describe/_max_reachiterate one shape. - A petal is two arcs, not a new primitive. § 6 fixes six primitives and
§ 15.2 forbids free paths. A pointed leaf is two circular arcs meeting at a
base and a tip:
_arc_geometryturns two endpoints and a sagitta into a centre + radius + start + sweep for our existingArc. So the most iconic mandala element is added with no seventh primitive — the same discipline that settled the clef (§ 15.3). beadsintroducesDotto the blade, noted honestly. § 7.11 listed the mandala marks as Arc/Segment/Polygon.Dotis a core primitive (§ 6), so a bead ring is not new vocabulary, only a mark the blade had not used; the spec's § 7.11 mark line is extended to say so rather than adding it silently.
Two later families joined on the same terms. scallops — a wavy ring — is N
arcs between adjacent base points, each reusing _arc_geometry with the side
that bulges away from the centre (inward flips it to cusps); no new geometry,
the petal's own two-arc helper. pinwheel — small polygons turned round a
ring — is polar_point + Polygon, the polygon family's own tools. Both are
single-or-list like the rest.
The area check follows the existing rule (§ 8.2, § 12 point 13): each ring
reports its reach — petal tip (outer, with a lateral term for a fat leaf),
bead ring (at + size/2), scallop lobe (at + depth, or at for cusps),
pinwheel (at + size) — so a motif past the pattern area is refused before
page one, named, never clipped.
§ 7. A calendar is the first generator that is not a blade (one pattern area, no knowledge of pages, § 3.3). It owns pages and their links. Rather than bend the blade model, four calls were made:
- A second seam, not a warped first one. A document generator offers
pages(cfg, area, q) -> Iterator[DocumentPage]instead ofgenerate. The handle detects it by thepagesmethod and takes a separate write path (no cover, frames-as-cycle, imposition, or snap). Existing blades are untouched;generatenever changed. Rejected: a blade asking for 380 pages viasheets()(it would need its page index and type, breaking § 3.3), and emitting many sub-definitions to stitch (heavy indirection, a global link pass). - Links are a capability outside the six primitives, like
outline. A link is a PDF annotation, not ink. A bookmark already lives outside the six primitives as a writer method; a link follows suit —define_dest/linkon the writer, a"link"capability (PDF yes, PNG no, so a calendar to PNG is refused by name). The visible part of a link is an ordinaryTextplus aSegmentunderline the page also emits, so § 6 stays a contract of six and the bytes stay minimal. No seventh primitive. - One page per view; paginate, do not shrink. Every view fills its page and refuses (not scales) when it cannot fit at a readable minimum — the same discipline as the cover (§ 8.2). The one unbounded set, the notes, paginates its numbered index over as many one-page sheets as the count needs rather than shrinking rows.
- Names from the definition, English default. Month and weekday names are
taken from the def (§ 7.8's language-neutrality), never a shipped locale
table; dates are computed from the year with the standard library, no
wall-clock (§ 10.1).
{year}is a document-supplied header placeholder, threaded throughresolve_placeholders/layout_bandas anextramap so blades are unaffected.
The view set was reshaped after use: the Quarter view (three mini-months × four
pages) was dropped for a single-page full-year overview. A first attempt (a
linear 53-week grid) read as scattered, so the overview is twelve mini-months,
three across — numbers only as links (day → day, month name → month), no cell
boxes, everything on one page (the reader zooms). The title page grew an optional
logo, resolved relative to the definition via a new base_dir in the blade
config context (the same anchor the handle gives band images), validated at load
time so a missing file is refused before page one. The old two-table
year page split into two full-page half-year tables, and a title page
(full-sheet colour) and contents page lead the document. Weeks stayed
opt-in, aligned to week_start (not ISO, which assumes Monday), with out-of-year
edge days shown without a link. The title page needed the handle to paint a
full-sheet colour a document generator cannot reach itself (§ 3.3), so
DocumentPage gained background (the colour) and plain (no header) — the
generator names them, the handle does the sheet geometry. The one thing still
deferred is holidays from a file — inline lists work; a file needs a
def-relative path resolved in the blade's own validation, unlike the handle's
images.
- Rounding is ties-away-from-zero, not Python's banker's rounding, and runs
in
Decimal. § 14's byte-identical requirement makes tie behaviour a correctness question, not a preference (§ 3.3). noneand an absent key mean the same for colours, since § 5.1 listsnoneamong the keywords that stand where a measure could.- Mark 0 of a family sits on the pattern origin — bottom edge for a horizontal family, left edge for a vertical one. The only choice consistent with the bottom-left origin of § 3.5.
- The
autostamp size targets 80 % of the sheet width, measured throughtext_widthrather than guessed. A diagonal word touching both edges would be clipped by the non-printable border on nearly every printer (§ 8.6). - Outline keys come from the page index, never a counter or random value, or the table of contents alone would break § 10.1's byte-identical guarantee.
- Angles run in micro-degrees inside
polar, for the reason § 3.3 gives for micrometres: a full turn is then the exact integer 360 000 000, so twelve 30° spokes close the circle instead of ending at 359.999999°. It also lets the angular family go through the very sameCycle.positionsas a spacing. typer0.27 vendors click astyper._click. Declaringclickas a direct dependency would install a second copy whose exception classes typer never raises, socli.pyavoids needing it at all — the preset-as-command dispatch overridesTyperGroup.parse_argsinstead.
holidays_file resolves in a CalendarConfig model_validator(mode="before"),
not a loader function like read_names. Reason: read_names is CLI-flag-driven
(--names), holidays are def-driven like logo; the before-validator is where
base_dir is in the validation context and the raw fields are still dicts, so
the normal Holiday validation runs on the merged result. The .ics parser is
hand-written and deliberately narrow — concrete VALUE=DATE events only;
recurring/timed events are skipped and counted, never silently dropped (§ 5.1).
Merge rule: inline wins on a shared date (hand-authored beats a feed). The
UTF-8/CP1252 decode is replicated from read_names rather than imported, to
avoid a generator→loader import cycle; the shared contract is the message text.
The title page's background PNG and its optional header/footer are drawn by the
handle, not the generator, for the same reason the background colour already
is: only the handle knows the sheet (§ 3.3). DocumentPage carries the resolved
image path and fit; pages.background_image_rect computes the cover/contain
rectangle in integer µm (determinism, #5), and the PDF MediaBox — not an
explicit clip — crops a cover overhang. plain (all-or-nothing) became
independent show_header/show_footer so the cover can show one band alone.
The image is validated at load time like logo, via a shared _resolve_def_image
helper. Header/footer band colours are deliberately out of scope — a later
step gives each band its own background and text colour; until then contrast on
a dark cover is the user's to manage.
The header/footer background strip and text colour are produced inside
layout_band, the single function both the blade and document pre-flight paths
call, rather than as a separate handle mark prepended at each of the four call
sites — one site would eventually be forgotten (§ 5.1). layout_band gained a
required sheet_width (the only fact it lacked for a full-bleed strip); a
required, not defaulted, parameter so a new caller cannot silently omit it. The
strip spans the full sheet width but only the band height, not the gap, and is
the band's first mark so the text paints over it (Layer is paint-order, not
sorted). Both fields default None — byte-identical to before.
The contents page was two page-wide columns anchored under the nav strip. It is now a single column, its width measured from its widest entry and centred on the sheet, the three groups (overviews, months, notes) separated by whitespace rather than rules, and the whole block centred vertically between the nav strip and the foot. Reasons: a contents page is read, and one common left edge gives the eye a single edge to follow — and a wider tap target on a pen device; whitespace already says "different kind", so a rule would be ink that says nothing.
The cost is real and is why this is recorded: one column holds fewer entries than
two, so a very long notes index (roughly notes.count above 390 on A4) no longer
fits one page. § 8.2 admits no scrolling and no shrinking, so check refuses it
before page one, naming the height needed, the height available and the number of
index entries. A definition that built before can therefore now be refused — the
deliberate trade for a contents page that is read rather than scanned. The height
arithmetic lives once, in calendar_layout.contents_height, used by both the
drawing and the refusal so the two cannot drift.
The specification was silent on a printed edge scale; the design settled with
the user (docs/superpowers/specs/2026-07-25-edge-ruler-design.md) makes four
calls that could each have gone the other way.
Zero at the pattern origin, not at the paper's corner. A scale zeroed on the sheet would measure the paper, which the cover sheet already does with its 50 mm square and 100 mm rule (§ 8.8). Zeroed on the pattern area it measures what is drawn on the sheet: the first grid line is 0, and reading a position off the grid needs no subtraction.
In the margin, reserving nothing. The alternative — a band-like strip that
shrinks the pattern area — always fits and never refuses, but it changes the
geometry of every definition the moment the ruler is switched on. § 8.1 already
decided this question for border, and one rule for both is worth more than the
convenience: switching furniture on moves no grid line. The price is the refusal
when the margin is too narrow, and that price is paid loudly, in millimetres.
Physical edges, not inner/outer. Margins are named for the binding
because they follow it under duplex (§ 8.1). A scale is a thing at the edge of
the paper and does not; edges: [bottom, left] therefore means those two edges
on both sides of a sheet. The drawing still comes from the placed geometry of
the page in hand, so the scale follows the pattern area when duplex moves it.
Rotated numbers on the vertical edges. Upright numbers would need the full width of "170" in the left margin — three to four times the height of a digit — so the same definition would fit at the bottom and be refused on the left. Turned 90°, all four edges need the same strip, and a scale read bottom-up is what a drawing ruler looks like anyway.
Two smaller ones, recorded because they are the kind that get "simplified" later:
the tick ladder and the label gap are fixed measures, like the cover sheet's
figures — a yardstick nobody can bend is the point of a yardstick; and the height
of a number is asked of the writer (text_metrics), never taken as a
proportion of the font size. The width that decides whether two numbers collide
is measured the same way. The whole arithmetic lives once, in ctrlgrid/ruler.py,
because the drawing and the pre-flight both need it and two copies drift.
§ 7.1 specified slanted families — perpendicular spacing, perpendicular offset and extent, clipping, no snapping — and said nothing about where line 0 sits. It sits on the pattern area's origin, which is where mark 0 of a horizontal family already sits. One rule, generalised, rather than two.
The consequence is that an unlimited slanted family has to grow to both
sides of line 0, since the line through one corner leaves half the sheet
behind it. For horizontal and vertical families the whole area lies on the
positive side, so their output is byte-identical to what it was — and the test
that says so compares 0deg against horizontal and 90deg against
vertical mark for mark. If the general case cannot reproduce the special one,
there are two rules again.
Rejected: anchoring at the corner with the smallest perpendicular coordinate (the anchor jumps from one corner to another on a one-degree change, so a family moves across the sheet when the user nudges the angle), and anchoring at the area's centre (a second rule for where mark 0 sits, contradicting the one horizontal and vertical follow).
Three smaller calls fall out of it. Downwards is the cycle read backwards,
not the forward positions negated, so spacing: [2, 1] stays symmetric about
line 0; the existing position formula already does this for negative indices,
because Python's floor division walks into the previous cycle. Negative
perpendicular coordinates are legal, so extent: { start: -40mm } means what
it looks like. And the perpendicular's sign points towards the area, which
is what makes 90deg count into the sheet exactly as vertical does; when the
centre lies exactly on line 0 — a square's diagonal — the canonical 90° turn
stands, so the choice is never arbitrary.
Snapping needed no new machinery: a slanted family reports no periodic axis at
all, so § 8.3's existing handling applies unchanged and the handle never learns
what an angle is. governing: true and law: log10 are refused on a slanted
family for the same reason — both name an axis that is not there.
§ 12.1 measures a definition against its medium by reading marks, and it read
them from a blade: build the geometry, take page 0's context, call generate.
A document generator refuses generate by design — it owns pages — so the check
raised on one, and the document pre-flight never called it. A calendar's weights
and colours were therefore measured against nothing at all, which the new
"every shipped preset is clean on its own medium" test found by having to skip
calendar-a4.
The sample now branches on the document seam and walks pages(), keeping one
mark per distinct stroke width and colour. Two calls inside that are worth
recording:
Every page, not the first of each kind. DocumentPage.kind is right there
and sampling one page per kind would be nine pages instead of 456. It would also
be wrong: a marked day carries its own colour and appears only on the pages
holding that date, so a birthday in May would go unmeasured while January passed.
The full walk costs about 0.17 s once per run — a calendar build went from 1.6 s
to 1.8 s — and the memory stays at a dozen marks because only distinct
(weight, colour) survive.
A page's background counts as a colour. The title page's full-sheet fill is
a page property the handle paints (§ 3.3), not a mark, so a mark-only sample
would miss it — and on a grayscale screen that fill is exactly what goes flat.
The findings are real, not theoretical: a calendar on a landscape reMarkable 2
now says that its link blue #2f5686 and a birthday's #cc4488 are 21 grey
levels apart and will read as one tone. Nothing said that before.
A blade produces marks for one pattern area; a document generator produces pages. A notebook needs both, and the question was who calls the blade.
The notebook never does. DocumentPage may carry a Fill — a generator's
name and its already-validated config — instead of its own marks, and
pages.document_page_marks calls that blade on the handle's side. One function
answers "what is on this page", and the writer, the capability pre-flight and
the media check all use it; three answers would drift the first time a page kind
changed.
Rejected, and worth recording because it is the smaller-looking option: the
notebook importing the registry and calling generate itself. It adds no
mechanism, but a generator would be doing handle work — and the moment a section
wanted snap or a leftover placed, the notebook would have to rebuild the
geometry machinery the handle already has. Also rejected: no generator at all,
with sections: as a handle key beside pages:; the page plan really is handle
territory, but a linked contents page never has been.
Three consequences fell out of it, each small and each deliberate:
- A section is a definition in miniature —
generator:plus that generator's own keys. A before-validator moves the blade's keys out and hands them to itsconfig_modelwith the loader's validation context, sopxand%wresolve in a section exactly as they do anywhere else and seam 1 stays one seam. - A document's bands are laid out per page (see the commit before this one).
{page}counts, and{section}is a per-page placeholder the page itself carries, because only the generator knows which section a page belongs to. Every page of a notebook answers{section}— including the title and the contents — since an unknown placeholder is an error and must stay one. page_layout.Pageis shared, extracted fromcalendar_layoutwhen the notebook needed the same builder, the waypolar_geometryis shared bypolarandmandala. The calendar's tests passed untouched, which is what made it an extraction rather than a rewrite.
net could have been written the way a person draws one: trace the outline,
then draw the creases inside it. Instead a style produces panels and one
rule turns them into marks — an edge two panels share is a fold, an edge only
one has is a cut.
The reason is that the first way has to be right twice. An outline and a crease list are two descriptions of the same box, and the moment a style changes, one of them is stale — a net that looks plausible and does not close, which is the failure class § 5.1 names. With panels there is one description, and the distinction is computed.
The matching is exact rather than tolerant: positions are integer micrometres
(§ 3.3), so two panels either share an edge or they do not, and there is no
epsilon anyone has to tune. What that costs is a rule every style must keep —
a flap spans its attachment edge completely and tapers only on its free
side, or its fold edge would only partly coincide with its neighbour's and the
crease would come out as two cuts. A carton is die-cut that way in any case, so
the constraint costs nothing real. Three panels on one edge is an
AssertionError, not a DefinitionError: no user can write it, only a wrong
style.
Two conventions had to be chosen, and both are written in § 7.14 and tested in
both directions rather than left in the arithmetic: dimensions are inner
(the number a user has is the thing that must go in the box), and thickness has
one rule — a panel that closes over a layer is widened by it, a flap that slides
inside one is shortened by it. thickness: 0 reduces every style to its ideal
net, and the test that says so is what keeps the rule from quietly growing
special cases.
52. A document generator carries the page model; three keys it refuses by name (§ 8.1, § 7.12, § 7.13)
Decision 42 gave a document generator its own write path — "no cover, no
imposition, no snap" — and that sentence was read as settling more than it did.
_build_document grew as a second, thinner page path and never gained the frame
the blade path draws, while the loader went on accepting every key. Measured
before the fix: a notebook's page content streams were byte-identical with
border, stamp, hole_marks, ruler and page.background all switched on
and with all five removed, and duplex left every page laid out as a front.
Two things had to be decided, and the second is the one that matters.
Which keys a document takes. All of the page model. § 8.1 puts background,
border, hole marks, the edge ruler and the stamp on the page, not in the
pattern — and a document generator owns pages. A planner gets punched and a
notebook gets bound, so these are not exotic on exactly the two artefacts that
have them. duplex most of all: it is the reason a notebook wants a wide inner
margin, and it was doing nothing.
Where that lives. In one function, pages.page_furniture, called by both
paths — not in a second copy inside the document path. This is the same call the
codebase has now made four times (layout_band, _LeavingOutWhatItCannotDraw,
document_page_marks, and now this), and the evidence for it is this very bug:
six features existed on one path and not the other, silently, for two whole
generators. A feature added there now reaches both paths or neither.
The order of drawing changed as a consequence: the bands paint after the
page's content, which is the blade path's order and § 6's layer order (pattern,
then frame). Bands and pattern do not overlap, so the sheets are unchanged —
verified by rasterising examples/15-notebook.pdf before and after at 72 dpi and
comparing page by page: twelve pages, no pixel different, identical operator
counts and text tokens. Only the bytes moved.
Three keys are refused by name instead of accepted and ignored, using the mechanism § 5.1 provides for exactly this:
--nup. Imposition works on the page loop a document does not run, and § 14 notes that imposing destroys links — which is most of what a calendar is. This one was worse than silent:cliprinted the imposition summary fromdocument.nupwithout asking whether it had been applied, so a run reported "imposed 2x2 on a4 — 1 sheet(s), at 100 %" over three plain A4 pages.--cover. A document writes its own first page;title_pageis the place for one.pattern.align. It anchors a pattern within its area (§ 8.5), and a document page is not one pattern area — § 7.13 already refuses it per section for the same reason.
A fourth refusal, and this one was deliberately temporary — lifted by
decision 55 on 2026-07-26. A notebook section
whose blade states a SheetPlan with more than one sheet per item — a maze
with solution: separate_page — was refused. On the blade path the handle
carries such a plan out (decision 27); the document path never asked, so
solutions printed before their puzzles, and adding a title page silently
redrew every maze because unrelated furniture shifted page.index. § 7.13 said
nothing about what a per-section sheet plan should mean, so it was refused until
it did rather than answered wrongly. Honouring it per section was a real design
question, not an oversight, and it stayed open until § 7.13 answered it:
decision 55 — pages: counts items on both paths, and a section's blade is
handed a page context of the section's own, which is what makes the parity and
the seed right.
53. The language of the sheet: the tool receives words, it does not know any (§ 7.12, § 7.8, § 10.3)
Three changes on 2026-07-26 that are one decision, and they came out of a question the user asked rather than out of the audit: can any Latin-script user set this up freely now? The answer was no, in three places.
A generator's text was never checked for missing glyphs. § 12 point 13 asks
the pre-flight to measure "Kopf-, Fuß- und Beschriftungstext" and then
whether every glyph is present; missing_glyphs was called from exactly two
sites, both of them the bands. So months: [styczeń, …] passed check,
reported a successful run, and put stycze and a box on the paper — the silent
almost-right of § 5.1, in the one place the tool had a query built to prevent it.
check_text_glyphs collects characters as a set per font over the marks —
page 0 for a blade, every page for a document (decision 49's reason: a month name
only appears on its own month's pages) — so a 386-page planner with tens of
thousands of glyphs is checked against a few dozen distinct ones and the run
still takes a second.
The tool wrote its own words on the user's sheet. § 7.12 had applied § 7.8 to
the names and not to the vocabulary, so a German calendar came out with
Jänner under Index Year Month Notes. words: names the nine, defaulting to
today's English so no definition moves. Rejected, and worth recording: shipping
month/weekday tables for common locales. It would be a translation layer by
another name (§ 3.4), it would need maintaining, and the very first user whose
language was not in it would be worse off than today.
A document generator could not name a font, which turned the first change
from a fix into a trap: refusing a character with no way to draw it is worse
advice than it looks. CalendarConfig and NotebookConfig take a font: like
every blade, reaching the pages through page_layout.Page, which now carries the
family token — so all seventeen call sites in calendar_layout and the
notebook's three follow without knowing they do. One font per document: there
is nothing on these pages that wants two, and the alternative (a font per view)
would be a typographic system nobody asked for.
The order these were done in is the lesson, not the code. Refusing the silent failure first and supplying the remedy second left a window in which a Polish calendar was less usable than before. Both belong in one step.
The specification described --nup and nothing that folds. Booklet imposition
was the one feature genuinely absent from it, and building it turned out to need
no new geometry at all: a booklet is a 2×1 imposition, so check_fits,
cell and crop_mark_segments hold unchanged. What it adds is which page goes
in which cell.
So impose.slots() answers that for both kinds of imposition, and
_write_imposed lost its own chunking loop. One description of "what is on this
sheet", for the same reason layout_band, the writer wrapper,
document_page_marks and page_furniture each exist: two descriptions drift the
first time either changes. Imposition gained one field, booklet, which only
slots reads — it sits beside crop_marks because the dataclass is a request
with its sheet, not the geometry.
Four calls were made where the specification was silent:
- A page count off a multiple of four is padded and reported, not refused.
Refusing is the more characteristic answer for this tool, and it is the wrong
one here: the blank leaf exists the moment paper is folded, so nothing is
invented, and no measure is changed, so § 8.2 is untouched. § 5.1 asks only
that it not happen silently. The padding never reaches the page loop, so
{page_count}still reports what the user asked for, and a padded cell draws nothing at all — not even the band that would have printed a page number on a leaf nobody filled. --bookletis its own flag, not--nup 2x1 --booklet-order: two pages per sheet follow from folding once and are not the user's to vary. The sheet keeps one spelling,--nup-sheet.- One turning edge, named in the run report, no switch. The same discipline
§ 8.2 applies to print scaling and § 7.5 to
back_mirrored. A switch would need a right default anyway, and its second setting would be untested. § 15 point 6 now namesimpose._foldedas the place it would attach. - Documents are refused, inheriting decision 52 — a hand-folded notebook is the obvious casualty, and it was weighed rather than overlooked. Allowing it would make one rule a rule with an exception and would put the sheet order on the document path.
One usability call the design had not foreseen and the plan added: a booklet
always needs a landscape sheet, and the format table stores sizes portrait
(§ 9.1), so --nup-sheet a4 can never be right. Rather than turn the sheet
silently — that would be the tool deciding against § 9.1's one convention — the
fit refusal names the free size that works (296x210mm for A5 pages), so the fix
is a copy-paste instead of arithmetic the user repeats.
A note for whoever next falsifies a probe here, because it cost a confused
minute: swapping front and back in _folded is a length-preserving edit.
Both writes landed inside one second, so Python judged its cached bytecode still
valid and kept running the mutated version after the revert — git reported the
tree clean while the tests stayed red. Clear __pycache__ before believing the
result of a length-preserving mutation.
Decision 52 refused a maze with separate solution pages inside a notebook
section, deliberately and temporarily: § 7.13 did not say what a per-section
sheet plan should mean, and the wrong answer had printed solutions before
their puzzles and let a title page silently redraw every maze.
Measured before designing, and it made the answer smaller than the problem
sounded: maze is the only blade with a sheets() at all, and no shipped
preset or example uses one in a notebook, so nothing committed could move. What
was actually broken is narrow — maze.generate reads page.index twice, for
the puzzle/solution parity and for the item number behind the seed, and inside a
notebook that index counted document pages.
So the fix is one idea rather than a mechanism. A Fill carries the page's
index within its section, and document_page_marks builds the page context from
that. § 7.13 already called a section "eine Definition im Kleinen"; this makes it
true of the one thing that mattered. Both readings come right at once, and a
section is stable against everything before it.
Four calls, each of which could have gone the other way:
pages:counts items, not sheets, exactly as § 7.5 reads--pageson the blade path. The alternative —pages:meaning sheets in a section and items on the blade path — is one word with two meanings, and it would have needed a rule for an odd count as well.back_mirroredis honoured, not refused, by inserting a blank leaf where the parity needs one. Refusing was the smaller change and was rejected because the pairing is computable: the tool would have pushed arithmetic onto the user that it can do itself. What is not acceptable is doing it silently, so the run report names every insertion (§ 12).- That leaf is a page. Deliberately the opposite call to the booklet's padded
cell (decision 54), which draws nothing at all because it is the absence of a
page. This one occupies a number a reader turns past, so it carries its bands,
answers
{section}and counts in{page}; only its pattern area is empty. Two cases that look alike, two answers, both written down so neither is copied onto the other by mistake. - Mirroring is applied in
_document_content, not indocument_page_marks. The first answer was the latter — the one function that says what is on a document page (decision 50) — and it is wrong: that function yields area-local marks, while § 7.5 mirrors about the sheet's centre, the physical turning edge. It goes where a document page's marks are translated onto the sheet, which is where the blade path mirrors too. The other three callers therefore see an unmirrored page, and that is correct rather than tolerated: a reflection preserves a mark's kind, weight, colour and text, which is all any of them looks at.
§ 7.5's own condition — back_mirrored needs duplex: false or equal
inner/outer — existed on the blade path only. The notebook now answers a
mirrored_sections query and the document pre-flight asks it: a question the
handle asks a generator, the shape periodic_axes and sheets already have.
Worth recording is where that refusal was written first: on the blade path, where
it could never fire, because preflight returns into _document_preflight for a
document long before reaching it. The test said DID NOT RAISE, which is what it
was written for.
§ 15 point 6 had stood open since M2 with an estimate attached: a switch for the
other turning edge "wäre eine einzige Fallunterscheidung beim Spiegeln". Building
it for --booklet showed the estimate was wrong, and the reason is worth keeping
because it is a fact about paper rather than about this code.
A booklet sheet is landscape with a vertical fold. Turned about its short edges it pivots on a vertical axis: left and right exchange, up stays up, and the back is drawn upright with the pair swapped — that is the default and what was already built. Turned about its long edges it pivots on a horizontal axis: left and right stay where they are and the sheet comes out upside down. The only way the reader sees the back the right way up is to print it upside down.
So the switch needed a half turn of whole imposed pages, and mirror_x cannot
give one: it deliberately does not reflect text (decision 27, § 7.5), because
mirrored writing is what nobody wants. Upside-down writing is exactly what is
wanted here, since the reader turns the paper. marks.rotate_180 is therefore
the third transformation in the vocabulary, and the first that carries text with
it — angle gains 180° and the anchor moves, while align is left alone,
because a rigid half turn puts a left-aligned label's box exactly where the
rotation sends it.
The fold order is the same for both edges, and getting that wrong first is
the part worth recording. The obvious implementation swaps the back's two pages
and rotates the side — and that exchanges the halves twice, so page 2 lands
back where it started. A half turn already exchanges them. slots therefore
returns one order and Imposition.rotates(side) says which sides are turned; a
test asserts the two orders are identical, so the mistake cannot come back.
Two smaller calls: --booklet-flip takes short | long and refuses a third
spelling as a typo rather than a wish, and it is refused without --booklet,
like --nup-sheet. The run report names the chosen edge and the other switch
as the way out, rather than always saying "short" — a report that keeps naming a
setting the run did not use is worse than none (§ 12).
One test-infrastructure bug fell out of this and would have bitten anything that
ever measured rotated text. tests/pdfread.texts_um read the text matrix
only, and reportlab draws rotated text by turning the CTM and then setting
the string at the origin. So every rotated string had been reported as upright
and sitting at (0, 0) — silently, and its docstring claimed the opposite. It now
tracks q/Q/cm and composes the two matrices, which also makes § 8.12's
rotated ruler numbers measurable for the first time.