Skip to content

feat(templates): draw the sections a preset has no slot for - #588

Merged
DemchaAV merged 13 commits into
feature/cv-constructor-layerfrom
feat/cv-leftover-sections
Aug 20, 2026
Merged

feat(templates): draw the sections a preset has no slot for#588
DemchaAV merged 13 commits into
feature/cv-constructor-layerfrom
feat/cv-leftover-sections

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

The three two-column presets place sections into a designed layout — a portrait sidebar,
a main narrative — and rendered the six or seven categories they thought to ask about. An
"Awards", a "Publications", a runtime module the catalogue has no role for: never looked
at, and invisible in the output, because the page came out looking finished. That was the
last silent loss in these presets, and it could not be fixed while their body was one
atomic row: there was nowhere to put an extra section.

What

Each preset now ends its main column with whatever no slot claimed, under the heading its
author wrote — the only label that can be right for a category the preset does not know
about.

The two pieces already existed and were not joined: SectionAllocation tracked claims and
could say what was left; SectionRouter knew how to lower a runtime module into the shape
a slot draws. So the router's finders were split into which section + what shape (the
existing statics delegate, no call site changed), SectionAllocation gained the four
shaped claims, and SectionRouter.naturalShape(...) answers the question a leftover raises
and a slot never does: what shape did this section's own author ask for.

A claim is provisional, which is what makes the tail complete rather than nearly so. A
slot's renderer is written against one section type and returns on anything else, so a
keyword can match a section of the wrong shape — "Selected Projects" written as timeline
entries matching a projects slot that draws rows. An unconditional claim would print the
heading, print nothing under it, and keep the section out of the tail: the same loss, one
layer in. It would also have newly lost content, because a second slot's independent
match was sometimes the only thing rendering such a section at all. A section whose lowered
shape is not the one the slot draws is handed back instead. The same guard stops a module
that names a role and carries nothing from shadowing the section that has the content.

Tests

LeftoverSectionTest runs all three presets over a CV carrying four unrecognised
categories — prose, rows, entries, and a module whose role is OTHER — and asserts each
reached the PDF under the author's heading; that a claimed section is still drawn exactly
once; that a section a slot matched but cannot draw still reaches the page; and that an
empty role-bearing module leaves the real section in its slot rather than in the tail.
SectionAllocationTest gains coverage of all five naturalShape arms, of which only one
was executed anywhere before.

Removing the tails, and each half of the provisional claim, were separately verified to
turn cases red.

Baselines

Mint Editorial's is re-recorded, and this is worth knowing: the canonical sample carries a
Projects and an Additional Information section that Mint has no block for, so the tail
draws content it previously dropped. The pixel gate did not catch that — the change is
12,370 px on page 1 and 7,634 on page 2, both under the 50,000 budget — so it was found by
diffing the render against the committed PNGs rather than by the suite going red. The other
two presets' baselines are unchanged, and the committed example previews are unaffected
(their fixtures map onto the slots these presets have).

Not in this change

These presets still print their own label over some claimed sections, so a heading the
author wrote can be renamed — SidebarPortrait labels all six of its slots, and a
"Certifications & Awards" routed into its education slot reads as "EDUCATION". That, plus a
CvRenderKit per preset, is what still keeps them off CvTemplates.modular(); the fidelity
gate rejects them today for exactly that reason.

The post-release bump always opens the next patch, so the train sat on
2.2.1-SNAPSHOT. The next release adds public API to graph-compose-templates,
which makes it a minor — and an @SInCE tag written while the poms and the
CHANGELOG name different lines has two answers to choose between, one of
which outlives the cycle.

The CHANGELOG heading records the real next version and the thirteen train
poms follow it, which is the order VersionConsistencyGuardTest pins. The
README development line and the roadmap's note of when the templates gate
ships move with them. The install snippets stay on 2.2.0 — the version
actually on Central.
The four CvSection records each fix one shape when the code is written,
which is right for a CV written in Java: you pick the record and the
compiler checks it. It is the wrong model when the CV arrives as data. A
user who has just chosen "Volunteering, shaped like Education, with
dates" cannot instantiate a different record per choice, so every shape
anyone thought of would have to become a type — and the shapes nobody
thought of stay impossible.

