feat(templates): let Monogram Sidebar draw a CV longer than one page - #586
Merged
DemchaAV merged 11 commits intoAug 20, 2026
Merged
Conversation
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
changed the base branch from
feat/continuation-safe-area
to
feature/cv-constructor-layer
August 20, 2026 20:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Monogram Sidebar held its body in one
flow.addRow. A row is one atomic band that mustfit 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
AtomicNodeTooLargeExceptionrather than paginating.What
The body is
addColumnFlownow, with the same 0.33 / 0.67 weights and no gap, and allfive 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:
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.
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
MonogramSidebarPaginationTestrenders a career denser than one page and asserts everydegree, 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
keepWithNextdeleted, becauseno 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
…PaginationTestbecause #577 adds aMonogramSidebarContentFidelityTestof its own.