feat(templates): let Mint Editorial flow instead of dealing its pages - #587
Merged
DemchaAV merged 12 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.
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.
DemchaAV
changed the base branch from
feat/monogram-sidebar-column-flow
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
Mint Editorial is the one preset that did not cap its body — it dealt it. The layout
was two atomic rows, one per page, each filled by hand:
addRow("…PageOne")heldsidebar[contact, interests, education] + main[profile, first two jobs];
addRow("…PageTwo")held sidebar[expertise, skills, social] + main[remaining jobs,awards, references]. So a career needing a third page had nowhere to go, and a short CV
had its page layout decided in advance rather than by what fit. Two blocks were capped
outright on top of that — six expertise labels, six skill bars — with the rest dropped in
silence.
What
One
addColumnFlowcarrying the whole document. Each column runs it end to end andbreaks where it runs out of page, so what lands on a later page is what did not fit. The
hand-slicing (
EXPERIENCE_PAGE_ONE) and both caps (EXPERTISE_LIMIT,SKILL_LIMIT) aregone, and block headings are kept with the block they introduce — a column can break
between a heading and its list, a row could not.
Awards and References stay in the main column, where they already lived: they sat inside
the section rather than the row precisely because
RowBuilderhas noaddTable, and acolumn is a
SectionBuilder, which does.The canonical sample now runs to three pages — its sidebar draws all thirty-odd skill
bars where the cap showed six, and the main column finishes on page 1, so pages 2–3 are
the sidebar alone. That is the honest shape of that fixture; a CV with a normal number of
skills is unaffected, and both committed example previews are still two pages. Rebalancing
the columns is a design change, deliberately not bundled here.
Unlike the sidebar presets, this one does not take the continuation safe area: it is
not full-bleed, the call is a no-op at its recommended 48pt margin, and its only reachable
effect would be to replace page-margin rules a caller set themselves.
Tests
MintEditorialPaginationTestasserts every expertise label and skill bar reached the PDF;that every job did, by employer — two of the six titles are substrings of other things
this preset draws (
BACKENDENGINEERofSENIORBACKENDENGINEER,SOFTWAREENGINEERof theeducation entry BSc Software Engineering), so title assertions would have passed through
a cap that dropped those jobs; 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.
Reintroducing the expertise cap and removing
keepWithNextwere each verified to turn itred.
MintEditorialSmokeTesthad a guard keyed onCvV2MintEditorialPageOne, a node that nolonger exists, and prose describing the two-row mechanism; both are migrated, and its
totalPages == 2assertion is now labelled as a property of its fixture rather than ofthe preset.
Full reactor gate green — qa 895 — javadoc clean under JDK 17 as well as the build JDK.