ModuleSection carries the choice as a value instead. One CvItem holds
every optional field — title, link, subtitle, period, location,
description — and a CvKind decides which of them are read: the same item
renders with or without its dates depending on the kind alone. BodyStyle
decides whether a description reads as prose or as bullets. SectionRole
states what a section means, separately from how it draws, which is the
input the sidebar routing needs and cannot get from an English keyword
list; no preset reads it yet.

ModuleRenderer lowers each kind onto the renderers this package already
ships rather than drawing anything itself, so a module and the
hand-written section carrying the same content are two spellings of one
document. ModuleSectionParityTest holds that node-for-node — layout
snapshot for structure, extracted text for content — for every kind.

Inline versus stacked bullets is the module's choice (BULLETS,
BULLETS_STACKED) rather than something inferred from how long a
description happens to be: a section reads one way throughout, and an
author who asked for descriptions underneath gets them whether the first
entry is one line or five.

Three ways a section shape a preset did not recognise used to be lost
are closed with it: BlueBanner and ClassicSerif threw from a private
copy of the dispatcher, EditorialBlue's had no final branch, and
SectionLookup.hasContent answered false for any subtype it had not been
taught — discarding the heading with the body, through the very fallback
that exists to place unclaimed sections. An entry with no date also stops
reserving an empty column for one, which its Javadoc has described since
it was written; no shipped fixture has a blank date, so no existing
render moves.
…able

The list copy was pinned from one side only — a caller's list cannot reach
into a built module. The other side, that the accessor hands out nothing a
caller can mutate, was left to List.copyOf's reputation.
A module is only worth building if the template renders it, and not every
preset can promise that. Several compose a fixed set of modules and find
each by matching headings, so a section they do not recognise never
reaches a renderer: the CV comes out, minus a section, looking finished.
Nothing about that failure is visible at the point it happens.

ModularCvTemplate is the promise and CvTemplates.modular() is the list a
CV builder should offer; CvTemplates also answers byId, all, ids, and
recommendedMargin, so picking a preset at runtime stops being a map kept
by hand in every consumer. Declaring the interface costs something:
ModularCvTemplateFidelityTest renders every kind, a section this
catalogue has no name for, a heading in a script no keyword list
contains, and a heading that does match one, through each template that
declares it. Seven presets qualify. ClassicSerif does not — it draws any
shape it is given but only gives itself the sections it recognises, and
finding that out is what the gate is for.

The promise covers Slot.MAIN and says so, rather than leaving "renders
whatever it is handed" to be read generously: every shipped preset
composes one main column, so a sidebar section is dropped by these
templates as by every other. The gate pins that too, so the contract and
the code have to change together.

CvRenderKit is the three shapes a section body reduces to — a paragraph,
a label/value row, a timeline entry — and a template hands back the kit
it draws them with. The lowering from CvItem stays shared: what a linked
title looks like, which fields a kind reads, what an empty description
does to a trailing colon are the model's decisions and must not be
re-made sixteen times. BlueBanner, ClassicSerif and EditorialBlue now
draw modules with their own entry and project shapes.

EditorialBlue also stops renaming a module's heading. Its keyword
vocabulary turned "Certifications & Awards" into EDUCATION, which is the
one thing the promise says cannot happen; the canonical sections keep the
rename that gives the preset its voice.

CvTemplatesCoverageTest derives the catalogue from the presets package
rather than trusting it, so a preset that ships unregistered fails the
build instead of being invisible to every caller that looks one up by id.
…y are called

A preset with a designed layout places sections into fixed slots, and it
chose what went where by matching the section's heading against a list of
English words each preset kept privately — then guarded the slot on the
section's Java type as well. A CV headed Ausbildung, Опыт работы, or
anything else in the author's own language matched nothing: the section
was dropped and the slot that wanted it rendered empty. Nothing failed.
The CV came out looking finished, one job short.

