Skip to content

feat(templates): let Monogram Sidebar draw a CV longer than one page - #586

Merged
DemchaAV merged 11 commits into
feature/cv-constructor-layerfrom
feat/monogram-sidebar-column-flow
Aug 20, 2026
Merged

feat(templates): let Monogram Sidebar draw a CV longer than one page#586
DemchaAV merged 11 commits into
feature/cv-constructor-layerfrom
feat/monogram-sidebar-column-flow

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why

Monogram Sidebar 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. The caps could not simply be deleted either: uncapped content raised
AtomicNodeTooLargeException rather than paginating.

What

The body is addColumnFlow 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 via ContinuationSafeArea.PRINTER_SAFE_TOP — no constant of its own.

Two visual changes ride along:

  • The main-column section rule 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. So on A4 this
    is a 5.8pt widening that lands the rule on the column's own edge, not a fix for an
    overflow.
  • 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; that is the bulk of the re-recorded page-1 baseline.

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. The same page used to come out with the badge drawn over the main column, because a
row slot never checked.

Tests

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 — each column taking the
pages it needs, which is the point of a column flow; that a continuation page keeps its
first line off the trimmed edge, measured again with the rule taken back off so the
number is attributable to it; that page 1 is laid out identically either way; and that
every heading is bound to the block it introduces.

That last one is asserted on the document tree rather than on a rendered page: a
"no heading is last on its page" check passed even with keepWithNext deleted, because
no realistic fixture breaks exactly at a heading — a guard that cannot go red is not a
guard. Reintroducing a cap, and removing the keep-with-next, were each verified to turn
the suite red.

Full reactor gate green — qa 891 — javadoc clean, baseline and committed preview
re-recorded (the canonical CV now runs to two pages).

Not in this change

This preset never draws the entry subtitle, so every employer name is lost. That is
already fixed in #577, which is not in this branch's ancestry — the test here asserts
positions rather than employers, and is named …PaginationTest because #577 adds a
MonogramSidebarContentFidelityTest of its own.

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.
@DemchaAV
DemchaAV changed the base branch from feat/continuation-safe-area to feature/cv-constructor-layer August 20, 2026 20:59
@DemchaAV
DemchaAV merged commit fc10287 into feature/cv-constructor-layer Aug 20, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feat/monogram-sidebar-column-flow 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