diff --git a/CHANGELOG.md b/CHANGELOG.md index eaeda76dd..4f3b911ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,246 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. -## v2.2.1 — Planned +## v2.3.0 — Planned + +### Public API + +- **A CV section whose shape is a value, for CVs assembled at runtime.** The four + section records each fix one shape at compile time, which is right when a CV is + written in Java — you pick the record, 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 somebody thought of would have to become a type. + + `ModuleSection` carries the choice instead. One `CvItem` record holds every optional + field — title, link, subtitle, period, location, description lines — and a `CvKind` + (`PARAGRAPH`, `BULLETS`, `BULLETS_STACKED`, `INLINE_LIST`, `ENTRIES`, + `ENTRIES_DATED`) 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, and + `SectionRole` states what a section *means* — the decision multi-column presets make + by matching headings against English keywords, which a CV headed `Ausbildung` or + `Навыки` never matches. The presets do not read the role yet; it travels with the + section now so a document built today needs no rewrite when the routing work lands. + + The existing four records are untouched and mix with modules in the same document. + A module renders through the existing components rather than beside them, so one + drawn as `ENTRIES_DATED` lays out exactly like the `EntriesSection` carrying the same + content — held node-for-node by a parity suite, for every kind, alongside the + extracted text so structure and content are both pinned. The addition is binary- + compatible (the japicmp gate covers this module); it is a fifth permit on a sealed + interface, so a downstream `switch` over `CvSection` that was exhaustive without a + `default` needs one. + +- **Which presets can be handed a runtime module, declared rather than assumed.** A module + is only useful 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 still comes out — + minus a section, looking finished — which is the kind of failure nobody reports. + + `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 hand-kept map in + every consumer. Declaring the interface is not free: `ModularCvTemplateFidelityTest` + renders a document carrying every kind, an invented heading, a heading in a + script no keyword list contains, and a heading that *does* match one, through each + template that declares it, and asserts every item reached the page under the words + the author wrote — the last case because `EditorialBlue` renamed any heading + matching "certification" to EDUCATION, so "Certifications & Awards" arrived as a + word nobody had written. The promise covers `Slot.MAIN`, and says so: every shipped + preset composes a single main column, so a sidebar section is dropped by these + templates as by every other. Seven presets qualify today. `ClassicSerif` does not, + and finding that out is what the gate is for — it draws any shape it is given, but + only gives itself the sections it recognises. + + `CvTemplatesCoverageTest` derives the catalogue from the presets package rather than + trusting it, so a preset that ships without being registered fails the build instead + of being invisible to every caller that looks a template up by id. + +- **Columns that continue on the next page.** 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 the one the engine already had rather than a new one: 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, so a column flow gives each column its own + cursor from the flow's entry position and 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. + + Rows are untouched and stay atomic; the new node is a sibling, not a change of + behaviour, and every existing layout snapshot and visual baseline is byte-identical. + `ColumnFlowPaginationTest` renders the same content twice — through a row, which + throws, and through a column flow, which paginates — then pins what the flow has to + hold while doing it: nothing dropped, reordered, or skipped over a page; a short + column neither stretches nor pulls the next block up; a panelled column is repainted + on every page it reaches; and a `keepWithNext` heading above the flow stays on the + page it was reached on rather than jumping and stranding the rest of that page. A + column flow inside a row slot or a stack layer is refused with the reason: those + layers are pinned to one page, and a flow that advances pages would have run past the + band and overlapped what followed. + +- **Sidebar Portrait stops truncating the sections it draws.** The preset held its body in + one row, and a row must fit the page it starts on, so it capped every block to stay under + that bound: two jobs, two degrees, five skills, three languages, two projects. A denser + CV came out looking finished, one job short, with nothing to tell the reader — and the + caps could not simply be deleted, because uncapped content raised + `AtomicNodeTooLargeException` instead of paginating. (A section the preset has no slot + for is still dropped; that is a separate, documented limit of every fixed-layout preset.) + + The body is a column flow now and the caps are gone: both columns continue onto the pages + they need, and the page backgrounds keep painting both fills on every page. + `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 — not only the main one — carried past the + first page, and that they stayed side by side. The visual baseline and the committed + preview are both re-recorded, and each now runs to two pages. + + Two smaller changes ride along. 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 never checks a child's + measured width, and a column does. And each section heading is now kept with the block it + introduces, as in every single-column preset; a body that can break at all is a body that + can strand its heading at the foot of a page. + + The portrait geometry is still 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. + +- **Monogram Sidebar renders the whole CV it is handed too.** The same shape as Sidebar + Portrait, and the same caps: two jobs, two degrees, seven skills, three projects, three + additional rows, with everything past them dropped in silence because the body was one + atomic row that had to fit its page. The body is a column flow now, the caps are gone, + and the preset reserves the same half-inch safe area on the pages it continues onto — + its recommended margin is zero so the sidebar fill can reach the paper edge. + + Two visual changes ride along. 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 5.8pt short of it. And both columns' section headings are now 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, which is visible in the re-recorded + 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. + + `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; 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. Baseline and committed preview re-recorded; the canonical CV now runs to + two pages. + +- **A safe area on the pages a body continues onto.** 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 happened to leave the first line — 3pt from the trimmed edge in the + dense CV the tests measure, inside the band most printers cannot reach; before the body + paginated there was no second page for it to happen on. + + `ContinuationSafeArea.applyTo(session, firstContinuationPage, topSafeArea)` (in + `templates`) raises the top margin from the page you name 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. The pages before it are left alone: a body's first page owns its + own top edge. It does nothing at all when the margin already provides the safe area, so a + template can call it unconditionally without turning an ordinary document into a per-page + one — and, because it does nothing rather than handing back an empty rule list, without + clearing rules its caller set. The mechanism it writes is the existing + `PageMarginRule.from(...)`; what the helper adds is the decision, which is why it sits in + `templates` rather than in the engine. `DocumentSession.margin()` reads the document + margin back, which is what lets it derive the rule from the margin the caller actually + chose rather than the one the template recommends. + + 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 — a block's start page decides which + page's width it is measured against — and a block's assigned start page feeds nothing but + the page's content width and left edge. A vertical-only rule leaves one width for the + whole document, so the resolver settles it in a single pass instead of recompiling to + reach the answer it already had. + + Sidebar Portrait asks for half an inch (`CONTINUATION_TOP_SAFE_AREA`), which adds 36pt to + wherever a continuation page's first line landed — 3pt becomes 39pt in that CV — and + leaves page 1 laid out identically; its visual baseline and committed preview are + re-recorded again. Monogram Sidebar takes the same call in the entry above; Mint + Editorial's 48pt margin already clears the safe area, so the helper would leave it + alone. This covers the top edge only: + content still flows to the bottom of every page it fills, and a bottom safe area is a + bottom inset on `margin(...)` itself, because unlike the top it has to apply to page 1 as + well and changes where page 1 breaks. + + `PageMarginTest` measures the defect before asserting the fix — a full-bleed column flow + whose second page opens on nothing but the spacing that preceded the break — so the guard + cannot pass on padding that was doing the work all along, and pins that a top-only rule + lays out identically to the same geometry expressed in a form that does take the fixed + point. `ContinuationSafeAreaTest` covers the helper's own decisions: the horizontal edges + it must keep, the no-op that leaves a caller's rules standing, a body that starts on page + 2 and is inset from page 3, and the arguments it refuses. + `SidebarPortraitContentFidelityTest` pins the first line's distance from the page top on + every continuation page of a dense CV, takes the rule back off to attribute the number to + it, and pins page 1's own 78pt opening either way. + +- **Presets route by what a section means, not by the language it is written in.** A + preset with a designed layout places sections into fixed slots, and it chose what went + where by matching the 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. + + `SectionRouter` asks the module's `SectionRole` first and falls back to the headings + for the sections that carry no role — every hand-written one, and any module left as + `OTHER` — so a document of hand-written sections routes exactly as it did. 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. The router also hands each slot the section in the shape that slot draws, so + a module reaching a slot written against `EntriesSection` is no longer discarded by + the guard — the preset draws it exactly as it draws everything else, with the entry + style, rules and spacing that make it that preset. `SectionAllocation.claim` gained + the same role-first overload for the preset that allocates rather than looks up. + + Nine presets and every slot they compose changed; a CV written in Russian and German + now renders on all sixteen, which `RoleRoutingTest` holds by rendering one. + +- **A preset can draw runtime modules in its own style.** `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, because deciding what a linked title looks like or which fields a kind + reads belongs to the model and must not be re-decided per preset; only the drawing is + the preset's. `BlueBanner`, `ClassicSerif`, and `EditorialBlue` now render modules + with their own entry and project shapes rather than the canonical ones — the + limitation the entry above left open. + +### Fixed + +- **A section shape a preset did not recognise was lost three different ways.** + `BlueBanner` and `ClassicSerif` each kept a private copy of the section dispatcher + whose final `else` threw `IllegalStateException`; `EditorialBlue`'s had no `else` at + all; and `SectionLookup.hasContent` — which presets consult *before* routing, and + which `SectionAllocation.remaining()` uses to decide what still needs a home — + answered `false` for any subtype it had not been taught, dropping the heading along + with the body. So a section type added to the model would have crashed two presets + and vanished from several more, including through the very fallback that exists to + catch unplaced sections. All three dispatchers now delegate unfamiliar shapes to the + canonical one, and `hasContent` answers for every permit. + +- **An entry with no date no longer reserves a column for it.** `EntryRenderer` always + emitted the two-column title/date header, so an undated entry — a certification, a + project — had its title wrapped early to leave room for nothing. Its Javadoc had + described the collapsing behaviour since the entry renderer was written. No shipped + fixture has a blank date, so no existing render moves. ### Build diff --git a/README.md b/README.md index fb97aa3c0..e63ee3bc8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ > **Release status** — > 🟢 **Latest stable**: [v2.2.0](https://github.com/DemchaAV/GraphCompose/releases/tag/v2.2.0) — the **right-to-left** release: Hebrew and Arabic lay out, shape, join and mirror through PDF, PowerPoint and Word — in paragraphs and in table cells — with the fonts to render them. See [CHANGELOG.md](./CHANGELOG.md). -> · 🟡 **In development**: v2.2.1 on `develop` — see [CHANGELOG.md](./CHANGELOG.md). +> · 🟡 **In development**: v2.3.0 on `develop` — see [CHANGELOG.md](./CHANGELOG.md).
Live Showcase
diff --git a/assets/readme/examples/cv-monogram-sidebar-v2.pdf b/assets/readme/examples/cv-monogram-sidebar-v2.pdf
index 8c1fa3c10..eab9d2429 100644
Binary files a/assets/readme/examples/cv-monogram-sidebar-v2.pdf and b/assets/readme/examples/cv-monogram-sidebar-v2.pdf differ
diff --git a/assets/readme/examples/cv-sidebar-portrait-v2.pdf b/assets/readme/examples/cv-sidebar-portrait-v2.pdf
index 7e18a86c1..c6daf78fb 100644
Binary files a/assets/readme/examples/cv-sidebar-portrait-v2.pdf and b/assets/readme/examples/cv-sidebar-portrait-v2.pdf differ
diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml
index 1ad6ce226..fb743b64f 100644
--- a/benchmarks/pom.xml
+++ b/benchmarks/pom.xml
@@ -7,7 +7,7 @@
Composition code that has to reason about the page's own edges reads it + * here rather than through {@link #canvas()}, whose margin is an engine type. + * A template that derives a {@link PageMarginRule} from it — reserving a safe + * area on continuation pages, say — keeps working when the caller chooses a + * margin other than the one the template recommends.
+ * + * @return the current page margin + * @since 2.3.0 + */ + public DocumentInsets margin() { + return margin; + } + /** * Returns the usable content height of the page in points — the page height * minus the top and bottom margins. Convenience alias for diff --git a/core/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java index e5aa793db..5c1117801 100644 --- a/core/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java +++ b/core/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java @@ -960,6 +960,36 @@ public T addRow(String name, ConsumerThe page-spanning counterpart to {@link #addRow(Consumer)}. A row is + * one band and is atomic, so a two-column body built from a row holds only + * as much as fits the page it starts on; a column flow lets each column + * break and resume.
+ * + * @param spec column-flow builder callback + * @return this builder + * @since 2.3.0 + */ + public T addColumnFlow(ConsumerUse this where a {@link RowBuilder} would cap the document at a page. A + * row places one band and is atomic — the whole band must fit where it starts, + * and a two-column body built that way can hold only as much as one page. A + * column flow places the same two columns and lets each break where it runs + * out of room:
+ * + *{@code
+ * flow.addColumnFlow("Body", body -> body
+ * .gap(18)
+ * .weights(0.72, 1.28)
+ * .addColumn(side -> side.spacing(6).addParagraph(...))
+ * .addColumn(main -> main.spacing(8).addParagraph(...)));
+ * }
+ *
+ * Every column is a vertical container, because a column is a + * vertical flow — that is what lets its contents paginate. Anything else + * (a paragraph, an image, a nested row) belongs inside one of those columns + * rather than beside them.
+ * + *The flow has no fill or border of its own. A column that wants a panel is + * a section with a fill, and the engine repeats a section's fill on every page + * it spans; chrome that must reach the page edge belongs in a page background, + * which paints on every page by definition.
+ * + * @author Artem Demchyshyn + * @since 2.3.0 + */ +public final class ColumnFlowBuilder { + private final ListThere is no split contract here, and there does not need to be. A + * vertical composite already spans pages — not by splitting itself, but + * because {@code LayoutCompiler} places its children one at a time against a + * shared page cursor, and any child may advance that cursor to a new page. A + * column is exactly such a flow. What a column flow adds is that its columns + * do not share one cursor: each gets its own, forked from the flow's entry + * position, and the flow rejoins them at the end.
+ * + *+ * fork: every column starts at (entryPage, entryUsed) + * flow: column i is compiled through the ordinary vertical path, so + * everything inside it — paragraphs, tables, nested sections — + * breaks and continues exactly as it does anywhere else + * join: page = max over columns of the page each ended on + * used = max over the columns that ended on that page + *+ * + *
The join takes the height only from columns that ended on the joined + * page: a shorter column that finished two pages earlier says nothing about + * how much of the last page is occupied, and taking its cursor would let the + * next sibling overwrite the longer column.
+ * + *Column widths are resolved once, at entry, from the flow's weights. + * Re-resolving them per page would let a column change width halfway down the + * document, and would also break the layout fixed point, which requires the + * geometry to be a pure function of the entry state. A document with per-page + * margins therefore keeps the entry page's column widths on the pages the flow + * continues onto — the same rule every nested composite already follows, since + * only a page-level column re-reads the region per page.
+ * + *Package-private — engine surface, not public API. Mirrors + * {@link StackedLayerCompiler} in taking the host compiler and calling back + * into it rather than owning the recursion.
+ * + * @author Artem Demchyshyn + */ +final class ColumnFlowCompiler { + + private ColumnFlowCompiler() { + // Utility class, no instantiation. + } + + /** + * Compiles a column flow starting at the current cursor. + * + * @param host the compiler that owns the per-column recursion + * @param prepared the prepared flow node + * @param definition the flow's node definition + * @param path the flow's layout path + * @param semanticName the flow's semantic name + * @param parentPath the parent's layout path + * @param childIndex the flow's index among its siblings + * @param depth the flow's tree depth + * @param regionX the content region's left edge + * @param state the page cursor, mutated to the joined position + * @param prepareContext preparation context for per-column measurement + * @param fragmentContext fragment emission context + * @param nodes placed-node sink + * @param fragments placed-fragment sink + * @param margin the flow's margin + * @param padding the flow's padding + * @param availableWidth the width available to the flow's content + * @param layoutSpec the flow's child-layout contract (gap + weights) + * @param naturalMeasure the flow's measured size + */ + static void compile(LayoutCompiler host, + PreparedNodeThe geometry is shared, not copied — per-page margins must resolve + * identically in every column, or the layout fixed point would not + * converge.
+ */ + CompilerState forkAtCurrentPosition() { + CompilerState fork = new CompilerState(canvas, geometry); + fork.pageIndex = pageIndex; + fork.usedHeight = usedHeight; + fork.maxTouchedPage = maxTouchedPage; + return fork; + } + + /** + * Moves this cursor to where the longest of several forked cursors ended. + * + *{@code usedHeight} is meaningful only together with the page it was + * measured on, which is why the caller passes both: a column that finished + * two pages earlier says nothing about how much of the final page is + * occupied.
+ * + * @param page the page the flow ends on + * @param used the height consumed on that page + * @param touchedPage the highest page any fork reached + */ + void rejoinAt(int page, double used, int touchedPage) { + pageIndex = page; + usedHeight = Math.min(activeInnerHeight(), Math.max(0.0, used)); + maxTouchedPage = Math.max(maxTouchedPage, Math.max(touchedPage, page)); + } + void newPage() { pageIndex++; usedHeight = 0.0; diff --git a/core/src/main/java/com/demcha/compose/document/layout/CompositeLayoutSpec.java b/core/src/main/java/com/demcha/compose/document/layout/CompositeLayoutSpec.java index 7dd9d0070..cf10dfb1e 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/CompositeLayoutSpec.java +++ b/core/src/main/java/com/demcha/compose/document/layout/CompositeLayoutSpec.java @@ -11,7 +11,8 @@ * * @param spacing spacing between child nodes (vertical for {@link Axis#VERTICAL}, horizontal for {@link Axis#HORIZONTAL}) * @param axis composite stacking axis - * @param weights optional per-child weights (only consulted for {@link Axis#HORIZONTAL}) + * @param weights optional per-child weights (consulted for {@link Axis#HORIZONTAL} + * and {@link Axis#COLUMN_FLOW}) */ public record CompositeLayoutSpec(double spacing, Axis axis, ListA {@code switch} over this enum that was exhaustive without a + * {@code default} needs one.
+ * + * @since 2.3.0 + */ + COLUMN_FLOW, /** * Children share the same bounding box and are painted in source order * (first child behind, last child in front). Used by {@code LayerStackNode} diff --git a/core/src/main/java/com/demcha/compose/document/layout/DocumentLayoutResolver.java b/core/src/main/java/com/demcha/compose/document/layout/DocumentLayoutResolver.java index 08de9607c..81ac1da3c 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/DocumentLayoutResolver.java +++ b/core/src/main/java/com/demcha/compose/document/layout/DocumentLayoutResolver.java @@ -68,7 +68,12 @@ public DocumentLayoutResolver(Context context) { */ public LayoutGraph resolve() { PageGeometry geometry = context.pageGeometry(); - boolean hasMargins = geometry != null; + // Only a horizontal difference makes the loop load-bearing: a block's + // assigned start page feeds nothing but the page's content width and left + // edge. Overrides that move the top or bottom margin — a continuation-page + // safe area, say — leave one width for the whole document, which the first + // pass already used, so re-running it would return the same graph. + boolean hasMargins = geometry != null && geometry.hasHorizontalOverrides(); boolean hasPageReference = context.hasPageReference(); LayoutGraph graph = compilePass(Map.of(), geometry, Map.of()); diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index f68b0e94e..7fd73f321 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -125,7 +125,7 @@ public LayoutGraph compile(DocumentGraph graph, PrepareContext prepareContext, F return layoutGraph; } - private void compileNode(PreparedNodeThat height is what the flow would occupy if a page were tall enough + * to hold it, and it routinely is not — the node exists to span pages. + * Nothing admits the flow onto a page on the strength of this number; the + * columns admit themselves child by child as they flow, exactly as a + * section does.
+ * + * @param node column flow node + * @param padding engine padding + * @param ctx prepare context + * @param constraints parent constraints + * @return measured outer flow size + */ + public static MeasureResult measureColumnFlow(ColumnFlowNode node, + Padding padding, + PrepareContext ctx, + BoxConstraints constraints) { + double availableWidth = Math.max(0.0, constraints.availableWidth() - padding.horizontal()); + ListPer-page margins are resolved through a fixed point: a block's start page + * decides which page's width it is measured against, and its measured height + * decides where the next block starts. That loop is only load-bearing when the + * width can actually differ between pages. A document whose overrides move only + * the top or bottom margin has one width throughout, so its layout is settled in + * a single pass and the resolver skips the loop.
+ * + * @return {@code true} when some override differs horizontally from the canvas + */ + public boolean hasHorizontalOverrides() { + for (PageMarginOverride override : overrides) { + Margin margin = override.margin(); + if (Math.abs(margin.left() - canvas.margin().left()) > EPS + || Math.abs(margin.right() - canvas.margin().right()) > EPS) { + return true; + } + } + return false; + } + /** * The content width on the given page (page width minus the page's horizontal margin). * diff --git a/core/src/main/java/com/demcha/compose/document/layout/definitions/ColumnFlowDefinition.java b/core/src/main/java/com/demcha/compose/document/layout/definitions/ColumnFlowDefinition.java new file mode 100644 index 000000000..d346c703f --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/layout/definitions/ColumnFlowDefinition.java @@ -0,0 +1,73 @@ +package com.demcha.compose.document.layout.definitions; + +import com.demcha.compose.document.layout.*; +import com.demcha.compose.document.node.ColumnFlowNode; +import com.demcha.compose.document.node.DocumentNode; + +import java.util.List; + +import static com.demcha.compose.document.layout.NodeDefinitionSupport.*; + +/** + * Layout definition for {@link ColumnFlowNode}: columns measured in weighted + * slots, each flowing down its own column across pages. + * + * @author Artem Demchyshyn + */ +public final class ColumnFlowDefinition implements NodeDefinition{@code ATOMIC} like every other composite. The compiler dispatches a + * composite on its axis before it ever reads this policy + * ({@code LayoutCompiler.compileNode}), so the value is inert here exactly + * as it is for {@code SectionNode} — which also spans pages. Splitting is + * a leaf contract; a composite spans pages by placing its children one at + * a time, which is what this node does per column.
+ */ + @Override + public PaginationPolicy paginationPolicy(ColumnFlowNode node) { + return PaginationPolicy.ATOMIC; + } + + @Override + public ListA {@link RowNode} puts children beside each other in one band and is + * atomic: the whole band must fit on the page it starts on, and content that + * does not fit has nowhere to go. That is right for a row of cells and wrong + * for a two-column document body, which is why layouts built that way cap + * their content at a page and truncate the rest.
+ * + *This is the other shape. Each child is a column, and each column is an + * ordinary vertical flow: it breaks where it runs out of page and resumes at + * the top of the next one, independently of its neighbours. The node ends on + * the last page any column reached, so whatever follows continues below the + * longest column.
+ * + *+ * page 1 page 2 + * ┌────────┬─────────┐ ┌────────┬─────────┐ + * │ side │ main │ │ side │ main │ + * │ … │ … │ → │ …cont. │ …cont. │ + * └────────┴─────────┘ └────────┴─────────┘ + *+ * + *
Widths are resolved once, from {@code weights} (or evenly when none are + * given), and every page uses the same ones — a column that changed width + * halfway down a document would not read as one column.
+ * + *The node carries no fill or border of its own: a column that wants a + * panel is a section with a fill, and the engine already repeats a section's + * fill on each page it spans. Chrome that must reach the page edge belongs in + * a page background, which paints on every page by definition.
+ * + * @param name diagnostic name for layout paths and snapshots + * @param children one node per column, left to right + * @param weights relative column widths; empty distributes evenly + * @param gap horizontal gap between columns + * @param padding inner padding applied to the whole flow + * @param margin outer margin applied to the whole flow + * @since 2.3.0 + */ +public record ColumnFlowNode( + String name, + ListA row is one band and is atomic: it must fit the page it starts on, and + * content that does not fit has nowhere to go — the compiler says so by + * throwing. That is correct for a row of cells and fatal for a document body, + * which is why layouts built from a row cap their content at a page and + * truncate the rest.
+ * + *The first two cases are the same content twice: through a row, which + * throws, and through a column flow, which paginates. Everything after that + * pins the properties the flow has to hold while doing it.
+ */ +class ColumnFlowPaginationTest { + + @Test + void aRowCannotHoldMoreThanOnePage() { + assertThatThrownBy(() -> render(session -> session.pageFlow() + .name("Root") + .addRow("Body", row -> row + .gap(18) + .weights(1.0, 1.0) + .addSection("Side", side -> paragraphs(side, "side", 40)) + .addSection("Main", main -> paragraphs(main, "main", 40))) + .build())) + .as("the row band is atomic, so overflowing it is an error rather than a page break") + .isInstanceOf(AtomicNodeTooLargeException.class); + } + + @Test + void aColumnFlowWithTheSameContentPaginatesInstead() throws Exception { + try (DocumentSession session = newSession()) { + session.pageFlow() + .name("Root") + .addColumnFlow("Body", body -> body + .gap(18) + .weights(1.0, 1.0) + .addColumn("Side", side -> paragraphs(side, "side", 40)) + .addColumn("Main", main -> paragraphs(main, "main", 40))) + .build(); + + LayoutGraph graph = session.layoutGraph(); + assertThat(graph.totalPages()) + .as("the same content that overflowed a row now spans pages") + .isGreaterThan(1); + assertThat(paragraphPages(graph, "side")) + .as("the left column continues past the first page") + .contains(0, 1); + assertThat(paragraphPages(graph, "main")) + .as("the right column continues past the first page") + .contains(0, 1); + } + } + + @Test + void everyLineSurvivesTheBreak() throws Exception { + // The failures a paginator must not have: content that vanished, content + // that came back in the wrong order, and content that skipped a page. + try (DocumentSession session = newSession()) { + session.pageFlow() + .name("Root") + .addColumnFlow("Body", body -> body + .gap(18) + .addColumn("Side", side -> paragraphs(side, "side", 40)) + .addColumn("Main", main -> paragraphs(main, "main", 40))) + .build(); + + LayoutGraph graph = session.layoutGraph(); + for (String column : List.of("side", "main")) { + PlacedNode previous = null; + for (int index = 0; index < 40; index++) { + String text = column + "-" + index; + PlacedNode current = graph.nodes().stream() + .filter(placed -> placed.semanticName().equals(text)) + .findFirst() + .orElseThrow(() -> new AssertionError(text + " was dropped by the break")); + if (previous != null) { + assertThat(current.startPage()) + .as("%s must not move backwards or skip a page", text) + .isBetween(previous.startPage(), previous.startPage() + 1); + if (current.startPage() == previous.startPage()) { + assertThat(current.placementY()) + .as("%s must sit below its predecessor on the same page", text) + .isLessThan(previous.placementY()); + } + } + previous = current; + } + } + } + } + + @Test + void columnsAreIndependentSoAShortColumnDoesNotStretch() throws Exception { + try (DocumentSession session = newSession()) { + session.pageFlow() + .name("Root") + .addColumnFlow("Body", body -> body + .gap(18) + .addColumn("Short", side -> paragraphs(side, "short", 2)) + .addColumn("Long", main -> paragraphs(main, "long", 40))) + .build(); + + LayoutGraph graph = session.layoutGraph(); + assertThat(paragraphPages(graph, "short")) + .as("a column that fits stays on the first page") + .containsExactly(0); + assertThat(paragraphPages(graph, "long")) + .as("its neighbour keeps flowing") + .contains(0, 1); + } + } + + @Test + void whatFollowsTheFlowStartsBelowTheLongestColumn() throws Exception { + try (DocumentSession session = newSession()) { + session.pageFlow() + .name("Root") + .addColumnFlow("Body", body -> body + .gap(18) + .addColumn("Short", side -> paragraphs(side, "short", 2)) + .addColumn("Long", main -> paragraphs(main, "long", 40))) + .addParagraph(p -> p.name("after").text("after the flow")) + .build(); + + LayoutGraph graph = session.layoutGraph(); + PlacedNode after = node(graph, "after"); + PlacedNode lastLong = lastNodeNamed(graph, "long"); + + assertThat(after.startPage()) + .as("the flow ends where its longest column ended, not its shortest") + .isEqualTo(lastLong.startPage()); + assertThat(after.placementY()) + .as("and the next block sits below that column, not on top of it") + .isLessThan(lastLong.placementY()); + } + } + + @Test + void aFlowThatFitsOnOnePageLaysOutLikeARowWould() throws Exception { + // The new node must not cost anything for the ordinary case: content + // that fits produces one page and side-by-side columns. + try (DocumentSession session = newSession()) { + session.pageFlow() + .name("Root") + .addColumnFlow("Body", body -> body + .gap(18) + .weights(1.0, 1.0) + .addColumn("Side", side -> paragraphs(side, "side", 2)) + .addColumn("Main", main -> paragraphs(main, "main", 2))) + .build(); + + LayoutGraph graph = session.layoutGraph(); + assertThat(graph.totalPages()).isEqualTo(1); + PlacedNode side = node(graph, "side-0"); + PlacedNode main = node(graph, "main-0"); + assertThat(side.placementX()) + .as("columns sit side by side") + .isLessThan(main.placementX()); + assertThat(side.placementY()) + .as("and start at the same height") + .isCloseTo(main.placementY(), org.assertj.core.data.Offset.offset(0.01)); + } + } + + @Test + void anEmptyFlowIsHarmless() { + assertThatCode(() -> render(session -> session.pageFlow() + .name("Root") + .addColumnFlow("Body", body -> body.gap(12)) + .build())) + .doesNotThrowAnyException(); + } + + @Test + void aColumnMustBeAVerticalContainer() { + // A column is a vertical flow — that is what paginates. Anything else + // beside its siblings belongs inside a column. + assertThatThrownBy(() -> new ColumnFlowBuilderProbe().addNonColumn()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("vertical container"); + } + + @Test + void weightsMustMatchTheColumns() { + assertThatThrownBy(() -> new ColumnFlowNode("Body", + List.of(emptySection("a"), emptySection("b")), + List.of(1.0), 0.0, DocumentInsets.zero(), DocumentInsets.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must match children size"); + } + + @Test + void aColumnFlowIsRejectedInsideARowSlot() { + // A row slot is pinned to one page; a column flow advances pages. Placed + // here it would stack its columns down the slot and run past the band, + // overlapping whatever follows. + assertThatThrownBy(() -> render(session -> session.pageFlow() + .name("Root") + .addRow("Band", row -> row + .addSection("Left", left -> left + .addColumnFlow("Nested", body -> body + .addColumn("A", a -> paragraphs(a, "a", 2)) + .addColumn("B", b -> paragraphs(b, "b", 2)))) + .addSection("Right", right -> paragraphs(right, "right", 2))) + .build())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot contain a column flow") + .hasMessageContaining("row slot"); + } + + @Test + void aColumnFlowIsRejectedInsideAStackLayer() { + assertThatThrownBy(() -> render(session -> session.pageFlow() + .name("Root") + .addLayerStack(stack -> stack.layer(new SectionBuilder() + .name("Layer") + .addColumnFlow("Nested", body -> body + .addColumn("A", a -> paragraphs(a, "a", 2)) + .addColumn("B", b -> paragraphs(b, "b", 2))) + .build())) + .build())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("cannot contain a column flow") + .hasMessageContaining("stack layer"); + } + + @Test + void aHeadingAboveTheFlowIsNotHoistedToTheNextPage() throws Exception { + // keepWithNext asks "does my run plus the first line of what follows fit + // here?". A flow answers with its own height unless the lookahead reads + // its first column -- and a heading above a body that is going to break + // anyway would then jump to the next page and strand the rest of this one. + try (DocumentSession session = newSession()) { + session.pageFlow() + .name("Root") + .spacing(4) + .addSection("Filler", filler -> paragraphs(filler, "filler", 38)) + .addSection("Heading", heading -> heading + .keepWithNext() + .addParagraph(p -> p.name("heading").text("Experience"))) + .addColumnFlow("Body", body -> body + .gap(18) + .addColumn("Side", side -> paragraphs(side, "side", 10)) + .addColumn("Main", main -> paragraphs(main, "main", 10))) + .build(); + + LayoutGraph graph = session.layoutGraph(); + assertThat(node(graph, "heading").startPage()) + .as("the heading stays on the page it was reached on") + .isEqualTo(lastNodeNamed(graph, "filler").startPage()); + assertThat(node(graph, "side-0").startPage()) + .as("and its body starts there too, using the rest of the page") + .isEqualTo(node(graph, "heading").startPage()); + } + } + + @Test + void aColumnPanelIsRepaintedOnEveryPageItSpans() throws Exception { + // The flow has no chrome of its own; a column that wants a panel is a + // section with a fill, and a section's fill already repeats per page. + // That is the whole decoration story, so it has to actually hold. + try (DocumentSession session = newSession()) { + session.pageFlow() + .name("Root") + .addColumnFlow("Body", body -> body + .gap(18) + .addColumn("Side", side -> { + side.fillColor(DocumentColor.rgb(240, 240, 240)); + paragraphs(side, "side", 40); + }) + .addColumn("Main", main -> paragraphs(main, "main", 40))) + .build(); + + LayoutGraph graph = session.layoutGraph(); + ListA template sets the page margin to zero so a page background can reach the + * paper edge, and gives up the safe area on all four sides to buy the two it needed. + * A column's own padding does not cover the difference: it is an edge of the column, + * spent on the page the column opens on.
+ */ +class ContinuationSafeAreaTest { + + @Test + void itInsetsTheContinuationPagesAndLeavesTheFirstAlone() { + try (DocumentSession session = fullBleed()) { + ContinuationSafeArea.applyTo(session, 2, 36); + fillPastOnePage(session); + + assertThat(session.layoutGraph().totalPages()).isGreaterThan(1); + assertThat(firstLineTopGap(session, 1)) + .as("page 2 clears the safe area") + .isGreaterThanOrEqualTo(36.0); + assertThat(firstLineTopGap(session, 0)) + .as("page 1 keeps the top edge its own design gave it") + .isCloseTo(30.0, within(0.5)); + } + } + + @Test + void itKeepsTheHorizontalEdgesItWasGiven() { + // The bug being prevented: reserving a top inset by rebuilding all four + // edges would silently un-bleed the sides, and the page backgrounds would + // no longer line up with the columns. + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(new DocumentInsets(10, 20, 30, 40)) + .create()) { + ContinuationSafeArea.applyTo(session, 2, 36); + fillPastOnePage(session); + + assertThat(leftEdgeOfFirstLine(session, 1)) + .as("page 2 keeps the left margin the caller chose, plus the column padding") + .isCloseTo(leftEdgeOfFirstLine(session, 0), within(0.5)); + } + } + + @Test + void aMarginThatAlreadyClearsTheSafeAreaIsLeftAlone() { + // The empty case has to be a no-op, not an empty rule list: pageMargins() + // replaces, and an empty list clears. A template calling this + // unconditionally must not be able to delete rules the caller set. + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(DocumentInsets.of(48)) + .create()) { + session.pageMargins(List.of(PageMarginRule.page(1, DocumentInsets.zero()))); + + ContinuationSafeArea.applyTo(session, 2, 36); + + fillPastOnePage(session); + assertThat(firstLineTopGap(session, 0)) + .as("the caller's own full-bleed first page survives the call") + .isCloseTo(30.0, within(0.5)); + } + } + + @Test + void aSafeAreaMustBeAUsableLengthOnAPageThatCanHaveOne() { + try (DocumentSession session = fullBleed()) { + assertThatThrownBy(() -> ContinuationSafeArea.applyTo(session, 1, 36)) + .as("page 1 is where a body starts; its own design owns its top edge") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("firstContinuationPage"); + assertThatThrownBy(() -> ContinuationSafeArea.applyTo(session, 2, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("topSafeArea"); + assertThatThrownBy(() -> ContinuationSafeArea.applyTo(session, 2, Double.NaN)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ContinuationSafeArea.applyTo(null, 2, 36)) + .isInstanceOf(NullPointerException.class); + } + } + + @Test + void aBodyThatStartsLaterInsetsFromThePageItSays() { + // A cover page and a body starting on page 2: the inset belongs from page 3, + // and page 2 — the body's own first page — must keep its design. + try (DocumentSession session = fullBleed()) { + ContinuationSafeArea.applyTo(session, 3, 36); + session.dsl().pageFlow() + .name("Root") + .padding(DocumentInsets.zero()) + .addParagraph(p -> p.name("cover").text("Cover")) + .addPageBreak(b -> b.name("break")) + .addColumnFlow("Body", body -> body.addColumn("Only", column -> { + column.padding(DocumentInsets.of(30)).spacing(6); + for (int index = 0; index < 60; index++) { + int current = index; + column.addParagraph(p -> p + .name("line-" + current) + .text("line " + current + " with enough words to occupy a line")); + } + })) + .build(); + + assertThat(session.layoutGraph().totalPages()).isGreaterThan(2); + assertThat(firstLineTopGap(session, 1)) + .as("page 2 opens the body and keeps its own top edge") + .isLessThan(36.0); + assertThat(firstLineTopGap(session, 2)) + .as("page 3 is the first continuation and clears the safe area") + .isGreaterThanOrEqualTo(36.0); + } + } + + // -- helpers ----------------------------------------------------------- + + private static DocumentSession fullBleed() { + return GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(DocumentInsets.zero()) + .create(); + } + + private static void fillPastOnePage(DocumentSession session) { + session.dsl().pageFlow() + .name("Root") + .padding(DocumentInsets.zero()) + .addColumnFlow("Body", body -> body.addColumn("Only", column -> { + column.padding(DocumentInsets.of(30)).spacing(6); + for (int index = 0; index < 60; index++) { + int current = index; + column.addParagraph(p -> p + .name("line-" + current) + .text("line " + current + " with enough words to occupy a line")); + } + })) + .build(); + } + + private static double firstLineTopGap(DocumentSession session, int pageIndex) { + double pageHeight = session.canvas().height(); + return session.layoutGraph().fragments().stream() + .filter(fragment -> fragment.pageIndex() == pageIndex) + .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload) + .mapToDouble(fragment -> pageHeight - (fragment.y() + fragment.height())) + .min() + .orElseThrow(() -> new AssertionError("no text on page " + (pageIndex + 1))); + } + + private static double leftEdgeOfFirstLine(DocumentSession session, int pageIndex) { + return session.layoutGraph().fragments().stream() + .filter(fragment -> fragment.pageIndex() == pageIndex) + .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload) + .mapToDouble(fragment -> fragment.x()) + .min() + .orElseThrow(() -> new AssertionError("no text on page " + (pageIndex + 1))); + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java new file mode 100644 index 000000000..291e40360 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplateFidelityTest.java @@ -0,0 +1,265 @@ +package com.demcha.compose.document.templates.cv.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.data.Slot; +import com.demcha.compose.document.templates.cv.presets.CvTemplates; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Every template that declares {@link ModularCvTemplate} renders every kind + * of module, under whatever heading the author wrote. + * + *The interface is a promise made to a caller who cannot check it: a CV + * builder offers the modular templates and trusts that whatever the user + * assembled comes out the other side. A template that quietly dropped a + * section would produce a CV that still looks finished — the failure has no + * symptom at the point it happens, only a missing job three weeks later. + * This is where the promise is checked, so wearing the interface costs + * something.
+ * + *It enumerates {@link CvTemplates#modular()} rather than a list of its + * own, and every {@link CvKind} rather than the kinds in use, so a template + * or a kind added later is covered the day it lands — the coverage cannot + * be forgotten, only made to pass.
+ * + *Text is read from the composed layout, not the PDF text layer: the CV + * themes draw with the standard-14 Helvetica, whose encoding has no + * Cyrillic, and the non-Latin heading below is the case that matters most. + * What the model owes is that the section is placed carrying its own words; + * which glyphs a font can draw is the caller's font choice.
+ */ +class ModularCvTemplateFidelityTest { + + private static StreamA runtime module is only as good as the weakest kind: an author who picks + * one the renderers never learned to lower gets a section that silently draws + * nothing, and the CV looks finished. Enumerating the enum rather than listing + * cases means a kind added later fails here until it is wired, which is the + * point — a new constant cannot ship half-rendered.
+ * + *The per-template promise — every kind, an invented heading, a non-Latin + * one — is checked in {@code ModularCvTemplateFidelityTest}, which enumerates + * the templates that declare the capability instead of a list kept by hand. + * What stays here is the kind-level coverage and the floor every preset owes + * whether or not it declares anything.
+ */ +class ModuleSectionKindCoverageTest { + + /** Presets that render every section the document carries, in order. */ + private static StreamThat equivalence is the whole basis of the runtime module: it renders + * through the existing components rather than beside them, so the two + * authoring routes are two spellings of one document. Left unchecked it is a + * claim in a Javadoc, and the failure it hides is silent — a module that + * merely looks close, on a preset nobody re-renders, in a CV nobody compares + * side by side.
+ * + *Each case pins both halves of "the same": the layout snapshot, which + * carries node structure and bounds but not text, and the extracted PDF text, + * which carries the words but not their positions. Either alone passes + * documents the other would catch.
+ */ +class ModuleSectionParityTest { + + @Test + void datedEntriesMatchAHandWrittenEntriesSection() throws Exception { + CvSection handWritten = EntriesSection.builder("Professional Experience") + .entry("Senior Backend Engineer", "Acme GmbH", "2021 - Present", + "Cut p99 latency by 40%.") + .entry("Backend Engineer", "Northwind Systems", "2018 - 2021", + "Owned the billing service.") + .build(); + + CvSection module = ModuleSection.builder("Professional Experience", + SectionRole.EXPERIENCE, CvKind.ENTRIES_DATED) + .item(CvItem.of("Senior Backend Engineer").at("Acme GmbH") + .period("2021 - Present").paragraphs("Cut p99 latency by 40%.")) + .item(CvItem.of("Backend Engineer").at("Northwind Systems") + .period("2018 - 2021").paragraphs("Owned the billing service.")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void anInlineListMatchesAHandWrittenPlainRowsSection() throws Exception { + CvSection handWritten = RowsSection.builder("Additional Information", RowStyle.PLAIN) + .row("Languages", "English (Fluent), German (B2)") + .row("Interests", "Chess, long-distance cycling") + .build(); + + CvSection module = ModuleSection.builder("Additional Information", + SectionRole.OTHER, CvKind.INLINE_LIST) + .item(CvItem.of("Languages").paragraphs("English (Fluent)", "German (B2)")) + .item(CvItem.of("Interests").paragraphs("Chess", "long-distance cycling")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void oneLineBulletsMatchAHandWrittenBulletedRowsSection() throws Exception { + CvSection handWritten = RowsSection.builder("Highlights", RowStyle.BULLETED) + .row("Throughput", "Doubled it") + .row("Onboarding", "Cut to two days") + .build(); + + CvSection module = ModuleSection.builder("Highlights", SectionRole.OTHER, CvKind.BULLETS) + .item(CvItem.of("Throughput").paragraphs("Doubled it")) + .item(CvItem.of("Onboarding").paragraphs("Cut to two days")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void stackedBulletsMatchAHandWrittenStackedRowsSection() throws Exception { + CvSection handWritten = RowsSection.builder("Projects", RowStyle.BULLETED_STACKED) + .row("GraphCompose (Java 21, PDFBox)", + "A declarative layout engine for programmatic documents.") + .row("Ledger (Kotlin)", "Double-entry bookkeeping for small studios.") + .build(); + + // paragraphs(), not bullets(): a stacked row indents its description under + // the title, which is what prose does. BodyStyle.BULLETS asks for a bullet + // on each description line instead — a different shape, pinned by the case + // below rather than smuggled into this comparison. + CvSection module = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("GraphCompose (Java 21, PDFBox)") + .paragraphs("A declarative layout engine for programmatic documents.")) + .item(CvItem.of("Ledger (Kotlin)") + .paragraphs("Double-entry bookkeeping for small studios.")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void aBulletedBodyNestsABulletUnderTheItemsOwn() throws Exception { + CvSection prose = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("GraphCompose").paragraphs("Shipped it", "Measured it")) + .build(); + CvSection bulleted = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("GraphCompose").bullets("Shipped it", "Measured it")) + .build(); + + assertThat(text(bulleted)) + .as("BodyStyle.BULLETS must reach the page as bullets, not as indented prose") + .isNotEqualTo(text(prose)) + .contains("• Shipped it", "• Measured it"); + } + + @Test + void proseMatchesAHandWrittenParagraphSection() throws Exception { + CvSection handWritten = new ParagraphSection("Professional Summary", + "Backend engineer with ten years on payment systems."); + + CvSection module = ModuleSection.summary("Professional Summary", + "Backend engineer with ten years on payment systems."); + + assertSameRender(handWritten, module); + } + + @Test + void undatedEntriesMatchAHandWrittenEntriesSectionWithBlankDates() throws Exception { + // The blank-date path is a change to EntryRenderer itself, so pin it the + // same way: an undated module and the hand-written section that has always + // been able to express one must produce the same layout. + CvSection handWritten = EntriesSection.builder("Certifications") + .entry("AWS Solutions Architect", "Amazon", "", "") + .entry("CKA", "Linux Foundation", "", "") + .build(); + + CvSection module = ModuleSection.builder("Certifications", SectionRole.OTHER, + CvKind.ENTRIES) + .item(CvItem.of("AWS Solutions Architect").at("Amazon")) + .item(CvItem.of("CKA").at("Linux Foundation")) + .build(); + + assertSameRender(handWritten, module); + } + + @Test + void anUndatedEntryDropsTheDateColumnRatherThanReservingIt() throws Exception { + // The kind's whole contract is that it ignores the period. Rendering an + // empty date column instead would still "ignore" it while narrowing every + // title on the page, so pin the shape, not just the absent text. + CvSection dated = ModuleSection.builder("Certifications", SectionRole.OTHER, + CvKind.ENTRIES_DATED) + .item(CvItem.of("AWS Solutions Architect").at("Amazon").period("2024")) + .build(); + CvSection undated = ModuleSection.builder("Certifications", SectionRole.OTHER, + CvKind.ENTRIES) + .item(CvItem.of("AWS Solutions Architect").at("Amazon").period("2024")) + .build(); + + assertThat(layoutJson(undated)) + .as("an undated entry must not lay out like a dated one") + .isNotEqualTo(layoutJson(dated)); + assertThat(text(undated)).contains("AWS Solutions Architect", "Amazon"); + assertThat(text(undated)) + .as("the period must not reach the page under CvKind.ENTRIES") + .doesNotContain("2024"); + assertThat(text(dated)).contains("2024"); + } + + @Test + void anItemLinkRendersAsAClickableTitle() throws Exception { + CvSection module = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("GraphCompose").linkedTo("https://example.dev/gc") + .paragraphs("A layout engine.")) + .build(); + + assertThat(text(module)) + .as("the link URL is the target, not the visible text") + .contains("GraphCompose") + .doesNotContain("https://example.dev/gc"); + assertThat(text(module)) + .as("markdown markers are instructions, not content — none may reach the page") + .doesNotContain("*", "[", "]"); + assertThat(externalLinkTargets(module)).contains("https://example.dev/gc"); + } + + @Test + void aBracketedTitleNeverLeaksItsUrlAsVisibleText() throws Exception { + // The markdown link label admits no brackets, so wrapping this title would + // match nothing and print the whole construction. Losing the click target + // is the acceptable outcome here; printing the URL is not. + CvSection module = ModuleSection.builder("Projects", SectionRole.PROJECTS, + CvKind.BULLETS_STACKED) + .item(CvItem.of("Ledger [v2]").linkedTo("https://example.dev/ledger") + .paragraphs("Double-entry bookkeeping.")) + .build(); + + assertThat(text(module)) + .contains("Ledger [v2]", "Double-entry bookkeeping.") + .doesNotContain("https://example.dev/ledger"); + } + + @Test + void aTitleOnlyBulletHasNoColonPointingAtNothing() throws Exception { + CvSection module = ModuleSection.builder("Interests", SectionRole.OTHER, CvKind.BULLETS) + .item("Chess") + .item("Long-distance cycling") + .build(); + + assertThat(text(module)) + .contains("Chess", "Long-distance cycling") + .doesNotContain("Chess:", "cycling:"); + } + + @Test + void anInlineListWithNothingToListRendersItsLabelAlone() throws Exception { + CvSection module = ModuleSection.builder("Languages", SectionRole.LANGUAGES, + CvKind.INLINE_LIST) + .item("English") + .build(); + + assertThat(text(module)).contains("English").doesNotContain("English:"); + } + + @Test + void everyKindIgnoresExactlyTheFieldsItSaysItIgnores() throws Exception { + // The contract that makes one item record serve every module is that the + // kind decides what is read. Stated in CvKind's Javadoc and the docs table; + // pinned here, per kind, by rendering one item that carries everything. + CvItem everything = CvItem.of("Item title") + .at("SubtitleValue").in("LocationValue").period("PeriodValue") + .paragraphs("Body line."); + + assertThat(render(CvKind.PARAGRAPH, everything)) + .as("PARAGRAPH reads the body alone") + .contains("Body line.") + .doesNotContain("Item title", "SubtitleValue", "PeriodValue", "LocationValue"); + assertThat(render(CvKind.BULLETS, everything)) + .as("BULLETS reads title and body") + .contains("Item title", "Body line.") + .doesNotContain("SubtitleValue", "PeriodValue", "LocationValue"); + assertThat(render(CvKind.BULLETS_STACKED, everything)) + .as("BULLETS_STACKED reads title and body") + .contains("Item title", "Body line.") + .doesNotContain("SubtitleValue", "PeriodValue", "LocationValue"); + assertThat(render(CvKind.INLINE_LIST, everything)) + .as("INLINE_LIST reads title and body") + .contains("Item title", "Body line.") + .doesNotContain("SubtitleValue", "PeriodValue", "LocationValue"); + assertThat(render(CvKind.ENTRIES, everything)) + .as("ENTRIES reads everything but the period") + .contains("Item title", "SubtitleValue", "LocationValue", "Body line.") + .doesNotContain("PeriodValue"); + assertThat(render(CvKind.ENTRIES_DATED, everything)) + .as("ENTRIES_DATED reads every field") + .contains("Item title", "SubtitleValue", "LocationValue", "PeriodValue", + "Body line."); + } + + private static String render(CvKind kind, CvItem item) throws Exception { + return text(ModuleSection.of("Section", SectionRole.OTHER, kind, item)); + } + + // -- helpers --------------------------------------------------------- + + private static void assertSameRender(CvSection handWritten, CvSection module) throws Exception { + assertThat(layoutJson(module)) + .as("a runtime module must lay out node-for-node like the hand-written section") + .isEqualTo(layoutJson(handWritten)); + assertThat(text(module)) + .as("...and carry the same words: the snapshot above compares structure, not content") + .isEqualTo(text(handWritten)); + } + + private static String layoutJson(CvSection section) throws Exception { + try (DocumentSession session = newSession()) { + ModernProfessional.create().compose(session, docWith(section)); + return LayoutSnapshotJson.toJson(session.layoutSnapshot()); + } + } + + private static String text(CvSection section) throws Exception { + try (DocumentSession session = newSession()) { + ModernProfessional.create().compose(session, docWith(section)); + try (PDDocument pdf = Loader.loadPDF(session.toPdfBytes())) { + return new PDFTextStripper().getText(pdf).replaceAll("\\s+", " ").trim(); + } + } + } + + private static java.util.ListPresets with a designed layout place sections into fixed slots, and they + * chose what goes where by matching the heading against a list of English + * words each kept privately. A CV headed {@code Berufserfahrung} or + * {@code Опыт работы} 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 states its {@link SectionRole}, so the routing has an answer + * that does not depend on the language the CV is written in. Every heading + * here is deliberately in Russian and German: if any preset still routes by + * keyword, its slot stays empty and this goes red.
+ */ +class RoleRoutingTest { + + private static StreamThe preset used to hold its body in one row. A row is atomic — it must fit + * the page it starts on — so the preset capped each block (two jobs, two degrees, + * seven skills, three projects, three additional rows) and dropped the rest + * without a word: the PDF looked finished and a reader had no way to know a job + * was never drawn. The body is a column flow now, so content that does not fit + * belongs on page two rather than in the bin.
+ * + *The entry subtitle — the employer — is not asserted here because this preset + * draws position, date and description but never the subtitle. That omission and + * its fix live in their own change; this one is about what the caps and the atomic + * row cost.
+ */ +class MonogramSidebarPaginationTest { + + /** + * The floor a continuation page has to clear regardless of what the preset + * reserves — past the widest non-printable band in common consumer printers. + */ + private static final double PRINTER_SAFE_MINIMUM = 24.0; + + @Test + void everyEntryOfADenseCvSurvivesIntoThePdf() throws Exception { + String text = renderText(denseDocument()); + + assertThat(text) + .describedAs("sidebar: every degree, not the first two") + .contains("University of Manchester") + .contains("Imperial College London") + .contains("Open University") + .contains("University of Leeds"); + + assertThat(text) + .describedAs("sidebar: every skill, not the first seven") + .contains("Groovy", "Python", "SQL") + .contains("PDFBox", "PostScript", "ICC colour profiles", "font metrics") + .contains("JUnit 5", "AssertJ", "Pitest"); + + assertThat(text) + .describedAs("main column: every position, not the first two") + .contains("PRINCIPAL PLATFORM ENGINEER") + .contains("SENIOR BACKEND ENGINEER") + .contains("BACKEND ENGINEER") + .contains("SOFTWARE ENGINEER") + .contains("JUNIOR DEVELOPER"); + + assertThat(text) + .describedAs("main column: every project, not the first three") + .contains("GraphCompose") + .contains("Ledger Sync") + .contains("Field Atlas") + .contains("Harbour Watch"); + + assertThat(text) + .describedAs("main column: every additional row, not the first three") + .contains("Languages") + .contains("Work Eligibility") + .contains("Open Source") + .contains("Speaking"); + } + + @Test + void aDenseCvPaginatesInsteadOfOverflowing() throws Exception { + LayoutSnapshot snapshot = layoutOf(denseDocument()); + + assertThat(snapshot.totalPages()) + .describedAs("the fixture outgrows one page; a row would have thrown " + + "here rather than opened a second one") + .isGreaterThan(1); + // Only the sidebar outgrows page one on this fixture — the main column + // finishes on it — which is the point of a column flow: each column takes + // the pages it needs, and neither waits for the other. + assertThat(lastPageOf(snapshot, "CvV2MonogramSidebarSidebar")) + .describedAs("the sidebar continues past the first page") + .isGreaterThan(0); + assertThat(lastPageOf(snapshot, "CvV2MonogramSidebarMain")) + .describedAs("while the main column finishes on the first — every " + + "column of a flow opens on the same page, so only where it " + + "ends says anything") + .isZero(); + } + + @Test + void theColumnsStaySideBySide() throws Exception { + LayoutSnapshot snapshot = layoutOf(denseDocument()); + LayoutNodeSnapshot sidebar = column(snapshot, "CvV2MonogramSidebarSidebar"); + LayoutNodeSnapshot main = column(snapshot, "CvV2MonogramSidebarMain"); + + assertThat(sidebar.placementX()) + .describedAs("the sidebar is the left column") + .isLessThan(main.placementX()); + assertThat(sidebar.startPage()) + .describedAs("and both columns open on the same page") + .isEqualTo(main.startPage()); + } + + @Test + void aContinuationPageKeepsItsFirstLineOffTheTrimmedEdge() throws Exception { + // RECOMMENDED_MARGIN is 0 so the sidebar fill reaches the paper edge, and + // the columns' padding is an edge of the columns rather than of each page. + try (DocumentSession session = newSession()) { + MonogramSidebar.create().compose(session, denseDocument()); + LayoutGraph graph = session.layoutGraph(); + double pageHeight = session.canvas().height(); + + assertThat(graph.totalPages()).isGreaterThan(1); + for (int page = 1; page < graph.totalPages(); page++) { + double gap = firstLineTopGap(graph, pageHeight, page); + // Two floors. The literal is the requirement and does not move when + // the preset changes its mind; the constant is what the preset + // actually asks for, so reserving less than it advertises is named + // as that rather than passing silently. + assertThat(gap) + .describedAs("page %d clears the widest common non-printable band", + page + 1) + .isGreaterThanOrEqualTo(PRINTER_SAFE_MINIMUM); + assertThat(gap) + .describedAs("page %d gets the inset the preset asks for", page + 1) + .isGreaterThanOrEqualTo(ContinuationSafeArea.PRINTER_SAFE_TOP); + } + } + + // The same document with the preset's rule taken back off, so the number + // above is attributable to the rule rather than to whatever else happened to + // sit at the top of the page. + try (DocumentSession session = newSession()) { + MonogramSidebar.create().compose(session, denseDocument()); + session.pageMargins(List.of()); + LayoutGraph graph = session.layoutGraph(); + + assertThat(firstLineTopGap(graph, session.canvas().height(), 1)) + .describedAs("without the rule the first line lands on the trimmed edge") + .isLessThan(PRINTER_SAFE_MINIMUM); + } + } + + @Test + void theFirstPageKeepsTheTopEdgeItsOwnDesignGaveIt() throws Exception { + // A safe area applied to every page rather than to the continuations would + // push the monogram badge and the whole first page down with it. + try (DocumentSession session = newSession()) { + MonogramSidebar.create().compose(session, denseDocument()); + double withRule = firstLineTopGap( + session.layoutGraph(), session.canvas().height(), 0); + + session.pageMargins(List.of()); + double withoutRule = firstLineTopGap( + session.layoutGraph(), session.canvas().height(), 0); + + assertThat(withRule) + .describedAs("page one is laid out identically either way") + .isCloseTo(withoutRule, within(0.01)); + } + } + + @Test + void everySectionHeadingIsBoundToTheBlockItIntroduces() { + // Once a column breaks at all, a heading can close a page with its list + // overleaf, or be parted from its own rule — neither of which an atomic row + // could do. Asserted on the tree rather than on a rendered page because a + // fixture that happens to break at a heading is a coincidence, and one that + // stops breaking there would take the guard with it. The pagination + // behaviour of the flag itself is the engine's own SectionKeepWithNextTest. + try (DocumentSession session = newSession()) { + MonogramSidebar.create().compose(session, denseDocument()); + + ListThe preset used to hold its body in one row. A row is atomic — it must + * fit the page it starts on — so the preset capped each block (two jobs, two + * degrees, five skills, three languages, two projects) and dropped the rest + * without a word: the PDF looked finished and a reader had no way to know a + * job was never drawn. The body is a column flow now, so the content that + * does not fit belongs on page two rather than in the bin.
+ * + *Every assertion below names an item the old caps dropped first, in both + * columns, so a cap reintroduced anywhere fails here rather than shipping.
+ */ +class SidebarPortraitContentFidelityTest { + + /** + * The floor a continuation page has to clear regardless of what the preset + * chooses to reserve — roughly 8.5mm, past the widest non-printable band in + * common consumer printers. Stated here as a literal so the guard measures the + * requirement rather than the preset's own constant. + */ + private static final double PRINTER_SAFE_MINIMUM = 24.0; + + @Test + void everyEntryOfADenseCvSurvivesIntoThePdf() throws Exception { + String text = renderText(denseDocument()); + + assertThat(text) + .describedAs("sidebar: every degree, not the first two") + .contains("University of Manchester") + .contains("Imperial College London") + .contains("Open University") + .contains("University of Leeds"); + + assertThat(text) + .describedAs("sidebar: every skill, not the first five") + .contains("Java 21", "Kotlin", "Groovy", "Python", "SQL") + .contains("PDFBox", "PostScript", "ICC colour profiles", "font metrics") + .contains("JUnit 5", "AssertJ", "Pitest"); + + assertThat(text) + .describedAs("sidebar: every language, not the first three") + .contains("ENGLISH", "GERMAN", "FRENCH", "UKRAINIAN", "SPANISH"); + + assertThat(text) + .describedAs("main column: every employer, not the first two") + .contains("Acme Rendering") + .contains("Northwind Data") + .contains("Helios Print") + .contains("Meridian Labs") + .contains("Nikoplast"); + + assertThat(text) + .describedAs("main column: every project, not the first two") + .contains("GraphCompose") + .contains("Ledger Sync") + .contains("Field Atlas"); + } + + @Test + void aDenseCvPaginatesInsteadOfOverflowing() throws Exception { + LayoutSnapshot snapshot = layoutOf(denseDocument()); + + assertThat(snapshot.totalPages()) + .describedAs("the fixture outgrows one page; a row would have " + + "thrown here rather than opened a second one") + .isGreaterThan(1); + + // Both columns have to reach the later pages. A flow that carried only + // the main column over would leave the sidebar's tail behind — the same + // silent loss the caps used to cause, one layer down. The column node's + // own endPage says it directly, without depending on where a child + // happens to start. + assertThat(lastPageOf(snapshot, "CvV2SidebarPortraitSidebar")) + .describedAs("the sidebar continues past the first page") + .isGreaterThan(0); + assertThat(lastPageOf(snapshot, "CvV2SidebarPortraitMain")) + .describedAs("the main column continues past the first page") + .isGreaterThan(0); + } + + @Test + void theColumnsStaySideBySide() throws Exception { + // Stacking the columns instead of placing them beside each other would + // satisfy every assertion above; only geometry rules it out. + LayoutSnapshot snapshot = layoutOf(denseDocument()); + LayoutNodeSnapshot sidebar = column(snapshot, "CvV2SidebarPortraitSidebar"); + LayoutNodeSnapshot main = column(snapshot, "CvV2SidebarPortraitMain"); + + assertThat(sidebar.placementX()) + .describedAs("the sidebar is the left column") + .isLessThan(main.placementX()); + assertThat(sidebar.startPage()) + .describedAs("and both columns open on the same page") + .isEqualTo(main.startPage()); + } + + @Test + void aContinuationPageKeepsItsFirstLineOffTheTrimmedEdge() throws Exception { + // RECOMMENDED_MARGIN is 0 so the sidebar fill reaches the paper edge, and + // the columns' padding is an edge of the columns rather than of each page: + // it holds page one's content down and says nothing about page two's. The + // preset asks for a page-margin rule to cover the difference; this is the + // measurement that says it arrived. + try (DocumentSession session = newSession()) { + SidebarPortrait.create().compose(session, denseDocument()); + LayoutGraph graph = session.layoutGraph(); + double pageHeight = session.canvas().height(); + + assertThat(graph.totalPages()) + .describedAs("the fixture has to reach a second page for this to mean anything") + .isGreaterThan(1); + for (int page = 1; page < graph.totalPages(); page++) { + double gap = firstLineTopGap(graph, pageHeight, page); + // Two floors, deliberately. The literal is the requirement — it + // does not move when the preset's constant does, so lowering + // CONTINUATION_TOP_SAFE_AREA to zero fails here rather than + // quietly turning this assertion into `>= 0`. The constant is the + // preset's advertised promise, checked separately so a preset that + // reserves less than it documents is named as that. + assertThat(gap) + .describedAs("page %d clears the widest common non-printable band", page + 1) + .isGreaterThanOrEqualTo(PRINTER_SAFE_MINIMUM); + assertThat(gap) + .describedAs("page %d gets the inset the preset advertises", page + 1) + .isGreaterThanOrEqualTo(SidebarPortrait.CONTINUATION_TOP_SAFE_AREA); + } + } + + // The same document with the preset's rule taken back off, so the number + // above is attributed to the rule and not to whatever else is on the page. + // A preset that stopped asking for the safe area fails the loop above; a + // preset whose padding silently started covering it fails here. + try (DocumentSession session = newSession()) { + SidebarPortrait.create().compose(session, denseDocument()); + session.pageMargins(List.of()); + LayoutGraph graph = session.layoutGraph(); + + assertThat(firstLineTopGap(graph, session.canvas().height(), 1)) + .describedAs("without the rule the first line lands on the trimmed edge") + .isLessThan(6.0); + } + } + + @Test + void theFirstPageKeepsTheTopEdgeItsOwnDesignGaveIt() throws Exception { + // A safe area applied to every page rather than to the continuations would + // push the portrait, the hero strip and the whole first page down with it. + try (DocumentSession session = newSession()) { + SidebarPortrait.create().compose(session, denseDocument()); + double withRule = firstLineTopGap( + session.layoutGraph(), session.canvas().height(), 0); + + session.pageMargins(List.of()); + double withoutRule = firstLineTopGap( + session.layoutGraph(), session.canvas().height(), 0); + + assertThat(withRule) + .describedAs("page one is laid out identically either way") + .isCloseTo(withoutRule, org.assertj.core.data.Offset.offset(0.01)); + // Pinned as a number too: comparing the two runs alone would pass just + // as well if the preset had stopped asking for a safe area at all. The + // hero strip's 59pt top margin plus its 19pt padding is what puts the + // first glyph here, and no page rule may move it. + assertThat(withRule) + .describedAs("and it is the hero strip's own top edge, 59 + 19") + .isCloseTo(78.0, org.assertj.core.data.Offset.offset(0.5)); + } + } + + // -- helpers ----------------------------------------------------------- + + /** + * Distance from the trimmed top edge to the top of the highest line of text on + * a page. Measured on text fragments rather than placed nodes: a paragraph that + * broke across the page boundary belongs to a node that started on the previous + * page, and it is exactly that line the reader sees at the top of this one. + */ + private static double firstLineTopGap(LayoutGraph graph, double pageHeight, int pageIndex) { + return graph.fragments().stream() + .filter(fragment -> fragment.pageIndex() == pageIndex) + .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload) + .mapToDouble(fragment -> pageHeight - (fragment.y() + fragment.height())) + .min() + .orElseThrow(() -> new AssertionError("no text on page " + (pageIndex + 1))); + } + + private static LayoutNodeSnapshot column(LayoutSnapshot snapshot, String name) { + return snapshot.nodes().stream() + .filter(node -> name.equals(node.entityName())) + .findFirst() + .orElseThrow(() -> new AssertionError("no column named " + name)); + } + + private static int lastPageOf(LayoutSnapshot snapshot, String columnName) { + return column(snapshot, columnName).endPage(); + } + + private static String renderText(CvDocument doc) throws Exception { + byte[] pdf; + try (DocumentSession session = newSession()) { + SidebarPortrait.create().compose(session, doc); + pdf = session.toPdfBytes(); + } + try (PDDocument document = Loader.loadPDF(pdf)) { + // Collapse wrapping the way the sibling fidelity test does: an item + // that reached the page but broke across two lines is present, and + // an assertion that failed on the line break would be reporting the + // wrong thing. (PDFBox's Latin GSUB also eats "ti" in the text + // layer, which is why no assertion here spans that pair.) + return new PDFTextStripper().getText(document).replaceAll("\\s+", " "); + } + } + + private static LayoutSnapshot layoutOf(CvDocument doc) throws Exception { + try (DocumentSession session = newSession()) { + SidebarPortrait.create().compose(session, doc); + return session.layoutSnapshot(); + } + } + + private static DocumentSession newSession() { + return GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(DocumentInsets.of(SidebarPortrait.RECOMMENDED_MARGIN)) + .create(); + } + + /** + * A career denser than any one page: five jobs, four degrees, a dozen + * skills, five languages, three projects. Every block reaches past the + * cap that used to truncate it. + */ + private static CvDocument denseDocument() { + ListA container's padding is an edge of the container: it is reserved once, at the + * top of the page the container 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 template, which + * sets the page margin to zero so a page background can reach the paper edge — and + * thereby gives up the safe area on all four sides when only the two horizontal ones + * had to go. A body that runs past its first page then resumes wherever the break + * left it, which can be a couple of points from the edge.
+ * + *{@link #applyTo(DocumentSession, int, double)} raises the top margin from a + * given page onward and keeps the other three edges of whatever margin the caller + * chose, so the layout stays full-bleed horizontally and page backgrounds — which + * are ratios of the page, not of the content box — keep bleeding on every page.
+ * + *This lives in {@code templates} rather than in the engine because the rule it + * writes is a design decision — which page, which edge, how much — over a per-page + * margin mechanism the engine already exposes. A template that wants a different + * shape writes its own {@link PageMarginRule}.
+ * + * @author Artem Demchyshyn + * @since 2.3.0 + */ +public final class ContinuationSafeArea { + + /** + * Half an inch — the safe area every common consumer printer's non-printable + * band fits inside. A template is free to ask for more or less; this is the + * number to reach for when there is no reason to pick another. + */ + public static final double PRINTER_SAFE_TOP = 36.0; + + private ContinuationSafeArea() { + } + + /** + * Reserves {@code topSafeArea} at the top of every page from + * {@code firstContinuationPage} onward, leaving the pages before it alone. + * + *Does nothing at all when the document margin already provides the safe + * area, so a template can call this unconditionally: a document that does not + * need per-page geometry is not given any, and any rules the caller set + * themselves survive untouched. When it does apply, it replaces + * the session's page-margin rules — {@link DocumentSession#pageMargins(List)} has + * no additive form — so a template that also wants rules of its own has to write + * them together rather than call this as well.
+ * + * @param document the session to configure + * @param firstContinuationPage 1-based number of the first page to inset — the + * page after the one the body starts on + * @param topSafeArea top inset in points; see {@link #PRINTER_SAFE_TOP} + * @throws IllegalArgumentException if {@code firstContinuationPage} is below 2, + * or {@code topSafeArea} is negative or not finite + */ + public static void applyTo(DocumentSession document, + int firstContinuationPage, + double topSafeArea) { + Objects.requireNonNull(document, "document"); + if (firstContinuationPage < 2) { + throw new IllegalArgumentException( + "firstContinuationPage must be 2 or greater — page 1 is where a body starts, " + + "and its own design owns its top edge. Was " + firstContinuationPage); + } + if (!Double.isFinite(topSafeArea) || topSafeArea < 0.0) { + throw new IllegalArgumentException( + "topSafeArea must be a finite, non-negative value but was " + topSafeArea); + } + DocumentInsets margin = document.margin(); + if (margin.top() >= topSafeArea) { + return; + } + document.pageMargins(List.of(PageMarginRule.from(firstContinuationPage, + new DocumentInsets(topSafeArea, margin.right(), margin.bottom(), margin.left())))); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java new file mode 100644 index 000000000..8d25a9f62 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/api/ModularCvTemplate.java @@ -0,0 +1,59 @@ +package com.demcha.compose.document.templates.cv.api; + +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.components.CvRenderKit; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.Slot; + +/** + * A CV template that renders every section placed in {@link Slot#MAIN} — + * every {@link CvKind}, under whatever heading the author wrote. + * + *The promise is exactly that, and the slot is part of it. + * Every shipped preset composes a single main column and reads + * {@code sectionsIn(Slot.MAIN)}; a section placed in {@link Slot#SIDEBAR} or + * {@link Slot#FOOTER} is dropped, by these templates as by every other, which + * is the behaviour {@link com.demcha.compose.document.templates.cv.data.CvDocument} + * has always documented. Saying so here rather than leaving "whatever the + * document hands it" to be read generously is the difference between a + * contract and a slogan — a caller assembling a CV at runtime needs to know + * that placing a module in a sidebar loses it today.
+ * + *This is the promise a CV assembled at runtime needs, and it is not one + * every preset can make. Several place their sections into fixed slots and + * guard each slot on the section's Java type, so a module routed to one is + * skipped rather than drawn; the CV still renders, minus a section, and + * looks finished. That failure is invisible from the outside, which is why + * the capability is declared in the type system rather than assumed: a + * constructor asks {@link com.demcha.compose.document.templates.cv.presets.CvTemplates#modular()} + * for the templates it may offer, and the rest stay available to callers + * who build the canonical sections by hand.
+ * + *Declaring it is not enough to have it. {@code ModularCvTemplateFidelityTest} + * enumerates the implementations and renders a document carrying 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 a keyword list — the + * last because a preset with an editorial vocabulary of its own is the one + * likely to rename what the author wrote. Each item must reach the page, so + * the interface cannot be worn by a template that would drop or retitle + * one.
+ * + *{@link #kit()} is how the promise stays compatible with a preset's own + * look: the shared lowering turns a {@link ModuleSection} into paragraphs, + * rows, and entries, and the kit draws them the way this template draws + * everything else.
+ * + * @since 2.3.0 + */ +public interface ModularCvTemplate extends DocumentTemplateA preset that wants runtime {@code ModuleSection}s to look like the + * rest of its own document implements this and hands it back through + * {@link com.demcha.compose.document.templates.cv.api.ModularCvTemplate}; + * {@link #defaults()} draws them the canonical way, and every method has a + * default, so a preset overrides only the shapes it actually styles + * differently.
+ * + *Why the primitives and not the kinds. The obvious + * alternative is a function per {@link CvKind}. It puts the wrong work on + * the preset: turning a {@link CvItem} into an entry or a row means + * deciding what a linked title looks like, how a subtitle and a location + * join, which fields the kind ignores, what an empty description does to a + * trailing colon — rules that belong to the model and must not be + * re-decided sixteen times. {@link ModuleRenderer} keeps that lowering and + * asks the kit only to draw what came out of it, which is exactly the part + * a preset has an opinion about. It is also the shape the presets already + * have: their private renderers take a {@code CvEntry} or a {@code CvRow} + * today.
+ * + *Implementations draw into the host and return; they do not set the + * host's spacing or padding, which the caller has already settled, and + * they do not insert separators between items — {@code ModuleRenderer} + * owns the gaps so that spacing stays uniform whoever is drawing.
+ * + * @since 2.3.0 + */ +public interface CvRenderKit { + + /** + * The canonical kit: every shape drawn by the shared components, which + * is what a section rendered through + * {@link SectionDispatcher#renderBody(SectionBuilder, com.demcha.compose.document.templates.cv.data.CvSection, BrandTheme)} + * has always produced. + * + * @return a kit that draws every shape the canonical way + */ + static CvRenderKit defaults() { + return DEFAULTS; + } + + /** The canonical kit. Stateless, so one instance serves every caller. */ + CvRenderKit DEFAULTS = new CvRenderKit() { + }; + + /** + * Draws one paragraph of prose. Blank text draws nothing. + * + * @param host host section receiving the paragraph + * @param text the prose; may carry inline markdown + * @param theme the active theme + */ + default void paragraph(SectionBuilder host, String text, BrandTheme theme) { + ParagraphRenderer.render(host, text, theme); + } + + /** + * Draws one label/value row with the given decoration. + * + * @param host host section receiving the row + * @param row label and body + * @param style plain, bulleted, or bulleted with the body stacked under + * the label + * @param theme the active theme + */ + default void row(SectionBuilder host, CvRow row, RowStyle style, BrandTheme theme) { + RowRenderer.render(host, row, style, theme); + } + + /** + * Draws one timeline entry. A blank {@code date} collapses the date + * column rather than reserving an empty one. + * + * @param host host section receiving the entry + * @param entry title, subtitle, date, and body + * @param theme the active theme + */ + default void entry(SectionBuilder host, CvEntry entry, BrandTheme theme) { + EntryRenderer.render(host, entry, theme); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java index 160302a03..f703bef09 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryRenderer.java @@ -44,28 +44,39 @@ public static void render(SectionBuilder section, CvEntry entry, BrandTheme them DocumentTextStyle subtitleStyle = theme.entrySubtitleStyle(); DocumentTextStyle bodyStyle = theme.bodyStyle(); - // -- title + date row ------------------------------------------- - // The two-column header is a row layout, not a paragraph, so it - // does not go through ParagraphPrimitive — its DSL shape is - // genuinely different. - section.addRow("CvV2EntryHeader", row -> row - .spacing(theme.spacing().entryHeaderRowSpacing()) - .weights(theme.spacing().entryTitleWeight(), - theme.spacing().entryDateWeight()) - .addSection("Title", titleColumn -> titleColumn - .padding(DocumentInsets.zero()) - .addParagraph(p -> p - .textStyle(titleStyle) - .align(TextAlign.LEFT) - .margin(DocumentInsets.zero()) - .rich(rich -> MarkdownInline.append(rich, entry.title(), titleStyle)))) - .addSection("Date", dateColumn -> dateColumn - .padding(DocumentInsets.zero()) - .addParagraph(p -> p - .text(entry.date()) - .textStyle(dateStyle) - .align(TextAlign.RIGHT) - .margin(DocumentInsets.zero())))); + // -- title (+ date) header -------------------------------------- + // With a date this is a two-column row, not a paragraph, so it does + // not go through ParagraphPrimitive — its DSL shape is genuinely + // different. Without one the row is dropped entirely rather than + // reserving an empty column: an undated entry — a certification, a + // project, anything a runtime module renders without dates — would + // otherwise have its title wrapped early to leave room for nothing. + if (entry.date().isBlank()) { + section.addParagraph(p -> p + .textStyle(titleStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero()) + .rich(rich -> MarkdownInline.append(rich, entry.title(), titleStyle))); + } else { + section.addRow("CvV2EntryHeader", row -> row + .spacing(theme.spacing().entryHeaderRowSpacing()) + .weights(theme.spacing().entryTitleWeight(), + theme.spacing().entryDateWeight()) + .addSection("Title", titleColumn -> titleColumn + .padding(DocumentInsets.zero()) + .addParagraph(p -> p + .textStyle(titleStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero()) + .rich(rich -> MarkdownInline.append(rich, entry.title(), titleStyle)))) + .addSection("Date", dateColumn -> dateColumn + .padding(DocumentInsets.zero()) + .addParagraph(p -> p + .text(entry.date()) + .textStyle(dateStyle) + .align(TextAlign.RIGHT) + .margin(DocumentInsets.zero())))); + } // -- italic subtitle -------------------------------------------- if (!entry.subtitle().isBlank()) { diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java new file mode 100644 index 000000000..9d2a52726 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/ModuleRenderer.java @@ -0,0 +1,230 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.templates.core.theme.BrandTheme; +import com.demcha.compose.document.templates.cv.data.BodyStyle; +import com.demcha.compose.document.templates.cv.data.CvEntry; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvRow; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.RowStyle; + +import java.util.List; + +/** + * Renders a {@link ModuleSection} by lowering it onto the renderers + * this package already ships. + * + *Nothing here draws. Each {@code CvKind} is a rule for turning + * {@link CvItem}s into the inputs {@link ParagraphRenderer}, + * {@link RowRenderer} and {@link EntryRenderer} already take, which is + * what makes a runtime-assembled module and a hand-written + * {@code EntriesSection} carrying the same content lay out the same + * way — a property the parity suite checks node for node rather than + * by eye.
+ * + *The lowering is also where a kind's documented indifference + * happens: {@code ENTRIES} builds its {@link CvEntry} with a blank + * date, so an item's {@code period} reaches no renderer at all. Every + * field a kind ignores is dropped here, in one place, rather than by + * each renderer deciding what to skip.
+ */ +public final class ModuleRenderer { + + private ModuleRenderer() { + } + + /** + * Renders every item of {@code module} into {@code host}, drawing the + * canonical way. + * + * @param host host section receiving the body + * @param module the module supplying items, kind, and role + * @param theme the active theme supplying palette, typography, and spacing + */ + public static void render(SectionBuilder host, ModuleSection module, BrandTheme theme) { + render(host, module, theme, CvRenderKit.defaults()); + } + + /** + * Renders every item of {@code module} into {@code host}, drawing + * through {@code kit}. + * + *The lowering below is the same whoever draws: which fields a kind + * reads, how a linked title is spelled, what an empty description does + * to a trailing colon. Only the three drawing calls go to the kit, so a + * preset can restyle its modules without re-deciding any of that.
+ * + * @param host host section receiving the body + * @param module the module supplying items, kind, and role + * @param theme the active theme supplying palette, typography, and spacing + * @param kit how this template draws paragraphs, rows, and entries + */ + public static void render(SectionBuilder host, ModuleSection module, BrandTheme theme, + CvRenderKit kit) { + ListThe title goes in unlinked. This row bolds its label by wrapping + * it in markdown markers, which would nest around link markup and + * reach the page as literal asterisks; a module whose titles are + * links wants {@link CvKind#BULLETS_STACKED}, which bolds through the + * text style and leaves the link intact.
+ */ + private static void bullet(SectionBuilder host, CvItem item, BrandTheme theme, + CvRenderKit kit) { + if (item.body().isEmpty()) { + // PLAIN/BULLETED end the label with a colon, which would point at + // nothing. A title-only entry is a plain bullet. + ParagraphPrimitive.writeBulleted(host, item.title(), theme.bodyBoldStyle(), + theme.decoration().bulletGlyph(), + DocumentInsets.top((float) theme.spacing().paragraphMarginTop()), theme); + return; + } + kit.row(host, new CvRow(item.title(), String.join(" ", item.body())), + RowStyle.BULLETED, theme); + } + + /** + * A bullet whose description is stacked underneath and indented to + * the title ({@link RowStyle#BULLETED_STACKED}). + */ + private static void stackedBullet(SectionBuilder host, CvItem item, BrandTheme theme, + CvRenderKit kit, boolean separate) { + // Stacked items are multi-line blocks, so they get the same gap the + // dispatcher puts between stacked rows — without it consecutive items + // read as one. + if (separate) { + host.spacer(0, theme.spacing().entrySeparation()); + } + kit.row(host, new CvRow(linkedTitle(item), ""), RowStyle.BULLETED_STACKED, theme); + // A bulleted body nests a bullet under the item's own; prose is indented + // to the title instead of carrying a second glyph. + String glyph = item.bodyStyle() == BodyStyle.BULLETS + ? theme.decoration().stackedIndent() + theme.decoration().bulletGlyph() + : theme.decoration().stackedIndent(); + for (String line : item.body()) { + ParagraphPrimitive.writeBulleted(host, line, theme.bodyStyle(), + glyph, DocumentInsets.zero(), theme); + } + } + + /** + * A timeline entry. The header goes through {@link EntryRenderer} + * with an empty body so the title / date / subtitle zones are the + * ones every other entry uses; the description follows underneath + * in the style the item asked for. + */ + private static void entry(SectionBuilder host, CvItem item, String date, + BrandTheme theme, CvRenderKit kit, boolean separate) { + if (separate) { + host.spacer(0, theme.spacing().entrySeparation()); + } + kit.entry(host, + new CvEntry(linkedTitle(item), subtitleWithLocation(item), date, ""), theme); + for (String line : item.body()) { + if (item.bodyStyle() == BodyStyle.BULLETS) { + bulletedLine(host, line, theme.bodyStyle(), theme); + } else { + ParagraphPrimitive.writeBody(host, line, theme.bodyStyle(), theme); + } + } + } + + private static void bulletedLine(SectionBuilder host, String line, + DocumentTextStyle style, BrandTheme theme) { + ParagraphPrimitive.writeBulleted(host, line, style, + theme.decoration().bulletGlyph(), + DocumentInsets.top((float) theme.spacing().paragraphMarginTop()), theme); + } + + /** + * The title, wrapped in markdown link syntax when the item carries a + * link. Every renderer here already routes titles through the shared + * markdown helper, so this needs no separate link path. + * + *A title containing a bracket is left alone. The markdown link + * pattern's label admits no brackets, so wrapping + * {@code "Ledger [v2]"} would match nothing and print the whole + * construction — URL included — as visible text. Either the title + * already carries its own {@code [text](url)}, which renders as the + * link it is, or it is prose with a bracket in it and reaches the + * page as written.
+ */ + private static String linkedTitle(CvItem item) { + if (item.link() == null + || item.title().indexOf('[') >= 0 + || item.title().indexOf(']') >= 0) { + return item.title(); + } + return "[" + item.title() + "](" + item.link().url() + ")"; + } + + /** + * The italic line under an entry title: subtitle and location joined + * when both are present, whichever exists when only one is, blank + * when neither — no separator left dangling. + */ + private static String subtitleWithLocation(CvItem item) { + if (item.subtitle().isBlank()) { + return item.location(); + } + if (item.location().isBlank()) { + return item.subtitle(); + } + return item.subtitle() + " · " + item.location(); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java index 560c5b94d..c79045b57 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionAllocation.java @@ -1,6 +1,8 @@ package com.demcha.compose.document.templates.cv.components; import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; import java.util.ArrayList; import java.util.IdentityHashMap; @@ -58,6 +60,53 @@ public static SectionAllocation of(ListHeadings are the fallback because a section that carries no role — + * every hand-written one — has nothing else to be found by. A module that + * did name a role is never claimed by a different slot's + * keywords: it would then render in two places, which is a worse failure + * than the one role routing exists to fix.
+ * + * @param role the role this slot holds; {@code null} or + * {@link SectionRole#OTHER} means "keywords only" + * @param keys candidate heading fragments + * @return the claimed section, or {@code null} when nothing matches + * @since 2.3.0 + */ + public CvSection claim(SectionRole role, ListHeading-only. A slot that knows which {@link SectionRole} it holds + * should call {@link #claim(SectionRole, List)}, so a CV written in another + * language routes on what its sections mean.
+ * * @param keys candidate title fragments; {@code null} claims nothing * @return the claimed section, or {@code null} when nothing matches */ diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java index c2280c71a..b450b8e67 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionDispatcher.java @@ -34,11 +34,31 @@ private SectionDispatcher() { * @throws IllegalStateException if the section subtype is unhandled */ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme theme) { + renderBody(host, section, theme, CvRenderKit.defaults()); + } + + /** + * Renders the section body, drawing through {@code kit}. + * + *The routing is identical to the three-argument form; only who draws + * differs. A preset with its own entry or row style passes its kit here + * so a runtime module looks like the rest of its document instead of + * like the canonical components.
+ * + * @param host host section receiving the body + * @param section the section whose subtype selects the renderer + * @param theme the active theme supplying palette, typography, and spacing + * @param kit how this template draws paragraphs, rows, and entries + * @throws IllegalStateException if the section subtype is unhandled + * @since 2.3.0 + */ + public static void renderBody(SectionBuilder host, CvSection section, BrandTheme theme, + CvRenderKit kit) { host.spacing(theme.spacing().sectionBodySpacing()) .padding(theme.spacing().sectionBodyPadding()); if (section instanceof ParagraphSection p) { - ParagraphRenderer.render(host, p.body(), theme); + kit.paragraph(host, p.body(), theme); } else if (section instanceof SkillsSection s) { SkillsRenderer.render(host, s, theme); } else if (section instanceof RowsSection r) { @@ -52,8 +72,13 @@ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme if (i > 0 && stackedNeedsSeparator) { host.spacer(0, theme.spacing().entrySeparation()); } - RowRenderer.render(host, r.rows().get(i), r.style(), theme); + kit.row(host, r.rows().get(i), r.style(), theme); } + } else if (section instanceof ModuleSection m) { + // Runtime-assembled module. The kind decides which of the + // renderers above each item lands on, so this branch draws + // nothing of its own — see ModuleRenderer. + ModuleRenderer.render(host, m, theme, kit); } else if (section instanceof EntriesSection e) { // Timeline entries (Education, Experience) get a spacer // between items — each entry is a multi-line block @@ -63,7 +88,7 @@ public static void renderBody(SectionBuilder host, CvSection section, BrandTheme if (i > 0) { host.spacer(0, theme.spacing().entrySeparation()); } - EntryRenderer.render(host, e.entries().get(i), theme); + kit.entry(host, e.entries().get(i), theme); } } else { throw new IllegalStateException( diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java index c116eb34f..e5f3a96ce 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionLookup.java @@ -50,9 +50,18 @@ public static CvSection firstMatching(ListThe default is {@code false}, which makes an unlisted subtype + * invisible rather than merely unstyled: presets filter on this + * before they route or render, so a section this method does not + * recognise never reaches a dispatcher at all. Every {@code CvSection} + * permit therefore needs a case here — the branch below for + * {@code ModuleSection} exists because the fallback dropped the section + * heading and body together, on presets that had a perfectly good + * rendering path for it.
+ * * @param section the section to inspect; may be {@code null} * @return {@code true} if the section has non-empty body, entries, - * rows, or skill groups + * rows, skill groups, or module items */ public static boolean hasContent(CvSection section) { if (section instanceof ParagraphSection paragraph) { @@ -67,6 +76,9 @@ public static boolean hasContent(CvSection section) { if (section instanceof SkillsSection skills) { return skills.groups() != null && !skills.groups().isEmpty(); } + if (section instanceof ModuleSection module) { + return !module.items().isEmpty(); + } return false; } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java new file mode 100644 index 000000000..b87f3ad16 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/components/SectionRouter.java @@ -0,0 +1,258 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.document.templates.core.identity.Link; +import com.demcha.compose.document.templates.cv.data.BodyStyle; +import com.demcha.compose.document.templates.cv.data.CvEntry; +import com.demcha.compose.document.templates.cv.data.CvItem; +import com.demcha.compose.document.templates.cv.data.CvKind; +import com.demcha.compose.document.templates.cv.data.CvRow; +import com.demcha.compose.document.templates.cv.data.CvSection; +import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ModuleSection; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.data.RowStyle; +import com.demcha.compose.document.templates.cv.data.RowsSection; +import com.demcha.compose.document.templates.cv.data.SectionRole; +import com.demcha.compose.document.templates.cv.data.SkillGroup; +import com.demcha.compose.document.templates.cv.data.SkillsSection; + +import java.util.ArrayList; +import java.util.List; + +/** + * Finds the section a preset's slot should hold, by what it means rather + * than by what it is called — and hands it back in the shape that slot + * knows how to draw. + * + *Presets with a designed layout place their sections into fixed slots, + * and they have been choosing what goes where by matching the section's + * heading against a list of English words each preset keeps privately. That + * works for a CV written in English by someone who used the expected + * headings. A CV headed {@code Ausbildung} or {@code Навыки} matches + * nothing and loses the section; so does {@code "Volunteering"}, and so does + * a second section whose heading matched a word the first one took.
+ * + *A {@link ModuleSection} carries a {@link SectionRole} because the + * author already knew the answer, so the role is asked first and the + * keywords are the fallback for the sections that have no role to give.
+ * + *The second half is the shape. These slots are written against a + * particular section type — {@code if (!(section instanceof EntriesSection + * entries)) return;} — because each draws its content its own way, and a + * module reaching one would be dropped by that guard however well it was + * routed. Each finder below therefore lowers a matched module to the type + * its slot expects, so the preset draws it exactly as it draws everything + * else. What that costs is stated per method: a module's description lines + * are joined where the target type holds one string, and a bulleted + * description reads as prose.
+ * + *These presets still drop a section that matches no slot at all — their + * whole body is one atomic row that cannot paginate, so there is nowhere to + * put it. Routing by role fixes the sections that were lost while a slot + * for them sat empty; the rest waits on pagination.
+ * + * @since 2.3.0 + */ +public final class SectionRouter { + + private SectionRouter() { + } + + /** + * The section for a timeline slot — education, experience, anything the + * preset draws as dated entries. + * + *A matched module becomes an {@link EntriesSection}: each item's + * title, its subtitle and location joined, its period (blank when the + * module's kind does not read one), and its description lines joined + * into the single body string a {@link CvEntry} holds. A description the + * author asked to bullet reads as prose here — the slot draws one + * paragraph.
+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @return an {@code EntriesSection}, or {@code null} when nothing matches + */ + public static CvSection entries(ListA matched module becomes a {@link RowsSection} in the caller's + * {@link RowStyle}: one row per item, its title the label and its + * description lines joined into the body.
+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @param style the decoration this slot draws rows with + * @return a {@code RowsSection}, or {@code null} when nothing matches + */ + public static CvSection rows(ListA matched module becomes a {@link ParagraphSection} whose body is + * every item's description, joined. The slot holds one block of prose, + * so a module with several items reads as one.
+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @return a {@code ParagraphSection}, or {@code null} when nothing matches + */ + public static CvSection paragraph(ListA matched module becomes a {@link SkillsSection}: an item with a + * description is a category whose skills are its lines, and the items + * with none are collected into one group under the module's own heading — + * a plain list of skills is a list of skills, not a set of categories + * each holding itself.
+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @return a {@code SkillsSection}, or {@code null} when nothing matches + */ + public static CvSection skills(ListRole first, and only a role the author actually chose — + * {@link SectionRole#OTHER} is what a module carries when the catalogue + * has no name for it, so it never claims a slot and falls through to the + * headings like any other section.
+ * + * @param sections the document's sections for this slot's column + * @param role the role this slot holds + * @param keys heading fragments to fall back on + * @return the section, or {@code null} when nothing matches + */ + public static CvSection find(ListThe kind decides the item's shape (a bullet, a dated entry, a + * line in a list); this decides what happens to + * {@link CvItem#body()} inside it. The same experience entry can list + * its achievements as bullets or read as a paragraph without changing + * the module's kind, which is the distinction authors actually make + * when they say "this section is bulleted".
+ * + * @since 2.3.0 + */ +public enum BodyStyle { + + /** + * Each body line is a paragraph of prose. The default: an item + * built without a stated style reads as text. + */ + PARAGRAPH, + + /** + * Each body line carries a bullet glyph and a hanging indent. + */ + BULLETS +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java new file mode 100644 index 000000000..db0f1716a --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvItem.java @@ -0,0 +1,189 @@ +package com.demcha.compose.document.templates.cv.data; + +import com.demcha.compose.document.templates.core.identity.Link; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * One entry inside a {@link ModuleSection} — the universal record every + * runtime-assembled module is built from. + * + *A job, a degree, a project, a skill category, a paragraph of a + * summary: all of them are a title plus some optional context plus a + * description. Rather than a record per shape, this carries every + * optional field and lets the section's {@link CvKind} decide which + * ones it reads — a {@code period} is drawn by + * {@link CvKind#ENTRIES_DATED} and ignored by {@link CvKind#ENTRIES}, + * with the same item on both sides. Each kind documents exactly what + * it reads.
+ * + *Only {@code title} is required, and only because a module entry + * with nothing to name it has nothing to render. Everything else is + * blank, {@code null}, or empty when the author has nothing to say — + * no placeholder text, no {@code "—"} stand-ins.
+ * + *Build one through {@link #of(String)} and the {@code with}-style + * methods, which read in the order the fields render:
+ * + *{@code
+ * CvItem.of("Senior Backend Engineer")
+ * .at("Acme GmbH")
+ * .in("Berlin, DE")
+ * .period("2021 - Present")
+ * .bullets("Cut p99 latency 40%", "Led the payments migration");
+ * }
+ *
+ * @param title what the entry is called; required, non-blank. May
+ * carry inline markdown, including {@code [text](url)}
+ * @param link optional click target for the title; {@code null}
+ * when the title is not a link. A {@code link} and a
+ * markdown link inside {@code title} do the same job —
+ * prefer this one, which needs no escaping
+ * @param subtitle employer, institution, client; blank when absent
+ * @param period date or range as the author wants it written
+ * ({@code "2021 - Present"}, {@code "2019"}); blank
+ * when absent, and read only by dated kinds
+ * @param location city, country, or "Remote"; blank when absent
+ * @param body description lines; empty when the entry is a
+ * heading only. One line renders as one paragraph or
+ * one bullet, per {@code bodyStyle}
+ * @param bodyStyle whether {@code body} reads as prose or as bullets
+ * @since 2.3.0
+ */
+public record CvItem(String title, Link link, String subtitle, String period,
+ String location, ListThis is the axis that lets one {@link CvItem} record serve every + * module: the kind decides which of the item's optional fields are + * read and which are ignored. An item carrying a + * {@code period} rendered under {@link #ENTRIES} simply does not show + * a date column — the same data under {@link #ENTRIES_DATED} does. + * Each constant below names exactly what it reads, so "ignored" is a + * documented contract rather than a surprise.
+ * + *Every kind lowers onto the renderers this package already ships + * (see {@code components.ModuleRenderer}); none of them draws + * anything a hand-built {@link RowsSection}, {@link EntriesSection} or + * {@link ParagraphSection} could not.
+ * + *The orthogonal axes are {@link SectionRole} — what the section + * means, which is what a multi-column preset places on — and + * {@link BodyStyle}, which decides how one item's description lines + * render. Keeping them apart is what lets a "Volunteering" module be + * shaped exactly like Education without a new type.
+ * + * @since 2.3.0 + */ +public enum CvKind { + + /** + * Prose — a summary, an objective, a statement. Each item renders + * as its description, one paragraph per body line. + * + *Reads {@code body} only. The {@code title} is ignored here on + * purpose: the section already carries a heading, and a prose block + * that repeated it would print the same words twice. For a labelled + * one-liner ({@code Languages: English, German}) reach for + * {@link #INLINE_LIST}, which is what that shape is.
+ */ + PARAGRAPH, + + /** + * A bullet per item, description on the same line — + * {@code • Throughput: doubled it}. The shape of a short list where + * each entry is a label and a value ({@link RowStyle#BULLETED}). + * + *Reads {@code title}, {@code link}, {@code body}. Ignores + * {@code subtitle}, {@code period}, {@code location}. A body of + * several lines is joined with spaces; if the lines are meant to + * stand apart, the module wants {@link #BULLETS_STACKED}.
+ */ + BULLETS, + + /** + * A bullet per item, description stacked underneath and indented to + * the title — the shape a Projects section takes when the + * description is a sentence rather than a value + * ({@link RowStyle#BULLETED_STACKED}). + * + *Reads {@code title}, {@code link}, {@code body}. Ignores + * {@code subtitle}, {@code period}, {@code location}.
+ * + *Inline or stacked is the module's choice, not something + * inferred from how long a description happens to be: the same + * section reads one way throughout, and an author who picked + * "bulleted list with descriptions underneath" gets it whether the + * first entry is one line or five.
+ */ + BULLETS_STACKED, + + /** + * One line per item, the description collapsed into a + * comma-separated run after a bold label — + * {@code Languages: Java 21, Kotlin, SQL}. The shape skills and + * languages take in a narrow column. + * + *Reads {@code title} and {@code body}. Ignores {@code link}, + * {@code subtitle}, {@code period}, {@code location}.
+ */ + INLINE_LIST, + + /** + * Timeline entries without the date column: bold title, italic + * subtitle line, description beneath. + * + *Reads {@code title}, {@code link}, {@code subtitle}, + * {@code location}, {@code body}. Ignores {@code period} — this + * is the kind to pick when the dates exist in the data but should + * not show.
+ */ + ENTRIES, + + /** + * Timeline entries with the date column right-aligned against the + * title — Education, Experience, and anything shaped like them. + * + *Reads every field: {@code title}, {@code link}, + * {@code subtitle}, {@code period}, {@code location}, + * {@code body}.
+ */ + ENTRIES_DATED +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java index e3da6bbe7..c5e2d2660 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvSection.java @@ -17,13 +17,21 @@ * items with four fields (title, subtitle, date, body). *Every implementation carries a {@code title} — the banner text * the renderer wraps in a styled panel above the section body.
*/ public sealed interface CvSection - permits ParagraphSection, RowsSection, EntriesSection, SkillsSection { + permits ParagraphSection, RowsSection, EntriesSection, SkillsSection, ModuleSection { /** * Banner heading shown above this section's body. diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java new file mode 100644 index 000000000..331078a78 --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/ModuleSection.java @@ -0,0 +1,178 @@ +package com.demcha.compose.document.templates.cv.data; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * A section assembled at runtime: a heading, what it means + * ({@link SectionRole}), how it draws ({@link CvKind}), and the + * {@link CvItem}s it holds. + * + *The other {@link CvSection} implementations each fix one shape at + * compile time — {@link ParagraphSection} is prose, {@link RowsSection} + * is rows, {@link EntriesSection} is a timeline. That is the right + * model for a CV written in Java, where the author picks the record and + * the compiler checks it. It is the wrong one for a CV assembled from + * data at runtime: a user who has just chosen "Volunteering, shaped + * like Education, with dates" cannot instantiate a different record per + * choice, and every new shape would mean a new type.
+ * + *So this record moves the choice into a value. One item type carries + * every optional field; the kind decides which are read and which are + * ignored; the role says where the section belongs without a preset + * having to recognise its heading. The result is that a module nobody + * anticipated needs no new code — only a different + * {@code (role, kind)} pair.
+ * + *It renders through the same components as everything else. Every + * kind lowers onto {@link ParagraphSection}-, {@link RowsSection}- or + * {@link EntriesSection}-shaped output, so a module drawn as + * {@link CvKind#ENTRIES_DATED} is laid out exactly like the + * {@code EntriesSection} carrying the same content — which the parity + * suite holds to, layout node for layout node.
+ * + *{@code
+ * ModuleSection.builder("Volunteering", SectionRole.OTHER, CvKind.ENTRIES_DATED)
+ * .item(CvItem.of("Mentor, Rails Girls")
+ * .at("Rails Girls Berlin")
+ * .period("2019 - 2021")
+ * .bullets("Ran three weekend workshops"))
+ * .build();
+ * }
+ *
+ * @param title non-blank banner heading, in the author's own words
+ * @param role what the section means; {@link SectionRole#OTHER} when
+ * the catalogue has no name for it
+ * @param kind how the items draw
+ * @param items ordered items; null entries are dropped
+ * @since 2.3.0
+ */
+public record ModuleSection(String title, SectionRole role, CvKind kind, ListMulti-column presets have to decide what belongs in a sidebar, + * and until now they decided it by matching the section's title + * against a list of English keywords each preset kept privately. A CV + * whose headings read {@code "Ausbildung"} or {@code "Навыки"} matched + * nothing, and a heading nobody anticipated was placed by whatever the + * preset does with leftovers. The role carries that decision in the + * data, where the author already knows the answer.
+ * + *It is deliberately separate from {@link CvKind}: the role says + * what a section is, the kind says how it draws. A "Volunteering" + * module shaped exactly like Education is + * {@code role = OTHER, kind = ENTRIES_DATED} — a combination no single + * enum could express without one constant per pairing.
+ * + *{@link #OTHER} is the honest default and is never a second-class + * citizen: a preset that cannot place it by role falls back to the + * heading the author wrote, in document order.
+ * + * @since 2.3.0 + */ +public enum SectionRole { + + /** Profile, objective, professional summary — the opening prose. */ + SUMMARY, + + /** Employment history. */ + EXPERIENCE, + + /** Degrees, certifications, courses. */ + EDUCATION, + + /** Technical or professional skills, however they are grouped. */ + SKILLS, + + /** Personal or professional projects. */ + PROJECTS, + + /** Spoken languages and proficiency. */ + LANGUAGES, + + /** + * Anything else — awards, volunteering, publications, interests, + * references, a section this catalogue has no name for. Carries no + * placement hint, so presets fall back to the author's own + * heading. + */ + OTHER +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java index f5ff77798..54adaf5e0 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/package-info.java @@ -40,8 +40,33 @@ * — grouped skills: category plus ordered skill labels. This * keeps skills semantic so presets can render them as tables, * sidebar chips, or inline rows without reparsing text. + *Writing a CV in Java: the four fixed shapes. The compiler checks + * the record you picked, and a project is visibly a + * {@code RowsSection} rather than a section that happens to hold + * rows.
+ * + *Assembling one from data — a form, a JSON payload, an LLM: the + * module. The section's shape and meaning arrive as values + * ({@code CvKind}, {@code SectionRole}), so a heading nobody + * anticipated — "Volunteering", shaped like Education — needs no new + * type and no new branch. Both routes render through the same + * components, and the parity suite holds them to laying out the same + * content identically, so the choice is about how the CV is authored, + * not about what it can look like.
+ * *Sections live inside a {@link com.demcha.compose.document.templates.cv.data.CvDocument}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java
index 6f70fd3ab..69e7e90c5 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/BlueBanner.java
@@ -10,6 +10,7 @@
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.api.ModularCvTemplate;
import com.demcha.compose.document.templates.cv.components.*;
import com.demcha.compose.document.templates.cv.data.*;
import com.demcha.compose.document.templates.core.theme.BrandTheme;
@@ -93,7 +94,7 @@ public static DocumentTemplate Stateless: every method takes its theme, so one instance serves
+ * every {@code create(theme)}. Not a {@code ModularCvTemplate}: this preset composes six
+ * fixed modules and finds each by matching headings, so a section
+ * it does not recognise never reaches a renderer at all. Drawing
+ * modules well and rendering every module are different promises,
+ * and it can only make the first. Built per call rather than cached: a record's methods are
+ * only reachable from an instance, and the kit closes over this
+ * template's theme. A preset is a class, and picking one at compile time is a constructor
+ * call. Picking one at runtime — from a dropdown, a config file, a
+ * request field — is a lookup, and until now every caller wrote its own:
+ * a switch, a map, a list that has to be remembered when a preset ships.
+ * The consumer this model exists for keeps exactly such a map in another
+ * repository, where nothing tells it a preset was added. {@link #modular()} is the list to offer when the document is assembled
+ * at runtime: the presets that promise to render whatever they are handed
+ * (see {@link ModularCvTemplate}). The rest stay in {@link #all()} for
+ * callers who build the canonical sections by hand — they are not lesser
+ * templates, they are templates with a fixed idea of what a CV contains. Every lookup builds a fresh template with the preset's own default
+ * theme; a caller wanting a variant calls that preset's
+ * {@code create(BrandTheme)} directly. {@code CvTemplatesCoverageTest} holds
+ * this catalogue to the presets package, so a preset added and not
+ * registered fails the build rather than staying invisible to every runtime
+ * caller. Entries take the experience styling. The preset picks
+ * between its experience and education variants by sniffing a
+ * section's heading, which is exactly what a module carries a
+ * role to avoid; until the kit is handed that role, one of the
+ * two has to be the answer, and experience is the shape most
+ * modules take.{@code
+ * CvTemplates.byId("modern-professional")
+ * .orElseThrow()
+ * .compose(session, doc);
+ * }
+ *
+ *
The body is a column flow, so each column continues on the next page and the + * preset draws every entry of the sections it recognises. It used to be a row — + * one atomic band that had to fit the page it started on — and carried per-section + * caps (two jobs, two degrees, seven skills, three projects, three additional rows) + * to stay under that bound; the rest was dropped without a word. Both are gone. A + * section this preset has no slot for is still dropped, as in every preset that + * composes a fixed layout.
+ * + *{@code compose(...)} sets two session-wide settings and replaces + * whatever the caller had set: the page backgrounds above, and a page-margin rule + * reserving a printer-safe top inset on the pages after the first. The columns' + * padding is an edge of the columns rather than of each page, so without that rule a + * body running past page one would resume at the trimmed edge — the safe area a zero + * {@link #RECOMMENDED_MARGIN} gives up along the top of the sheet as well as the + * sides.
+ * + *The monogram badge is sized for A4 and needs a page about 450pt wide or more: + * below that the sidebar column is narrower than the {@value #MONOGRAM_DIAMETER}pt + * badge and the layout fails rather than drawing it. A row slot did not check a + * child's measured width, so a narrower page used to come out with the badge laid + * over the main column.
*/ public final class MonogramSidebar { @@ -94,6 +117,13 @@ public final class MonogramSidebar { private static final double MONOGRAM_DIAMETER = 122; private static final double SIDEBAR_RULE_WIDTH = 118; + + /** + * Vertical spacing inside each column. The heading groups repeat their column's + * value, so wrapping a heading in its own section leaves the rhythm as it was. + */ + private static final double SIDEBAR_SPACING = 8.0; + private static final double MAIN_CONTENT_SPACING = 5.0; private static final double CONTACT_ICON_SIZE = 22; /** @@ -101,13 +131,7 @@ public final class MonogramSidebar { */ private static final double SIDEBAR_WIDTH_RATIO = 0.33; - private static final double MAIN_SECTION_RULE_WIDTH = 355.0; - private static final int EDUCATION_LIMIT = 2; - private static final int SKILL_LIMIT = 7; - private static final int EXPERIENCE_LIMIT = 2; - private static final int PROJECT_LIMIT = 3; - private static final int ADDITIONAL_LIMIT = 3; private static final String CONTACT_ICON_ROOT = "/templates/cv/monogram-sidebar/icons/"; @@ -316,18 +340,10 @@ public void compose(DocumentSession document, CvDocument doc) { Objects.requireNonNull(doc, "doc"); double pageInnerWidth = document.canvas().innerWidth(); - double sidebarOuterWidth = pageInnerWidth * 0.33; + double sidebarOuterWidth = pageInnerWidth * SIDEBAR_WIDTH_RATIO; double sidebarHorizontalPadding = 13.0 * 2.0; double sidebarInnerWidth = Math.max(0.0, sidebarOuterWidth - sidebarHorizontalPadding); - double mainOuterWidth = pageInnerWidth - sidebarOuterWidth; - // Main section has 20pt left + 18pt right padding (see addMain). - // Spacer width must be the content-area width so the inner - // content fills exactly the section's allocated outer width - // (= mainOuterWidth) — passing the outer width directly - // would overflow because outer = content + padding. - double mainContentWidth = Math.max(0.0, - mainOuterWidth - (20.0 + 18.0)); ListBoth session-wide settings are set by {@code compose(...)} and replace + * whatever the caller had set: the page backgrounds above, and a page-margin rule + * that reserves {@link #CONTINUATION_TOP_SAFE_AREA} at the top of every page after + * the first. The columns' padding is an edge of the columns rather than of each + * page, so without that rule a body running past page one resumed at the trimmed + * edge — the safe area a zero {@link #RECOMMENDED_MARGIN} gives up along the top + * of the sheet as well as the sides.
+ * + *The body is a column flow, so each column continues on the next page and + * the preset draws every entry of the sections it recognises. It used to be a + * row — one atomic band that had to fit the page it started on — and carried + * per-section caps (two jobs, two degrees, five skills, three languages, two + * projects) to stay under that bound; the rest was dropped without a word. Both + * are gone. A section this preset has no slot for is still dropped, as in every + * preset that composes a fixed layout.
+ * + *The portrait geometry is sized for A4. A narrower page cannot hold it: the + * photo is clipped to the sidebar column — at 420pt roughly a third of it + * survives — and below about 310pt the column runs out of room altogether and + * the layout fails rather than drawing. The clipping predates the column flow; + * the failure is new, and replaces a page that came out with the portrait laid + * over the main column.
*/ public final class SidebarPortrait { @@ -65,6 +90,22 @@ public final class SidebarPortrait { */ public static final double RECOMMENDED_MARGIN = 0.0; + /** + * Top inset (in points) the body reserves on the pages it continues onto. + * + *Half an inch: the safe area every common printer's non-printable band + * fits inside. {@link #RECOMMENDED_MARGIN} is zero so the sidebar fill reaches + * the paper edge, and a zero margin gives up the safe area at the top of the + * sheet along with the one at the sides — which only matters now that the body + * paginates and there is a page whose top edge the columns' own padding does + * not cover. Page one is unaffected: it opens where its own design puts it — + * 54pt into the sidebar column, and 59pt into the main one, where the hero + * strip's top margin starts.
+ * + * @since 2.3.0 + */ + public static final double CONTINUATION_TOP_SAFE_AREA = 36.0; + /** * Ratio of the page width allocated to the left sidebar column. */ @@ -127,18 +168,12 @@ public final class SidebarPortrait { private static final double SIDEBAR_HEADER_RULE_WIDTH = 50.0; /** - * Width of the divider rule under each main-column section title - * (Professional Profile / Experience / Projects). V1 token — - * sized to match the natural main-column inner width once the 34pt - * left + right padding is subtracted from the column's allocated - * outer width. + * Vertical spacing inside the sidebar column and inside the main column's + * content section. The heading groups repeat these so wrapping a heading in + * its own section leaves the rhythm exactly as it was. */ - private static final double MAIN_SECTION_RULE_WIDTH = 346.0; - - private static final int EDUCATION_LIMIT = 2; - private static final int SKILL_LIMIT = 5; - private static final int LANGUAGE_LIMIT = 3; - private static final int EXPERIENCE_LIMIT = 2; + private static final double SIDEBAR_SPACING = 9.0; + private static final double MAIN_CONTENT_SPACING = 10.0; private static final String TEMPLATE_ASSET_ROOT = "/templates/cv/sidebar-portrait/"; @@ -163,22 +198,6 @@ public final class SidebarPortrait { private static final ListThe side-by-side body is wrapped in a {@code flow.addRow}, - * which is atomic by engine contract (see {@code RowBuilder}'s - * error message: "tables are splittable and would conflict - * with the row's atomic pagination"). That means the whole - * sidebar + main row has to fit on a single page — content - * overflow raises {@code AtomicNodeTooLargeException} instead of - * page-breaking. Capping projects keeps the dense canonical - * sample data inside the page bound; richer CVs that genuinely - * need page-breaking sidebar layouts will need a separate - * preset wired against a future splittable-row engine primitive.
- */ - private static final int PROJECT_LIMIT = 2; - private SidebarPortrait() { } @@ -357,19 +376,32 @@ public void compose(DocumentSession document, CvDocument doc) { PageBackgroundFill.rightColumn(1.0 - SIDEBAR_WIDTH_RATIO, mainFill))); + // The columns' padding is an edge of the columns, not of each page, so + // it holds the first page's content off the trimmed edge and says + // nothing about the second's. Reserve the safe area the zero page + // margin gave up, on the pages the body continues onto only. Derived + // from the caller's own margin, so a caller who chose a margin of their + // own keeps it and a caller who already clears 36pt gets no rule at all. + ContinuationSafeArea.applyTo(document, 2, CONTINUATION_TOP_SAFE_AREA); + document.dsl() .pageFlow() .name("CvV2SidebarPortraitRoot") .spacing(theme.spacing().pageFlowSpacing()) .padding(DocumentInsets.zero()) - .addRow("CvV2SidebarPortraitBodyRow", row -> row - .spacing(0) + // A column flow rather than a row: both columns keep + // flowing onto the next page instead of having to fit the + // first one. The page backgrounds above already repeat the + // two fills per page, so a continuation page looks like the + // page it continues. + .addColumnFlow("CvV2SidebarPortraitBody", body -> body + .gap(0) .weights(SIDEBAR_WIDTH_RATIO, 1.0 - SIDEBAR_WIDTH_RATIO) - .addSection("CvV2SidebarPortraitSidebar", + .addColumn("CvV2SidebarPortraitSidebar", section -> addSidebar(section, doc, sections)) - .addSection("CvV2SidebarPortraitMain", + .addColumn("CvV2SidebarPortraitMain", section -> { section.spacing(0) .padding(DocumentInsets.zero()); @@ -386,27 +418,25 @@ private void addSidebar(SectionBuilder section, CvDocument doc, // Sidebar section deliberately has no fillColor — the // pageBackgrounds emitted in compose() paint the pale fill // edge-to-edge on every page. - section.spacing(9) + section.spacing(SIDEBAR_SPACING) .padding(new DocumentInsets(54, 20, 45.45, 26)); addPhotoBlock(section); addContactBlock(section, doc.identity()); - CvSection education = SectionLookup.firstMatching(sections, - EDUCATION_KEYS); + CvSection education = SectionRouter.entries(sections, SectionRole.EDUCATION, EDUCATION_KEYS); if (hasContent(education)) { addSidebarHeader(section, "Education"); addEducationEntries(section, education); } - CvSection skills = SectionLookup.firstMatching(sections, SKILL_KEYS); + CvSection skills = SectionRouter.skills(sections, SectionRole.SKILLS, SKILL_KEYS); if (hasContent(skills)) { addSidebarHeader(section, "Key Skills"); addSkillsList(section, skills); } - CvSection languages = SectionLookup.firstMatching(sections, - LANGUAGE_KEYS); + CvSection languages = SectionRouter.rows(sections, SectionRole.LANGUAGES, LANGUAGE_KEYS, RowStyle.PLAIN); if (hasContent(languages)) { addSidebarHeader(section, "Languages"); addLanguageList(section, languages); @@ -470,20 +500,29 @@ private void addContactBlock(SectionBuilder section, CvIdentity identity) { } } + /** + * Rule + heading as one keep-with-next group, so the pair travels to the + * next page rather than closing this one with a heading whose block is + * overleaf. A row could not break at all; a column can, which is what + * makes the stranding reachable. + */ private void addSidebarHeader(SectionBuilder section, String title) { if (title == null || title.isBlank()) { return; } - section.addLine(line -> line - .horizontal(SIDEBAR_HEADER_RULE_WIDTH) - .color(accent) - .thickness(0.75) - .margin(new DocumentInsets(12, 0, 7, 0))); - section.addParagraph(paragraph -> paragraph - .text(spacedUpper(title)) - .textStyle(sidebarHeaderStyle()) - .align(TextAlign.LEFT) - .margin(DocumentInsets.zero())); + section.addSection("CvV2SidebarPortraitSidebarHeading", heading -> heading + .spacing(SIDEBAR_SPACING) + .keepWithNext() + .addLine(line -> line + .horizontal(SIDEBAR_HEADER_RULE_WIDTH) + .color(accent) + .thickness(0.75) + .margin(new DocumentInsets(12, 0, 7, 0))) + .addParagraph(paragraph -> paragraph + .text(spacedUpper(title)) + .textStyle(sidebarHeaderStyle()) + .align(TextAlign.LEFT) + .margin(DocumentInsets.zero()))); } private void addEducationEntries(SectionBuilder section, @@ -494,9 +533,7 @@ private void addEducationEntries(SectionBuilder section, DocumentTextStyle headingStyle = sidebarEntryTitleStyle(); DocumentTextStyle metaStyle = sidebarEntryMetaStyle(); - ListThe routing half is checked end to end by {@code RoleRoutingTest}, which + * renders a foreign-language CV through every preset. What that cannot see is + * the text itself: a preset draws whatever it is handed, so a module lowered + * with the wrong separator or a doubled label renders perfectly and reads + * wrong. These cases pin the strings.
+ */ +class SectionRouterTest { + + private static ListA registry is only useful while it is complete, and the way it stops + * being complete is that someone ships a preset and forgets the one line. + * Nothing about that fails: the preset works, its tests pass, its example + * renders — it is merely invisible to every caller that picks a template by + * id, which is the whole audience the catalogue exists for. So the list is + * derived from the package rather than trusted, by reading the directory + * the presets live in.
+ * + *Reading source files rather than scanning the classpath is deliberate: + * it needs no reflection dependency, and the failure message can name the + * file to add.
+ */ +class CvTemplatesCoverageTest { + + /** The presets package, relative to this module's directory. */ + private static final Path PRESETS = Path.of( + "src/main/java/com/demcha/compose/document/templates/cv/presets"); + + @Test + void everyPresetInThePackageIsInTheCatalogue() throws IOException { + List