A module already knows the answer, because its author said so.
SectionRouter asks the role first and falls back to the headings for the
sections that carry none — every hand-written one, and any module left as
OTHER — so a document of hand-written sections routes exactly as before. A
heading may not overrule a role: a module declared EXPERIENCE and headed
"Projects" goes where its author put it, and the projects slot does not
also claim it, which would have rendered it twice. An empty module does
not shadow a section that has content.

The second half is the shape. These slots are written against one section
type because each draws its content its own way, so a module reaching one
was discarded by the guard however well it was routed. The router hands
each slot the section lowered to the type it expects, and the preset then
draws it with the entry style, rules and spacing that make it that preset.
What the lowering costs is stated per finder and pinned by tests: a
description is joined where the target holds one string, bulleted points
join with a comma and prose with a space, a linked title survives only the
row style that bolds without markdown markers, and a skill with nothing
under it stays a skill rather than becoming a category holding itself.

SectionAllocation.claim gained the same role-first overload for the preset
that allocates rather than looks up, and TimelineMinimal learned to
flatten a module the way it flattens everything else — one line per item,
because those lines are what its column pagination measures.

SidebarPortrait's languages slot sniffs its rows for something
language-shaped, because it also accepts an Additional Information section
and has to pick them out. A section routed there by role is entirely
languages and needs to look like nothing, so when the sniff finds none it
now takes them all; otherwise role routing would have replaced a silent
drop with a heading over blank space.

These presets still drop a section they have no slot for. Their whole body
is one atomic row that cannot break across pages, so there is nowhere to
put it — that half waits on pagination, not routing.
A row places its children in one band and is atomic: the band must fit the
page it starts on. That is right for a row of cells and fatal for a document
body — a two-column layout built from a row holds exactly one page, and the
moment it holds more the compiler throws AtomicNodeTooLargeException, which
is why such layouts carry truncation limits to stay under it.

addColumnFlow(...) places the same columns and lets each break where it runs
out of page. The mechanism is one the engine already had: a section spans
pages because the compiler places its children one at a time against a page
cursor and any child may start a new page. A column is that kind of flow, so
each column gets its own cursor, forked from the flow's entry position, and
the flow rejoins them at the end — everything inside a column breaks exactly
as it does anywhere else. The flow ends on the last page any column reached,
and what follows continues below the longest one.

Widths resolve once, at entry, from the weights: a column that changed width
halfway down would not read as one column, and the layout's fixed point needs
the geometry to be a pure function of the entry state. The flow carries no
chrome — a column that wants a panel is a section with a fill, which already
repeats per page. A column flow inside a row slot or a stack layer is refused,
because those layers are pinned to one page. keepWithNext reads the flow's
first column rather than its multi-page height, so a heading above a body that
is going to break anyway stays where it was reached.

DOCX passes the flow through as a container and lets Word reflow its own
columns; PDF and PPTX render per fragment and are unaffected. Rows are
untouched and stay atomic; every layout snapshot and visual baseline is
byte-identical.
The preset held its body in one flow.addRow. A row is one atomic band that
must fit the page it starts on, so the preset capped every block to stay
under that bound — two jobs, two degrees, five skills, three languages, two
projects — and dropped the rest without a word. A denser CV came out looking
finished, one job short. The caps could not simply be deleted either:
uncapped content raised AtomicNodeTooLargeException rather than paginating.

The body is a column flow now, with the same 0.34 / 0.66 weights and no gap,
and all five caps are gone. Both columns continue onto the pages they need,
and the page backgrounds keep painting both fills on every page.

Two smaller changes come with it. The rule under each main-column section
title fills the column instead of drawing a fixed 346pt: that width was 21pt
wider than the column's content box on A4 and wider still on a narrower page
— a row slot does not check a child's measured width, and a column does. And
each section heading is kept with the block it introduces, as in every
single-column preset; a body that can break at all can strand its heading at
the foot of a page.

The portrait geometry stays A4-sized, which now has an edge: below roughly
310pt of page width the sidebar column has no room for the photo and the
layout fails instead of drawing it over the main column.

SidebarPortraitContentFidelityTest renders a career denser than one page and
asserts that each of its four degrees, twelve skills, five languages, five
employers and three projects reached the PDF, that both columns carried past
the first page, and that they stayed side by side. Visual baseline and the
committed preview re-recorded; both now run to two pages.
…ater pages

A container's padding is an edge of the container: reserved once, at the top
of the page it opens on and the bottom of the page it closes on. The page
margin is the inset applied once per page. In an ordinary document the
distinction never surfaces, because the margin is already holding content off
the paper edge everywhere. It surfaces in a full-bleed layout, which sets the
margin to zero so a page background can reach the paper edge — giving up the
safe area on all four sides when only the two horizontal ones had to go. A
continuation page then opens wherever the break left the first line: 3pt from
the trimmed edge in the dense CV the tests measure, inside the band most
printers cannot reach. Before Sidebar Portrait's body paginated there was no
second page for it to happen on.

ContinuationSafeArea.applyTo(session, firstContinuationPage, topSafeArea)
raises the top margin from the page it is given onward and keeps the other
three edges of whatever margin the caller chose, so the layout stays
full-bleed horizontally and page backgrounds — ratios of the page, not of the
content box — keep bleeding on every page. It does nothing at all when the
margin already provides the safe area, so a template can call it
unconditionally: an ordinary document is not turned into a per-page one, and
rules the caller set themselves survive, which handing back an empty rule list
would not have managed against a pageMargins() that replaces. The mechanism it
writes is the existing PageMarginRule.from(...); what it adds is the decision,
which is why it sits in templates rather than in the engine.

DocumentSession.margin() reads the document margin back, so a template can
derive the rule from the margin its caller actually chose.

A rule that moves only the top or bottom margin now costs nothing extra to lay
out. Per-page margins are resolved through a fixed point, and a block's
assigned start page feeds nothing but its page's content width and left edge —
so a vertical-only rule leaves one width for the whole document and the
resolver settles it in a single pass instead of recompiling to reach the graph
it already had.

Sidebar Portrait asks for half an inch, which puts its continuation pages'
first line 39pt below the trimmed edge and leaves page 1 laid out identically.
Monogram Sidebar recommends the same zero margin and will need the same call
once its body moves onto a column flow; Mint Editorial's 48pt margin already
clears the safe area, so the helper would leave it alone.
The preset held its body in one flow.addRow. A row is one atomic band that
must fit the page it starts on, so the preset capped every block — two jobs,
two degrees, seven skills, three projects, three additional rows — and dropped
the rest without a word. Deleting the caps was not an option on its own:
uncapped content raised AtomicNodeTooLargeException rather than paginating.

The body is a column flow now, with the same 0.33 / 0.67 weights and no gap,
and all five caps are gone. Both columns continue onto the pages they need,
the page backgrounds keep painting both fills per page, and the preset takes
the same continuation safe area as Sidebar Portrait — its recommended margin
is zero so the sidebar fill can reach the paper edge, which gives up the top
of the sheet along with the sides.

Two visual changes come with it. The rule under each main-column section title
fills the column instead of drawing a fixed 355pt: that width fit A4's 360.83pt
content box with 5.8pt to spare, and stopped fitting below a page width of
about 587pt, where a row slot drew it over the sidebar without a word — a fixed
slot does not check a child's measured width, and a column does. Filling also
ends the rule on the column's own edge rather than short of it. And both
columns' section headings are kept with the block they introduce, as in every
single-column preset; wrapping the sidebar heading in its own group tightens
that column's rhythm slightly.

One behaviour is newly strict: the monogram badge is a fixed 122pt, so a page
narrower than about 450pt no longer has a sidebar column wide enough for it and
the layout says so instead of drawing the badge over the main column.

MonogramSidebarPaginationTest renders a career denser than one page and asserts
every degree, skill, position, project and additional row reached the PDF; that
the sidebar carried onto page 2 while the main column finished on page 1; that
a continuation page keeps its first line off the trimmed edge, measured again
with the rule off so the number is attributable to it; that page 1 is laid out
identically either way; and that every heading is bound to its block. Baseline
and committed preview re-recorded — the canonical CV now runs to two pages.
…gine

The margin() accessor pointed at PageMarginRule.continuationSafeArea, which
this branch moved into templates as ContinuationSafeArea. JDK 17's doclint
reads the dangling reference as an error and fails the Javadoc gate; the JDK
the change was written on does not, so CI caught it and the local run did not.
Names the rule type instead of a method the engine no longer has.
This preset did not cap its body the way its siblings did — it dealt it. Each
page was a separate atomic row filled by hand: profile and the first two jobs
on page 1, the remaining jobs plus awards and references on page 2, with the
sidebar split to match. A career needing a third page had nowhere to go, and a
short CV had its layout decided in advance rather than by what fit. Two blocks
were capped outright on top of that: six expertise labels and six skill bars,
with the rest dropped in silence.

The body is a single column flow now. Each column runs the whole document and
breaks where it runs out of page, so what lands on a later page is what did not
fit. The hand-slicing and both caps are gone, and block headings are kept with
the block they introduce, since a column can break between them and a row could
not. Awards and References keep their place in the main column: they sat inside
the section rather than the row because a row cannot host a table, and a column
has no such rule.

A CV with many skills is longer than it used to be, and honestly so — the
canonical sample runs to three pages because its sidebar draws all thirty-odd
skill bars rather than the first six.

MintEditorialPaginationTest asserts every expertise label and skill bar reached
the PDF, that every job did — by employer, because two of the titles are
substrings of other entries this preset draws — that no per-page row node
survives, that the sidebar carries past the first page, and that every block
heading is bound to its block. The class Javadoc, which described the two-page
composition in four places, now describes the flow. Baselines and both committed
previews re-recorded.
The three two-column presets place sections into a designed layout — a
portrait sidebar, a main narrative — and rendered the six or seven categories
they thought to ask about. An "Awards", a "Publications", a runtime module the
catalogue has no role for: never looked at, and invisible in the output,
because the page came out looking finished. That was the last silent loss in
these presets, and it could not be fixed while their body was one atomic row,
because there was nowhere to put an extra section.

Each now ends its main column with whatever no slot claimed, under the heading
its author wrote — the only label that can be right for a category the preset
does not know about.

SectionAllocation gained the four shaped claims (entries, rows, paragraph,
skills) pairing its role-first choice with SectionRouter's shape lowering, so a
slot gets the section in the form it draws and the claim is recorded, which is
what makes remaining() the truth. SectionRouter.naturalShape answers the
question a leftover raises and a slot never does: what shape did this section's
own author ask for.

A claim is provisional. A slot's renderer is written against one section type
and returns on anything else, so a keyword can match a section of the wrong
shape — "Selected Projects" written as timeline entries matching a projects
slot that draws rows — and an unconditional claim would take it out of reach of
the tail while drawing nothing under its heading. A section whose lowered shape
is not the one the slot draws is handed back, and stays available to the next
slot and to the leftovers; before this, the second slot's independent match was
sometimes the only thing rendering such a section at all. The same guard stops
a module that names a role and carries nothing from shadowing the section that
has the content.

LeftoverSectionTest covers all three presets: four unrecognised categories —
prose, rows, entries, and a module whose role is OTHER — each reaching the PDF
under the author's heading; a claimed section still drawn exactly once; a
section a slot matched but cannot draw still reaching the page; and an empty
role-bearing module leaving the real section in its slot.

Mint Editorial's visual baseline is re-recorded: the canonical sample carries a
Projects and an Additional Information section it has no block for, and it drew
neither until now. The other two presets' baselines are unchanged, and the
committed example previews are unaffected.
@DemchaAV
DemchaAV changed the base branch from feat/mint-editorial-column-flow to feature/cv-constructor-layer August 20, 2026 20:59
@DemchaAV
DemchaAV merged commit 12e595f into feature/cv-constructor-layer Aug 20, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feat/cv-leftover-sections branch August 20, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant