diff --git a/CHANGELOG.md b/CHANGELOG.md
index 568900e6a..453583921 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,229 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A proposal block carries the paragraph that opens it.** `ProposalScope`,
+ `ProposalGoals` and `ProposalGlance` each set a heading and a list, and every one-page
+ sales proposal measured for the header spine also sets a paragraph between the two —
+ five of the seven set two of them, once over the solution and once over the reasons to
+ choose the issuer. The header survey missed it, so it arrives here rather than inside
+ the first preset that needs it. All three records gain `intro`, a plain string blank
+ when absent, and each keeps its previous constructor explicitly, so existing calls
+ compile and link unchanged and every block built through them opens straight into its
+ list as before.
+
+- **A proposal carries the header a one-page sales proposal has.** The proposal model was
+ shaped for a two-page consulting document: a running header naming three things by role,
+ and a title of exactly three lines. A one-page sales proposal opens differently — an
+ addressed organisation with its own address block, a named person at it with a role and
+ two channels, a row of marked tiles whose captions are the document's own, a headline
+ broken across however many lines the design breaks it across, and a foot carrying the
+ legal entity rather than only a name. None of that had a home, and each of the seven
+ designs measured for this needs the same five things, which is why they arrive together
+ rather than one per preset: five separate additions to a published record would leave
+ the model a patchwork of near-duplicates.
+
+ `ProposalRecipient`, `ProposalAttention` and `ProposalFooter` are new;
+ `StructuredProposalData` carries them as `recipient`, `attention` and `footer`, and its
+ twelve-argument constructor is kept explicitly so existing calls compile and link
+ unchanged. `ProposalTitleLines` gains `eyebrow`, `lines` and `standfirst`, with `lines`
+ the canonical reading and `lead`/`second`/`third` its first three — one statement kept
+ consistent by construction rather than by the caller, so a preset written against the
+ three reads the same title a four-line document states. The list form is a factory,
+ `ProposalTitleLines.of`, because a second three-argument constructor taking
+ `(String, List, List)` is ambiguous for a caller passing nulls.
+ `ProposalMetaLine` gains `entries`, and those do not derive from the trio in
+ either direction: turning three roles into tiles would mean inventing their captions,
+ and reading roles back out of arbitrary tiles would mean guessing which is which. A
+ preset draws the one its design has.
+
+- **An invoice line carries where it was delivered.** A design that bills the same
+ service in more than one place — the same instance type in two datacentres, the same
+ plan in two jurisdictions — prints where beside what, as its own column next to the
+ service. `InvoiceServiceLines.Line` had nowhere to put it: `servicePeriod` is the
+ neighbouring column on such a sheet, so folding the two together collapses two columns
+ the design draws apart, and `vatRate` is a tax rate that happens to be free. `Line` now
+ carries `region` and `Columns` its caption, both plain strings blank when absent, and a
+ design with one location per invoice leaves them alone. All three constructors that
+ predate it — the ones before the per-line tax rate, the mark, and the region — are kept
+ explicitly, so existing calls compile and link unchanged and every line built through
+ them still prints no region.
+
+- **A supplier carries the legal line it prints under its name.** A footer band that
+ identifies the issuer often has to carry more than a name and an address: a
+ parent-company or regulator disclosure — "X is a subsidiary of Y" — is a statement the
+ jurisdiction requires on the sheet, and deriving it from the fields beside it is not
+ possible because it is prose, not a number. Composed from `legalName` alone, that line
+ simply went missing. `InvoiceContactBlock` now carries `legalFootnote`, a plain string
+ blank when absent, like the fields beside it; a preset with no footer band ignores it.
+ Both constructors that predate it — the one before the second registration and the one
+ before the footnote — are kept explicitly, so existing calls compile and link unchanged
+ and every block built through them still prints no disclosure.
+
+- **A billed party carries a printed registration.** A supplier could already state a
+ tax registration through `InvoiceContactBlock.taxRegistrationLabel` /
+ `taxRegistrationNumber`, and the party being billed could not — but most B2B invoices
+ print the customer's VAT or tax number too, under its address. `subline` was the only
+ spare field and it is contractually the attention line that sits *above* the address,
+ so a registration put there renders in the wrong place. `InvoiceRecipient` now carries
+ `registrationLabel` and `registrationNumber`, mirroring the pair the supplier block
+ already has, with `hasRegistration()` for the presets that draw the row only when
+ there is a number to draw. Both are plain strings, blank when absent, and the
+ six-argument constructor is kept explicitly, so existing calls compile and link
+ unchanged and every recipient built through them still prints no registration.
+
+- **An invoice line carries a mark.** A design that opens each service line with a
+ glyph — a card for a billing line, a shield for fraud screening, a globe for a hosted
+ service — had nowhere to say which one, and deriving it from the description would
+ have been guesswork dressed as a feature. `InvoiceServiceLines.Line` now carries
+ `icon`, a plain string blank when absent, exactly as `CvEntry.icon` already works on
+ the CV side: the token means something only to the preset that packages it, and a
+ preset that draws no marks ignores it. It costs the layout nothing where nothing is
+ set. Both constructors that predate it — the one before the per-line tax rate and the
+ one before the mark — are kept explicitly, so existing calls compile and link
+ unchanged and every line built through them still carries no mark.
+
+- **`CvSkill` carries the level as the document words it.** A rated skill could say how
+ much — a number in `[0, 1]` a preset draws as dots or a meter — or it could say it in
+ words by not being a skill at all and living in a `RowsSection` instead. It could not
+ say both, which is why `CharcoalGold` sets its languages as rows: that design writes
+ "Native" and "B2 – Upper Intermediate" out, and a number could not carry them back. A
+ design that shows a rating *and* names it had nowhere to go. `CvSkill` now carries
+ `note` — a plain string, blank when absent, like the fields beside it — reachable
+ through `CvSkill.of(name, level, note)`. The two channels stay separate because a
+ number cannot carry a wording and a wording cannot be measured into a meter; a preset
+ draws whichever it has room for, or both. The two-argument constructor and both
+ existing factories are kept explicitly, so existing calls compile and link unchanged
+ and every skill built through them still carries no note.
+
+- **The structured invoice model carries what a second sheet needs.** It landed with
+ one consumer, `ConsultingInvoice`, and a model shaped around one document is a model
+ nobody has tested. Fitting a second published invoice to it found six things it could
+ not say, each of them general rather than one design's whim: a brand lockup drawn as
+ a two-line monogram instead of a logo (`InvoiceBrand.monogramTop` / `monogramBottom`);
+ a second labelled registration, because a UK sender prints both a company number and
+ a VAT number (`InvoiceContactBlock.taxRegistrationLabel` / `taxRegistrationNumber`);
+ a delivery address beside the billing one (`StructuredInvoiceData.shipTo`); a tax
+ rate printed per line and its column (`InvoiceServiceLines.Line.vatRate`,
+ `Columns.vat`), written as the design shows it because the wording differs by
+ jurisdiction; and who the money is paid to, with the closing line beside the due
+ notice (`InvoicePaymentBlock.accountHolder` / `signOff`). Every addition is blank
+ when absent, and every constructor that predates one is kept explicitly, so existing
+ calls compile and link unchanged — `ConsultingInvoice` passes its snapshot and pixel
+ gates untouched, which is the proof.
+
+- **`CvEntry` carries a link.** An entry that points somewhere — a repository, a case
+ study, a company — had no way to say so, and a preset had no way to make its title
+ reachable. `CvEntry` now carries `link`, a plain string blank when absent like the
+ fields beside it, set through `CvEntry.Builder.link(...)`. It costs the layout
+ nothing: a link is an annotation rather than ink, so a linked title and a plain one
+ are the same sheet, which is also why the parity gates cannot see it and a test
+ asserts the targets directly. The six-argument constructor is kept explicitly, so
+ existing calls compile and link unchanged.
+
+- **`CvEntry` carries a location and a mark, and gains a builder.** The record held a
+ title, a subtitle, a date and a body, which is enough for a dated block and not
+ enough for the designs that set the city beside the employer in its own colour, or
+ that open a project with an icon. Folding a location into the subtitle would have
+ merged two things a design styles apart; deriving an icon from the text would have
+ been guesswork. `CvEntry` now carries `place` and `icon` — both plain strings, blank
+ when absent, matching how `subtitle` and `date` already behave — and
+ `CvEntry.builder(title)` reaches them without counting six positions. The icon
+ vocabulary is preset-scoped: a token means something only to the preset that packages
+ it, and the presets that draw no marks ignore it. The four-argument constructor is
+ kept explicitly, so existing calls compile and link unchanged.
+
+- **`CvIdentity` carries an optional portrait.** A CV design with a photograph in it
+ had nowhere to put one: the identity record held the name, the title, the contact
+ triple and the links, and a preset that wanted a face had to ship a silhouette of its
+ own. `CvIdentity` now carries `Optional portrait` — the image
+ itself, because a photograph is caller-supplied content rather than template chrome —
+ reachable through `CvIdentity.Builder.portrait(...)`. The four- and three-argument
+ constructors are kept explicitly, so existing calls compile and link unchanged; only a
+ record deconstruction pattern over `CvIdentity` sees the extra component. A document
+ carrying a portrait still renders through every preset in the family — the ones with
+ nowhere to draw it ignore it.
+
+- **A structured invoice document model.** The invoice family's data layer knew one
+ shape — an invoice as pre-formatted display strings, with one address block per
+ party, line items whose quantity and money are already rendered, and a flat list of
+ summary rows. That cannot carry the structured business invoice: a brand lockup with
+ the sender's own logo, labelled masthead metadata, a contact block with a business
+ registration, priced service lines carrying `BigDecimal` figures and the unit they
+ are counted in, a totals stack with its own total band, bank payment fields, and a
+ footer line. `templates.data.invoice` now carries that second model —
+ `StructuredInvoiceData` (+ its section records) wrapped by
+ `StructuredInvoiceDocumentSpec` — alongside the display one; a preset consumes the
+ model whose shape it renders. The brand logo arrives as `DocumentImageData`, because
+ the logo is caller-supplied content rather than template chrome, and it stays
+ optional so a wordmark-only lockup composes. Every component normalizes `null` to
+ its empty form, money and quantities default to zero, and collections are frozen.
+
+- **A structured proposal document model.** The proposal family's data layer knew one
+ shape — a titled run of prose sections with a flat timeline and pricing list — which
+ cannot carry the structured business proposal: brand marks, an authored multi-line
+ title, an at-a-glance fact card, goal cells, a numbered scope list, authored
+ deliverable columns, a phase grid with its own headers, priced rows with a
+ `Role` (`NONE` / `SUBTOTAL` / `OPTIONAL`) and a total band, and a signing card.
+ `templates.data.proposal` now carries that second model —
+ `StructuredProposalData` (+ its section records) wrapped by
+ `StructuredProposalDocumentSpec` — alongside the narrative one; a preset consumes
+ the model whose shape it renders. Every component normalizes `null` to its empty
+ form and freezes its collections, matching the family's existing records.
+
+- **A rota document model, replacing `data.schedule`.** The library already shipped a
+ weekly-roster model — `templates.data.schedule`, public since the templates module was
+ extracted — and nothing ever rendered it, because it is not shaped like a rota is
+ drawn: its people are ordered by a token and grouped nowhere, so there are no staff
+ bands; an assignment cannot say that one half of a split day is drawn more quietly
+ than the other; a day heading is one string, which a design that raises the ordinal's
+ suffix cannot split; and a category carries three `java.awt.Color`s, which puts the
+ rendering decision in the document and stops two presets drawing the same rota in two
+ palettes. `templates.data.rota` is the replacement — `StructuredRotaData` (venue,
+ week, day columns, legend, staff bands, footer) wrapped by
+ `StructuredRotaDocumentSpec`, with `RotaVenue` / `RotaWeek` / `RotaDay` /
+ `RotaCovers` / `RotaLegend` / `RotaGroup` / `RotaStaff` / `RotaShift` / `RotaFooter`
+ and the `ShiftStatus` / `ShiftEmphasis` enumerations.
+
+ **The grid is the document's, not the preset's.** `days()` is the columns and every
+ `RotaStaff.days()` runs in that same order, so a cell is found by position and a rota
+ of five days is as ordinary as one of seven — the span is a label the document
+ carries, not a shape the model imposes. A person's day is a *list* of entries rather
+ than one, which makes the two shapes a rota actually has — a blank cell and a split
+ shift — ordinary rather than special cases a preset has to invent a representation
+ for. `RotaStaff.day(int)` answers with nothing for a day the rota does not reach, so a
+ short row is a short row and not a broken document.
+
+ **What a cell means is separate from what it prints.** `ShiftStatus` is the meaning a
+ preset colours by; the text is the word a particular site uses for it, so a rota
+ printing `A/L` and one printing `HOL` colour alike, and a legend entry pairs the two.
+ `ShiftEmphasis` says how loudly an entry is drawn — the day someone is off is what a
+ reader scans for, the hours they work is what they read once they have found the
+ person. There is deliberately no `marked(text, status)` factory that picks the
+ emphasis: a design that halves a day draws the halves differently, sometimes quiet
+ second and sometimes not, so `RotaShift.strong(...)` and `RotaShift.soft(...)` make
+ the caller say which. Every component normalizes `null` to its empty form and freezes
+ its collections, the inner day lists included, matching the family's existing records.
+
+- **One place turns a printed contact into a followable one:
+ `core.identity.ContactUri`.** The rule that a printed telephone number and the number
+ a device dials are different strings had three named homes — one in the CV presets, one
+ in the invoice presets, one in the proposal presets — byte-identical where they
+ overlapped. They are now one helper in the family-neutral identity layer, beside the
+ contact-block and link records it serves, with `tel` / `telLink` / `mailLink` /
+ `web` / `webLink` / `channelLink`. Thirteen further copies of the same rule, written
+ inline inside the presets rather than named, are swept onto it under *Fixed* below.
+
+ It is public because the three families are three packages and a helper shared between
+ them cannot be package-private; it is not marked experimental because the shape is not
+ a guess — sixteen independent implementations had already agreed on it. The link forms
+ answer `null` rather than throwing, including where the string cannot be a URI at all:
+ a notice, a name or a line of prose can reach a channel field, and losing the
+ affordance is not worth failing to compose the page over. `tel` and `web` are the
+ target forms behind `telLink` and `webLink`, for the call sites that take a plain
+ string; a target is not yet a link, so it is the link forms that carry that guarantee.
+ Nothing renders differently — a link annotation is not ink, and every pixel baseline in
+ the suite passes untouched.
+
- **Text style carries typographic tracking.** `DocumentTextStyle.builder().letterSpacing(...)`
takes a `DocumentLetterSpacing` — either `ofFontSize(0.12)`, a share of the font size, or
`points(1.2)`, an absolute amount. Negative values tighten. The unit lives in the value
@@ -119,6 +342,84 @@ follow semantic versioning; release dates are ISO 8601.
it was measured and rejected: a reserved-column approximation renders a gap that is not
the one you asked for, and misaligns outright for a marker wider than the column.
+- **A list item can be styled in pieces.** `ListBuilder.addItem(Consumer)` takes
+ the same inline runs a paragraph is made of, so `"Status: pending"` with the label bold
+ is one list item; `addItem(Consumer, Consumer)` lets a styled label
+ head a sub-tree. `addItem("plain string")` is unchanged and remains the short form.
+
+ An item was a string in one style, so a design that emphasises the opening words of each
+ bullet had to be built as a two-column row per item — which stops being a list. The
+ marker becomes a table cell, the wrapped lines are the column's business rather than the
+ item's, and nothing about it paginates as one thing.
+
+ Runs, not a second rich-text model: the same `RichText` builder `ParagraphBuilder.rich`
+ takes, so a chip, an icon, a link or a coloured span inside an item is whatever it already
+ is inside a paragraph. An item is content whatever its runs draw, which is why a row of an
+ icon and no text is still a row — it reads as the empty string, exactly as an item that
+ draws nothing does, and the runs are what separate the two.
+ `InlineRun.plainText(runs)` is that reading and `InlineRun.textRuns(runs)` its styled
+ form; a paragraph's own `text()` and `inlineTextRuns()` now come from those two, so which
+ kinds of run read as text is stated once instead of once per surface.
+
+ The geometry is the marker column's, unchanged: the marker is measured, `markerGap`
+ applies, and the first line, the lines it wraps onto and the lines that continue on the
+ next page all start at one x, with the marker drawn once. Which means `hangingIndent(true)`
+ is required — the older layout makes the marker part of the item's text and so carries one
+ style for the whole item, and rendering the plain reading instead would drop silently
+ every style, icon and chip the author asked for. A rich item in a list that has not opted
+ in says so, and names the call.
+
+ A rich item does not change what its list's marker is. Runs do not fit a list of labels,
+ so the list carries an item tree instead — and that is a change of representation, not of
+ markers: a dashed flat list still dashes at its top level when one of its items needs
+ styling. A list whose author declared depth is unaffected and still resolves every level
+ from the per-depth cascade and `markerFor(...)`.
+
+ **The semantic DOCX export carries this one**, unlike the geometry above it: Word holds a
+ style per run inside a paragraph, so a rich item writes one Word run per authored run —
+ the marker leading in the list's own style — and image, shape and SVG runs drop with the
+ same one-per-kind warning a rich paragraph's do. Because the semantic export lays nothing
+ out, it is indifferent to `hangingIndent` and needs no opt-in to carry the runs.
+
+- **A list marker can be drawn, and can carry a colour of its own.**
+ `ListBuilder.marker(Consumer)` takes the marker as inline runs, so
+ `m -> m.dot(4, ACCENT)` is a coloured disc, `m -> m.color("•", ACCENT)` is an accent
+ bullet beside near-black copy, and `m -> m.svgIcon(icon, 8)` is an icon.
+ `marker(String)` is unchanged and remains the simple form.
+
+ The marker's one span was built in the list's own text style, so a design with an accent
+ mark and dark text could not be written as a list at all. What the designs that wanted it
+ did instead was a paragraph per item — the mark, then a run of spaces standing in for the
+ gap, then the text — which buys the mark and loses the column. Measured on that
+ construction at a width that wraps: the first line's text starts at 25.246 and every line
+ after it at 12.000, because a paragraph has no marker column to hang under. The spaces are
+ a second cost. Rounded to a whole count and then measured at each item's own type size,
+ one declared 9.175pt gap came out as 9.308pt in one column of a real CV and 8.356pt in
+ another — the same constant, two gaps.
+
+ A drawn marker is measured as what it draws: a disc's column is its diameter, an icon's is
+ its box. Nothing is counted in characters and nothing is approximated, so `markerGap` is
+ points of real space after it and `contentX` is `markerX + width + gap` exactly. It is
+ measured once per distinct marker for the whole list, through the same pipeline that
+ measures the items' own content — which is also why a marker can be anything an inline run
+ can be, and why no renderer needed a new branch to draw one.
+
+ Everything the marker column already promised holds: every visual line of an item starts at
+ one x, the marker is drawn once when an item continues onto later pages, a child's marker
+ starts where its parent's text does, and the marker rides the item's first baseline without
+ making the row taller. A marker drawn larger than that line overflows it, which is the
+ answer a marker wider than its column already gets.
+
+ Because the marker column is what makes this possible, `hangingIndent(true)` is required
+ and the layout says so if it is missing. A drawn marker's plain reading is empty, so the
+ older layout — where the marker is characters at the front of the item's text — would render
+ the list with no marker at all and no signal.
+
+ **In the semantic DOCX export** a marker written as text keeps the colour and face it was
+ given, because a run's style is something Word holds. A marker that draws a disc or an icon
+ has no Word analogue, so it drops with the export's usual one-per-kind warning and its item
+ is written unmarked, rather than substituting a glyph nobody asked for.
+
- **A timeline's rail is one line, drawn from where its markers landed.**
It was a left border repeated on every entry section, which is why it sat at the entry's
edge whatever the markers did, could not stop short of them, and had no way to be
@@ -126,19 +427,30 @@ follow semantic versioning; release dates are ISO 8601.
markers' and entries' resolved positions, and contributed as one fragment per page —
one logical rail, however many pages it crosses, bounded on each by that page alone.
- Two independent choices, and they stay independent. `TimelineRailExtent` says how far the
- rail runs: `ENTRY_BOUNDS`, the default and what every existing timeline already draws, or
- `MARKER_TO_MARKER`, which starts at the first marker and stops at the last.
- `markerOnRail()` says where it runs: it aligns every marker's declared anchor with the
- timeline axis. With the current centre anchor, markers of different sizes are centred
- within the axis column and share one continuous rail — a 6pt dot, a 14pt numbered disc
- and a 24pt square all sit on the same line rather than on the same left edge. An entry's
- body moves with them into the content column beside the marker, so the line is left with
- only markers to cross — see *Fixed* below. A timeline that does not call it keeps the
- left-edge anchor and the placement it has always had.
- A timeline with one entry and `MARKER_TO_MARKER` emits no rail at all rather than a line
- of no length. `TIMELINE_BOUNDS` is named and rejected — on one page it is the same line
- as `ENTRY_BOUNDS`, and across pages there is nothing to measure it against.
+ Two independent choices, and they stay independent. `rail(r -> r.from(...).to(...))` says
+ how far the rail runs, one end at a time: each is a `TimelineRailEnd`, either
+ `ENTRY_BOUND` — the entries' own bound, the default, and what every existing timeline
+ already draws — or `MARKER`, the marker's anchor point. `markerOnRail()` says where it
+ runs: it aligns every marker's declared anchor with the timeline axis. With the current
+ centre anchor, markers of different sizes are centred within the axis column and share one
+ continuous rail — a 6pt dot, a 14pt numbered disc and a 24pt square all sit on the same
+ line rather than on the same left edge. An entry's body moves with them into the content
+ column beside the marker, so the line is left with only markers to cross — see *Fixed*
+ below. A timeline that does not call it keeps the left-edge anchor and the placement it has
+ always had.
+
+ **The two ends are chosen separately because a design that wants them the same is only one
+ of four.** A sidebar whose line begins at the first dot and carries on past the last one to
+ close the block is a real design, and with a single value naming both ends it could not be
+ asked for: `ENTRY_BOUND` at both ends drew a stub above the first dot, and `MARKER` at both
+ cut the tail the block draws on purpose. The preset that wanted it painted over the stub
+ with the page colour and redrew the line below — and measured, that did not even work,
+ because an accent draws above the mask. It now says `from(MARKER).to(ENTRY_BOUND)`, which
+ is 4.650pt shorter at the top than the first and 26.690pt longer at the foot than the
+ second.
+
+ A timeline with one entry and both ends on the marker emits no rail at all rather than a
+ line of no length.
**A leading column sits to the left of the timeline axis; it does not move the rail to
the entry boundary.** The layout is `LEADING | AXIS | CONTENT`, and the rail belongs to
@@ -199,6 +511,26 @@ follow semantic versioning; release dates are ISO 8601.
row and both align exactly. Leading content without a declared column throws too, naming
the call to add, rather than inventing a width per entry.
+- **The gap before a timeline's marker is its own number.**
+ `TimelineBuilder.leadingGap(double)` sets the space between the leading column and the
+ marker, leaving `markerGap` to mean what it already says it means — the gap between the
+ marker and the content beside it.
+
+ With one number for both, a three-column timeline could not be asked for what it looks
+ like. A row spaces every pair of its columns equally, so writing `x0` for where the
+ columns start, `L` for the leading width and `A` for the axis, one gap `s` puts the rail
+ at `x0 + L + s + A/2` and the content at `x0 + L + 2s + A`. Subtract them and
+ `L = (rail − x0) − (s + A/2)`: the leading width is decided by where the rail and the
+ content sit, whatever `A` and `s` are given. A dated timeline states all three — where
+ the dates are, where the rail is, where the copy starts — and could have any two.
+ Measured on a real one, the date column came out 55.860pt against a longest date of
+ 57.005 and broke `2022 - Present` across two lines; padding the column only narrows what
+ goes in it, and widening the axis re-pins the same number.
+
+ With the gaps separate the leading width is the caller's and the gap absorbs the
+ difference. Unset, `leadingGap` is `markerGap`, which is the single-gap layout exactly —
+ so a timeline that does not ask for it is laid out as before, snapshots included.
+
- **A timeline entry can fill its own content column.**
`TimelineBuilder.entry(Consumer)` is a longer form of the existing
`entry(marker, ...)` that takes its marker from `TimelineEntryBuilder.marker(...)` inside
@@ -446,6 +778,59 @@ follow semantic versioning; release dates are ISO 8601.
### Fixed
+- **The weekly-schedule board's foot is a footer.** The rule, its seal and the build
+ line were the last three nodes in the flow, so they were drawn wherever the board
+ happened to end — on the sample week, forty points above the bottom margin. They are
+ now a page zone, which is what they had always been describing. A zone is measured
+ from the foot of the sheet rather than from the margin, so the band reserves the
+ margin too: without that the build line printed within three millimetres of the paper
+ edge, inside the dead zone of most office printers, and this board is meant to be
+ printed. The line itself stays against the left margin, where the board's own first
+ column starts — it is a credit, not part of the device above it.
+
+ What made the old foot look off centre was the artwork, not the seal — the seal had
+ always been within a five-thousandth of a point of the centre line, because equal
+ weights centre its slot whatever is drawn beside it. The rules were the problem: fixed
+ at 292pt inside slots of 382pt, and a shape narrower than its slot is drawn at the
+ slot's left edge, so the seal carried 98 points of air on one side and 8 on the other
+ while the right rule stopped 90 points short of the margin. The rule width now comes
+ off the page — printable width less the seal and its two gaps, halved — so both rules
+ reach the seal and the right one ends on the margin. The rules also sat below the
+ seal's middle by 2.45pt of its 13.7; they now share its centre line exactly.
+
+ The seal's letter rode high in its pill, because a line box is tall enough for a
+ descender and a capital has none, so centring the box centres the unused space with
+ it. The pill's padding now carries that half descent, computed from the Times-Bold
+ metrics, and the letter's ink sits within 0.07pt of the pill's centre. The letter
+ itself is no longer the hardcoded `S`: it is the board's own initial, so a schedule
+ printed for one venue is not sealed with another's, and a blank brand is now rejected
+ rather than sealed with nothing.
+
+ Being a zone rather than flow content, the foot is drawn on every page instead of once
+ at the end, and reserves its height on every page. The sample week is a page shorter
+ than that reservation, so its table is untouched; a rota long enough to run over now
+ carries its foot on both pages.
+
+- **Five designs dialled a number they had not printed.** A printed telephone number and
+ the number a device dials are different strings, and the conversion between them lived
+ in thirteen private copies — one per design. Eight of them dropped the parenthesised
+ trunk prefix and five did not, so a sheet printing `+44 (0)20 7946 0832` linked
+ `tel:+4402079460832`, a number that reaches nobody from abroad. `ConsultingInvoice` was
+ further out still: its copy removed spaces and nothing else, so the printed brackets
+ went into the target and the link was not a dialable number at all. The five are
+ `CharcoalGold`, `NavySidebar`, `ProfessionalSidebar` and `SerifHeadline` on the CV
+ side, and `ConsultingInvoice` on the invoice side.
+
+ All thirteen copies are gone and every design routes through `ContactUri` — which is
+ what eight of them were already doing, in eight separate places: four spelling the
+ regex out inline and four naming a constant for it. That duplication is as much the
+ cost being removed as the five that got it wrong. The same sweep removes three copies
+ of the scheme-prefixing rule for a printed website, which `ContactUri` gains as `web`.
+
+ Nothing on any page moves: a link annotation is not ink, and no fixture prints a trunk
+ prefix, so no pixel baseline and no layout snapshot changes — what changes is where the
+ link goes when a caller's own number carries one.
+
- **`markerOnRail()` no longer draws the rail through the entry's text.**
Putting the markers on the rail moves the line into the middle of the axis column, and an
entry's body spanned the whole entry — so the line was drawn straight through ordinary
@@ -511,8 +896,989 @@ follow semantic versioning; release dates are ISO 8601.
alike — a wrapper handled in one and missed in the other loses a subtree just as
completely. PDF and PPTX were never affected; they draw what the layout produced.
+### Templates
+
+- **A stacked row's body now hangs under its name, not a couple of points to the left of
+ it.** The shared bullet path every themed CV preset draws its rows through —
+ `RowRenderer`, `SkillsRenderer` — put the glyph inside the paragraph's text as a
+ `bulletOffset` and indented what followed with a run of spaces measured to clear it.
+ `Decoration.stackedIndent`, the two spaces under a stacked row's bold name, carries its own
+ contract: it "must visually occupy the same width as bulletGlyph". Measured on the
+ canonical document it does not, by **0.720pt to 3.078pt** depending on the theme's type
+ size — so the body of every Projects-style row started slightly left of the name it hangs
+ under.
+
+ Each row is a list now. A bulleted row is one item, so its marker sits in a measured
+ column and every visual line of it starts at one content origin. A stacked row is one item
+ with a **markerless child**: a child's marker column opens at its parent's content origin,
+ and a child with no marker takes no marker width and no gap, so the body begins exactly
+ where the name's text begins — by construction rather than by a count of spaces. The
+ content is inline runs, so the bold label of a `label: body` row stays part of one item.
+ The gap after a bullet is a quarter of the type size, which is what the trailing space
+ inside `bulletGlyph` was approximating.
+
+ Measured per preset, at the pixel gate's own canonical document. Page counts, wrapping and
+ vertical rhythm are unchanged everywhere — the correction is horizontal:
+
+ | preset | pages | row text origin | stacked body → name | pixels (of 500 395) |
+ |---|---|---|---|---|
+ | `blue_banner` | 2 → 2 | 38.437 → 38.391 | own path, unchanged | 7 422, maxΔ 12 |
+ | `boxed_sections` | 2 → 2 | 38.003 → 38.063 | 36.180 → 38.338 (**+2.158**) | 26 085, maxΔ 201 |
+ | `centered_headline` | 2 → 2 | 35.273 → 35.221 | own path, unchanged | 8 709, maxΔ 12 |
+ | `executive` | 2 → 2 | 35.819 → 35.885 | 32.864 → 35.885 (**+3.021**) | 29 553, maxΔ 202 |
+ | `minimal_underlined` | 2 → 2 | 42.003 → 42.063 | 40.180 → 42.338 (**+2.158**) | 26 300, maxΔ 202 |
+ | `modern_professional` | 2 → 2 | 36.280 → 36.000 | 35.560 → 36.000 (**+0.440**) | 32 165, maxΔ 98 |
+ | `panel` | 1 → 1 | 33.858 → 33.802 | 313.451 → 316.440 (**+2.989**) | 29 990, maxΔ 201 |
+
+ The row text origin moves by at most 0.280pt, which is the difference between a quarter of
+ the type size and the space it replaces. Every multi-line count is identical before and
+ after, and so is every page count; the only structural change is one extra fragment per row,
+ which is the marker's own. The seven pixel baselines and seven committed previews are
+ re-recorded, with all 126 baselines and all 118 previews hashed either side: exactly seven
+ of each moved. `engineering_resume`'s baseline differs from its render by 306 pixels for a
+ reason that predates this work — verified by measuring it against the unmodified code — so
+ it is left alone, as are the four preview drifts under separate investigation.
+
+- **A long degree title no longer draws over its own institution line on Professional
+ Sidebar.** The title sat in a container of exactly `ENTRY_HEAD_HEIGHT`, centred, which put
+ its centre on the rail dot's — and works for a title of one line and no other. Measured
+ with a real degree, "MSc Advanced Computer Science and Software Engineering", the title
+ needed three lines in a sidebar that narrow and the box stayed 9.3pt tall: under
+ `OVERFLOW_VISIBLE` the extra lines drew rather than vanished, putting the title's foot
+ **16.840pt below its own institution line**, across that line and the dates under it.
+
+ A declared box that stays its declared size is the engine doing as it was told, so the fix
+ is where the box was declared. The title is now the entry's own paragraph with the band's
+ surplus over one line as air above and below it — `EDUCATION_DEGREE_AIR`, declared as air
+ rather than left implicit in a height, so a title needing two lines gets two lines with the
+ same air around them. **Nothing about the canonical sheet moves:** the paragraph occupies
+ the same 9.3pt, its centre stays on the dot's, and its wrap width is the same number,
+ because the container's declared width and the timeline's content column resolve to the
+ same expression. Zero named nodes move in the layout snapshot; the two
+ `EducationHead_*` wrapper nodes are gone with the containers, and the pixel baseline and
+ the committed preview are untouched.
+
+- **Orange Ops' skills are a real list.** Each name was a paragraph opened by an accent
+ dot, because a list marker used to take the list's own text colour and these dots are
+ accent against charcoal. The gap between dot and name was four literal spaces, which is a
+ measurement of the face rather than a distance the design states; it is now
+ `SKILL_BULLET_GAP`, the nearest round value in the sheet's own unit, and the dot is the
+ list's marker. Measured: **one named node moves, by `placementWidth` +0.067pt** — the
+ difference between those four spaces and the declared gap — with no x, no y, no page
+ ownership, still one page. 4 784 pixels of 500 395 change at zero tolerance, maxDelta 15,
+ so the pixel gate never noticed; the baseline is re-recorded anyway, with all 126 hashed
+ either side to prove only this one moved. Eleven `Skill*` paragraph nodes become one
+ `SkillNames` list node.
+
+- **Teal Pulse's dotted lines are a real list, so its one declared gap is the gap it
+ draws.** The sheet's competencies, experience highlights and certifications were a
+ paragraph per label: a teal dot, then a run of spaces standing in for the gap, then the
+ text. `TealPulseStyles.DOT_GAP` declares that gap as `px(17)` = 9.175pt, and the spaces
+ could not deliver it — the count is rounded to a whole number of them and then measured at
+ each label's own type size, so one constant rendered as **9.308pt** beside the
+ competencies, **8.356pt** beside the highlights and **8.806pt** in the closing band.
+ Wrapped labels were the larger cost: with no marker column to hang under, every line after
+ the first returned to the label's own left edge.
+
+ Each block is now one list with the dot as its marker and `DOT_GAP` as `markerGap`, so the
+ gap is that number everywhere and every line of a wrapped label starts where its first
+ line's text does. Measured: **7 named nodes moved and every one is a `placementWidth`** —
+ the sidebar 0.133pt narrower, the three highlight blocks 0.819pt wider, the certifications
+ column 0.369pt wider, each exactly its own gap correction. No x, no y, no page ownership,
+ still one page; the blocks are content-sized boxes, so the correction is all that moves
+ them. 22 883 pixels of 457 555 change at zero tolerance (maxDelta 192), inside the 50 000
+ budget the gate already allowed; the baseline and the committed preview are re-recorded for
+ it, and all 126 baselines were hashed either side to prove only Teal Pulse's moved.
+
+ The rows are one list node per block rather than one paragraph node per label, so the
+ layout snapshot has 96 named nodes where it had 121, and the per-label names
+ (`Competency_*`, `Highlight_*`, `Certification_*`) are gone with the paragraphs that
+ carried them.
+
+- **New `receipt` family — payment confirmations.** The fifth family on the layered
+ architecture, for the document a bank sends after money moves: a transfer
+ confirmation, a direct debit advice, a card receipt. `ReceiptDocumentSpec` carries
+ the amount, the two parties, titled groups of label/value rows, the steps the
+ payment went through, and the footer small print; `ModernReceipt` sequences them
+ over `BrandTheme.receiptModern()`.
+
+ It is laid out as a receipt rather than as an invoice without line items. The
+ amount is set larger than the document title, because on a receipt the amount is
+ the headline; the status carries a chip coloured by tone — settled green, failed
+ red — in every theme, so a reader checking whether money arrived does not have to
+ learn a colour scheme per issuer; the two parties share one panel with a direction
+ arrow instead of sitting in two tables the reader has to relate; detail rows are
+ joined by a dotted leader, the same construction the table-of-contents builder
+ uses, because across a full page width a bare label and a bare value read as two
+ unrelated columns. The verification QR code and the small print are seated on the
+ bottom margin: the engine has no vertical flex, so the preset measures what the
+ body left on the last page and spends it — and only when the receipt owns the
+ session, since the measurement costs a second composition pass.
+
+ The theme carries no brand colour at all. An issuer's mark and accent arrive per
+ document through `ModernReceipt.Options`, so one preset and one theme render every
+ institution rather than one theme per institution.
+
+- **`SvgGlyph.fromFile(Path)`.** The classpath variant covers glyphs a template ships
+ with; this covers the glyph a template is given — a receipt's issuer mark, a
+ report's client logo — which arrives as a file beside the running application
+ rather than repackaged into its jar.
+
+- **The invoice presets set their page number in their own face.** A header or footer
+ zone draws in the standard-14 face unless a document says otherwise, and until the
+ zone gained `fontName` there was no way to say otherwise — so `PaymentsInvoice` and
+ `WorkspaceInvoice` left the page number as the one line on an otherwise Lato sheet set
+ in Helvetica. Both now name the sheet's own face. The change is confined to that line:
+ measured at 600 dpi it moves 3 885 pixels of 34 794 400 on one sheet and 3 898 on the
+ other, all of them inside the number's own box, and nothing around it shifts.
+- **Seven CV presets make their telephone number dialable.** `EngineeringResume`,
+ `Executive`, `MintEditorial`, `MonogramSidebar`, `Panel`, `SidebarPortrait` and
+ `TimelineMinimal` already made an email and a profile reachable and printed the phone
+ as plain text, so a reader on a phone or a tablet could tap every contact on the sheet
+ except the one they would actually ring. Each now carries a `tel:` target built the
+ way the rest of the library builds it — a parenthesised trunk prefix is dropped,
+ because it is the digit a caller omits from abroad, while a parenthesised area code is
+ kept. `ContactUri` is the one place that conversion now lives.
+
+ Two of the seven printed the address and the number as one joined string, which cannot
+ carry a target on half of itself; they now set the two as separate runs of the same
+ paragraph, with the same glyphs in the same order. Nothing moves: every pixel baseline
+ in the suite passes untouched, because a link annotation is not ink.
+
+ A number inside a referee's free-form prose stays plain. An address in running text is
+ recognisable; a number is not — dates, reference numbers and amounts all look like
+ one — so only the structured contact field is linked.
+
+- **A commerce invoice preset: `MerchantInvoice`.** A lockup against the title over a
+ short accent rule, the supplier's details beside the invoice's metadata, two addressed
+ parties on filled discs, a line-item table whose rows are bordered boxes, a bank panel
+ beside the totals and the due-by card, a closing note, and an identity band closing the
+ sheet on a marked tile. Ships as `invoice.presets.MerchantInvoice` on the existing
+ `StructuredInvoiceData` model, porting the rendered layout of a published standalone
+ template: rendered against the frozen original with the same data the text layers are
+ **identical, all 225 words in the same place**, and the sheet above the identity band
+ carries **no differing pixel**. What differs is the two marks — the masthead lockup and
+ the band's tile, both the caller's — the enumeration's face, and the social row below.
+
+ **The social row is deliberately not ported.** The design closes with three discs
+ carrying social-platform marks. Those marks belong to their platforms, and the templates
+ artifact does not redistribute other companies' trademarks, so neither they nor the rule
+ that divided them off are drawn; the band keeps the tile and the issuer's identity, which
+ is what identifies the sheet. This is stated on the preset rather than left to be
+ noticed.
+
+ Each table row is one cell. A cell's stroke draws all four of its edges, which is
+ exactly what this design's rows are — a soft box round each, no interior verticals — so
+ a row is a single cell spanning every column with the five columns composed inside it.
+
+ The supplier's contact rows are the one block whose line box is not its type: an inline
+ mark taller than the text sets the box, so a pitch measured from the text and solved
+ against the text lands short by the difference. Those pitches are solved against the
+ mark. A quantity of nothing prints the design's own dash rather than a zero, because a
+ zero in a quantity column reads as none delivered rather than as not counted. Guarded by
+ a smoke test (including the dash, the unknown-mark data error, a line with no mark, an
+ invoice that ships nowhere, where the currency is stated, and pagination), an exact
+ layout snapshot, and a pixel baseline — all three fed by one fixture.
+
+- **A dark invoice preset: `ObsidianInvoice`.** The first dark sheet in the library: a
+ wordmark over the document's name against a ruled metadata stack, the issuer and the
+ billed party on filled discs in cards of their own, a line-item table in a third, the
+ totals in a fourth, the notes and payment details side by side in a fifth and sixth,
+ and a closing band under a hairline. Ships as `invoice.presets.ObsidianInvoice` on the
+ existing `StructuredInvoiceData` model, porting the rendered layout of a published
+ standalone template: rendered against the frozen original with the same data, every
+ card, the table, the totals and both information cards are **pixel-identical**, and the
+ only regions that differ are the two discs — which carry the caller's mark where the
+ original carried its own — and the enumeration, which sets the sheet's own face.
+
+ The fill is set on the page rather than on the flow. A container's fill is bounded by
+ its content height, so a continuation page carrying three line items would show two
+ thirds of a white sheet under them; the page background covers the paper whatever the
+ content does.
+
+ Sizes are solved from ink rather than from cap height. The design's own face is not
+ bundled and matching a cap height alone would still set every string to the wrong
+ width, so each size is solved from the measured ink width of the string it sets, with
+ the substitute's width ratio applied once — and every size is named, in the styles, by
+ the string it came from. Vertical positions are cap tops rather than box edges, because
+ ink is what a design can be measured on.
+
+ The tax column is worked out rather than asked for. This design gives the tax its own
+ money column where the model carries a rate, and the figure is not a second thing to
+ state: a line already gives the quantity, the unit price and the total it comes to, so
+ the tax is the difference between what was charged and what the goods cost. Reading it
+ off the line is also what keeps the column and the total consistent when either moves.
+
+ The issuer's disc carries the caller's logo when there is one, the brand's monogram when
+ it states one, and initials taken from the name otherwise; the billed party's always
+ shows initials taken from its name. Guarded by a smoke test (including the derived tax
+ and its zero-rated case, both disc fallbacks, the due sentence keeping its accented date
+ inside one sentence, and pagination), an exact layout snapshot, and a pixel baseline —
+ all three fed by one fixture.
+
+- **A per-seat subscription invoice preset: `SubscriptionInvoice`.** A lockup against the
+ title, the supplier's details beside a metadata panel whose rows each open with a
+ coloured bar, two addressed parties under coloured underlines, a six-column table
+ carrying a tax rate on every line, the notes beside the totals, a marked payment band,
+ a four-segment strip, and a closing band that bleeds to three paper edges. Ships as
+ `invoice.presets.SubscriptionInvoice` on the existing `StructuredInvoiceData` model,
+ porting the rendered layout of a published standalone template — and this one is exact:
+ rendered against the frozen original with the same data the text layers are
+ **identical, all 218 words in the same place**, and the body carries **no differing
+ pixel at all**. Only two things differ, both by design: the masthead mark, which the
+ preset leaves to the caller, and the enumeration, which sets the sheet's own face where
+ the original took the zone default.
+
+ It is the first preset in the family to draw `InvoiceServiceLines.Line.vatRate()` and
+ its caption — it bills in a jurisdiction that prints the rate per line rather than only
+ as a total — and the first to number its lines, from `lineNumber()` when a line states
+ one and from its position when it does not.
+
+ Colour is by position and never by meaning: the metadata bars, the party underlines and
+ the closing strip all draw from one four-colour cycle indexed by where a thing sits, so
+ the fourth metadata row is amber because it is fourth and nothing in the document ever
+ names a colour. The page carries no side margins, because the strip and the closing band
+ have to reach the paper edges and do it by being the only blocks without horizontal
+ padding; the band reaches the bottom edge by bleeding rather than by being last, which
+ is what lets the enumeration sit over its fill for nothing.
+
+ Money is written with the currency's mark against the digits, so every figure names its
+ own currency and no column states one — which is also the preset's one boundary, and it
+ is documented and tested rather than left to be discovered: the columns are measured for
+ a one-character mark, so a currency the runtime knows only by its three-letter code does
+ not fit the unit-price column and the render is refused there rather than letting the
+ figure run under its neighbour. Guarded by a smoke test (including the per-line rate,
+ line numbering from both sources, the currency boundary, a payment band with a different
+ cell count, an invoice that ships nowhere, and pagination), an exact layout snapshot,
+ and a pixel baseline — all three fed by one fixture.
+
+- **A region-billed platform invoice preset: `PlatformInvoice`.** A lockup against the
+ title, the supplier split from the invoice's own metadata by a full-height hairline,
+ two addressed parties on differently-treated marks, a six-column usage table, an
+ outlined bank panel beside the totals and the due-date card, and an identity band
+ closing the sheet. Ships as `invoice.presets.PlatformInvoice` on the existing
+ `StructuredInvoiceData` model, porting the rendered layout of a published standalone
+ template: rendered against the frozen original with the same data, the document body
+ carries **no ink displaced at all** — every one of 225 paired words within 0.15 pt,
+ and zero pixels differing by more than a rounding step at 150 dpi between the masthead
+ and the closing note. The regions that differ are the two lockups, which the preset
+ leaves to the caller, the enumeration, which sets the sheet's own face where the
+ original took the zone default, and one phrase in the note.
+
+ This is the design that needed `Line.region`: it bills the same service in more than
+ one place and prints where beside what, as its own column between the service and the
+ usage.
+
+ Two scales, on purpose. The design is 1.50 aspect where A4 is 1.414, so one conversion
+ constant cannot serve both axes: widths, x-offsets and type sizes go through the
+ horizontal one because that is where advance widths have to fit, heights and pitches
+ through the vertical one, which is 6.3% tighter, and marks and discs through their
+ mean — about 3% out in each axis rather than 6% out in one. Writing a vertical
+ measurement on the horizontal scale would stretch the sheet by 6.3% with nothing
+ failing, which is what the split exists to make visible. The design's line pitches are
+ set cap-top to cap-top, so the preset solves each gap from Barlow's own cap height and
+ ascender rather than from the type size.
+
+ The table's separators are rows. A table's rules come from each cell's own style and
+ cover all four of that cell's edges, so any stroke that buys a horizontal separator
+ also buys five verticals — which this design has nowhere inside its table. Every cell
+ is stroked at zero width and each separator is a row of its own: one cell spanning
+ every column, zero padding, and a full-width line as its content.
+
+ A unit price is written at the precision it is quoted at, between two places and four.
+ Usage that names a unit carries two places even on a whole number, because the meter
+ reads to that precision; usage that names none is a count of whole things and is
+ written bare. The currency is stated once per money column with the figures under it
+ bare, and once more on the grand total, which stands under no caption. Guarded by a
+ smoke test (including the region column, the unknown-mark data error, a line with no
+ mark, an invoice that ships nowhere, both figure rules, and pagination bringing the
+ header back), an exact layout snapshot, and a pixel baseline — all three fed by one
+ fixture.
+
+- **A metered-usage invoice preset: `MeteredInvoice`.** An orange-accented masthead over
+ its own rule, the supplier split from the invoice's metadata by a hairline, two
+ addressed parties, a service-line table whose marks sit on bordered tiles, a closing
+ row pairing the bank details against the totals and the due date, and a full-bleed dark
+ band along the foot of every page. Ships as `invoice.presets.MeteredInvoice` on the
+ existing `StructuredInvoiceData` model, porting the rendered layout of a published
+ standalone template: rendered against the frozen original with the same data, the
+ document body is pixel-identical — 0 of 2,173,720 at 150 dpi between the title and the
+ footer band — and the two regions that differ are the masthead lockup, which the preset
+ leaves to the caller, and the band, which sets the sheet's own face where the original
+ took the zone default. It flows: the table's header repeats on every page it reaches,
+ the bottom margin reserves the band on every page so no row runs into the chrome, and
+ every page carries its number.
+
+ A unit price is written at the precision it is quoted at, between two places and four,
+ where every other figure on the sheet is written at two. That is not a formatting
+ preference: a metered rate is a fraction of a currency unit, and an hour of compute at
+ 0.0710 rounded to an amount's two places is 0.07 — a different price that multiplies
+ out to a different bill. Figures state their locale rather than inheriting it.
+
+ The design's side margins are unequal, 35.5 px against 43.5 px of a 1055 px sheet, and
+ are kept as measured: regularising them at the mean costs four pixels of horizontal
+ error on every glyph to save eight on one edge. Its hairlines are a pixel of ink rather
+ than a pixel of geometry, so they are drawn at the weight that reproduces them instead
+ of the 0.6 pt they measure, which rasterises to less than a pixel and drops half the
+ row rules to partial opacity. The table is one column wide with a composed row inside
+ each cell, because a cell strokes all four of its own edges and five real columns would
+ draw four interior verticals the design does not have.
+
+ Guarded by a smoke test (including the unknown-mark data error, a line that names no
+ mark, the rate-versus-amount precision rule, pagination bringing the header back with
+ it, and the disclosure line falling back to the supplier's name), an exact layout
+ snapshot, and a pixel baseline — both fed by one fixture.
+
+- **A violet SaaS invoice preset: `WorkspaceInvoice`.** A brand masthead over a short
+ accent bar, a half-split issuer and metadata header, two addressed parties on filled
+ discs, a service-line table whose marks sit on coloured tiles, a settlement row
+ pairing the bank details against the totals, and a closing band carrying the wordmark.
+ Ships as `invoice.presets.WorkspaceInvoice` on the existing `StructuredInvoiceData`
+ model, porting the rendered layout of a published standalone template. Like
+ `PaymentsInvoice` it flows — the table's header repeats on every page it reaches, a
+ continuation page reserves a deeper bottom margin, and every page carries its number,
+ which is a departure from the one-page design and the only way a lost page is
+ detectable. The sheet's own closing band cannot be page chrome, because a header
+ footer zone takes only strings and the band holds a wordmark and a link, so it is body
+ content and the number is the only chrome. Two of the design's own type corrections
+ are carried as measured constants rather than smoothed away: a cap read off a
+ screenshot includes half a pixel of antialiasing on each edge, and the body face sets
+ uppercase about 6% wider than the design's at the same cap height — measured across
+ three all-caps runs while mixed-case runs at the same sizes matched within 2%. This
+ design states its currency once per money column and writes the figures under it bare,
+ carrying the code only on the total, which is the opposite of what `PaymentsInvoice`
+ does and is why the two presets format money differently. A quantity is written with
+ what it counts. Both parties print a registration under their address through the pair
+ `InvoiceRecipient` gained for it, and a party with no number prints no label. Guarded
+ by a smoke test (including the unknown-mark data error, a line with no mark, the
+ wordmark fallback, the currency named once and not on every figure, the quantity with
+ its unit, both registrations and the absent one, the closing address as an annotation,
+ a missing ship-to, a payment card with no note, an empty document, and a thirty-line
+ invoice that runs on, repeats its column names and numbers its pages), an exact layout
+ snapshot and a pixel-parity gate; the examples showcase gains `invoice-workspace-v2`.
+
+- **A paginating invoice preset: `PaymentsInvoice`.** A lavender-and-navy sheet with a
+ diagonal band crossing the masthead, a half-split issuer and metadata header, two
+ addressed parties on discs, a marked service-line table, a settlement row pairing bank
+ details against the totals, a note block and a two-cell document footer. Ships as
+ `invoice.presets.PaymentsInvoice` on the existing `StructuredInvoiceData` model,
+ porting the rendered layout of a published standalone template. It is the first preset
+ in the family built to **flow**: the design shows six service lines and a real billing
+ month brings dozens, so the table's header repeats on every page it reaches, a
+ continuation page reserves a deeper bottom margin than page one, and every page
+ carries its number — a financial record that runs over has to make a missing page
+ detectable. The table is one column, not five: the design shows an outer box and a
+ rule between rows with no interior verticals, which is exactly what a single-column
+ table draws, and the five columns are a row inside each cell. Building it the other
+ way round would leave every continuation page ending in an empty bordered strip,
+ because a section's box fills its page fragment rather than hugging its rows. The
+ lockup beside the title is the caller's: the design's mark occupies a measured
+ 136 × 55.3 px box, a document that brings a logo has it drawn to that height, and one
+ that brings only a name has the name set as a wordmark — the templates artifact
+ carries no mark of its own. Figures are written with the locale stated rather than
+ inherited, because both the grouping of a number and the symbol for a currency code
+ change with it and a preset that let the JVM decide would render a different sheet on
+ a different machine. Guarded by a smoke test (including the unknown-mark data error, a
+ line with no mark, the wordmark fallback, the currency named once and carried, the
+ uppercased due line, a trunk prefix printed but not dialled, the support contacts as
+ annotations, a missing ship-to and a missing note block, an empty document, and a
+ thirty-line invoice that runs on, repeats its column names and numbers its pages), an
+ exact layout snapshot and a pixel-parity gate; the examples showcase gains
+ `invoice-payments-v2`.
+
+- **A navy-plate CV preset: `MidnightNavy`.** A one-page sheet on a full-height navy
+ plate: an outlined monogram over a two-weight name and a tracked role line, then
+ contact, education, metered skills and dotted languages down the plate, beside a
+ paper column carrying the summary, the roles held on a rail, three achievement discs
+ and the certifications in divided columns. Ships as `cv.presets.MidnightNavy` on the
+ existing `CvDocument` model, porting the rendered layout of a published standalone
+ template; it needed no model change. The plate is a page background rather than a
+ section fill — it reaches three paper edges and a fill stops at its own box — sized
+ by the same ratio the body row splits on, so the two cannot drift apart. Every
+ horizontal pair goes through one wrapper: a row nested directly in a row cell is
+ refused and both columns are cells, so a skill and its meter, a language and its
+ dots, a title and its dates, a disc and its line and the certification columns are
+ each a row wrapped in a single layer of a stack. Three marks are relationships rather
+ than lengths: the experience rail is the entry section's left accent, so its height
+ derives from the entry and the inter-entry gap is padding inside the border, which is
+ what makes consecutive rails meet; a certification divider is the column's own
+ accent; and a skill meter is three layers sharing the track's axis rather than three
+ pieces placed apart. The monogram and the role line are built from the identity — two
+ initials and the name's own words — so a document fills neither in twice. Seven
+ berths reach their sections by title, and the contact heading is the preset's own
+ because a document has no section to carry it. Unlike its ported siblings this sheet
+ is one page **strictly**: the body is a single row and a row is atomic, so a longer CV
+ is refused with an `AtomicNodeTooLargeException` naming the node rather than being cut
+ — splitting the row would leave the plate on one page and half the aside on the next.
+ Two things depart from the ported sheet deliberately, and both are measured. A link is
+ drawn as its own label with the address behind it, as on the presets before it. And
+ every rail marker is centred on its rail: an accent is drawn centred on the edge it
+ belongs to, so the section's left edge already is the rail's axis, and the ported
+ sheet's extra half-thickness correction put each marker a rail width to the right of
+ the line it sits on — 0.72 pt, visible at reading size. Together the two come to
+ 2 498 of 2 173 720 pixels, of which the centring is 200. Guarded by a smoke test (including the unknown-mark data error, the
+ monogram taken from the name, the uppercased name and tracked role, a language rating
+ rounded to the nearest of five dots, a skill with no level, a trunk prefix left
+ undialled, the link targets on every kind of title, a dropped berth, a document with
+ nothing but an identity, and the refusal past one page), an exact layout snapshot and
+ a pixel-parity gate; the examples showcase gains `cv-midnight-navy-v2`.
+
+- **A two-column operations CV preset: `OrangeOps`.** A one-page sheet in three bands
+ over a split body: a two-tone name above a dark role bar whose right edge and three
+ accent slashes all lean by one ratio, a contact strip whose items sit on one axis
+ between hairlines, then a narrow column of dotted skills, achievement discs, a degree
+ and certifications beside a wide column carrying the profile, the roles held with
+ their dates on the right margin, a four-metric strip on full-height rules, and the
+ closing lines. Ships as `cv.presets.OrangeOps` on the existing `CvDocument` model,
+ porting the rendered layout of a published standalone template. It is the first
+ preset whose display family the engine does not carry: Oswald is not one of the
+ families `graph-compose-fonts` ships, so the preset names it through
+ `OrangeOps.DISPLAY_FONT` and the caller registers it — the gate and the example each
+ register the two faces from their own module's resources, and the class documentation
+ shows the call. Eight berths reach their sections by title, four to a column, and a
+ berth nobody fills takes its heading, its accent rule and its join hairline with it
+ rather than leaving a rule over a gap. Two shapes are what the design shows rather
+ than what a row could hold: the role bar is a layer stack because the plate and the
+ slashes overlap horizontally, and an achievement is a layer stack because a table cell
+ anchors its disc to the foot of the pair instead of the top. A badge is one leaf, not
+ two — the disc is re-emitted around the glyph inside a widened viewBox, because a
+ shape container inside a table cell reserves its box and composes none of its
+ children. A heading's parenthetical is set smaller on the same line, split off at the
+ title's first bracket, which is how `KEY KPI SNAPSHOT (Recent 12 Months)` reaches the
+ page as the design sets it. As on the presets before it a link is drawn as its own
+ label with the address behind it, the one deliberate departure from the ported sheet,
+ measured at 7 241 of 2 173 720 pixels. Guarded by a smoke test (including the unknown
+ mark data error, an entry with no mark, the uppercased name and role bar, the
+ parenthetical heading, a trunk prefix left undialled, the link targets on every kind
+ of title, a dropped berth taking its join rule with it, a document with nothing but an
+ identity, and the run onto a second page), an exact layout snapshot and a pixel-parity
+ gate; the examples showcase gains `cv-orange-ops-v2`.
+
+- **A banded single-column CV preset: `VioletGrid`.** A one-page sheet with no page-level
+ grid at all: a two-tone name beside the contact list, three opening lines, a six-up
+ grid of marked skills divided by dotted rules, a strip of tools on inline discs, the
+ roles held on a dated timeline whose dates sit outside the rail, the projects behind
+ tinted tiles, education and languages side by side, and a tinted band closing on a
+ quotation. Ships as `cv.presets.VioletGrid` on the existing `CvDocument` model,
+ porting the rendered layout of a published standalone template; like `SlateOrange` it
+ reads a rated language's wording from `CvSkill.note`. Every split is local to one
+ band — the masthead splits where the contact marks begin, the timeline into dates,
+ rail and content, a project into tile and copy, the credentials into two halves — and
+ none of the four knows about the others, which is why every horizontal arrangement is
+ either a top-level row or a table and none of them nests. Three marks are
+ relationships rather than lengths: a section rule starts where its heading ends
+ because it is a weighted column taking what an auto column leaves; the timeline rail
+ is the left border of every entry but the last, so consecutive entries butt into one
+ line; and a project's hairline is its copy's left border, so it is exactly as tall as
+ the copy. Seven berths reach their sections by title, including a quotation berth
+ whose body is drawn and whose title is not. Unlike its ported siblings this sheet
+ flows: each experience entry is held together, so a longer CV runs onto a second page
+ rather than cutting a role in half. As on the presets before it a link is drawn as its
+ own label with the address behind it, the one deliberate departure from the ported
+ sheet, measured at 1 882 of 2 173 720 pixels. Guarded by a smoke test (including the
+ unknown-mark data error, a skill with no mark, the two-tone name, the quotation's
+ title staying off the sheet, the link targets on every kind of title, an identity with
+ no links, a document with nothing but an identity, and the run onto a second page), an
+ exact layout snapshot and a pixel-parity gate; the examples showcase gains
+ `cv-violet-grid-v2`.
+
+- **A masthead-and-rail CV preset: `SlateOrange`.** A one-page sheet built as a
+ full-bleed slate band over a two-column body: an orange monogram tile beside the name,
+ the role line and a tracked specialism strip, with the contact lines across an orange
+ hairline; then a narrow column of marked competencies, trophied achievements, rated
+ languages and closing facts, beside a wide column carrying the profile, the roles held
+ on a rail, and a credentials footer of education and certifications. Ships as
+ `cv.presets.SlateOrange` on the existing `CvDocument` model, porting the rendered
+ layout of a published standalone template; it is the first preset to use
+ `CvSkill.note`, which its language rows need to show a rating and name it on the same
+ line. The four fills that reach a paper edge — the slate band, the orange tile and the
+ two column dividers — are page backgrounds rather than section fills, because a fill
+ on a section is bounded by its content. Every horizontal pair inside a body column is
+ a table with fixed widths: a row cannot nest in a row cell and both columns are cells,
+ so mark and label, language and rating, role and dates, and the two credential columns
+ are all tables, and where a cell needs several stacked lines it holds a single-column
+ table of its own. Seven berths reach their sections by title, including a specialism
+ berth whose body is drawn and whose title is not. As on the presets before it, a link
+ is drawn as its own label with the address behind it — the one deliberate departure
+ from the ported sheet, measured at 2 100 of 2 173 720 pixels — and a role or a degree
+ becomes a link when its entry carries one. Guarded by a smoke test (including the
+ unknown-mark data error, an achievement with no mark falling back to the trophy, a
+ language with and without its wording, the monogram and the strip, the link targets,
+ an identity with no links, a document with nothing but an identity, and the refusal of
+ a CV taller than the sheet), an exact layout snapshot and a pixel-parity gate; the
+ examples showcase gains `cv-slate-orange-v2`.
+
+- **A clinical CV preset in five bands: `TealPulse`.** A one-page sheet whose mark is a
+ heart crossed by a flat pulse, beside a letter-spaced name over a contact strip
+ divided by short rules; then a two-column body carrying the competencies as dotted
+ lines beside the summary and the roles under badged headings; then a three-column
+ closing band for the degree, the certifications and the facts; and a tracked line
+ under a rule that ends in a small heart. Ships as `cv.presets.TealPulse` on the
+ existing `CvDocument` model with **no model change at all**, porting the rendered
+ layout of a published standalone template. **It sets its own page**, unlike every CV
+ preset before it: the design was drawn on a raster whose proportion is not A4's and
+ every length is a share of that grid, so a caller's page size and margin are
+ overwritten rather than followed. Six berths reach their sections by title, including
+ a tagline berth whose body is drawn and whose title is not — the title is how a
+ document names the berth. Both vertical rules are the left border of the column to
+ their right rather than lines placed beside them, so they are the grid: change a
+ weight and the rules follow. The main headings are laid over their own rule with the
+ paper knocked out behind every letter, because a row cannot nest in a row cell and the
+ rule's visible length still has to follow the words. **What happens past one page is
+ not what the other ported CV presets do:** the bands are stacked in the page flow, so
+ a CV with more roles than the design holds carries the closing band onto a second page
+ rather than losing anything, while the body row itself is atomic and a body taller
+ than a page raises `AtomicNodeTooLargeException`. As on `TerracottaRail`, a link is
+ drawn as its own label with the address behind it, which is the one deliberate
+ departure from the ported sheet — writing the URL out makes the strip's gaps depend on
+ how long a profile is called — measured at six of 121 nodes shifting sideways and
+ 7 724 of 1 987 720 pixels, with nothing moving vertically. Guarded by a smoke test
+ (including the empty document, the page override, the tagline's title staying off the
+ sheet, the link targets, a linked role and degree, the run onto a second page and the
+ refusal of a body taller than one), an exact layout snapshot and a pixel-parity gate;
+ the examples showcase gains `cv-teal-pulse-v2`.
+
+- **An architect's two-column CV preset: `TerracottaRail`.** A one-page A4 sheet whose
+ narrow column carries a serif monogram over a terracotta rule, the contact channels
+ behind their marks, two bulleted lists and a block of closing facts, beside a wide
+ column carrying a letter-spaced masthead, the summary, the roles held on a ringed
+ rail, a projects grid and the degrees. Ships as `cv.presets.TerracottaRail` on the
+ existing `CvDocument` model with **no model change at all**, porting the rendered
+ layout of a published standalone template. Like `CharcoalGold` it leaves the page to
+ the caller. Eight berths reach their sections by title; the two bulleted lists are
+ skills without levels, because this design writes them as plain lines — one takes a
+ terracotta square and no dash under its heading, the other a disc and a dash, which
+ is how the sheet tells two lists of one-liners apart. Each fact and each project
+ takes the mark its entry names in `CvEntry.icon()` from this preset's own vocabulary,
+ every title it draws — a role, a project, a degree, a credential — is a link when its
+ entry carries one, and the monogram is drawn from
+ the name's own initials rather than a field of its own — a document states its name
+ once, and a monogram that could disagree with it would be a second place to keep
+ true. **A link in the contact block is drawn as its own label with the address behind
+ it**, which is where the preset departs from the sheet it ports: writing the URL out
+ makes that row as wide as whatever the reader's profile happens to be called — long
+ enough that the published design sets it smaller and nudged in, on an axis of its
+ own. Stating `Link("LinkedIn", "https://…")` puts the four rows on one axis at one
+ size for every document. The departure is exactly measured — two of 154 nodes narrow,
+ 1 743 of 2 173 720 pixels change, and nothing moves vertically — and both baselines
+ were recorded with it. A link takes the mark of the network it points at, or a globe.
+ Like its siblings it holds one page: the body is a single atomic row, so a longer CV raises
+ `AtomicNodeTooLargeException` rather than flowing or dropping entries. Guarded by a
+ smoke test (including the unknown-mark data error, an entry with no mark, an identity
+ with no links, a document with nothing but an identity, the monogram, the link
+ targets on every kind of title, the four contact rows sharing one axis and the
+ one-page limit), an exact layout snapshot and a pixel-parity gate;
+ the examples showcase gains `cv-terracotta-rail-v2`.
+
+- **The first invoice preset that paginates what it ports: `LumaStudioInvoice`.** A
+ studio invoice built around a cream sidebar — the two-line monogram and the wordmark
+ on a terracotta block at its head, a tinted quarter-disc, an arch and a sprig running
+ down the rest of it — beside a billing sheet that reads sender, title and metadata,
+ the billed-to and shipped-to pair across a rule, the priced service lines with a VAT
+ column, the totals stack closing on a filled total-due band, and the notes and bank
+ details above a dark sign-off band. Ships as `invoice.presets.LumaStudioInvoice` on
+ the structured invoice model, porting the rendered layout of the published standalone
+ `luma-co-studio-invoice` template. Unlike the CV presets promoted before it this one
+ flows: the line-items table repeats its dark header on the next page, the totals stack
+ and each closing block stay whole, and the paper tint, the sidebar column and the dark
+ foot band are page backgrounds, so every page carries the same frame and the folio
+ always has a dark ground. **The sign-off carries its own strip, which is where the
+ preset deliberately departs from the sheet it ports.** On the published template the
+ words rely on that background band, which is pinned to the paper's edge — right on a
+ one-page invoice, and white-on-cream in the middle of the last page of a longer one,
+ where the flow ends well above the paper's foot. Drawing the strip with the words
+ fixes it wherever they land; the cost is 7 440 of 2 173 720 pixels on the reference
+ sheet, where the strip starts three points above the background band it sits on, and
+ one extra layout node. It is flow content only because footer chrome carries text
+ today — one size, one colour, no glyph, where this band needs two faces and a disc —
+ so once a footer zone can hold a node the sign-off belongs in one and the strip goes
+ away. Its geometry is not a set of round numbers: the design was drawn
+ on a pixel grid, so the preset carries that grid as page ratios and states every
+ vertical gap as the white the drawing shows, subtracting the blank a line box already
+ carries — per family, because the three faces it sets fill different line boxes at the
+ same size. Letter-spaced runs are written letter by letter with an invisible inline
+ rectangle between the pairs, since a text style carries no tracking; the six spacings
+ are frozen constants rather than a measurement, because measuring at compose time
+ would tie the preset to the font artifact's resource layout. Amounts take their mark
+ from `StructuredInvoiceData.currencyCode()` — the code is the authority, so the sheet
+ cannot contradict itself — and the contact channels carry `tel:`, `mailto:` and
+ `https:` targets derived from their values, with a parenthesised trunk prefix dropped
+ from the dial target the way a caller dialling from abroad drops it. Guarded by a
+ smoke test (including the empty document, an unknown and a blank currency code, a
+ supplier with one registration and with none, the link targets, the repeated header
+ and folio on a continuation page, and the sign-off landing on its own ground), an
+ exact layout snapshot over both a one-page and a three-page invoice, and a
+ pixel-parity gate; the examples showcase gains `invoice-luma-studio-v2`.
+
+- **`SerifHeadline` links its titles and lets its bands breathe.** Every title the
+ preset draws — a role, a project, a degree, an achievement — is now a link when its
+ entry carries one. And a band column keeps a gutter at its right edge, so its text
+ stops short of the hairline between columns instead of running into it: **the first
+ place a promoted preset deliberately departs from the sheet it ports**, where a line
+ that happens to fill its column touches the rule. The departure is exactly measured —
+ six of 235 nodes narrow, 8 191 of 2 173 720 pixels change, and nothing else moves —
+ and both baselines were re-recorded with it. The class documentation names the two
+ colours the packaged marks come in, so a document chooses one deliberately rather
+ than mixing navy and gold in a row by accident.
+
+- **A photographic CV preset: `CharcoalGold`.** A one-page sheet in two columns: a
+ charcoal sidebar carrying a ringed photograph, the contact channels behind their
+ marks, rated skills, languages and degrees, beside a paper column carrying a two-tone
+ name — the given name in ink, the family name larger and in gold — the summary, the
+ roles held on a dated rail, a pair of credential columns divided by a hairline, and a
+ closing strip of tools. Ships as `cv.presets.CharcoalGold` on the existing
+ `CvDocument` model with **no model change at all**, porting the rendered layout of the
+ published standalone `charcoal-gold-cv` template. Unlike its ported siblings it leaves
+ the page size to the caller: the design is drawn on A4 and its geometry follows the
+ page's own width, so a different page rescales rather than breaks. Seven berths reach
+ their sections by title; credentials take the mark each entry names in
+ `CvEntry.icon()` from this preset's own set, every title it draws is a link when the
+ entry carries one, and a skill the document leaves unlevelled draws no rating rather
+ than five empty dots. Guarded by a smoke test (including the unknown-mark data error,
+ the missing portrait, the unlevelled skill, the link targets and the one-page limit),
+ an exact layout snapshot and a pixel-parity gate; the examples showcase gains
+ `cv-charcoal-gold-v2`.
+
+- **A two-column editorial CV preset: `SerifHeadline`.** A one-page A4 sheet under a
+ Volkhov masthead — the name in the display serif over its role and a short gold rule,
+ the contact channels stacked opposite — then a two-column body: the roles held on a
+ timeline rail and the projects as marked cards on the left, the degrees and grouped
+ skill meters across a hairline divider on the right, closing with full-width bands of
+ certifications and achievements. Ships as `cv.presets.SerifHeadline` on the existing
+ `CvDocument` model, porting the rendered layout of the published standalone
+ `serif-headline-cv` template. Its geometry is not a set of round numbers: the design
+ was drawn on a 1024-pixel grid, so the preset carries that grid and scales it onto A4
+ — heights by an extra factor — and states every vertical gap as the white the drawing
+ shows, subtracting the blank a line box already carries above and below its own type.
+ Eight berths reach their sections by title, the projects and achievements take the
+ mark each entry names in `CvEntry.icon()` from this preset's own vocabulary, and the
+ employer's city and the campus come from `CvEntry.place()`. Like its siblings it owns
+ its page and holds one: the body is a single atomic row, so a longer CV raises
+ `AtomicNodeTooLargeException` rather than flowing or dropping entries. Guarded by a
+ smoke test (including the unknown-mark data error, an entry with no mark, an employer
+ with no place, the overlapping soft-skills berth, the link targets and the one-page
+ limit), an exact layout snapshot and a pixel-parity gate; the examples showcase gains
+ `cv-serif-headline-v2`.
+
+- **A portrait CV preset: `NavySidebar`.** A one-page A4 CV in two columns on Lato —
+ a navy plate carrying a ringed portrait, the contact channels behind their marks, the
+ degrees, the skills and the languages, beside a white column of the name, the summary,
+ the roles held on a timeline rail with a filled marker at each one, and the
+ achievements and certifications behind badged headings. Ships as
+ `cv.presets.NavySidebar` on the existing `CvDocument` model, porting the rendered
+ layout of the published standalone `navy-sidebar-cv` template. Like its sibling it
+ owns its page and holds one: the two columns are a single atomic row, so a CV longer
+ than the sheet raises `AtomicNodeTooLargeException` rather than flowing or dropping
+ entries. Sections reach their berth by title rather than by `Slot`; languages are a
+ `RowsSection` because this design writes the proficiency out — "Native", "Advanced" —
+ which a levelled skill could not carry back. The photograph comes from the new
+ `CvIdentity.portrait()`; an identity without one draws the ring around an empty navy
+ disc; its education entries read the `place` field for the campus line. The phone,
+ the email and each link are reachable from the PDF, with the
+ `tel:` and `mailto:` targets built from the values — the published sheet drew its
+ channels as plain text, and this is the one place the port deliberately improves on
+ it, at no cost to the render: annotations move no pixel and no layout node, which
+ both gates confirm without re-blessing. Guarded by a smoke test (including the link
+ targets, the missing portrait, the capitals this design imposes, the one-page limit
+ and the fields it has no place for), an exact layout snapshot and a pixel-parity
+ gate; the examples showcase gains `cv-navy-sidebar-v2`.
+
+- **The first CV preset that owns its page: `ProfessionalSidebar`.** A one-page CV
+ in two columns on the Barlow Condensed / Lato pair — a navy monogram plate over a
+ pale sidebar carrying the contact channels, meter-bar skills, an education rail with
+ dot markers and five-dot language ratings, beside a white column of the tracked name,
+ the profile, the roles held with bulleted highlights, the projects and the references
+ note. Ships as `cv.presets.ProfessionalSidebar` on the existing `CvDocument` model —
+ no model change was needed — porting the rendered layout of the published standalone
+ `professional-sidebar-cv` template. The preset owns its page: a 491.6 x 737.28pt
+ sheet with no margin, the page fill and the pale sidebar painted as page backgrounds. The
+ sheet holds one page — its two columns are a single atomic row, so a CV longer than the
+ sheet raises `AtomicNodeTooLargeException` rather than flowing onto a second page or
+ silently dropping entries the way the capped sidebar presets do. The class documentation
+ says so, points at `TimelineMinimal` for a preset that splits its own columns, and
+ `docs/templates/v2-layered/using-templates.md` carries it beside the capped presets.
+ Sections reach their berth by title rather than by `Slot`,
+ because the columns are fixed; skills and languages are both `SkillsSection`s drawn
+ differently, and a level the document omits draws the name alone. The contact channels
+ come off `CvIdentity`, with `tel:` and `mailto:` targets built from the values and the
+ packaged marks chosen per channel. Guarded by a smoke test (including the identity-only
+ document, the unlevelled skill, the PDF link targets, the one-page limit and the fields
+ this design has no place for), an exact layout snapshot and a pixel-parity gate; the
+ examples showcase gains `cv-professional-sidebar-v2`.
+
+- **A second structured proposal preset: `EditorialProposal`.** The same document the
+ `NorthlineProposal` preset renders, in a different hand: an orange accent, section
+ headings set in the display serif over short accent rules instead of in the body sans
+ inside icon badges, a brand mark drawn from vector paths instead of a monogram letter,
+ an untitled fact card, a scope ordinal set in plain accent text, and a hairline page
+ foot. Moving a document between the two presets is a one-line change plus two data
+ checks — the badge and goal icon tokens are preset-scoped (the four `fact-*` tokens
+ are not), and headings are drawn as authored, so the sibling's tracked capitals stay
+ capitals here. Ships as
+ `proposal.presets.EditorialProposal` on the structured proposal model, porting the
+ rendered layout of the published standalone `northline-proposal-orange` template,
+ with its SVG icon set packaged in the artifact. Guarded by a smoke test (including
+ the empty document, both data contracts, and the proof that one document renders
+ through both proposal presets), an exact two-page layout snapshot, and a
+ pixel-parity gate; the examples showcase gains `proposal-editorial-v2`.
+
+- **A professional-services invoice preset: `ConsultingInvoice`.** A corporate masthead
+ — brand lockup and contact channels beside the document title and its metadata —
+ over priced service lines that carry a service period and a unit per line, closing
+ with an emphasized total band and the bank details beside the notes and the due-by
+ chip. Ships as `invoice.presets.ConsultingInvoice` consuming the new structured
+ invoice model, with its contact marks, bank badge and calendar packaged in the
+ templates artifact, porting the rendered layout of the published standalone
+ `northpoint-consulting-invoice` template. Long invoices flow: the line-items table
+ repeats its header and the totals stack stays whole. Guarded by a smoke test
+ (including the empty document, the wordmark fallback, the rendered figures and the
+ repeated header), exact layout snapshots for the single page and the overflow, and a
+ pixel-parity gate; the examples showcase gains `invoice-consulting-v2`.
+
+- **A second invoice preset: `ClassicInvoice`.** The letterhead-style invoice — a header
+ band with the company name and a 28pt INVOICE title, a TOTAL DUE hero strip,
+ BILL TO / FROM party columns, and a dedicated Summary table composed after the
+ line items (subtotal / tax / TOTAL, the last row emphasized) — now ships as
+ `invoice.presets.ClassicInvoice` on the layered stack, with the same
+ `create()` / `create(BrandTheme)` contract as `ModernInvoice`, porting the rendered
+ layout of the published standalone `invoice-classic` template. Guarded by a smoke
+ test, exact layout snapshots (the canonical single page plus a forty-line-item
+ overflow that freezes the two-page table continuation), and the invoice pixel-parity
+ gate; the examples showcase gains `invoice-classic-v2`.
+
+- **The first structured proposal preset: `NorthlineProposal`.** A two-page
+ teal-and-navy business proposal on the Spectral/Lato pair — brand header with logo
+ mark and wordmark, three stacked title lines on the reference's own pitch, the
+ executive summary beside an at-a-glance fact card, icon goal cells, a numbered
+ scope list, deliverable columns, the phase grid, an investment table with subtotal /
+ optional / total row styling, and a signing card — with the navy footer band, the
+ teal page-number block and the `01`/`02` page numbers as page chrome rather than
+ flow content. Ships as `proposal.presets.NorthlineProposal` consuming the new
+ structured proposal model, with its icon set packaged in the templates artifact,
+ porting the rendered layout of the published standalone `northline-proposal`
+ template. Guarded by a smoke test (including the empty document and the
+ phase-grid header-count contract), an exact two-page layout snapshot, and a
+ pixel-parity gate; the examples showcase gains `proposal-northline-v2`.
+
+- **A one-page sales proposal preset: `IndigoProposal`.** A wordmark against the
+ document's label over a full-width rule, the addressed organisation and the person to
+ reply to beside a four-line headline and a row of marked discs, a tinted band that
+ bleeds to both paper edges carrying a paragraph about the issuer over four marked
+ tiles, a numbered plan beside the priced rows and their total card, and a foot with the
+ issuer's identity and its channels. Ships as `proposal.presets.IndigoProposal` on the
+ structured proposal model, with its icon set packaged in the templates artifact,
+ porting the rendered layout of a published standalone template — and this one is exact:
+ rendered against the frozen original with the same data the text layers are
+ **identical, all 262 words in the same place to 0.000 pt**, and the head, the lower
+ half, the closing rule and the foot's own lines carry **no differing pixel at all**.
+
+ **Two of the design's own brand assets are deliberately not ported.** The band's right
+ is a product photograph carrying a payment network's mark, and the foot closes on the
+ issuer's logotype. Those belong to their owners, and the templates artifact does not
+ redistribute other companies' marks. Both places are kept rather than closed up — the
+ band keeps the height and the column that give the copy beside it the measure it was
+ solved against, and the foot keeps the column that sets where the name begins — and
+ what fills them is the document's: the foot sets whatever monogram the document names,
+ and the band's column stays flat tint. This is stated on the preset rather than left to
+ be noticed.
+
+ The sheet is one page by design and not by accident. Every block's position is a cap
+ top in one vertical map and a single cursor walks the page once, turning each cap top
+ into the margin that puts it there — so a block moves by changing one number rather
+ than by re-deriving the gaps around it. Four blocks state how many lines they wrap to,
+ because the engine cannot be asked at compose time; content heavier than the design's
+ own pushes what follows down and can carry the foot onto a second page, which the
+ preset says rather than capping what it is given.
+
+ **No block is ever drawn over another.** A stated position and heavier-than-designed
+ content produce a negative margin, which the engine honours by *raising* the block —
+ so a plan of six or seven steps still fits the page and prints the closing rule
+ through its last step. Every margin the cursor returns is therefore floored at
+ nothing, and the counts either side of that window are gates: five steps is the
+ design's own, six and seven are where an unclamped margin overlaps silently, and
+ fourteen is where the foot has to take a second page.
+
+ Sizes are solved from ink rather than from cap height: the design's own face is not
+ bundled, the substitute sets 10–20% wide at bold, and matching a cap height alone put
+ every bold block over its measure. A foot's confidentiality line, when a document
+ carries one, closes the channels line rather than opening one of its own — the
+ design's last line already ends against the bottom margin, so a fourth would carry the
+ whole foot onto a second page — and it is set as prose, not as a channel: a notice is
+ a sentence, and a sentence is not somewhere a reader can be sent.
+
+ The two packaged mark sets are not interchangeable, and the preset says so by
+ refusing: the header's marks are drawn in the accent for a pale disc and the band's in
+ white for a near-black tile, so a band token named on a header disc would render as
+ nothing at all. It is a data error reported by name instead. Guarded by a smoke test
+ (including the empty document, a tile that names no mark, both unknown-token cases,
+ the notice, the overlap window and every contact being followable), an exact layout
+ snapshot, and a pixel baseline — all three fed by one fixture.
+
+- **The first rota preset: `CobaltRota`.** A staff rota on one landscape sheet — a
+ navy-ruled grid of who works when, read across a row rather than down a page. The
+ label column carries the venue's mark over the staff names; the day columns carry a
+ heading, whatever else is happening that day, a strip of swatches saying what the
+ colours mean, the covers each service is expecting, and then the people in bands, each
+ band opening with a navy strip and its own mark. Ships as `rota.presets.CobaltRota` on
+ the new `StructuredRotaData` model, with its icon set packaged in the templates
+ artifact, porting the rendered layout of a published standalone template: rendered at
+ 150 dpi against the frozen original driven by the original's own data file, **every
+ pixel of the sheet is identical** — the grid, all eighty-four cells, the legend, the
+ covers, the bands and the foot's own line. The one region that differs is the foot's
+ left slot, which the original leaves empty and the port fills with the note the
+ document states.
+
+ **The whole sheet is one table.** Everything from the masthead rule to the last person
+ is rows of it — that is what keeps the columns aligned, where a masthead above or a
+ legend beside would be a second structure to keep in step. The four header rows are
+ declared repeating with their count, because `repeatHeader()` with no argument repeats
+ one row and a rota longer than a page would carry a stray rule onto the next one
+ instead of its day headings.
+
+ **The grid follows the document, not the design.** The sheet has as many day columns
+ as the rota has days and every person's entries are read by position against that
+ list, so a five-day rota makes five wider columns; nothing in the preset says a week.
+ A legend documenting more statuses than there are days runs onto a second strip rather
+ than losing its tail — a status a reader cannot look up is worse than an extra row —
+ and one documenting fewer is closed off with empty cells, so the table stays square
+ either way. The label column keeps its own padding whatever the day count, so the
+ wordmark does not resize with the number of days. A rota that states no legend and no
+ covers carries neither row: an empty navy bar over empty boxes is two rows of a sheet
+ that is short of rows already.
+
+ A day cell is a block of a stated height rather than whatever its chips come to,
+ because a table cell places a node child at its *top* — `textAnchor` seats text, not a
+ node — so a short chip in a row made tall by a neighbour would otherwise hang with all
+ the slack beneath it. An unmarked entry is drawn as a chip with no fill and no outline
+ rather than as bare text, so it occupies exactly the height a marked one does. The
+ row's tint is threaded by hand into every cell and on into those chips rather than
+ left to the table's own striping, which would stripe the header, the legend, the
+ covers and the bands too. Guarded by a smoke test (including the empty rota, a
+ five-day rota, a split day, a band naming no mark, the unknown-token data error, a
+ legend longer and shorter than the week, a rota stating neither legend nor covers, and
+ one long enough to need a second page), an exact layout snapshot, and a pixel baseline
+ — all three fed by one fixture.
+
+ **The gates say what they can see.** The sheet is composed table cells almost end to
+ end, and a composed cell emits fragments rather than a placed node, so the snapshot
+ holds two nodes for eighty-four shift cells: it pins the page and the table that
+ carries them, and nothing inside. The pixel baseline is the only gate that sees the
+ sheet, and one chip is under two thousand of the page's half-million pixels — a budget
+ wide enough for a renderer's antialiasing is wide enough to hide several chips — so
+ its budget is a fraction of the sibling presets', and a third test counts the ink of
+ each status colour directly. Removing a chip's fill turns that one red by name.
+
+- **The rails seven CV presets drew by hand are timelines.** Midnight Navy, Charcoal Gold,
+ Navy Sidebar, Serif Headline, Slate Orange, Terracotta Rail and Professional Sidebar each
+ stood their entry rail up as a left accent border repeated per entry and withheld from the
+ last one, so the line would appear to stop at the final marker. `addTimeline` with a
+ `rail(...)` extent says that directly, so it says it: `MARKER_TO_MARKER` where the line
+ runs between the marks, `ENTRY_BOUNDS` where it spans the entries.
+
+ What a reader sees change is that a rail now meets its markers instead of stopping at the
+ edge of the line box a marker sits in — a few points, at the two ends only, and each
+ preset's own figure is recorded with its migration. Markers, date columns and content
+ columns hold their x, no preset gains or loses a page, and node accounting is exact either
+ side of every one.
+
+ Professional Sidebar's education rail loses 56 lines with it. It filled the first entry's
+ head band with the sidebar colour, painted a shape over the rail above the first dot, and
+ redrew the line below — to make the rail begin at that dot. Scanned in the rail column of
+ its own baseline, it never did: an accent draws above both the fill and the mask, so the
+ masked stretch stayed visible and the construction's only effect was drawing 2.5pt of rail
+ twice, a shade darker than the rest of the line.
+
+ **Violet Grid keeps its hand-built rail**, deliberately. It is the one of these presets
+ whose timeline crosses a page, and two things the rail API cannot express turn up there and
+ only there. A rail's extent on a page that is not the last comes from that page's content
+ band rather than from the entries on it, which would run this rail 55.25pt past its final
+ role to the bottom margin. And a timeline's leading column width is fixed by where the rail
+ and the content sit, which pins this design's date column at 55.860pt against a longest
+ date of 57.005 and wraps a line that has never wrapped. Both are measured against an
+ overflow fixture written for the purpose, and both are recorded as gaps rather than
+ approximated.
+
+- **Serif Headline's bullet gap is a measurement rather than a space.** Its highlight lists
+ asked for the air after the dot by writing the marker `"• "`. A list marker's authored
+ trailing whitespace is stripped and a single space re-appended, so the second one never
+ reached the page and the gap has always been one space: 2.067pt. It is `markerGap` now, at
+ 0.256 em — the body face's real space advance — with `hangingIndent(true)`. Every dot and
+ every first line holds its position to 0.001pt. What moves is the wrapped lines, which used
+ to resume 1.518pt *past* the text they belong to, because the legacy indent pads a
+ continuation in whole spaces and cannot land on anything finer; they start on it now.
+
+- **A Timeline Minimal marker that never drew is gone.** Its module body lines set
+ `bulletOffset("-")` and rendered no dash: a bullet prefix reaches the page only under an
+ indent strategy that asks for it, and the site set none, so the prefix was built, measured
+ for the continuation indent, and applied to no line. The call is removed rather than made
+ to work — plain lines are what the design has always shown — and the render is unchanged to
+ the pixel.
+
+- **The receipt and both structured proposals stop padding their spaced caps.** Eight labels
+ across `ModernReceipt`, `EditorialProposal` and `NorthlineProposal` built the look by
+ rewriting the string with a space between every letter, so the canonical receipt's text
+ layer read `N O R T H W I N D P A Y`, `A M O U N T C O L L E C T E D`, `T R A N S F E R D
+ E T A I L S`. The tracking moves onto the style — `TextOrnaments.SPACED_CAPS`, on
+ `ReceiptStyles.eyebrow` and `.groupTitle` and on each proposal's `DOC_LABEL`, which is what
+ those styles' own javadoc already said they were — and the text is the text. This is what
+ makes the note under **Public API** true of every built-in family rather than of the CV and
+ cover-letter ones alone.
+
+ The receipt's labels narrow by 5–9% and nothing re-flows: one page, 130 nodes, and the nine
+ that change are all width and none position. Each proposal's label is right-aligned inside
+ its own section, so its node does not move at all and the change shows only in pixels and
+ on the text layer. Both families carry a new assertion on that text layer, because it is
+ the reason for the change and no pixel or geometry gate can see it.
+
### Tests
+- **The schedule fixtures no longer carry a real venue's staff.** The weekly-schedule
+ test fixture and the example data factory were written from a real bar's rota and kept
+ its people's names, its venue name, and two third parties named in the day notes.
+ Fixture content should be invented, and this is now: eleven invented names, an
+ invented venue, and generic notes. The shape — eleven people, seven days, the same
+ categories and the same assignments — is unchanged, so every assertion that read it
+ reads the same thing. `assets/readme/examples/weekly-schedule.pdf` is re-rendered for
+ the two note strings it prints; nothing else in it moves.
+
+- **The CodeQL scope guard can no longer be emptied by rewriting a deploy command.**
+ `CodeQlScopeGuardTest` asks whether every module a release publishes is inside the
+ security scan, and it read the answer's left-hand side out of the publish workflows by
+ matching `-f /pom.xml` on a line mentioning `deploy`. A deploy written any
+ other way — `-pl :graph-compose-fonts`, or the same command wrapped across two lines —
+ simply left that module out of the inventory, and a shorter inventory is an easier
+ comparison rather than a failing one. Verified by mutation: with `publish-fonts.yml`
+ switched to the `-pl` form and `graph-compose-fonts` removed from the scan's module
+ list, the suite stayed green while a module published to Maven Central went unscanned.
+ Two checks now key on the absence of a positive signal instead — a publish workflow
+ that contributes no module to the inventory fails, and `publish.yml`'s deploy steps
+ are held against the `order` line it validates its own resume against, which is a
+ second statement of the train that nothing derives from the first. Both go red under
+ the mutation that used to pass.
+
+- **The sidebar CV samples are held to the width of the column they are drawn in.**
+ A contact channel in `ProfessionalSidebar` and `NavySidebar` is one paragraph — the
+ mark and the value share a line — so a value wider than the sidebar's text column
+ wraps and leaves the mark alone on the first line. The promotion gates could not see
+ it: they measure the published template's own fixture, whose addresses fit, while the
+ example sample data is written for the repository and can outgrow the column with
+ every test still green. It shipped that way once, spotted in the rendered preview
+ rather than by a build. `SidebarContactRowsFitTest` now measures the shape of the row
+ instead of the length of the string — a single-line channel is as tall as its mark,
+ a wrapped one close to twice that — and the two sample addresses were shortened to
+ fit.
+
+- **Every promoted CV design is asked the same question about its telephone link.**
+ `CvPresetDialTargetTest` renders all ten with a contact block whose number carries a
+ trunk prefix and reads the `tel:` targets back out of the file. This is the check the
+ thirteen private copies never had: two designs had a test naming a trunk prefix and
+ both handled one, while the other eight asserted their dial target with a number
+ carrying none — so every copy agreed with its own test, and four of them still built a
+ target no caller could reach. Proved fails-closed by removing the trunk-prefix rule
+ from `ContactUri` and watching all ten go red. `ConsultingInvoiceSmokeTest` asks the
+ same of the invoice design that was furthest out, giving the masthead and the closing
+ prose different numbers so that neither site can answer for the other.
+
- **The timeline's finished visual model is pinned scene by scene.** Ten scenarios, each
given the instrument that can decide it: a coordinate where the claim is a coordinate,
a picture where the claim is a shape or a paint order, and neither where the other
@@ -545,6 +1911,14 @@ follow semantic versioning; release dates are ISO 8601.
### Deprecations
+- **`templates.data.schedule` — every type.** `WeeklyScheduleData`,
+ `WeeklyScheduleDocumentSpec`, `ScheduleDay`, `ScheduleCategory`, `SchedulePerson`,
+ `ScheduleAssignment`, `ScheduleSlot` and `ScheduleMetricRow` are deprecated since
+ 2.4.0 in favour of `templates.data.rota`, each naming its replacement. They still
+ ship and still compile; nothing in the library ever rendered them, so no output moves.
+ `docs/templates/which-template-system.md` now points a caller at the rota model
+ instead.
+
- **`TextOrnaments.spacedUpper(String)`** is `@Deprecated(since = "2.4.0", forRemoval = true)`.
It is not removed, and its behaviour has not changed by a single character — code written
against 2.3.0 keeps compiling and keeps getting the same strings back. What changed is that
diff --git a/assets/readme/examples/cv-blue-banner-v2.pdf b/assets/readme/examples/cv-blue-banner-v2.pdf
index eaf9a6052..acdc018f0 100644
Binary files a/assets/readme/examples/cv-blue-banner-v2.pdf and b/assets/readme/examples/cv-blue-banner-v2.pdf differ
diff --git a/assets/readme/examples/cv-boxed-sections-v2.pdf b/assets/readme/examples/cv-boxed-sections-v2.pdf
index a2f8fb368..6904d64d9 100644
Binary files a/assets/readme/examples/cv-boxed-sections-v2.pdf and b/assets/readme/examples/cv-boxed-sections-v2.pdf differ
diff --git a/assets/readme/examples/cv-centered-headline-v2.pdf b/assets/readme/examples/cv-centered-headline-v2.pdf
index ae1f63cdb..0632c0ef2 100644
Binary files a/assets/readme/examples/cv-centered-headline-v2.pdf and b/assets/readme/examples/cv-centered-headline-v2.pdf differ
diff --git a/assets/readme/examples/cv-charcoal-gold-v2.pdf b/assets/readme/examples/cv-charcoal-gold-v2.pdf
new file mode 100644
index 000000000..f3190db15
Binary files /dev/null and b/assets/readme/examples/cv-charcoal-gold-v2.pdf differ
diff --git a/assets/readme/examples/cv-engineering-resume-v2.pdf b/assets/readme/examples/cv-engineering-resume-v2.pdf
index dec3ff8a2..8e2bccd5d 100644
Binary files a/assets/readme/examples/cv-engineering-resume-v2.pdf and b/assets/readme/examples/cv-engineering-resume-v2.pdf differ
diff --git a/assets/readme/examples/cv-executive-v2.pdf b/assets/readme/examples/cv-executive-v2.pdf
index 5fa628266..3ff095451 100644
Binary files a/assets/readme/examples/cv-executive-v2.pdf and b/assets/readme/examples/cv-executive-v2.pdf differ
diff --git a/assets/readme/examples/cv-midnight-navy-v2.pdf b/assets/readme/examples/cv-midnight-navy-v2.pdf
new file mode 100644
index 000000000..91f11bbfe
Binary files /dev/null and b/assets/readme/examples/cv-midnight-navy-v2.pdf differ
diff --git a/assets/readme/examples/cv-minimal-underlined-v2.pdf b/assets/readme/examples/cv-minimal-underlined-v2.pdf
index c4d1082cf..505478f17 100644
Binary files a/assets/readme/examples/cv-minimal-underlined-v2.pdf and b/assets/readme/examples/cv-minimal-underlined-v2.pdf differ
diff --git a/assets/readme/examples/cv-mint-editorial-v2-custom.pdf b/assets/readme/examples/cv-mint-editorial-v2-custom.pdf
index 993262500..3d553946a 100644
Binary files a/assets/readme/examples/cv-mint-editorial-v2-custom.pdf and b/assets/readme/examples/cv-mint-editorial-v2-custom.pdf differ
diff --git a/assets/readme/examples/cv-mint-editorial-v2.pdf b/assets/readme/examples/cv-mint-editorial-v2.pdf
index e4d327e40..c74cc0965 100644
Binary files a/assets/readme/examples/cv-mint-editorial-v2.pdf and b/assets/readme/examples/cv-mint-editorial-v2.pdf differ
diff --git a/assets/readme/examples/cv-modern-professional-v2.pdf b/assets/readme/examples/cv-modern-professional-v2.pdf
index 114f66c5f..0677aaa2e 100644
Binary files a/assets/readme/examples/cv-modern-professional-v2.pdf and b/assets/readme/examples/cv-modern-professional-v2.pdf differ
diff --git a/assets/readme/examples/cv-monogram-sidebar-v2.pdf b/assets/readme/examples/cv-monogram-sidebar-v2.pdf
index bbd3951d5..bec4f121e 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-navy-sidebar-v2.pdf b/assets/readme/examples/cv-navy-sidebar-v2.pdf
new file mode 100644
index 000000000..d9356c094
Binary files /dev/null and b/assets/readme/examples/cv-navy-sidebar-v2.pdf differ
diff --git a/assets/readme/examples/cv-orange-ops-v2.pdf b/assets/readme/examples/cv-orange-ops-v2.pdf
new file mode 100644
index 000000000..e31a502c1
Binary files /dev/null and b/assets/readme/examples/cv-orange-ops-v2.pdf differ
diff --git a/assets/readme/examples/cv-panel-v2.pdf b/assets/readme/examples/cv-panel-v2.pdf
index 1375fc645..18ee0bf21 100644
Binary files a/assets/readme/examples/cv-panel-v2.pdf and b/assets/readme/examples/cv-panel-v2.pdf differ
diff --git a/assets/readme/examples/cv-professional-sidebar-v2.pdf b/assets/readme/examples/cv-professional-sidebar-v2.pdf
new file mode 100644
index 000000000..59801874b
Binary files /dev/null and b/assets/readme/examples/cv-professional-sidebar-v2.pdf differ
diff --git a/assets/readme/examples/cv-serif-headline-v2.pdf b/assets/readme/examples/cv-serif-headline-v2.pdf
new file mode 100644
index 000000000..b86f5b925
Binary files /dev/null and b/assets/readme/examples/cv-serif-headline-v2.pdf differ
diff --git a/assets/readme/examples/cv-sidebar-portrait-v2.pdf b/assets/readme/examples/cv-sidebar-portrait-v2.pdf
index 5f79c49f8..a60cb9968 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/assets/readme/examples/cv-slate-orange-v2.pdf b/assets/readme/examples/cv-slate-orange-v2.pdf
new file mode 100644
index 000000000..0f954dbaa
Binary files /dev/null and b/assets/readme/examples/cv-slate-orange-v2.pdf differ
diff --git a/assets/readme/examples/cv-teal-pulse-v2.pdf b/assets/readme/examples/cv-teal-pulse-v2.pdf
new file mode 100644
index 000000000..72277c2da
Binary files /dev/null and b/assets/readme/examples/cv-teal-pulse-v2.pdf differ
diff --git a/assets/readme/examples/cv-terracotta-rail-v2.pdf b/assets/readme/examples/cv-terracotta-rail-v2.pdf
new file mode 100644
index 000000000..f122afc92
Binary files /dev/null and b/assets/readme/examples/cv-terracotta-rail-v2.pdf differ
diff --git a/assets/readme/examples/cv-timeline-minimal-v2.pdf b/assets/readme/examples/cv-timeline-minimal-v2.pdf
index c9124479f..e26d082e9 100644
Binary files a/assets/readme/examples/cv-timeline-minimal-v2.pdf and b/assets/readme/examples/cv-timeline-minimal-v2.pdf differ
diff --git a/assets/readme/examples/cv-violet-grid-v2.pdf b/assets/readme/examples/cv-violet-grid-v2.pdf
new file mode 100644
index 000000000..9d165c9c7
Binary files /dev/null and b/assets/readme/examples/cv-violet-grid-v2.pdf differ
diff --git a/assets/readme/examples/invoice-classic-v2.pdf b/assets/readme/examples/invoice-classic-v2.pdf
new file mode 100644
index 000000000..1a4c2dc20
Binary files /dev/null and b/assets/readme/examples/invoice-classic-v2.pdf differ
diff --git a/assets/readme/examples/invoice-consulting-v2.pdf b/assets/readme/examples/invoice-consulting-v2.pdf
new file mode 100644
index 000000000..da323a244
Binary files /dev/null and b/assets/readme/examples/invoice-consulting-v2.pdf differ
diff --git a/assets/readme/examples/invoice-luma-studio-v2.pdf b/assets/readme/examples/invoice-luma-studio-v2.pdf
new file mode 100644
index 000000000..35493c777
Binary files /dev/null and b/assets/readme/examples/invoice-luma-studio-v2.pdf differ
diff --git a/assets/readme/examples/invoice-payments-v2.pdf b/assets/readme/examples/invoice-payments-v2.pdf
new file mode 100644
index 000000000..6d65b071d
Binary files /dev/null and b/assets/readme/examples/invoice-payments-v2.pdf differ
diff --git a/assets/readme/examples/invoice-workspace-v2.pdf b/assets/readme/examples/invoice-workspace-v2.pdf
new file mode 100644
index 000000000..365b8ebc5
Binary files /dev/null and b/assets/readme/examples/invoice-workspace-v2.pdf differ
diff --git a/assets/readme/examples/proposal-editorial-v2.pdf b/assets/readme/examples/proposal-editorial-v2.pdf
new file mode 100644
index 000000000..45f0ccd6b
Binary files /dev/null and b/assets/readme/examples/proposal-editorial-v2.pdf differ
diff --git a/assets/readme/examples/proposal-northline-v2.pdf b/assets/readme/examples/proposal-northline-v2.pdf
new file mode 100644
index 000000000..4216dc4e5
Binary files /dev/null and b/assets/readme/examples/proposal-northline-v2.pdf differ
diff --git a/assets/readme/examples/receipt-modern.pdf b/assets/readme/examples/receipt-modern.pdf
new file mode 100644
index 000000000..b77dedc96
Binary files /dev/null and b/assets/readme/examples/receipt-modern.pdf differ
diff --git a/assets/readme/examples/weekly-schedule.pdf b/assets/readme/examples/weekly-schedule.pdf
index 8c43e6531..e80469d6f 100644
Binary files a/assets/readme/examples/weekly-schedule.pdf and b/assets/readme/examples/weekly-schedule.pdf differ
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java
index 685096e18..5c6c76c73 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/ListBuilder.java
@@ -1,5 +1,6 @@
package com.demcha.compose.document.dsl;
+import com.demcha.compose.document.node.InlineRun;
import com.demcha.compose.document.node.ListItem;
import com.demcha.compose.document.node.ListMarker;
import com.demcha.compose.document.node.ListNode;
@@ -19,7 +20,21 @@ public final class ListBuilder {
private final List items = new ArrayList<>();
private final Map markerOverrides = new LinkedHashMap<>();
private String name = "";
- private boolean usedNestedAuthoring = false;
+ /**
+ * Whether the items still fit the flat {@code List} shape a list had
+ * before it could nest. Nesting is one reason they do not; a rich item, whose
+ * content is runs rather than a label, is the other — it has to reach the
+ * layout as a {@link ListItem}, and the flat path carries only labels.
+ */
+ private boolean needsItemTree = false;
+ /**
+ * Whether the author declared depth. Kept apart from {@link #needsItemTree}
+ * because the two answer different questions, and only this one decides
+ * whose marker depth 0 takes: a nested list resolves every level from the
+ * per-depth cascade and {@link #markerFor(int, ListMarker)}, while a flat
+ * list's marker is its own — and a list of rich items is still flat.
+ */
+ private boolean declaredDepth = false;
private ListMarker marker = ListMarker.bullet();
private DocumentTextStyle textStyle = DocumentTextStyle.DEFAULT;
private TextAlign align = TextAlign.LEFT;
@@ -50,7 +65,7 @@ private static List applyMarkerOverrides(List items,
? item.marker()
: overrides.get(depth);
List resolvedChildren = applyMarkerOverrides(item.children(), depth + 1, overrides);
- out.add(new ListItem(item.label(), effective, resolvedChildren));
+ out.add(new ListItem(item.label(), item.runs(), effective, resolvedChildren));
}
return List.copyOf(out);
}
@@ -129,13 +144,77 @@ public ListBuilder addItem(String item) {
*/
public ListBuilder addItem(String label, Consumer body) {
Objects.requireNonNull(body, "body");
- this.usedNestedAuthoring = true;
+ this.needsItemTree = true;
+ this.declaredDepth = true;
ListBuilder childScope = new ListBuilder();
body.accept(childScope);
this.items.add(new ListItem(label, null, childScope.snapshotItems()));
return this;
}
+ /**
+ * Appends one list item whose content is styled in pieces.
+ *
+ *
{@code "Bold label: normal description"} becomes one item rather than a
+ * row of two columns pretending to be one. The content is a {@link RichText}
+ * — the same inline runs a paragraph is made of, taken by the same builder
+ * {@link ParagraphBuilder#rich(Consumer)} takes — so the library has one
+ * rich-text model and not a second one for lists.
+ *
+ *
The item lays out on the measured marker geometry:
+ * {@link #hangingIndent(boolean)} is required, and the layout says so if it
+ * is missing. The marker is measured, {@link #markerGap(double)} applies, and
+ * every visual line of the content — the first, the ones it wraps onto, the
+ * ones that continue on the next page — starts at one x. A rich item is
+ * content whatever its runs draw, so runs of an icon and no text are still a
+ * row.
+ *
+ *
Seed the supplied builder with {@link RichText#plain(String)} — not
+ * {@code t.text(...)}: {@link RichText#text(String)} is a static factory, so
+ * that call compiles but builds a separate, discarded {@code RichText} and
+ * leaves this item empty.
+ *
+ * @param content callback that appends this item's inline runs
+ * @return this builder
+ * @throws NullPointerException if {@code content} is null
+ * @since 2.4.0
+ */
+ public ListBuilder addItem(Consumer content) {
+ Objects.requireNonNull(content, "content");
+ RichText rich = RichText.empty();
+ content.accept(rich);
+ this.needsItemTree = true;
+ this.items.add(ListItem.ofRuns(rich.runs()));
+ return this;
+ }
+
+ /**
+ * Appends one nested list item whose own content is styled in pieces.
+ *
+ *
{@link #addItem(Consumer)} with children, so a styled label can head a
+ * sub-tree.
+ *
+ * @param content callback that appends this item's inline runs
+ * @param body callback that adds children of this item
+ * @return this builder
+ * @throws NullPointerException if either callback is null
+ * @since 2.4.0
+ */
+ public ListBuilder addItem(Consumer content, Consumer body) {
+ Objects.requireNonNull(content, "content");
+ Objects.requireNonNull(body, "body");
+ this.needsItemTree = true;
+ this.declaredDepth = true;
+ RichText rich = RichText.empty();
+ content.accept(rich);
+ ListBuilder childScope = new ListBuilder();
+ body.accept(childScope);
+ List runs = rich.runs();
+ this.items.add(new ListItem(InlineRun.plainText(runs), runs, null,
+ childScope.snapshotItems()));
+ return this;
+ }
+
/**
* Overrides the marker used for items at the given depth when no
* per-item marker is set. Depth 0 is the top-level marker; depth 1
@@ -185,6 +264,49 @@ public ListBuilder marker(String marker) {
return marker(ListMarker.custom(marker));
}
+ /**
+ * Sets a marker that is drawn rather than typed — a coloured disc, an icon,
+ * a glyph in a face of its own.
+ *
+ *
The marker is a {@link RichText}, the same inline runs a paragraph and
+ * a list item are made of, so {@code m -> m.dot(4, ACCENT)} is a teal disc,
+ * {@code m -> m.color("•", ACCENT)} is an accent bullet beside near-black
+ * text, and {@code m -> m.svgIcon(icon, 8)} is an icon. A marker's colour is
+ * its own here; the text form takes the list's, which is what a design with
+ * a coloured mark and dark copy could not say.
+ *
+ *
Drawn or typed, the marker is measured and occupies the marker column:
+ * {@link #markerGap(double)} is points of real space after it and every
+ * visual line of the item starts at one x. So this needs
+ * {@link #hangingIndent(boolean)}, and the layout says so if it is missing —
+ * the older layout puts the marker inside the item's text, where a drawing
+ * cannot go.
+ *
+ *
Seed the supplied builder with {@link RichText#plain(String)} — not
+ * {@code m.text(...)}: {@link RichText#text(String)} is a static factory, so
+ * that call compiles but builds a separate, discarded {@code RichText} and
+ * leaves the list markerless.
+ *
+ *
This sets the list's marker, which is its depth-0 marker. A drawn marker
+ * at a deeper level goes through {@link #markerFor(int, ListMarker)} as
+ * {@code markerFor(1, ListMarker.ofRuns(RichText.empty().dot(4, ACCENT).runs()))}
+ * — deliberately not a second lambda overload, because
+ * {@code markerFor(depth, null)} clears an override and a lambda overload
+ * would make that call ambiguous for code that already compiles.
+ *
+ * @param marker callback that appends the marker's inline runs
+ * @return this builder
+ * @throws NullPointerException if {@code marker} is null
+ * @since 2.4.0
+ */
+ public ListBuilder marker(Consumer marker) {
+ Objects.requireNonNull(marker, "marker");
+ RichText rich = RichText.empty();
+ marker.accept(rich);
+ return marker(ListMarker.ofRuns(rich.runs()));
+ }
+
+
/**
* Uses bullet markers.
*
@@ -404,18 +526,19 @@ public ListBuilder margin(float top, float right, float bottom, float left) {
* Builds the semantic list node.
*
*
When only {@link #addItem(String)} was used the result is a
- * flat list (back-compat with v1.4 / v1.5). As soon as
- * {@link #addItem(String, Consumer)} is called at least once the
- * result is a nested list — flat items added before the first
- * nested call become depth-0 leaves alongside the nested entries,
- * preserving source order. Per-depth marker overrides set via
- * {@link #markerFor(int, ListMarker)} are baked into each item's
- * resolved marker before the node is sealed.
+ * flat list (back-compat with v1.4 / v1.5). As soon as something is
+ * added that a list of labels cannot hold — a nested item via
+ * {@link #addItem(String, Consumer)}, or a rich item via
+ * {@link #addItem(Consumer)}, either of them once — the result is an
+ * item tree; flat items added before that become depth-0 leaves
+ * alongside the rest, preserving source order. Per-depth marker
+ * overrides set via {@link #markerFor(int, ListMarker)} are baked
+ * into each item's resolved marker before the node is sealed.
*
* @return list node
*/
public ListNode build() {
- if (!usedNestedAuthoring) {
+ if (!needsItemTree) {
// Back-compat flat path. node.items() carries the labels
// and node.nestedItems() is empty; rendering matches the
// v1.4 / v1.5 flat-list behaviour exactly.
@@ -441,7 +564,17 @@ public ListNode build() {
}
// Nested path. Source order across flat and nested entries is
// preserved because both flow through the unified `items` list.
- List resolved = applyMarkerOverrides(items, 0, markerOverrides);
+ // A flat list's marker is its own, whether its items are strings or
+ // runs. Only a list whose author declared depth hands depth 0 to the
+ // per-depth cascade — that is the nested contract, and markerFor(0, …)
+ // is how it is overridden. Without this a dashed list would render one
+ // bullet the moment one of its items needed styling.
+ Map effectiveOverrides = markerOverrides;
+ if (!declaredDepth) {
+ effectiveOverrides = new LinkedHashMap<>(markerOverrides);
+ effectiveOverrides.putIfAbsent(0, marker);
+ }
+ List resolved = applyMarkerOverrides(items, 0, effectiveOverrides);
return new ListNode(
name,
List.of(),
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
index 1d2914df5..2458722cd 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
@@ -39,7 +39,7 @@
*
*
The rail is one logical line, computed after layout from where the markers and
* entries actually landed and drawn as one fragment per page it crosses. How far it
- * runs is a {@link TimelineRailExtent}; where it runs comes from the marker anchor,
+ * runs is a {@link TimelineRailEnd} at each end; where it runs comes from the marker anchor,
* a gutter to the left of the markers by default or through them after
* {@link #markerOnRail()}. It is drawn beneath the markers, so a filled marker
* covers the line passing under it.
@@ -61,10 +61,13 @@ public final class TimelineBuilder {
private final List entries = new ArrayList<>();
private DocumentStroke railStroke = DocumentStroke.of(DEFAULT_RAIL, 1.5);
private String railDeclaredBy;
- private TimelineRailExtent railExtent = TimelineRailExtent.ENTRY_BOUNDS;
+ private TimelineRailEnd railStart = TimelineRailEnd.ENTRY_BOUND;
+ private TimelineRailEnd railEnd = TimelineRailEnd.ENTRY_BOUND;
private TimelineMarkerAnchor markerAnchor;
private double gutter = 8.0;
private double markerGap = 8.0;
+ /** Unset until asked for, because unset means "the same as the marker gap". */
+ private Double leadingGap;
private TimelineAxisSize axis = new TimelineAxisSize.Weight(0.10);
private String axisDeclaredBy;
private DocumentRowColumn leadingColumn;
@@ -143,8 +146,11 @@ public TimelineBuilder rail(Consumer spec) {
Objects.requireNonNull(spec, "spec");
TimelineRailBuilder builder = new TimelineRailBuilder();
spec.accept(builder);
- if (builder.extent() != null) {
- this.railExtent = builder.extent();
+ if (builder.start() != null) {
+ this.railStart = builder.start();
+ }
+ if (builder.end() != null) {
+ this.railEnd = builder.end();
}
if (builder.stroke() == null) {
return this;
@@ -234,6 +240,34 @@ public TimelineBuilder markerGap(double gap) {
return this;
}
+ /**
+ * Sets the horizontal gap between the leading column and the marker.
+ *
+ *
Without this the gaps either side of the marker column are one number, and that
+ * over-constrains a three-column timeline. Writing {@code x0} for where the columns
+ * start, {@code L} for the leading width and {@code A} for the axis, one gap {@code s}
+ * puts the rail at {@code x0 + L + s + A/2} and the content at {@code x0 + L + 2s + A};
+ * subtract them and {@code L = (rail − x0) − (s + A/2)}. The leading width is then
+ * decided by where the rail and the content sit, whatever {@code A} and {@code s} are
+ * given — so a design stating all three, as a dated timeline does, cannot have them.
+ * Nor can it recover them by padding the leading column, which only narrows what goes
+ * inside it, or by widening the axis, which re-pins {@code L} to the same number.
+ *
+ *
With the two gaps separate, {@code L} is the caller's and this absorbs the
+ * difference. Unset, it is {@link #markerGap(double)} — which is the single-gap layout
+ * exactly, so a timeline that does not ask for this is laid out as it was.
+ *
+ * @param gap gap in points; negative values are ignored
+ * @return this builder
+ * @since 2.4.0
+ */
+ public TimelineBuilder leadingGap(double gap) {
+ if (gap >= 0) {
+ this.leadingGap = gap;
+ }
+ return this;
+ }
+
/**
* Sets the relative width of the marker column (its weight against a content
* weight of 1.0). Increase it for large numbered discs on narrow timelines.
@@ -471,21 +505,17 @@ private TimelineSpec normalize() {
// One owner per timeline, allocated here. Every marker below anchors on this
// instance, so the pass that draws the rail asks for it and gets these markers and
// nobody else's — two timelines on a page never merge.
- if (railExtent == TimelineRailExtent.TIMELINE_BOUNDS) {
- throw new IllegalArgumentException(
- "TimelineRailExtent.TIMELINE_BOUNDS is not implemented. On one page it is the "
- + "same line as ENTRY_BOUNDS, and across pages there is nothing to measure it "
- + "against — a timeline's own box draws nothing. Use ENTRY_BOUNDS or "
- + "MARKER_TO_MARKER.");
- }
TimelineRailSpec railSpec = new TimelineRailSpec(railStroke);
// The gutter is only knowable here, so the default anchor is resolved here too —
// and it is the same model the opted-in one uses, not a branch beside it.
TimelineMarkerAnchor anchor =
markerAnchor == null ? TimelineMarkerAnchor.atLeftEdge(gutter) : markerAnchor;
- return new TimelineSpec(new TimelineRailOwner(railSpec, railExtent, anchor),
- railSpec, leadingColumn, gutter, markerGap, axis, anchor, entrySpacing,
- keepTogether, keepEntriesTogether, List.copyOf(specs));
+ // Unset resolves to the marker gap, which is the single-gap layout the two columns
+ // either side of the axis have always shared.
+ double resolvedLeadingGap = leadingGap == null ? markerGap : leadingGap;
+ return new TimelineSpec(new TimelineRailOwner(railSpec, railStart, railEnd, anchor),
+ railSpec, leadingColumn, resolvedLeadingGap, gutter, markerGap, axis, anchor,
+ entrySpacing, keepTogether, keepEntriesTogether, List.copyOf(specs));
}
/**
@@ -502,7 +532,9 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
// only when the rail moved into the axis; with the rail beside it the body spans the
// entry as it always has, and the header row publishes nothing.
boolean bodyClearsTheAxis = spec.markerAnchor().railRunsThroughTheAxis();
- int contentColumn = spec.leadingColumn() == null ? 1 : 2;
+ // [axis][content] with no leading column; [leading][gap][axis][gap][content] with
+ // one, because the two gaps are columns rather than one row spacing.
+ int contentColumn = spec.leadingColumn() == null ? 1 : 4;
List entries = spec.entries();
for (int i = 0; i < entries.size(); i++) {
TimelineEntrySpec entry = entries.get(i);
@@ -524,7 +556,6 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
.padding(new DocumentInsets(0, 0, bottom, spec.gutter()))
.spacing(4);
Consumer headerSpec = header -> {
- header.spacing(spec.markerGap());
DocumentRowColumn axis = column(spec.axis());
if (spec.leadingColumn() == null && spec.axis() instanceof TimelineAxisSize.Weight weight) {
// The same two columns either way — columns(weight, weight) resolves
@@ -532,17 +563,35 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
// weights(...) is what a timeline has always put on its RowNode, and
// RowNode.weights() is public; spelling it the other way empties that
// list for every timeline that exists. Sugar where the sugar applies.
+ header.spacing(spec.markerGap());
header.weights(weight.weight(), 1.0);
} else if (spec.leadingColumn() == null) {
+ header.spacing(spec.markerGap());
header.columns(axis, DocumentRowColumn.weight(1.0));
} else {
- header.columns(spec.leadingColumn(), axis, DocumentRowColumn.weight(1.0));
+ // Three columns need two gaps, and a row spaces every pair of its
+ // columns by one number — which is what tied the leading width to
+ // the rail and content positions. So the gaps are columns of their
+ // own and the row spaces nothing. With leadingGap defaulting to
+ // markerGap this resolves to the same widths the single spacing
+ // gave: fixed columns take their width either way, and the weighted
+ // remainder is the same subtraction in a different order.
+ header.spacing(0.0);
+ header.columns(spec.leadingColumn(),
+ DocumentRowColumn.fixed(spec.leadingGap()),
+ axis,
+ DocumentRowColumn.fixed(spec.markerGap()),
+ DocumentRowColumn.weight(1.0));
// Present even when this entry put nothing in it: the column is the
// timeline's, not the entry's, and an entry that skipped it must
// still start its marker where every other entry starts one.
header.addSection(entry.leading() == null ? column -> { } : entry.leading());
+ header.addSection(column -> { });
}
header.addSection(anchoredMarker(spec, entry, index));
+ if (spec.leadingColumn() != null) {
+ header.addSection(column -> { });
+ }
header.addSection(entry.beside());
};
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java
index 2dfbeaf9a..8bd459f0d 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java
@@ -23,7 +23,8 @@
public final class TimelineRailBuilder {
private DocumentStroke stroke;
- private TimelineRailExtent extent;
+ private TimelineRailEnd start;
+ private TimelineRailEnd end;
TimelineRailBuilder() {
}
@@ -41,19 +42,37 @@ public TimelineRailBuilder stroke(DocumentStroke stroke) {
}
/**
- * Sets how far the rail runs.
+ * Sets where the rail begins.
*
- *
Independent of where it runs: an extent is the line's two ends, and its x comes
- * from the marker anchor. A timeline that sets neither keeps
- * {@link TimelineRailExtent#ENTRY_BOUNDS}, which is what it already draws.
+ *
Independent of where it ends, and of where it runs: the two ends are chosen one at
+ * a time and the x comes from the marker anchor. A rail that is asked for neither end
+ * begins at {@link TimelineRailEnd#ENTRY_BOUND}, which is what a timeline already
+ * draws.
*
- * @param extent the rail's extent
+ * @param start where the rail's first end sits
* @return this builder
- * @throws NullPointerException if {@code extent} is null
+ * @throws NullPointerException if {@code start} is null
* @since 2.4.0
*/
- public TimelineRailBuilder extent(TimelineRailExtent extent) {
- this.extent = Objects.requireNonNull(extent, "extent");
+ public TimelineRailBuilder from(TimelineRailEnd start) {
+ this.start = Objects.requireNonNull(start, "start");
+ return this;
+ }
+
+ /**
+ * Sets where the rail ends.
+ *
+ *
The companion of {@link #from(TimelineRailEnd)} and just as independent: a rail
+ * may begin on its first marker and still run to the foot of its last entry. Unasked,
+ * it ends at {@link TimelineRailEnd#ENTRY_BOUND}.
+ *
+ * @param end where the rail's last end sits
+ * @return this builder
+ * @throws NullPointerException if {@code end} is null
+ * @since 2.4.0
+ */
+ public TimelineRailBuilder to(TimelineRailEnd end) {
+ this.end = Objects.requireNonNull(end, "end");
return this;
}
@@ -62,8 +81,13 @@ DocumentStroke stroke() {
return stroke;
}
- /** The extent this rail was given, or null when the caller set none. */
- TimelineRailExtent extent() {
- return extent;
+ /** The start this rail was given, or null when the caller asked for none. */
+ TimelineRailEnd start() {
+ return start;
+ }
+
+ /** The end this rail was given, or null when the caller asked for none. */
+ TimelineRailEnd end() {
+ return end;
}
}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailEnd.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailEnd.java
new file mode 100644
index 000000000..8dfd086e0
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailEnd.java
@@ -0,0 +1,44 @@
+package com.demcha.compose.document.dsl;
+
+/**
+ * Where one end of a timeline's rail sits.
+ *
+ *
The two ends are chosen separately, because a design that wants them the same is only
+ * one of four and was never the interesting case. A rail can begin at the first marker and
+ * still run to the foot of the last entry; that is a real design — a sidebar whose line
+ * starts at the first dot and carries on past the last one to close the block — and with a
+ * single value naming both ends it could not be asked for. The previous model had two
+ * symmetric constants and no way to mix them, so a template wanting this one painted the
+ * top of an entry-bounds rail over with the page colour and redrew the rest. Measured, that
+ * did not even work: an accent draws above the mask.
+ *
+ *
Nothing here says where the rail sits horizontally. That comes from the marker anchor,
+ * and the two are independent on purpose: an end moves the line's start or finish and never
+ * moves it sideways.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public enum TimelineRailEnd {
+
+ /**
+ * The entries' own bound on that page — the top of the first entry at the start, the
+ * foot of the last at the end.
+ *
+ *
What a timeline written before there was a choice draws at both ends, to the
+ * point: measured against the per-entry border it replaces, the two agree to 0.000000
+ * on every page. The gaps between entries are inside it, because an entry's spacing is
+ * padding within its own box, and there is no tail after the last entry because the
+ * last entry has no such padding.
+ */
+ ENTRY_BOUND,
+
+ /**
+ * The marker's anchor point — the first marker's at the start, the last's at the end.
+ *
+ *
No rail above the first marker or below the last. With one entry and both ends on
+ * the marker the extent is zero and no rail is drawn at all, because a zero-length line
+ * is not a shorter line.
+ */
+ MARKER
+}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java
deleted file mode 100644
index da2f6a5c5..000000000
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.demcha.compose.document.dsl;
-
-/**
- * How far a timeline's rail runs.
- *
- *
The two ends of the line, and nothing about where it sits horizontally — that comes
- * from the marker anchor. The two are independent on purpose: a rail can start and stop at
- * the markers while passing through their left edges, or span the entries while passing
- * through their centres.
- *
- * @author Artem Demchyshyn
- * @since 2.4.0
- */
-public enum TimelineRailExtent {
-
- /**
- * From the first marker's anchor point to the last's.
- *
- *
No rail above the first marker or below the last. With a single entry the extent
- * is zero and no rail is drawn at all — a zero-length line is not a shorter line.
- */
- MARKER_TO_MARKER,
-
- /**
- * The union of the entries' resolved boxes, page by page.
- *
- *
What a timeline written before there was a choice already draws, to the point:
- * measured against the per-entry border it replaces, the two agree to 0.000000 in both
- * ends on every page. The gaps between entries are inside it, because an entry's
- * spacing is padding within its own box; there is no tail after the last entry,
- * because the last entry has no such padding.
- *
- *
Page by page is load-bearing. An entry that spans pages has a different
- * extent on each of them, and its box as a whole is a coordinate belonging to no
- * page.
- */
- ENTRY_BOUNDS,
-
- /**
- * The timeline's own box, rather than the union of the entries in it.
- *
- *
Defined but not implemented: it is indistinguishable from
- * {@link #ENTRY_BOUNDS} on a single page — a timeline has no padding of its own — and
- * across pages there is nothing to measure it against, because the container draws
- * nothing. Asking for it throws rather than quietly resolving to the neighbour it
- * happens to equal in the easy case.
- */
- TIMELINE_BOUNDS
-}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java
index 5a2af7fc4..8608f4447 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java
@@ -42,12 +42,15 @@ final class TimelineRailOwner implements ResolvedLayoutPass {
private static final double EPS = 1e-9;
private final TimelineRailSpec rail;
- private final TimelineRailExtent extent;
+ private final TimelineRailEnd start;
+ private final TimelineRailEnd end;
private final TimelineMarkerAnchor markerAnchor;
- TimelineRailOwner(TimelineRailSpec rail, TimelineRailExtent extent, TimelineMarkerAnchor markerAnchor) {
+ TimelineRailOwner(TimelineRailSpec rail, TimelineRailEnd start, TimelineRailEnd end,
+ TimelineMarkerAnchor markerAnchor) {
this.rail = rail;
- this.extent = extent;
+ this.start = start;
+ this.end = end;
this.markerAnchor = markerAnchor;
}
@@ -103,11 +106,19 @@ public List contribute(LayoutGraph graph, ResolvedLayout
/**
* The rail's vertical extent, one segment per page it appears on.
*
- *
Both extents are built from the entries' resolved slices, because those already
+ *
Every segment starts as the entries' own resolved slices, because those already
* carry the one thing neither the markers nor the node boxes do: what a page's content
- * band is, on that page, after per-page margins. {@code MARKER_TO_MARKER} then trims
- * the first and last of them back to the markers rather than deriving a band of its
- * own.
+ * band is, on that page, after per-page margins. An end asked for on the marker then
+ * trims that page's slice back to the marker rather than deriving a band of its own —
+ * and only that page's, which is what lets the two ends be chosen one at a time. A
+ * middle page is trimmed by neither, because neither the first marker nor the last is
+ * on it.
+ *
+ *
Trimming back is the whole of it: {@code min} at the top and {@code max}
+ * at the bottom, so an end on a marker can only shorten the line, never invent rail
+ * outside the entries. A page past the marker that bounds it drops out for the same
+ * reason — with the start on the first marker there is nothing to draw on a page before
+ * it.
*/
private List segments(ResolvedLayoutMetadata metadata, List markers) {
Map byPage = new LinkedHashMap<>();
@@ -116,7 +127,9 @@ private List segments(ResolvedLayoutMetadata metadata, List segments(ResolvedLayoutMetadata metadata, List trimmed = new ArrayList<>();
for (Segment segment : byPage.values()) {
- if (segment.page < first.pageIndex() || segment.page > last.pageIndex()) {
+ if (startsOnMarker && segment.page < first.pageIndex()) {
+ continue;
+ }
+ if (endsOnMarker && segment.page > last.pageIndex()) {
continue;
}
- double top = segment.page == first.pageIndex() ? Math.min(segment.top, startY) : segment.top;
- double bottom = segment.page == last.pageIndex() ? Math.max(segment.bottom, endY) : segment.bottom;
+ double top = startsOnMarker && segment.page == first.pageIndex()
+ ? Math.min(segment.top, startY)
+ : segment.top;
+ double bottom = endsOnMarker && segment.page == last.pageIndex()
+ ? Math.max(segment.bottom, endY)
+ : segment.bottom;
trimmed.add(new Segment(segment.page, top, bottom));
}
return trimmed;
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java
index 6677091ad..ed6c1dd8d 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java
@@ -10,7 +10,7 @@
* configuration for the layout to read rather than an old shape and a new one.
*
*
A stroke is all it takes to describe, because the rail's geometry is not here: how far
- * it runs is a {@link TimelineRailExtent} and where it runs comes from the marker anchor,
+ * it runs is a {@link TimelineRailEnd} at each end and where it runs comes from the marker anchor,
* both resolved after layout. This is only what it is drawn with.
*
* @param stroke the rail's colour and width
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java
index eefe9b6c9..3f8f613bd 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java
@@ -18,6 +18,8 @@
* @param rail the connector rail
* @param leadingColumn how wide the column before the marker is, or null when the
* timeline has no leading column at all
+ * @param leadingGap horizontal gap between the leading column and the marker
+ * column; carries no meaning without a leading column
* @param gutter space between the rail and the entry's content
* @param markerGap horizontal gap between the marker column and the content
* beside it
@@ -32,6 +34,7 @@
record TimelineSpec(TimelineRailOwner owner,
TimelineRailSpec rail,
DocumentRowColumn leadingColumn,
+ double leadingGap,
double gutter,
double markerGap,
TimelineAxisSize axis,
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 a518eea7e..bb4872714 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
@@ -275,8 +275,31 @@ private void compileComposite(PreparedNode prepared,
boolean keepWhole = node.keepTogether()
&& outerHeight <= state.activeInnerHeight() + CAPACITY_TOLERANCE;
double startReservation = margin.top() + padding.top();
+
+ // A box must not open on a page its first indivisible unit cannot start on.
+ // The box is recorded here and its children are placed afterwards, so when the
+ // first thing inside it has to move — it asked to be kept whole, or it is
+ // indivisible and too tall for the space left — the box stays behind spanning a
+ // page it holds nothing on. Every consumer of that box then believes it: a
+ // section's accent paints a stub at the foot of the page beside nothing, and
+ // anything deriving an extent from it runs to the bottom margin, which is how a
+ // timeline's rail grew a tail below its last entry.
+ //
+ // So the box relocates too. This is the keep-together rule above asked of the
+ // leading unit instead of the whole node, and the same fallback applies: a unit
+ // that would not fit a fresh page either has nothing to gain from the break, so
+ // it flows where it stands. A box whose first unit does fit is untouched, which
+ // is why this moves no content — the content had already moved.
+ double leadingUnit = leadingUnitHeight(node, regionWidth, prepareContext,
+ state.activeInnerHeight());
+ boolean relocateWithLeadingUnit =
+ leadingUnit > state.remainingHeight() + EPS
+ && leadingUnit <= state.activeInnerHeight() + CAPACITY_TOLERANCE;
+
if (keepWhole && outerHeight > state.remainingHeight() + EPS && state.usedHeight > EPS) {
state.newPage();
+ } else if (relocateWithLeadingUnit && state.usedHeight > EPS) {
+ state.newPage();
} else if (startReservation > state.remainingHeight() + EPS && state.usedHeight > EPS) {
state.newPage();
}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java b/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java
index 9fb9aa229..0e3561801 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/ListItemNormalizer.java
@@ -77,9 +77,12 @@ private static void normalizeNested(ListNode node,
? item.marker()
: ListMarker.defaultForDepth(depth);
String content = ListMarker.normalizeItemText(item.label(), node.normalizeMarkers());
- boolean rendered = rendersSomething(marker, content);
+ // A rich item renders whatever its runs draw, which its label — their
+ // plain-text reading — need not describe: runs of an icon and a chip
+ // carry no text at all and are still content.
+ boolean rendered = item.isRich() || rendersSomething(marker, content);
if (rendered) {
- out.add(new ListItemSpec(depth, marker, content));
+ out.add(new ListItemSpec(depth, marker, content, item.runs()));
}
// Children are walked either way: an item that draws nothing is a
// reason to skip that one row, never a reason to lose the sub-tree
diff --git a/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java b/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java
index a7316b130..c01ee1497 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/ListMarkerGeometry.java
@@ -2,6 +2,8 @@
import com.demcha.compose.document.layout.payloads.ListItemSpec;
import com.demcha.compose.document.layout.payloads.MarkerContentItem;
+import com.demcha.compose.document.layout.payloads.ParagraphLine;
+import com.demcha.compose.document.node.ListMarker;
import com.demcha.compose.document.node.ListNode;
import com.demcha.compose.engine.components.content.text.TextStyle;
import com.demcha.compose.engine.measurement.TextMeasurementSystem;
@@ -45,12 +47,16 @@ private ListMarkerGeometry() {
* @param node the list, for its marker gap and text style
* @param availableItemWidth width one row may occupy, inside the list padding
* @param measurement text measurement service
+ * @param drawnMarkers the already-measured single line of each drawn
+ * marker used in the list, keyed by the marker;
+ * empty when every marker is text
* @return one resolved item per spec, in the same order
*/
static List resolve(List specs,
ListNode node,
double availableItemWidth,
- TextMeasurementSystem measurement) {
+ TextMeasurementSystem measurement,
+ Map drawnMarkers) {
if (specs.isEmpty()) {
return List.of();
}
@@ -75,10 +81,20 @@ static List resolve(List specs,
double markerX = depth == 0 ? 0.0 : contentXByDepth[depth - 1];
boolean hasMarker = spec.hasMarker();
- double markerWidth = hasMarker
- ? markerWidths.computeIfAbsent(spec.markerText(),
- text -> measurement.textWidth(style, text))
- : 0.0;
+ // A drawn marker was measured as a line of inline runs before this
+ // loop, by the same pipeline that measures the item's own content, so
+ // its width is the width of what it actually draws — a disc's
+ // diameter, an icon's box — rather than of any text.
+ ParagraphLine drawn = drawnMarkers.get(spec.marker());
+ double markerWidth;
+ if (!hasMarker) {
+ markerWidth = 0.0;
+ } else if (drawn != null) {
+ markerWidth = drawn.width();
+ } else {
+ markerWidth = markerWidths.computeIfAbsent(spec.markerText(),
+ text -> measurement.textWidth(style, text));
+ }
double gap = hasMarker ? node.markerGap() : 0.0;
double contentX = markerX + markerWidth + gap;
// Floored at the width the text pipeline already treats as its
@@ -91,7 +107,8 @@ static List resolve(List specs,
ParagraphWrapping.MIN_TEXT_WIDTH, availableItemWidth - contentX);
contentXByDepth[depth] = contentX;
- out.add(new MarkerContentItem(spec, markerX, markerWidth, gap, contentX, contentWidth));
+ out.add(new MarkerContentItem(spec, markerX, markerWidth, gap, contentX, contentWidth,
+ drawn == null || !hasMarker ? List.of() : drawn.spans()));
}
return List.copyOf(out);
}
diff --git a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
index 58a4a7345..74e2b5f41 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
@@ -15,7 +15,9 @@
import com.demcha.compose.engine.measurement.TextMeasurementSystem;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import static com.demcha.compose.document.layout.DocumentNodeAdapters.*;
import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS;
@@ -41,6 +43,13 @@ public final class TextFlowSupport {
*/
private static final String NESTED_LIST_INDENT_UNIT = " ";
+ /**
+ * The width a drawn list marker is measured at — wide enough that nothing a
+ * marker could reasonably be will wrap, so its measured width is the width of
+ * what it draws rather than of the column it happens to sit in.
+ */
+ private static final double MARKER_MEASURE_WIDTH = 100_000.0;
+
// ------------------------------------------------------------------
// Paragraph entry points
// ------------------------------------------------------------------
@@ -208,6 +217,9 @@ public static PreparedNode prepareList(ListNode node,
private static PreparedNode prepareLegacyPrefixList(ListNode node,
PrepareContext ctx,
BoxConstraints constraints) {
+ // The list's own marker reaches a flat list without going through the
+ // nested walk, so it is checked here as well as there.
+ refuseDrawnMarker(node.marker());
ListNode effective = node.nestedItems().isEmpty()
? node
: flattenNestedListNode(node);
@@ -233,12 +245,32 @@ private static PreparedNode prepareMarkerContentList(ListNode node,
PrepareContext ctx,
BoxConstraints constraints) {
double availableItemWidth = Math.max(0.0, constraints.availableWidth() - node.padding().horizontal());
+ List specs = ListItemNormalizer.normalize(node);
List geometry = ListMarkerGeometry.resolve(
- ListItemNormalizer.normalize(node), node, availableItemWidth, ctx.textMeasurement());
+ specs, node, availableItemWidth, ctx.textMeasurement(), prepareDrawnMarkers(specs, node, ctx));
List items = new ArrayList<>(geometry.size());
for (MarkerContentItem item : geometry) {
- ParagraphNode content = new ParagraphNode(
+ // A rich item's content is its runs, wrapped by the same inline
+ // algorithm a paragraph uses. That is the point of expressing it as
+ // runs rather than as a second rich-text model: the marker column,
+ // the gap and contentX resolve for it exactly as for a plain item,
+ // and the wrapping inside that width is the paragraph's own.
+ ParagraphNode content = item.spec().isRich()
+ ? new ParagraphNode(
+ "",
+ item.content(),
+ item.spec().runs(),
+ node.textStyle(),
+ node.align(),
+ node.lineSpacing(),
+ "",
+ DocumentTextIndent.NONE,
+ null,
+ null,
+ DocumentInsets.zero(),
+ DocumentInsets.zero())
+ : new ParagraphNode(
"",
item.content(),
node.textStyle(),
@@ -268,6 +300,56 @@ private static PreparedNode prepareMarkerContentList(ListNode node,
new PreparedListLayout(items, maxLineWidth, totalHeight, resolvedWidth, geometry));
}
+ /**
+ * Measures each distinct drawn marker in the list as one line of inline
+ * runs, through the pipeline that measures the items' own content.
+ *
+ *
A marker's width has to be known before any content origin can be, so
+ * this runs ahead of {@link ListMarkerGeometry}. Measuring it as a paragraph
+ * line rather than as a special case is what makes a disc, an icon and a
+ * coloured glyph all resolve to the width of the thing they draw, and what
+ * lets the emit phase place the marker's pieces with spans the renderers
+ * already know how to draw.
+ *
+ *
Measured unbounded, because a marker is one line by definition and a
+ * marker wider than its column overflows — the same answer a too-wide text
+ * marker already gets, rather than wrapping into a second line the row has
+ * no space for.
+ */
+ private static Map prepareDrawnMarkers(List specs,
+ ListNode node,
+ PrepareContext ctx) {
+ Map lines = null;
+ for (ListItemSpec spec : specs) {
+ ListMarker marker = spec.marker();
+ if (!marker.isRich()) {
+ continue;
+ }
+ if (lines == null) {
+ lines = new LinkedHashMap<>();
+ } else if (lines.containsKey(marker)) {
+ // A list shows the same handful of markers over and over, one per
+ // depth, so each distinct one is measured once for the whole list.
+ continue;
+ }
+ ParagraphNode asParagraph = new ParagraphNode(
+ "", "", marker.runs(), node.textStyle(), TextAlign.LEFT, 0.0, "",
+ DocumentTextIndent.NONE, null, null,
+ DocumentInsets.zero(), DocumentInsets.zero());
+ PreparedParagraphLayout layout = prepareParagraphLayout(
+ asParagraph, MARKER_MEASURE_WIDTH, ctx.textMeasurement(), false);
+ if (layout.visualLines().size() != 1) {
+ throw new IllegalStateException(
+ "a list marker is one line: \"" + marker.value() + "\" measured "
+ + layout.visualLines().size() + " lines. A marker sits on the item's "
+ + "first line and shares its baseline, so it has nowhere to put a "
+ + "second one");
+ }
+ lines.put(marker, layout.visualLines().get(0));
+ }
+ return lines == null ? Map.of() : lines;
+ }
+
/**
* Widest point any row reaches — the marker column and the content column
* are both candidates, and a marker-only row is measured by its marker.
@@ -318,9 +400,39 @@ private static ListNode flattenNestedListNode(ListNode node) {
node.markerGap());
}
+ /**
+ * Refuses a drawn marker on the legacy layout, where a marker is characters
+ * at the front of the item's text and a drawing has nowhere to go. Its plain
+ * reading is empty for a marker that draws only a disc or an icon, so
+ * rendering that would leave the list markerless with no signal at all.
+ */
+ private static void refuseDrawnMarker(ListMarker marker) {
+ if (marker != null && marker.isRich()) {
+ throw new IllegalStateException(
+ "a drawn list marker needs marker/content geometry: call "
+ + "hangingIndent(true) on the list. The legacy layout puts the marker "
+ + "inside the item's text, which can hold characters but not a drawing");
+ }
+ }
+
private static void flattenNestedItems(List items, int depth, List output) {
for (ListItem item : items) {
+ // A rich item's content is a sequence of independently styled runs and
+ // a label is a single string: there is no flattening of one into the
+ // other that keeps what the author asked for. Rendering the plain
+ // reading instead would silently drop every style, icon and chip,
+ // which is a worse answer than saying so. This is the only walk a rich
+ // item can reach the legacy layout through, so the check belongs here
+ // rather than in a second pass over the tree.
+ if (item.isRich()) {
+ throw new IllegalStateException(
+ "a list item made of inline runs needs marker/content geometry: call "
+ + "hangingIndent(true) on the list. The legacy layout makes an item's "
+ + "marker part of its text and so carries one style for the whole item, "
+ + "which cannot hold the runs of \"" + item.label() + "\"");
+ }
ListMarker marker = item.marker() != null ? item.marker() : ListMarker.defaultForDepth(depth);
+ refuseDrawnMarker(marker);
StringBuilder prefix = new StringBuilder(NESTED_LIST_INDENT_UNIT.repeat(depth));
if (marker.isVisible()) {
// ListMarker.normalize already appends a trailing space
@@ -502,6 +614,21 @@ private static ParagraphLine markerLine(ListNode node,
MarkerContentItem geometry) {
ParagraphLine first = itemLayout.visualLines().get(0);
String text = geometry.markerText();
+ // A drawn marker's pieces were measured already, as inline runs. They are
+ // placed in a line whose metrics are still the content's first line, so a
+ // disc or an icon rides the item's own baseline exactly as a glyph does
+ // and does not make the row taller; one drawn larger than the line
+ // overflows it, the answer a marker wider than its column already gets.
+ List spans = geometry.hasDrawnMarker()
+ ? geometry.markerSpans()
+ : List.of(new ParagraphTextSpan(
+ text,
+ toTextStyle(node.textStyle()),
+ geometry.measuredMarkerWidth(),
+ first.textLineHeight(),
+ null,
+ null,
+ false));
return new ParagraphLine(
text,
geometry.measuredMarkerWidth(),
@@ -509,14 +636,7 @@ private static ParagraphLine markerLine(ListNode node,
first.textLineHeight(),
first.textAscent(),
first.baselineOffsetFromBottom(),
- List.of(new ParagraphTextSpan(
- text,
- toTextStyle(node.textStyle()),
- geometry.measuredMarkerWidth(),
- first.textLineHeight(),
- null,
- null,
- false)));
+ spans);
}
private static ParagraphFragmentPayload paragraphPayload(ListNode node,
diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/ListItemSpec.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/ListItemSpec.java
index 5ec2bd98c..8dc57b214 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/payloads/ListItemSpec.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/ListItemSpec.java
@@ -1,7 +1,9 @@
package com.demcha.compose.document.layout.payloads;
+import com.demcha.compose.document.node.InlineRun;
import com.demcha.compose.document.node.ListMarker;
+import java.util.List;
import java.util.Objects;
/**
@@ -36,7 +38,7 @@
* @author Artem Demchyshyn
* @since 2.4.0
*/
-public record ListItemSpec(int depth, ListMarker marker, String content) {
+public record ListItemSpec(int depth, ListMarker marker, String content, List runs) {
/**
* Normalizes nullable inputs and rejects a negative depth.
@@ -49,6 +51,27 @@ public record ListItemSpec(int depth, ListMarker marker, String content) {
}
marker = marker == null ? ListMarker.none() : marker;
content = content == null ? "" : content;
+ runs = runs == null ? List.of() : List.copyOf(runs);
+ }
+
+ /**
+ * Creates a spec whose content is plain text.
+ *
+ * @param depth nesting depth
+ * @param marker the item's resolved marker
+ * @param content the item's text
+ */
+ public ListItemSpec(int depth, ListMarker marker, String content) {
+ this(depth, marker, content, List.of());
+ }
+
+ /**
+ * Returns whether this item's content is runs rather than plain text.
+ *
+ * @return {@code true} when the item carries inline runs
+ */
+ public boolean isRich() {
+ return !runs.isEmpty();
}
/**
diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/MarkerContentItem.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/MarkerContentItem.java
index 066028c69..ff7aa7a3f 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/payloads/MarkerContentItem.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/MarkerContentItem.java
@@ -1,5 +1,6 @@
package com.demcha.compose.document.layout.payloads;
+import java.util.List;
import java.util.Objects;
/**
@@ -40,6 +41,11 @@
* unexplained inset
* @param contentX {@code markerX + measuredMarkerWidth + markerGap}
* @param contentWidth the width the item's text wraps within
+ * @param markerSpans the measured pieces a drawn marker is made of —
+ * a disc, an icon, a glyph in a colour of its own —
+ * and empty for a text marker, whose one span the
+ * emit phase builds from the item's own first line
+ * so that it shares that line's metrics
* @author Artem Demchyshyn
* @since 2.4.0
*/
@@ -49,7 +55,8 @@ public record MarkerContentItem(
double measuredMarkerWidth,
double markerGap,
double contentX,
- double contentWidth
+ double contentWidth,
+ List markerSpans
) {
/**
@@ -62,6 +69,7 @@ public record MarkerContentItem(
*/
public MarkerContentItem {
Objects.requireNonNull(spec, "spec");
+ markerSpans = markerSpans == null ? List.of() : List.copyOf(markerSpans);
requireFinite(markerX, "markerX");
requireFinite(measuredMarkerWidth, "measuredMarkerWidth");
requireFinite(markerGap, "markerGap");
@@ -78,6 +86,34 @@ public record MarkerContentItem(
}
}
+ /**
+ * Creates an item whose marker is text, so it has no pre-measured spans.
+ *
+ * @param spec the item's authored depth, marker and content
+ * @param markerX where the marker starts
+ * @param measuredMarkerWidth the marker's measured width
+ * @param markerGap the gap applied between marker and content
+ * @param contentX {@code markerX + measuredMarkerWidth + markerGap}
+ * @param contentWidth the width the item's text wraps within
+ */
+ public MarkerContentItem(ListItemSpec spec,
+ double markerX,
+ double measuredMarkerWidth,
+ double markerGap,
+ double contentX,
+ double contentWidth) {
+ this(spec, markerX, measuredMarkerWidth, markerGap, contentX, contentWidth, List.of());
+ }
+
+ /**
+ * Returns whether this item's marker is drawn rather than typed.
+ *
+ * @return {@code true} when the marker has pre-measured spans
+ */
+ public boolean hasDrawnMarker() {
+ return !markerSpans.isEmpty();
+ }
+
private static void requireFinite(double value, String name) {
if (Double.isNaN(value) || Double.isInfinite(value) || value < 0) {
throw new IllegalArgumentException(name + " must be finite and non-negative: " + value);
diff --git a/core/src/main/java/com/demcha/compose/document/node/InlineRun.java b/core/src/main/java/com/demcha/compose/document/node/InlineRun.java
index dce057960..9fe17454c 100644
--- a/core/src/main/java/com/demcha/compose/document/node/InlineRun.java
+++ b/core/src/main/java/com/demcha/compose/document/node/InlineRun.java
@@ -1,5 +1,7 @@
package com.demcha.compose.document.node;
+import java.util.List;
+
/**
* Marker for a single inline run inside a {@link ParagraphNode}.
*
@@ -14,4 +16,74 @@
*/
public sealed interface InlineRun
permits InlineTextRun, InlineImageRun, InlineShapeRun, InlineSvgRun, InlineHighlightRun {
+
+ /**
+ * Returns what a sequence of runs reads as in plain text.
+ *
+ *
Text runs and highlight chips contribute their text; image, shape and
+ * SVG runs contribute nothing, because they are not text and inventing a
+ * placeholder for them would put characters in the reading that the author
+ * never wrote.
+ *
+ *
This lives next to the {@code permits} clause on purpose. Which
+ * variants read as text is knowledge that has to be revisited every time a
+ * variant is added, and a second copy of it elsewhere is a copy that will
+ * be missed: the reduction is here once, and both
+ * {@link ParagraphNode#text()} and a rich
+ * {@link com.demcha.compose.document.node.ListItem#label()} are derived
+ * from it.
+ *
+ * @param runs runs in source order; {@code null} reads as empty
+ * @return the concatenated text of the runs that carry text
+ * @since 2.4.0
+ */
+ static String plainText(List runs) {
+ if (runs == null || runs.isEmpty()) {
+ return "";
+ }
+ StringBuilder out = new StringBuilder();
+ for (InlineRun run : runs) {
+ if (run instanceof InlineTextRun textRun) {
+ out.append(textRun.text());
+ } else if (run instanceof InlineHighlightRun highlight) {
+ out.append(highlight.text());
+ }
+ }
+ return out.toString();
+ }
+
+ /**
+ * Returns the runs that carry text, in source order, for the surfaces that
+ * consume text and its styling but cannot draw a picture — the semantic DOCX
+ * export, and text-only tests.
+ *
+ *
Image, shape and SVG runs are dropped (emoji lower to SVG runs, so they
+ * drop too). A highlight chip degrades to a plain text run, because its
+ * background is a fixed-layout decoration while its text is content, and its
+ * newlines collapse to spaces to match how the PDF tokenizer lowers a chip —
+ * one line — so both surfaces read a chip the same way.
+ *
+ *
That collapsing is the one thing this does not share with
+ * {@link #plainText(List)}, which concatenates a chip's text as authored.
+ *
+ * @param runs runs in source order; {@code null} reads as empty
+ * @return the text-carrying runs, chips degraded to plain runs
+ * @since 2.4.0
+ */
+ static List textRuns(List runs) {
+ if (runs == null || runs.isEmpty()) {
+ return List.of();
+ }
+ List textRuns = new java.util.ArrayList<>(runs.size());
+ for (InlineRun run : runs) {
+ if (run instanceof InlineTextRun textRun) {
+ textRuns.add(textRun);
+ } else if (run instanceof InlineHighlightRun highlight) {
+ String chipText = highlight.text()
+ .replace("\r\n", " ").replace('\r', ' ').replace('\n', ' ');
+ textRuns.add(new InlineTextRun(chipText, highlight.textStyle(), highlight.linkTarget()));
+ }
+ }
+ return List.copyOf(textRuns);
+ }
}
diff --git a/core/src/main/java/com/demcha/compose/document/node/ListItem.java b/core/src/main/java/com/demcha/compose/document/node/ListItem.java
index 7a17a0357..4799bd7bc 100644
--- a/core/src/main/java/com/demcha/compose/document/node/ListItem.java
+++ b/core/src/main/java/com/demcha/compose/document/node/ListItem.java
@@ -16,21 +16,89 @@
* item. Direct {@code new ListItem(...)} construction is supported but
* should be reserved for record-shaped fixtures and serialization.
*
- * @param label visible item text
+ *
An item's content is either its {@code label} or, when it needs more than
+ * one style, a sequence of {@code runs} — the same {@link InlineRun} sequence a
+ * paragraph is made of, so {@code "Label: description"} with the label bold is
+ * one list item rather than a hand-built row of two columns. When {@code runs}
+ * is non-empty it is the content, and {@code label} is its plain-text reading,
+ * kept so that anything reading an item as text still can.
+ *
+ * @param label visible item text; the plain-text reading when {@code runs} is
+ * set
+ * @param runs inline runs making up the content, empty when the item is
+ * just its label
* @param marker per-item marker override, or {@code null} to inherit
* the per-depth default from the parent list
* @param children nested child items, empty for leaves
* @author Artem Demchyshyn
*/
-public record ListItem(String label, ListMarker marker, List children) {
+public record ListItem(String label, List runs, ListMarker marker,
+ List children) {
/**
- * Normalizes nullable inputs and copy-protects {@code children}.
+ * Normalizes nullable inputs and copy-protects {@code runs} and {@code children}.
*/
public ListItem {
label = label == null ? "" : label;
+ runs = runs == null ? List.of() : List.copyOf(runs);
children = children == null ? List.of() : List.copyOf(children);
}
+ /**
+ * Creates an item whose content is its label, which is all an item could
+ * carry before it could carry runs.
+ *
+ *
Kept as its own constructor rather than folded into the canonical one,
+ * so code written against the three-argument shape still compiles and still
+ * links.
+ *
+ * @param label visible item text
+ * @param marker per-item marker override, or {@code null} to inherit
+ * @param children nested child items, empty for leaves
+ */
+ public ListItem(String label, ListMarker marker, List children) {
+ this(label, List.of(), marker, children);
+ }
+
+ /**
+ * Creates a leaf item whose content is a sequence of inline runs, reading as
+ * whatever those runs say.
+ *
+ * @param runs the runs making up the content
+ * @return leaf item
+ * @since 2.4.0
+ */
+ public static ListItem ofRuns(List runs) {
+ return new ListItem(InlineRun.plainText(runs), runs, null, List.of());
+ }
+
+ /**
+ * Creates a leaf item whose content is a sequence of inline runs, reading as
+ * the given label.
+ *
+ *
Use this over {@link #ofRuns(List)} when the runs draw something that
+ * does not read as itself — a row that is an icon and a chip has no text to
+ * derive a label from, and {@code label} is then what anything reading the
+ * item as text gets.
+ *
+ * @param label plain-text reading of the runs
+ * @param runs the runs making up the content
+ * @return leaf item
+ * @since 2.4.0
+ */
+ public static ListItem ofRuns(String label, List runs) {
+ return new ListItem(label, runs, null, List.of());
+ }
+
+ /**
+ * Returns whether this item's content is runs rather than its label alone.
+ *
+ * @return {@code true} when the item carries inline runs
+ * @since 2.4.0
+ */
+ public boolean isRich() {
+ return !runs.isEmpty();
+ }
+
/**
* Creates a leaf item with the given label and inherited marker.
*
diff --git a/core/src/main/java/com/demcha/compose/document/node/ListMarker.java b/core/src/main/java/com/demcha/compose/document/node/ListMarker.java
index fa19caa25..e8cfdbc90 100644
--- a/core/src/main/java/com/demcha/compose/document/node/ListMarker.java
+++ b/core/src/main/java/com/demcha/compose/document/node/ListMarker.java
@@ -1,22 +1,74 @@
package com.demcha.compose.document.node;
+import java.util.List;
+
/**
* Marker rendered before each item in a canonical list node.
*
*
The marker keeps the public list API small while still allowing custom
* list prefixes for CVs, reports, and template-specific layouts.
*
- * @param value visible marker prefix, or an empty string for markerless lists
+ *
A marker is either a piece of text — {@code value}, which is all a marker
+ * could be before it could be drawn — or a sequence of {@code runs}: the same
+ * {@link InlineRun} sequence a paragraph and a list item are made of. Runs are
+ * what let a marker be a coloured disc, an icon or a glyph in a face and colour
+ * of its own, independent of the item beside it. When {@code runs} is non-empty
+ * it is the marker, and {@code value} is its plain-text reading.
+ *
+ * @param value visible marker prefix, or an empty string for markerless lists;
+ * the plain-text reading when {@code runs} is set
+ * @param runs inline runs the marker draws, empty when the marker is text
* @author Artem Demchyshyn
*/
-public record ListMarker(String value) {
+public record ListMarker(String value, List runs) {
/**
- * Creates a normalized marker.
+ * Normalizes the text form and copy-protects the runs.
*
* @param value visible marker value, or {@code null} for no marker
+ * @param runs inline runs, or {@code null} for a text marker
*/
public ListMarker {
value = normalize(value);
+ runs = runs == null ? List.of() : List.copyOf(runs);
+ }
+
+ /**
+ * Creates a marker that is a piece of text, which is all a marker could be
+ * before it could be drawn.
+ *
+ *
Kept as its own constructor rather than folded into the canonical one,
+ * so code written against the one-argument shape still compiles and still
+ * links.
+ *
+ * @param value visible marker value, or {@code null} for no marker
+ */
+ public ListMarker(String value) {
+ this(value, List.of());
+ }
+
+ /**
+ * Creates a marker drawn from a sequence of inline runs.
+ *
+ *
Its plain-text reading is whatever text the runs carry, which for a
+ * marker that draws only a disc or an icon is nothing — the reading is what
+ * a surface that cannot draw falls back to, not the marker itself.
+ *
+ * @param runs the runs the marker draws
+ * @return drawn marker
+ * @since 2.4.0
+ */
+ public static ListMarker ofRuns(List runs) {
+ return new ListMarker(InlineRun.plainText(runs), runs);
+ }
+
+ /**
+ * Returns whether this marker is drawn from runs rather than being text.
+ *
+ * @return {@code true} when the marker carries inline runs
+ * @since 2.4.0
+ */
+ public boolean isRich() {
+ return !runs.isEmpty();
}
/**
@@ -121,12 +173,17 @@ public static String normalizeItemText(String value, boolean normalizeMarkers) {
}
/**
- * Returns {@code true} when this marker has non-whitespace content.
+ * Returns {@code true} when this marker draws anything — non-whitespace
+ * text, or any run at all.
+ *
+ *
A drawn marker is visible whatever its reading says, which is the whole
+ * difference between a marker of a teal disc and a markerless item: both
+ * read as nothing, and only one of them puts ink on the page.
*
* @return whether the marker is visible
*/
public boolean isVisible() {
- return value.chars().anyMatch(ch -> !Character.isWhitespace(ch));
+ return isRich() || value.chars().anyMatch(ch -> !Character.isWhitespace(ch));
}
/**
diff --git a/core/src/main/java/com/demcha/compose/document/node/ParagraphNode.java b/core/src/main/java/com/demcha/compose/document/node/ParagraphNode.java
index 888f15f21..0f1ba15dd 100644
--- a/core/src/main/java/com/demcha/compose/document/node/ParagraphNode.java
+++ b/core/src/main/java/com/demcha/compose/document/node/ParagraphNode.java
@@ -60,15 +60,8 @@ public record ParagraphNode(
inlineRuns = normalizeInlineRuns(inlineRuns);
text = Objects.requireNonNullElse(text, "");
if (text.isBlank() && !inlineRuns.isEmpty()) {
- StringBuilder concatenated = new StringBuilder();
- for (InlineRun run : inlineRuns) {
- if (run instanceof InlineTextRun textRun) {
- concatenated.append(textRun.text());
- } else if (run instanceof InlineHighlightRun highlight) {
- concatenated.append(highlight.text());
- }
- }
- text = concatenated.toString();
+ // One reduction, shared with a rich list item: see InlineRun.plainText.
+ text = InlineRun.plainText(inlineRuns);
}
textStyle = textStyle == null ? DocumentTextStyle.DEFAULT : textStyle;
align = align == null ? TextAlign.LEFT : align;
@@ -338,21 +331,8 @@ private static List normalizeInlineRuns(List runs) {
* @return inline text runs in source order
*/
public List inlineTextRuns() {
- if (inlineRuns.isEmpty()) {
- return List.of();
- }
- List textRuns = new java.util.ArrayList<>(inlineRuns.size());
- for (InlineRun run : inlineRuns) {
- if (run instanceof InlineTextRun textRun) {
- textRuns.add(textRun);
- } else if (run instanceof InlineHighlightRun highlight) {
- // Collapse newlines to spaces to match how the PDF tokenizer
- // lowers a chip (it stays one line), so both text surfaces agree.
- String chipText = highlight.text().replace("\r\n", " ").replace('\r', ' ').replace('\n', ' ');
- textRuns.add(new InlineTextRun(chipText, highlight.textStyle(), highlight.linkTarget()));
- }
- }
- return List.copyOf(textRuns);
+ // One reduction, shared with a rich list item: see InlineRun.textRuns.
+ return InlineRun.textRuns(inlineRuns);
}
}
diff --git a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
index 3a2967a7f..43f60460f 100644
--- a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
+++ b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
@@ -547,7 +547,7 @@ void axisWidthTakesOnlyAPositiveFiniteNumberOfPoints() {
// --- the leading column --------------------------------------------------
@Test
- void aLeadingColumnPutsAThirdColumnBeforeTheMarker() {
+ void aLeadingColumnPutsTheLeadingContentBeforeTheMarker() {
SectionNode timeline = timelineOf(t -> t
.leadingColumn(DocumentRowColumn.fixed(48))
.entry(e -> e
@@ -555,15 +555,24 @@ void aLeadingColumnPutsAThirdColumnBeforeTheMarker() {
.leading(date -> date.addParagraph("2023"))
.title("Senior Engineer")));
+ // Five columns, because each gap beside the marker is a column of its own: a row
+ // spaces every pair of its columns by one number, and two gaps that have to differ
+ // cannot both be that number. Indices 1 and 3 are those gaps and hold nothing.
RowNode header = header(entry(timeline, 0));
- assertThat(header.children()).hasSize(3);
+ assertThat(header.children()).hasSize(5);
assertThat(paragraphTexts(header.children().get(0)))
.as("leading first, before the marker")
.containsExactly("2023");
- assertThat(markerContent(header.children().get(1)))
- .as("then the marker")
+ assertThat(markerContent(header.children().get(2)))
+ .as("then the marker, a gap after the leading")
.isInstanceOf(EllipseNode.class);
- assertThat(paragraphTexts(header.children().get(2))).containsExactly("Senior Engineer");
+ assertThat(paragraphTexts(header.children().get(4)))
+ .as("and the content, a gap after the marker")
+ .containsExactly("Senior Engineer");
+ assertThat(paragraphTexts(header.children().get(1)))
+ .as("the gaps carry nothing")
+ .isEmpty();
+ assertThat(paragraphTexts(header.children().get(3))).isEmpty();
}
@Test
@@ -576,15 +585,16 @@ void anEntryWithNoLeadingContentStillGetsTheColumn() {
.leading(date -> date.addParagraph("2023")).title("With"))
.entry(e -> e.marker(TimelineMarker.dot(8, NAVY)).title("Without")));
+ RowNode withLeading = header(entry(timeline, 0));
RowNode withoutLeading = header(entry(timeline, 1));
assertThat(withoutLeading.children())
- .as("three columns either way")
- .hasSize(3);
+ .as("the same columns either way")
+ .hasSameSizeAs(withLeading.children());
assertThat(paragraphTexts(withoutLeading.children().get(0)))
- .as("the first is simply empty")
+ .as("the leading one is simply empty")
.isEmpty();
- assertThat(markerContent(withoutLeading.children().get(1)))
- .as("so the marker is still the second column, as in the entry above")
+ assertThat(markerContent(withoutLeading.children().get(2)))
+ .as("so the marker is in the column it is in above, and they line up")
.isInstanceOf(EllipseNode.class);
}
diff --git a/core/src/test/java/com/demcha/compose/document/node/InlineRunPlainTextTest.java b/core/src/test/java/com/demcha/compose/document/node/InlineRunPlainTextTest.java
new file mode 100644
index 000000000..48b59a4ee
--- /dev/null
+++ b/core/src/test/java/com/demcha/compose/document/node/InlineRunPlainTextTest.java
@@ -0,0 +1,95 @@
+package com.demcha.compose.document.node;
+
+import com.demcha.compose.document.dsl.RichText;
+import com.demcha.compose.document.style.DocumentColor;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The two reductions of a run sequence, and the fact that every surface needing
+ * one shares them.
+ *
+ *
Which {@link InlineRun} variants carry text is knowledge that has to be
+ * revisited whenever a variant is added, so it lives in one place. These tests
+ * guard that: a second copy of the rule elsewhere would pass its own tests and
+ * drift from this one silently.
+ */
+class InlineRunPlainTextTest {
+
+ private static final DocumentColor INK = DocumentColor.rgb(0x33, 0x66, 0x99);
+ private static final DocumentColor PAPER = DocumentColor.rgb(0xEE, 0xEE, 0xEE);
+
+ @Test
+ void textAndChipsReadAsTheirTextAndPicturesReadAsNothing() {
+ List runs = RichText.empty()
+ .bold("Status: ")
+ .dot(6.0, INK)
+ .chip("pending", INK, PAPER)
+ .plain(" today")
+ .runs();
+
+ assertThat(InlineRun.plainText(runs))
+ .as("the dot contributes nothing rather than a placeholder character")
+ .isEqualTo("Status: pending today");
+ }
+
+ @Test
+ void aNullOrEmptySequenceReadsAsNothing() {
+ assertThat(InlineRun.plainText(null)).isEmpty();
+ assertThat(InlineRun.plainText(List.of())).isEmpty();
+ assertThat(InlineRun.textRuns(null)).isEmpty();
+ assertThat(InlineRun.textRuns(List.of())).isEmpty();
+ }
+
+ @Test
+ void textRunsKeepStylesDropPicturesAndFlattenAChipToOneLine() {
+ List runs = RichText.empty()
+ .bold("Head")
+ .dot(6.0, INK)
+ .chip("two\nlines", INK, PAPER)
+ .runs();
+
+ List textRuns = InlineRun.textRuns(runs);
+ assertThat(textRuns).as("the dot is dropped, the chip degrades to text").hasSize(2);
+ assertThat(textRuns.get(0).text()).isEqualTo("Head");
+ assertThat(textRuns.get(0).textStyle())
+ .as("a run keeps the style it was authored with")
+ .isNotNull();
+ assertThat(textRuns.get(1).text())
+ .as("a chip stays one line wherever it is read, so its newline is a space")
+ .isEqualTo("two lines");
+ }
+
+ @Test
+ void aParagraphsTextIsThatSameReading() {
+ List runs = RichText.empty()
+ .bold("Status: ")
+ .chip("pending", INK, PAPER)
+ .runs();
+ ParagraphNode paragraph = new ParagraphNode("", "", runs, null, null, 0.0, "",
+ null, null, null, null, null);
+
+ assertThat(paragraph.text()).isEqualTo(InlineRun.plainText(runs));
+ assertThat(paragraph.inlineTextRuns()).isEqualTo(InlineRun.textRuns(runs));
+ }
+
+ @Test
+ void aRichListItemReadsAsThatSameReadingToo() {
+ List runs = RichText.empty()
+ .bold("Status: ")
+ .chip("pending", INK, PAPER)
+ .runs();
+
+ assertThat(ListItem.ofRuns(runs).label()).isEqualTo("Status: pending");
+ assertThat(ListItem.ofRuns(runs).isRich()).isTrue();
+ assertThat(ListItem.of("plain").isRich())
+ .as("an item that is only a label is not rich, so it keeps the label path")
+ .isFalse();
+ assertThat(ListItem.ofRuns("Done", runs).label())
+ .as("an explicit reading wins, for runs that draw something that is not text")
+ .isEqualTo("Done");
+ }
+}
diff --git a/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java b/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java
index 7b587b377..2065bfca9 100644
--- a/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java
+++ b/core/src/test/java/com/demcha/documentation/CodeQlScopeGuardTest.java
@@ -97,6 +97,77 @@ void everyDeployedModuleWithSourcesIsScanned() throws IOException {
.isEmpty();
}
+ /**
+ * The inventory the second test compares against is itself read out of the publish
+ * workflows, so it can be emptied by editing them — and an emptier inventory is an
+ * easier comparison, not a failing one. Both halves below key on the absence of a
+ * positive signal instead: a publish workflow that deploys nothing, and a train
+ * whose declared modules are not among the steps read from it.
+ *
+ *
Writing a deploy as {@code -pl :graph-compose-fonts} rather than
+ * {@code -f fonts/pom.xml} is enough to do it, and nothing about that edit looks
+ * like it touches the scan.
+ */
+ @Test
+ void everyPublishWorkflowContributesToTheInventoryItIsRead() throws IOException {
+ Map> byWorkflow = PublishedModules.deployedByWorkflow(PROJECT_ROOT);
+
+ assertThat(byWorkflow)
+ .describedAs("no publish workflow was found at all — they were renamed, and the "
+ + "inventory the deployed-module test compares against is now empty")
+ .isNotEmpty();
+
+ Set silent = new TreeSet<>();
+ byWorkflow.forEach((workflow, modules) -> {
+ if (modules.isEmpty()) {
+ silent.add(workflow);
+ }
+ });
+
+ assertThat(silent)
+ .describedAs("a publish workflow whose deploy steps this guard can no longer "
+ + "read: whatever it ships is now invisible to the deployed-module test, "
+ + "which will pass without ever asking about it. Either the workflow "
+ + "stopped deploying — in which case it should stop being a publish "
+ + "workflow — or its deploy is written some way other than "
+ + "`-f /pom.xml`, and this guard has to learn that shape before "
+ + "the edit lands")
+ .isEmpty();
+ }
+
+ /**
+ * The train {@code publish.yml} declares matches the steps it carries.
+ *
+ *
The workflow states its module set twice and neither statement is derived from
+ * the other: the {@code order} the resume input is validated against, and the deploy
+ * steps themselves. A module dropped from the steps — or written in a shape this
+ * guard cannot read — leaves the two disagreeing, which is the signal that the
+ * inventory shrank rather than the train.
+ */
+ @Test
+ void theDeployStepsCoverThePublishTrainTheWorkflowDeclares() throws IOException {
+ List declared = PublishedModules.declaredTrain(PROJECT_ROOT);
+ List steps = PublishedModules.deployedByWorkflow(PROJECT_ROOT)
+ .getOrDefault("publish.yml", List.of());
+
+ assertThat(declared)
+ .describedAs("publish.yml no longer declares its train as `order=\"...\"` — the "
+ + "resume validation moved, and with it the second, independent statement "
+ + "of what a release publishes that this guard holds the steps against")
+ .isNotEmpty();
+
+ Set missing = new TreeSet<>(declared);
+ missing.removeAll(steps);
+
+ assertThat(missing)
+ .describedAs("publish.yml names these in its train but this guard finds no deploy "
+ + "step for them. Either the module stopped shipping and belongs out of "
+ + "the train, or its step is written some way other than "
+ + "`-f /pom.xml` — in which case the module is deployed, absent "
+ + "from the inventory, and therefore never checked against the scan")
+ .isEmpty();
+ }
+
/**
* The {@code -pl} selectors of the {@code mvnw} invocation carrying {@code goal}, or
* every reactor module when the command carries no {@code -pl} at all — a build
diff --git a/core/src/test/java/com/demcha/documentation/PublishedModules.java b/core/src/test/java/com/demcha/documentation/PublishedModules.java
index 2871fcf8e..5d7fb0a25 100644
--- a/core/src/test/java/com/demcha/documentation/PublishedModules.java
+++ b/core/src/test/java/com/demcha/documentation/PublishedModules.java
@@ -33,6 +33,10 @@ private PublishedModules() {
private static final Pattern DEPLOY_STEP =
Pattern.compile("-f\\s+([\\w-]+)/pom\\.xml");
+ /** The {@code order="core render-pdf ..."} line publish.yml validates its resume against. */
+ private static final Pattern TRAIN_ORDER =
+ Pattern.compile("order=\"([^\"]+)\"");
+
/**
* The modules a release actually deploys, read from the publish workflows.
*
@@ -42,26 +46,72 @@ private PublishedModules() {
* comparison, which is precisely the shape that keeps it green.
*/
static List deployed(Path repoRoot) throws IOException {
- Path workflows = repoRoot.resolve(".github/workflows");
List deployed = new ArrayList<>();
+ deployedByWorkflow(repoRoot).values().forEach(modules -> modules.forEach(module -> {
+ if (!deployed.contains(module)) {
+ deployed.add(module);
+ }
+ }));
+ return deployed;
+ }
+
+ /**
+ * The modules each publish workflow deploys, keyed by the workflow's file name —
+ * including the workflows that deploy none.
+ *
+ *
Attribution is what lets a caller tell "this workflow publishes nothing" from
+ * "this workflow was not read". A flat list cannot: both look like a shorter list,
+ * and a shorter list is exactly what a guard comparing against it wants to see.
+ *
+ * @param repoRoot the repository root
+ * @return every {@code publish*.yml}, mapped to the module directories it deploys
+ * @throws IOException when a workflow cannot be read
+ */
+ static Map> deployedByWorkflow(Path repoRoot) throws IOException {
+ Path workflows = repoRoot.resolve(".github/workflows");
+ Map> byWorkflow = new LinkedHashMap<>();
try (var files = Files.list(workflows)) {
for (Path workflow : files.sorted().toList()) {
String name = workflow.getFileName().toString();
if (!name.startsWith("publish") || !name.endsWith(".yml")) {
continue;
}
+ List modules = new ArrayList<>();
for (String line : Files.readAllLines(workflow)) {
if (!line.contains("deploy")) {
continue;
}
Matcher module = DEPLOY_STEP.matcher(line);
- if (module.find() && !deployed.contains(module.group(1))) {
- deployed.add(module.group(1));
+ if (module.find() && !modules.contains(module.group(1))) {
+ modules.add(module.group(1));
}
}
+ byWorkflow.put(name, modules);
}
}
- return deployed;
+ return byWorkflow;
+ }
+
+ /**
+ * The publish train {@code publish.yml} declares for itself, in order.
+ *
+ *
The workflow states its module set twice — once as the {@code order} the resume
+ * input is validated against, and once as the deploy steps themselves. Neither is
+ * derived from the other, so holding them together catches the step list drifting
+ * away from the train without anything having to restate it a third time.
+ *
+ * @param repoRoot the repository root
+ * @return the module directories the train names, or an empty list when the
+ * declaration is absent
+ * @throws IOException when the workflow cannot be read
+ */
+ static List declaredTrain(Path repoRoot) throws IOException {
+ Path workflow = repoRoot.resolve(".github/workflows/publish.yml");
+ if (!Files.isRegularFile(workflow)) {
+ return List.of();
+ }
+ Matcher order = TRAIN_ORDER.matcher(Files.readString(workflow));
+ return order.find() ? List.of(order.group(1).strip().split("\\s+")) : List.of();
}
/** The module directories the root reactor builds, in declaration order. */
diff --git a/docs/templates/v2-layered/README.md b/docs/templates/v2-layered/README.md
index 00cd3ccc1..9b82f6135 100644
--- a/docs/templates/v2-layered/README.md
+++ b/docs/templates/v2-layered/README.md
@@ -11,10 +11,10 @@
> archived at [templates/v1-classic/](../v1-classic/README.md).
The **template surface** for building business documents on
-GraphCompose. All four families ship on it — CV (the reference
+GraphCompose. All five families ship on it — CV (the reference
implementation, `com.demcha.compose.document.templates.cv`),
-cover-letter, invoice, and proposal — and any new template family
-follows the same shape.
+cover-letter, invoice, proposal, and receipt — and any new template
+family follows the same shape.
This is the entry point. Pick the doc that matches your goal.
@@ -97,7 +97,8 @@ The detailed contract for each layer is in
- ❌ **Not one shape for every family.** `presets/` is the only
package a family always has. CV carries all four; cover letter has no
- `widgets/`; invoice and proposal are presets alone, reading data
+ `widgets/`; invoice and proposal are presets alone; receipt carries
+ `components/` + `widgets/` + `presets/`. All of them read their data
records from the shared `templates.data.`. Add a layer when
the family needs one.
- ❌ **Not a framework with magic.** Every file is plain
@@ -123,6 +124,14 @@ The detailed contract for each layer is in
- **Examples**:
[`examples/cv/v2/`](../../../examples/src/main/java/com/demcha/examples/templates/cv/v2)
has runnable rendering examples for the shipped presets.
+- **Receipt family**: the newest family, and the smallest complete one
+ to read end-to-end —
+ [`receipt/package-info.java`](../../../templates/src/main/java/com/demcha/compose/document/templates/receipt/package-info.java)
+ for the layer map,
+ [`receipt/AUTHORS.md`](../../../templates/src/main/java/com/demcha/compose/document/templates/receipt/AUTHORS.md)
+ for its recipes, and
+ [`ModernReceiptExample`](../../../examples/src/main/java/com/demcha/examples/templates/receipt/ModernReceiptExample.java)
+ for a rendered transfer confirmation.
- **Archived classic surface**:
[`docs/templates/v1-classic/README.md`](../v1-classic/README.md) describes the older
spec / preset / theme split that was removed in 2.0; kept for
diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md
index e260f3a37..2d84290c0 100644
--- a/docs/templates/v2-layered/using-templates.md
+++ b/docs/templates/v2-layered/using-templates.md
@@ -234,6 +234,14 @@ whole or the paginator raises `AtomicNodeTooLargeException`. Lifting a
cap without teaching the preset to pick its own page boundaries turns a
CV that silently lost an entry into one that fails to render.
+`ProfessionalSidebar.create()`, `NavySidebar.create()`,
+`SerifHeadline.create()` and `CharcoalGold.create()` are compositions
+for a fixed amount of content that cap nothing: each reproduces a
+specific one-page sheet, so a CV longer than that sheet raises
+`AtomicNodeTooLargeException` instead of losing an entry to a cap. Size
+the document to them — roughly five or six roles with three or four
+highlights each, alongside the other blocks.
+
If the document's length is the author's rather than the template's,
pick a preset that paginates — `TimelineMinimal` splits its own columns
and carries every entry it is given onto as many pages as it needs.
diff --git a/docs/templates/which-template-system.md b/docs/templates/which-template-system.md
index 327b60986..50ae32543 100644
--- a/docs/templates/which-template-system.md
+++ b/docs/templates/which-template-system.md
@@ -88,7 +88,7 @@ migration is the theme + data-record swap:
|---|---|
| `InvoiceTemplateV1` / `InvoiceTemplateV2` | `templates.invoice.presets.ModernInvoice` — `create()` or `create(BrandTheme)`, data record `InvoiceDocumentSpec`. |
| `ProposalTemplateV1` / `ProposalTemplateV2` | `templates.proposal.presets.ModernProposal` — same shape, data record `ProposalDocumentSpec`. |
-| `WeeklyScheduleTemplateV1` | No 2.0 template yet. The `templates.data.schedule` records still ship; author the rendering on the canonical DSL. |
+| `WeeklyScheduleTemplateV1` | No template yet. Model the rota on `templates.data.rota` (`StructuredRotaDocumentSpec`) and author the rendering on the canonical DSL. The `templates.data.schedule` records still ship but are **deprecated since 2.4.0**: they hold their colours in the data, know nothing of staff bands, and nothing ever rendered them. |
### Legacy PDF API → canonical DSL
diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
index 201445dee..210f111ab 100644
--- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
+++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
@@ -81,6 +81,13 @@
import com.demcha.examples.templates.coverletter.CvPanelLetterV2Example;
import com.demcha.examples.templates.coverletter.CvSidebarPortraitLetterV2Example;
import com.demcha.examples.templates.coverletter.CvTimelineMinimalLetterV2Example;
+import com.demcha.examples.templates.cv.v2.CharcoalGoldExample;
+import com.demcha.examples.templates.cv.v2.SlateOrangeExample;
+import com.demcha.examples.templates.cv.v2.MidnightNavyExample;
+import com.demcha.examples.templates.cv.v2.OrangeOpsExample;
+import com.demcha.examples.templates.cv.v2.VioletGridExample;
+import com.demcha.examples.templates.cv.v2.TealPulseExample;
+import com.demcha.examples.templates.cv.v2.TerracottaRailExample;
import com.demcha.examples.templates.cv.v2.CvBlueBannerExample;
import com.demcha.examples.templates.cv.v2.CvBoxedV2Example;
import com.demcha.examples.templates.cv.v2.CvCenteredHeadlineExample;
@@ -98,11 +105,22 @@
import com.demcha.examples.templates.cv.v2.CvPanelExample;
import com.demcha.examples.templates.cv.v2.CvSidebarPortraitExample;
import com.demcha.examples.templates.cv.v2.CvTimelineMinimalExample;
+import com.demcha.examples.templates.cv.v2.NavySidebarExample;
+import com.demcha.examples.templates.cv.v2.ProfessionalSidebarExample;
+import com.demcha.examples.templates.cv.v2.SerifHeadlineExample;
+import com.demcha.examples.templates.invoice.ClassicInvoiceV2Example;
+import com.demcha.examples.templates.invoice.ConsultingInvoiceV2Example;
+import com.demcha.examples.templates.invoice.LumaStudioInvoiceV2Example;
+import com.demcha.examples.templates.invoice.PaymentsInvoiceV2Example;
+import com.demcha.examples.templates.invoice.WorkspaceInvoiceV2Example;
import com.demcha.examples.templates.invoice.InvoiceCinematicFileExample;
import com.demcha.examples.templates.invoice.ModernInvoiceV2Example;
import com.demcha.examples.templates.proposal.CinematicProposalFileExample;
+import com.demcha.examples.templates.proposal.EditorialProposalV2Example;
+import com.demcha.examples.templates.proposal.NorthlineProposalV2Example;
import com.demcha.examples.templates.proposal.ProposalCinematicFileExample;
import com.demcha.examples.templates.proposal.ModernProposalV2Example;
+import com.demcha.examples.templates.receipt.ModernReceiptExample;
import com.demcha.examples.templates.schedule.WeeklyScheduleFileExample;
/**
@@ -143,6 +161,16 @@ public static void main(String[] args) throws Exception {
System.out.println("Generated: " + CvPanelExample.generate());
System.out.println("Generated: " + CvSidebarPortraitExample.generate());
System.out.println("Generated: " + CvTimelineMinimalExample.generate());
+ System.out.println("Generated: " + NavySidebarExample.generate());
+ System.out.println("Generated: " + ProfessionalSidebarExample.generate());
+ System.out.println("Generated: " + SerifHeadlineExample.generate());
+ System.out.println("Generated: " + CharcoalGoldExample.generate());
+ System.out.println("Generated: " + TerracottaRailExample.generate());
+ System.out.println("Generated: " + TealPulseExample.generate());
+ System.out.println("Generated: " + SlateOrangeExample.generate());
+ System.out.println("Generated: " + VioletGridExample.generate());
+ System.out.println("Generated: " + OrangeOpsExample.generate());
+ System.out.println("Generated: " + MidnightNavyExample.generate());
// Cover letters (v2 layered — 15 paired letters, one per CV preset)
System.out.println("Generated: " + CvBlueBannerLetterV2Example.generate());
@@ -164,12 +192,22 @@ public static void main(String[] args) throws Exception {
// Invoices
System.out.println("Generated: " + InvoiceCinematicFileExample.generate());
System.out.println("Generated: " + ModernInvoiceV2Example.generate());
+ System.out.println("Generated: " + ClassicInvoiceV2Example.generate());
+ System.out.println("Generated: " + ConsultingInvoiceV2Example.generate());
+ System.out.println("Generated: " + LumaStudioInvoiceV2Example.generate());
+ System.out.println("Generated: " + PaymentsInvoiceV2Example.generate());
+ System.out.println("Generated: " + WorkspaceInvoiceV2Example.generate());
// Proposals
System.out.println("Generated: " + ProposalCinematicFileExample.generate());
System.out.println("Generated: " + ModernProposalV2Example.generate());
+ System.out.println("Generated: " + NorthlineProposalV2Example.generate());
+ System.out.println("Generated: " + EditorialProposalV2Example.generate());
System.out.println("Generated: " + CinematicProposalFileExample.generate());
+ // Receipts
+ System.out.println("Generated: " + ModernReceiptExample.generate());
+
// Schedule
System.out.println("Generated: " + WeeklyScheduleFileExample.generate());
diff --git a/examples/src/main/java/com/demcha/examples/support/CharcoalGoldSampleData.java b/examples/src/main/java/com/demcha/examples/support/CharcoalGoldSampleData.java
new file mode 100644
index 000000000..971fc1c6c
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/CharcoalGoldSampleData.java
@@ -0,0 +1,186 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvSkill;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+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.SkillGroup;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Sample data for the Charcoal Gold CV example.
+ *
+ *
Sized to the design: the preset draws a fixed one-page sheet, and a CV
+ * much longer than this one does not compose at all — the two columns are a
+ * single atomic row, so it raises {@code AtomicNodeTooLargeException}.
+ *
+ *
The credential marks are this preset's own vocabulary —
+ * {@code certificate}, {@code trophy}, {@code growth}, {@code star} — and
+ * each entry names the one it wants. The portrait is a neutral silhouette
+ * rather than a photograph, because the example ships in the repository; a
+ * real CV passes the candidate's own image the same way.
+ */
+public final class CharcoalGoldSampleData {
+
+ private static final String NEWLINE = String.valueOf((char) 10);
+ private static final String PORTRAIT = "/cv-portrait-placeholder.png";
+
+ private CharcoalGoldSampleData() {
+ }
+
+ /**
+ * A delivery lead's one-page CV.
+ *
+ * @return the sample document
+ */
+ public static CvDocument sample() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("HELENA", "MARSH")
+ .jobTitle("DELIVERY LEAD")
+ .contact(new Contact("+44 161 496 0114",
+ "helena@example.com",
+ "Manchester, United Kingdom"))
+ .link(new Link("helenamarsh.co.uk", "https://helenamarsh.co.uk"))
+ .link(new Link("linkedin.com/in/hmarsh",
+ "https://www.linkedin.com/in/hmarsh"))
+ .portrait(portrait())
+ .build())
+ .section(new ParagraphSection("Summary", String.join(NEWLINE,
+ "Delivery lead with nine years running software programmes for"
+ + " regulated clients, from discovery through the audit that"
+ + " follows go-live.",
+ "Happiest with a small team, a short feedback loop, and a plan that"
+ + " survives contact with the first week.")))
+ .section(SkillsSection.of("SKILLS", new SkillGroup("Core", List.of(
+ CvSkill.of("Delivery Management", 1.0),
+ CvSkill.of("Stakeholder Management", 1.0),
+ CvSkill.of("Risk & Assurance", 1.0),
+ CvSkill.of("Budget Ownership", 0.8),
+ CvSkill.of("Agile Coaching", 0.8),
+ CvSkill.of("Vendor Management", 0.8),
+ CvSkill.of("Roadmapping", 1.0),
+ CvSkill.of("Jira / Confluence", 1.0),
+ CvSkill.of("Power BI", 0.6),
+ CvSkill.of("Public Speaking", 0.6)))))
+ .section(RowsSection.builder("LANGUAGES", RowStyle.PLAIN)
+ .row("English", "Native")
+ .row("Welsh", "Native")
+ .row("Spanish", "B2 – Upper Intermediate")
+ .row("Italian", "A2 – Basic")
+ .build())
+ .section(EntriesSection.builder("EDUCATION")
+ .entry(CvEntry.builder("MSc Programme Management")
+ .subtitle("University of Manchester")
+ .date("2015 – 2016")
+ .build())
+ .entry(CvEntry.builder("BA Economics")
+ .subtitle("University of Leeds")
+ .date("2012 – 2015")
+ .build())
+ .build())
+ .section(EntriesSection.builder("EXPERIENCE")
+ .entry(CvEntry.builder("Delivery Lead")
+ .subtitle("NORTHWALL GROUP")
+ .place("MANCHESTER, UK")
+ .date("2021 – Present")
+ .link("https://northwall.example.com")
+ .body(List.of(
+ "Runs a portfolio of four programmes worth £8M against"
+ + " a fixed regulatory deadline.",
+ "Rebuilt the intake process so a request is either"
+ + " scheduled or refused within a week.",
+ "Coaches three delivery managers and chairs the monthly"
+ + " assurance review.",
+ "Cut third-party spend by 18% by consolidating four"
+ + " vendor contracts into one."))
+ .build())
+ .entry(CvEntry.builder("Senior Project Manager")
+ .subtitle("BRIDGEMOOR CONSULTING")
+ .place("LEEDS, UK")
+ .date("2018 – 2021")
+ .body(List.of(
+ "Delivered fourteen client programmes across finance"
+ + " and public sector.",
+ "Owned budgets, timelines and the escalation path to"
+ + " the client's board.",
+ "Introduced the risk register the practice still uses."))
+ .build())
+ .entry(CvEntry.builder("Project Manager")
+ .subtitle("ALDGATE DIGITAL")
+ .place("LONDON, UK")
+ .date("2016 – 2018")
+ .body(List.of(
+ "Ran delivery for two product teams and the platform"
+ + " work beneath them.",
+ "Built the reporting pack the leadership team read"
+ + " weekly."))
+ .build())
+ .build())
+ .section(EntriesSection.builder("CERTIFICATIONS")
+ .entry(CvEntry.builder("PMP® Certification")
+ .subtitle("Project Management Institute")
+ .date("2019")
+ .icon("certificate")
+ .build())
+ .entry(CvEntry.builder("PRINCE2® Practitioner")
+ .subtitle("AXELOS")
+ .date("2018")
+ .icon("certificate")
+ .build())
+ .entry(CvEntry.builder("Certified Scrum Professional")
+ .subtitle("Scrum Alliance")
+ .date("2017")
+ .icon("certificate")
+ .build())
+ .build())
+ .section(EntriesSection.builder("ACHIEVEMENTS")
+ .entry(CvEntry.builder("Programme of the Year")
+ .subtitle("Northwall Group")
+ .date("2024")
+ .icon("trophy")
+ .build())
+ .entry(CvEntry.builder("Intake Redesign")
+ .subtitle("Lead time down 62%")
+ .date("2023")
+ .icon("growth")
+ .build())
+ .entry(CvEntry.builder("Client Choice Award")
+ .subtitle("Bridgemoor Consulting")
+ .date("2020")
+ .icon("star")
+ .build())
+ .build())
+ .section(new ParagraphSection("TECHNICAL TOOLS", String.join(NEWLINE,
+ "Jira", "Confluence", "Miro", "Power BI", "Smartsheet", "Slack")))
+ .build();
+ }
+
+ /**
+ * The packaged silhouette that stands in for a photograph.
+ *
+ * @return the portrait image data
+ */
+ private static DocumentImageData portrait() {
+ try (InputStream in = Objects.requireNonNull(
+ CharcoalGoldSampleData.class.getResourceAsStream(PORTRAIT),
+ "cv-portrait-placeholder.png missing from examples/src/main/resources/")) {
+ return DocumentImageData.fromBytes(in.readAllBytes());
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to read the sample portrait", e);
+ }
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/ConsultingInvoiceSampleData.java b/examples/src/main/java/com/demcha/examples/support/ConsultingInvoiceSampleData.java
new file mode 100644
index 000000000..ff61b3188
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/ConsultingInvoiceSampleData.java
@@ -0,0 +1,127 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.templates.data.invoice.InvoiceBrand;
+import com.demcha.compose.document.templates.data.invoice.InvoiceContactBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoiceMasthead;
+import com.demcha.compose.document.templates.data.invoice.InvoiceNotesBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoicePaymentBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoiceRecipient;
+import com.demcha.compose.document.templates.data.invoice.InvoiceServiceLines;
+import com.demcha.compose.document.templates.data.invoice.InvoiceSummaryBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoiceTotalsBlock;
+import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceData;
+import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceDocumentSpec;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shared sample data for the Consulting Invoice example.
+ *
+ *
The sample is the preset's reference content: a
+ * professional-services invoice with a brand logo, five masthead metadata
+ * rows (one emphasized), five priced service lines, a subtotal / tax
+ * stack with the total band, five bank fields, and notes naming both query
+ * channels.
+ *
+ *
Kept in lockstep with the qa module's
+ * {@code ConsultingInvoiceFixtures} — the two modules cannot share a
+ * source file, so a content change here belongs there too.
+ */
+public final class ConsultingInvoiceSampleData {
+
+ private ConsultingInvoiceSampleData() {
+ }
+
+ /** The single-page reference invoice. */
+ public static StructuredInvoiceDocumentSpec sample() {
+ return StructuredInvoiceDocumentSpec.from(baseBuilder(serviceLines(5)).build());
+ }
+
+ /** The sample logo, read from the examples resources. */
+ public static DocumentImageData sampleLogo() {
+ String path = "/consulting-invoice-logo.png";
+ try (InputStream input = ConsultingInvoiceSampleData.class.getResourceAsStream(path)) {
+ if (input == null) {
+ throw new IllegalStateException("Missing sample logo: " + path);
+ }
+ return DocumentImageData.fromBytes(input.readAllBytes());
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to read sample logo: " + path, e);
+ }
+ }
+
+ private static StructuredInvoiceData.Builder baseBuilder(InvoiceServiceLines lines) {
+ return StructuredInvoiceData.builder()
+ .brand(new InvoiceBrand(sampleLogo(), "NORTHPOINT", "CONSULTING",
+ "Strategy. Solutions. Results."))
+ .supplier(new InvoiceContactBlock("Northpoint Consulting Pty Ltd",
+ List.of("Level 8, 1 Collins Street", "Melbourne VIC 3000 Australia"),
+ "+61 3 9876 5432", "hello@northpoint.com.au", "northpoint.com.au",
+ "ABN", "12 345 678 901"))
+ .masthead(new InvoiceMasthead("INVOICE", List.of(
+ new InvoiceMasthead.Entry("Invoice Number:", "INV-2025-0478", false),
+ new InvoiceMasthead.Entry("Issue Date:", "26 May 2025", false),
+ new InvoiceMasthead.Entry("Due Date:", "25 June 2025", true),
+ new InvoiceMasthead.Entry("Project:", "Digital Strategy Engagement", false),
+ new InvoiceMasthead.Entry("PO Number:", "PO-7892", false))))
+ .billTo(new InvoiceRecipient("BILLED TO", "Greenfield Industries Ltd.",
+ "Accounts Payable Department",
+ List.of("12 Innovation Drive", "Melbourne VIC 3000", "Australia"),
+ "Email:", "ap@greenfield.com.au"))
+ .summary(new InvoiceSummaryBlock("INVOICE SUMMARY",
+ "Professional services rendered in accordance with the statement of "
+ + "work for the period",
+ "1 May 2025 – 25 May 2025."))
+ .serviceLines(lines)
+ .totals(new InvoiceTotalsBlock(List.of(
+ new InvoiceTotalsBlock.Row("SUBTOTAL", new BigDecimal("14000.00")),
+ new InvoiceTotalsBlock.Row("GST (10%)", new BigDecimal("1400.00"))),
+ "TOTAL DUE", new BigDecimal("15400.00")))
+ .payment(new InvoicePaymentBlock("PAYMENT INFORMATION", List.of(
+ new InvoicePaymentBlock.Field("Bank Name:", "Example Bank"),
+ new InvoicePaymentBlock.Field("Account Name:",
+ "Northpoint Consulting Pty Ltd"),
+ new InvoicePaymentBlock.Field("BSB:", "123-456"),
+ new InvoicePaymentBlock.Field("Account Number:", "12345678"),
+ new InvoicePaymentBlock.Field("Reference:", "INV-2025-0478")),
+ "Please ensure the invoice number is included in the payment reference.",
+ "Payment is due within 30 days from the issue date.", "30 days"))
+ .notes(new InvoiceNotesBlock("NOTES", List.of(
+ "Thank you for your business.",
+ "If you have any questions regarding this invoice, please contact us "
+ + "at accounts@northpoint.com.au or +61 3 9876 5432."),
+ "accounts@northpoint.com.au", "+61 3 9876 5432"))
+ .currencyCode("AUD");
+ }
+
+ private static InvoiceServiceLines serviceLines(int count) {
+ String[][] source = {
+ {"Strategic Consultation", "Leadership alignment workshops", "1–10 May 2025",
+ "10.00", "hrs", "250.00", "2500.00"},
+ {"Market & Competitive Analysis", "Research and analysis report", "1–15 May 2025",
+ "1.00", "ea", "3500.00", "3500.00"},
+ {"Digital Roadmap Development", "Strategy and roadmap creation", "5–20 May 2025",
+ "1.00", "ea", "4800.00", "4800.00"},
+ {"Stakeholder Review Sessions", "Facilitation and documentation", "15–22 May 2025",
+ "6.00", "hrs", "220.00", "1320.00"},
+ {"Presentation & Final Report", "Executive presentation and delivery",
+ "20–25 May 2025", "1.00", "ea", "1880.00", "1880.00"}};
+ List lines = new ArrayList<>();
+ for (int index = 0; index < count; index++) {
+ String[] row = source[index % source.length];
+ lines.add(new InvoiceServiceLines.Line(index + 1, row[0], row[1], row[2],
+ new BigDecimal(row[3]), row[4], new BigDecimal(row[5]),
+ new BigDecimal(row[6])));
+ }
+ return new InvoiceServiceLines(
+ new InvoiceServiceLines.Columns("#", "DESCRIPTION", "SERVICE PERIOD", "QTY",
+ "UNIT PRICE", "AMOUNT"),
+ lines);
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/EditorialProposalSampleData.java b/examples/src/main/java/com/demcha/examples/support/EditorialProposalSampleData.java
new file mode 100644
index 000000000..343a08e61
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/EditorialProposalSampleData.java
@@ -0,0 +1,147 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.data.proposal.ProposalAcceptance;
+import com.demcha.compose.document.templates.data.proposal.ProposalBrand;
+import com.demcha.compose.document.templates.data.proposal.ProposalDeliverables;
+import com.demcha.compose.document.templates.data.proposal.ProposalGlance;
+import com.demcha.compose.document.templates.data.proposal.ProposalGoals;
+import com.demcha.compose.document.templates.data.proposal.ProposalInvestment;
+import com.demcha.compose.document.templates.data.proposal.ProposalMetaLine;
+import com.demcha.compose.document.templates.data.proposal.ProposalPhaseGrid;
+import com.demcha.compose.document.templates.data.proposal.ProposalScope;
+import com.demcha.compose.document.templates.data.proposal.ProposalSummaryBlock;
+import com.demcha.compose.document.templates.data.proposal.ProposalTermsBlock;
+import com.demcha.compose.document.templates.data.proposal.ProposalTitleLines;
+import com.demcha.compose.document.templates.data.proposal.StructuredProposalData;
+import com.demcha.compose.document.templates.data.proposal.StructuredProposalDocumentSpec;
+
+import java.util.List;
+
+/**
+ * Shared sample data for the Editorial proposal example.
+ *
+ *
Kept in lockstep with the qa module's
+ * {@code EditorialProposalFixtures} — the two modules cannot share a source
+ * file, so a content change here belongs there too.
+ *
+ *
The sample is the preset's reference content: a two-page
+ * brand-refresh proposal exercising every band — all four glance facts
+ * (one with the optional note), four goal cells, five scope rows, both
+ * deliverable columns, the four-phase grid, an investment table with
+ * ordinary, subtotal and optional rows plus the total band, four terms,
+ * and the three-field signing card.
+ *
+ */
+public final class EditorialProposalSampleData {
+
+ private EditorialProposalSampleData() {
+ }
+
+ public static StructuredProposalDocumentSpec sample() {
+ return StructuredProposalDocumentSpec.from(StructuredProposalData.builder()
+ .brand(new ProposalBrand("N", "NORTHLINE", "STUDIO", "PROPOSAL",
+ "northlinestudio.com", "NORTHLINE STUDIO"))
+ .title(new ProposalTitleLines("Proposal —", "Brand Refresh &",
+ "Website Redesign"))
+ .meta(new ProposalMetaLine("PREPARED FOR ASTERA HEALTH CO.",
+ "PREPARED BY NORTHLINE STUDIO", "25 AUGUST 2026"))
+ .executiveSummary(new ProposalSummaryBlock("Executive Summary", "", List.of(
+ "Thank you for the opportunity to partner with Astera Health Co. "
+ + "on your next chapter. This proposal outlines our approach to "
+ + "refreshing your brand identity and redesigning your website to "
+ + "better reflect your mission, elevate credibility, and deliver a "
+ + "seamless experience for the people you serve.",
+ "Our process blends strategic thinking with thoughtful design to "
+ + "create a cohesive brand system and a modern, conversion-focused "
+ + "website. The outcome will be a clear, confident presence that "
+ + "strengthens trust, improves engagement, and supports your "
+ + "growth goals.")))
+ .glance(new ProposalGlance("", List.of(
+ new ProposalGlance.Fact("fact-duration", "PROJECT DURATION",
+ "6 weeks", null),
+ new ProposalGlance.Fact("fact-start", "START WINDOW",
+ "September 2026", null),
+ new ProposalGlance.Fact("fact-contact", "PRIMARY CONTACT",
+ "Project Lead", null),
+ new ProposalGlance.Fact("fact-validity", "PROPOSAL VALIDITY",
+ "14 days", "(until 8 September 2026)"))))
+ .goals(new ProposalGoals("Project Goals", "goal-check", List.of(
+ new ProposalGoals.Goal("",
+ "Strengthen brand credibility and visual consistency."),
+ new ProposalGoals.Goal("",
+ "Improve website usability and drive meaningful action."),
+ new ProposalGoals.Goal("",
+ "Communicate your value clearly across all touchpoints."),
+ new ProposalGoals.Goal("",
+ "Build a scalable digital foundation for future growth."))))
+ .scope(new ProposalScope("Scope of Work", "", List.of(
+ new ProposalScope.Item("01", "Discovery & Research",
+ "Stakeholder interviews, brand audit, competitor review, and "
+ + "audience insights to inform strategy."),
+ new ProposalScope.Item("02", "Visual Identity Refinement",
+ "Refine logo system, color palette, typography, and visual "
+ + "guidelines for a cohesive, modern identity."),
+ new ProposalScope.Item("03", "Website UX/UI Design",
+ "Design a responsive, accessibility-minded website with "
+ + "intuitive navigation and clear conversion pathways."),
+ new ProposalScope.Item("04", "Content Structure & Messaging",
+ "Define site architecture, key messaging, and content "
+ + "hierarchy to communicate value and build trust."),
+ new ProposalScope.Item("05", "Launch Support",
+ "Developer handoff, QA support, training, and go-live "
+ + "assistance to ensure a smooth launch."))))
+ .deliverables(new ProposalDeliverables("Deliverables", "",
+ List.of("Brand strategy summary",
+ "Refined logo suite & brand guidelines",
+ "Color, typography & design system",
+ "Website UX wireframes",
+ "High-fidelity website designs"),
+ List.of("Responsive design for desktop, tablet & mobile",
+ "Content strategy & page templates",
+ "Developer-ready assets & specifications",
+ "Launch checklist & training session")))
+ .timeline(new ProposalPhaseGrid("Timeline", "",
+ List.of("PHASE", "FOCUS", "DURATION", "OUTPUT"), List.of(
+ new ProposalPhaseGrid.Phase("01", "Discover",
+ "Research, audit, insights, and strategy", "1 week",
+ "Discovery report & creative direction"),
+ new ProposalPhaseGrid.Phase("02", "Design",
+ "Brand refinement and website UX/UI design", "2 weeks",
+ "Design system & high-fidelity designs"),
+ new ProposalPhaseGrid.Phase("03", "Develop Prep",
+ "Content structure, developer handoff & asset preparation",
+ "1 week", "Specs, assets & content framework"),
+ new ProposalPhaseGrid.Phase("04", "Launch Support",
+ "QA, training & go-live assistance", "2 weeks",
+ "QA sign-off & successful launch"))))
+ .investment(new ProposalInvestment("Investment", "",
+ "ITEM", "AMOUNT (GBP)", List.of(
+ new ProposalInvestment.Row("Discovery & Strategy", "£2,500",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Brand Identity Refinement", "£4,500",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Website UX/UI Design", "£7,000",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Content Structure & Messaging", "£2,000",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Launch Support", "£1,500",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Subtotal", "£17,500",
+ ProposalInvestment.Role.SUBTOTAL),
+ new ProposalInvestment.Row("Optional: 3 Months Post-Launch Support",
+ "£1,750", ProposalInvestment.Role.OPTIONAL)),
+ "TOTAL INVESTMENT", "£19,250"))
+ .terms(new ProposalTermsBlock("Terms", "", List.of(
+ "50% deposit is required to secure the project and schedule the "
+ + "kick-off.",
+ "Includes two rounds of revisions per major deliverable.",
+ "Final files and handoff delivered upon final payment.",
+ "Ownership of all final deliverables transfers to Astera Health Co. "
+ + "after full payment is received.")))
+ .acceptance(new ProposalAcceptance("Acceptance", "",
+ "By signing below, you agree to the scope, timeline, investment, "
+ + "and terms outlined in this proposal.",
+ List.of("Accepted by:", "Signature:", "Date:")))
+ .build());
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/ExampleDataFactory.java b/examples/src/main/java/com/demcha/examples/support/ExampleDataFactory.java
index 4ab8455be..501fda72a 100644
--- a/examples/src/main/java/com/demcha/examples/support/ExampleDataFactory.java
+++ b/examples/src/main/java/com/demcha/examples/support/ExampleDataFactory.java
@@ -11,6 +11,8 @@
import com.demcha.compose.document.templates.cv.data.SkillsSection;
import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec;
import com.demcha.compose.document.templates.data.proposal.ProposalDocumentSpec;
+import com.demcha.compose.document.templates.data.receipt.ReceiptDocumentSpec;
+import com.demcha.compose.document.templates.data.receipt.ReceiptStatus;
import com.demcha.compose.document.templates.data.schedule.ScheduleSlot;
import com.demcha.compose.document.templates.data.schedule.WeeklyScheduleDocumentSpec;
@@ -58,6 +60,65 @@ public static InvoiceDocumentSpec sampleInvoice() {
.build();
}
+ /**
+ * A settled direct-debit collection, for the Modern Receipt preset.
+ *
+ *
Northwind Pay is invented, and so is every account, reference, and
+ * name below — the footer note on the rendered page says so, because a
+ * sample bank document that does not is a sample somebody eventually
+ * mistakes for a real one.
The sample is the preset's reference content: a design studio's
+ * invoice with a monogram lockup, five labelled masthead metadata rows,
+ * a billed-to and a shipped-to party, five priced service lines with a VAT
+ * column, a subtotal / VAT stack closing on the total-due band, five bank
+ * fields, two closing notes and the sign-off band.
+ *
+ *
Kept in lockstep with the qa module's
+ * {@code LumaStudioInvoiceFixtures} — the two modules cannot share a source
+ * file, so a content change here belongs there too.
+ */
+public final class LumaStudioInvoiceSampleData {
+
+ /** The break the sheet stacks its multi-line notes on. */
+ private static final String NEWLINE = String.valueOf((char) 10);
+
+ private LumaStudioInvoiceSampleData() {
+ }
+
+ /** The single-page reference invoice. */
+ public static StructuredInvoiceDocumentSpec sample() {
+ return StructuredInvoiceDocumentSpec.from(StructuredInvoiceData.builder()
+ .brand(new InvoiceBrand(null, "LUMA & CO. STUDIO", null,
+ "Branding. Design. Digital.", "L", "&Co."))
+ .supplier(new InvoiceContactBlock("LUMA & CO. STUDIO",
+ List.of("Studio 3.02, The Loom", "14 Gower Street",
+ "London WC1E 6BT", "United Kingdom"),
+ "+44 (0)20 7946 0832", "hello@lumaandco.studio",
+ "www.lumaandco.studio",
+ "Company No.", "12578934", "VAT No.", "369 4567 89"))
+ .masthead(new InvoiceMasthead("INVOICE", List.of(
+ new InvoiceMasthead.Entry("INVOICE NO.", "INV-2024-0587", false),
+ new InvoiceMasthead.Entry("ISSUE DATE", "20 May 2024", false),
+ new InvoiceMasthead.Entry("DUE DATE", "19 June 2024", false),
+ new InvoiceMasthead.Entry("PAYMENT TERMS", "30 Days", false),
+ new InvoiceMasthead.Entry("CURRENCY", "GBP", false))))
+ .billTo(new InvoiceRecipient("BILL TO", "Northfield Consulting Ltd",
+ "Attn: Sarah Mitchell",
+ List.of("21 Jubilee Way", "London SE1 3SS", "United Kingdom"), "", ""))
+ .shipTo(new InvoiceRecipient("SHIP TO", "Northfield Consulting Ltd", "",
+ List.of("The Foundry, 2nd Floor", "17-19 Great Suffolk Street",
+ "London SE1 0NS", "United Kingdom"), "", ""))
+ .serviceLines(serviceLines())
+ .totals(new InvoiceTotalsBlock(List.of(
+ new InvoiceTotalsBlock.Row("SUBTOTAL", new BigDecimal("8500.00")),
+ new InvoiceTotalsBlock.Row("VAT (20%)", new BigDecimal("1700.00"))),
+ "TOTAL DUE", new BigDecimal("10200.00")))
+ .notes(new InvoiceNotesBlock("NOTES", List.of(
+ "Thank you for your business." + NEWLINE
+ + "If you have any questions regarding" + NEWLINE
+ + "this invoice, please get in touch.",
+ "All work remains the intellectual property" + NEWLINE
+ + "of LUMA & CO. STUDIO until payment" + NEWLINE
+ + "has been received in full."), "", ""))
+ .payment(new InvoicePaymentBlock("PAYMENT DETAILS", List.of(
+ new InvoicePaymentBlock.Field("BANK", "Starling Bank"),
+ new InvoicePaymentBlock.Field("SORT CODE", "60-83-71"),
+ new InvoicePaymentBlock.Field("ACCOUNT NO.", "98765432"),
+ new InvoicePaymentBlock.Field("IBAN", "GB36 SRLG 6083 7198 7654 32"),
+ new InvoicePaymentBlock.Field("BIC", "SRLGGB2L")),
+ "Please make payment by bank transfer to:",
+ "Payment is due by 19 June 2024.", "",
+ "LUMA & CO. STUDIO LTD",
+ "Thank you for choosing LUMA & CO. STUDIO."))
+ .currencyCode("GBP")
+ .build());
+ }
+
+ private static InvoiceServiceLines serviceLines() {
+ return new InvoiceServiceLines(
+ new InvoiceServiceLines.Columns("", "DESCRIPTION", "", "QTY", "UNIT PRICE",
+ "AMOUNT", "VAT"),
+ List.of(
+ line(1, "Brand Strategy Workshop",
+ "Discovery session, research & brand positioning.", "1200.00"),
+ line(2, "Visual Identity Design",
+ "Logo suite, colour palette, typography & guidelines.", "2650.00"),
+ line(3, "Website Design (Up to 8 pages)",
+ "UX/UI design for desktop and mobile.", "3200.00"),
+ line(4, "Copywriting", "Website copy & key messaging.", "850.00"),
+ line(5, "Project Management",
+ "Planning, coordination & client liaison.", "600.00")));
+ }
+
+ private static InvoiceServiceLines.Line line(int number, String title, String description,
+ String price) {
+ return new InvoiceServiceLines.Line(number, title, description, "", BigDecimal.ONE, "",
+ new BigDecimal(price), new BigDecimal(price), "20%");
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/MidnightNavySampleData.java b/examples/src/main/java/com/demcha/examples/support/MidnightNavySampleData.java
new file mode 100644
index 000000000..9b6f85d16
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/MidnightNavySampleData.java
@@ -0,0 +1,198 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvName;
+import com.demcha.compose.document.templates.cv.data.CvSkill;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.SkillGroup;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.data.Slot;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Shared sample data for the Midnight Navy CV example.
+ *
+ *
Kept in lockstep with the qa module's {@code MidnightNavyFixtures} — the
+ * two modules cannot share a source file, so a content change here belongs
+ * there too.
+ */
+public final class MidnightNavySampleData {
+
+ /** The break a body stacks its lines on. */
+ private static final String NEWLINE = String.valueOf((char) 10);
+
+ private MidnightNavySampleData() {
+ }
+
+ /**
+ * The canonical one-page CV — five contact rows including two links, two
+ * degrees, ten metered skills, three rated languages, the summary, three
+ * roles, three achievement discs and two certifications.
+ *
+ * @return the document
+ */
+ public static CvDocument sample() {
+ List placements = new ArrayList<>();
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, education()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, skills()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, languages()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, summary()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, experience()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, achievements()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, certifications()));
+ return new CvDocument(identity(), placements);
+ }
+
+ private static CvIdentity identity() {
+ return new CvIdentity(
+ CvName.of("Alex", "Morgan"),
+ "Marketing Manager",
+ new Contact("+1 (555) 123-4567",
+ "alex.morgan@email.com",
+ "New York, NY, USA"),
+ List.of(new Link("LinkedIn", "https://www.linkedin.com/in/alexmorgan"),
+ new Link("Portfolio", "https://alexmorgan.com")),
+ Optional.empty());
+ }
+
+ /** The degrees, newest first. */
+ private static EntriesSection education() {
+ return new EntriesSection("EDUCATION", List.of(
+ CvEntry.builder("MASTER OF BUSINESS ADMINISTRATION")
+ .subtitle("University of Chicago Booth School of Business")
+ .date("2016 \u2013 2018")
+ .build(),
+ CvEntry.builder("BACHELOR OF SCIENCE IN MARKETING")
+ .subtitle("Boston University")
+ .date("2012 \u2013 2016")
+ .build()));
+ }
+
+ /** The metered skills, as one flat group. */
+ private static SkillsSection skills() {
+ List entries = new ArrayList<>();
+ entries.add(CvSkill.of("Strategic Planning", 0.82));
+ entries.add(CvSkill.of("Digital Marketing", 0.84));
+ entries.add(CvSkill.of("Market Research", 0.76));
+ entries.add(CvSkill.of("Brand Management", 0.84));
+ entries.add(CvSkill.of("Data Analysis", 0.81));
+ entries.add(CvSkill.of("Project Management", 0.68));
+ entries.add(CvSkill.of("Google Analytics", 0.83));
+ entries.add(CvSkill.of("SEO / SEM", 0.76));
+ entries.add(CvSkill.of("Microsoft Office Suite", 0.75));
+ entries.add(CvSkill.of("Communication", 0.96));
+ return new SkillsSection("SKILLS",
+ List.of(new SkillGroup("SKILLS", entries)));
+ }
+
+ /** The languages. The design shows a rating in fifths. */
+ private static SkillsSection languages() {
+ List entries = new ArrayList<>();
+ entries.add(CvSkill.of("English", 1.0));
+ entries.add(CvSkill.of("Spanish", 0.8));
+ entries.add(CvSkill.of("French", 0.6));
+ return new SkillsSection("LANGUAGES",
+ List.of(new SkillGroup("LANGUAGES", entries)));
+ }
+
+ /** The opening prose. */
+ private static ParagraphSection summary() {
+ return new ParagraphSection("PROFESSIONAL SUMMARY",
+ "Results-driven Marketing Manager with 6+ years of "
+ + "experience in developing and executing data-driven "
+ + "marketing strategies that drive brand growth and "
+ + "customer engagement. Proven track record in leading "
+ + "cross-functional teams, managing successful campaigns, "
+ + "and delivering measurable results. Passionate about "
+ + "building strong brands and creating impactful marketing "
+ + "initiatives.");
+ }
+
+ /** The roles held, each a headline over its bullets. */
+ private static EntriesSection experience() {
+ return new EntriesSection("EXPERIENCE", List.of(
+ CvEntry.builder("MARKETING MANAGER")
+ .subtitle("Starwave Solutions")
+ .place("New York, NY")
+ .date("2021 \u2013 Present")
+ .body(String.join(NEWLINE,
+ "Develop and implement comprehensive marketing strategies "
+ + "that increased brand awareness by 40% and lead "
+ + "generation by 35%.",
+ "Manage a team of 6 marketing professionals and "
+ + "collaborate with sales, product, and design teams to "
+ + "drive campaign success.",
+ "Oversee digital campaigns across SEO, SEM, social media, "
+ + "and email marketing resulting in a 25% increase in ROI.",
+ "Analyze market trends and competitor activities to "
+ + "identify new opportunities and optimize marketing "
+ + "efforts."))
+ .build(),
+ CvEntry.builder("SENIOR MARKETING SPECIALIST")
+ .subtitle("BrightLine Technologies")
+ .place("New York, NY")
+ .date("2018 \u2013 2021")
+ .body(String.join(NEWLINE,
+ "Executed multi-channel marketing campaigns that improved "
+ + "customer engagement by 30%.",
+ "Conducted market research and data analysis to support "
+ + "strategic decision-making.",
+ "Managed social media channels and content strategy, "
+ + "growing followers by 50%.",
+ "Coordinated with creative teams to develop compelling "
+ + "content and visuals."))
+ .build(),
+ CvEntry.builder("MARKETING COORDINATOR")
+ .subtitle("Peak Performance Group")
+ .place("Boston, MA")
+ .date("2016 \u2013 2018")
+ .body(String.join(NEWLINE,
+ "Supported the development and execution of marketing "
+ + "plans and campaigns.",
+ "Assisted in organizing events, webinars, and promotional "
+ + "activities.",
+ "Monitored campaign performance and prepared reports for "
+ + "management.",
+ "Maintained and updated marketing databases and CRM "
+ + "systems."))
+ .build()));
+ }
+
+ /**
+ * The discs. A card has one line and no heading over it, so the line is
+ * the entry's title.
+ */
+ private static EntriesSection achievements() {
+ return new EntriesSection("ACHIEVEMENTS", List.of(
+ CvEntry.builder("Increased brand awareness by 40% within 2 years.")
+ .icon("trophy")
+ .build(),
+ CvEntry.builder("Boosted lead generation by 35% annually.")
+ .icon("growth")
+ .build(),
+ CvEntry.builder("Recognized as Top Performer in 2022.")
+ .icon("award")
+ .build()));
+ }
+
+ /** The certifications, one to a column. */
+ private static EntriesSection certifications() {
+ return new EntriesSection("CERTIFICATIONS", List.of(
+ CvEntry.builder("Google Analytics Certified")
+ .subtitle("Google")
+ .date("2020")
+ .build(),
+ CvEntry.builder("HubSpot Content Marketing Certified")
+ .subtitle("HubSpot Academy")
+ .date("2021")
+ .build()));
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java b/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java
new file mode 100644
index 000000000..6068025ca
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java
@@ -0,0 +1,148 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+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.SkillGroup;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.data.Slot;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.Objects;
+
+/**
+ * Sample data for the Navy Sidebar CV example.
+ *
+ *
Sized to the design: the preset draws a fixed one-page sheet, and a CV
+ * much longer than this one does not compose at all — the two columns are a
+ * single atomic row, so it raises {@code AtomicNodeTooLargeException}.
+ *
+ *
The portrait is a neutral silhouette rather than a photograph, because
+ * the example ships in the repository; a real CV passes the candidate's own
+ * image to {@code CvIdentity.Builder.portrait(...)} the same way.
+ */
+public final class NavySidebarSampleData {
+
+ private static final String PORTRAIT = "/cv-portrait-placeholder.png";
+
+ private NavySidebarSampleData() {
+ }
+
+ /**
+ * A marketing manager's one-page CV.
+ *
+ * @return the sample document
+ */
+ public static CvDocument sample() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Priya", "Raghavan")
+ .jobTitle("Marketing Manager")
+ .contact(new Contact("+44 20 7946 0812",
+ "priya.r@example.com",
+ "Bristol, United Kingdom"))
+ .link(new Link("linkedin.com/in/praghavan",
+ "https://linkedin.com/in/praghavan"))
+ .portrait(portrait())
+ .build())
+ .section(Slot.MAIN, new ParagraphSection("Summary",
+ "Marketing manager with eight years in B2B software, most of it"
+ + " running the demand side end to end: positioning, the"
+ + " campaigns that carry it, and the reporting that says"
+ + " whether it worked. Happiest with a small team and a"
+ + " short feedback loop."))
+ .section(Slot.SIDEBAR, EntriesSection.builder("Education")
+ .entry(CvEntry.builder("MSc Marketing Analytics")
+ .subtitle("University of Bristol")
+ .date("2015 - 2016")
+ .place("Bristol, UK")
+ .build())
+ .entry(CvEntry.builder("BA Business Management")
+ .subtitle("University of Leeds")
+ .date("2012 - 2015")
+ .place("Leeds, UK")
+ .build())
+ .build())
+ .section(Slot.SIDEBAR, SkillsSection.of("Skills", SkillGroup.of("Core",
+ "Positioning",
+ "Demand Generation",
+ "Marketing Analytics",
+ "SEO / SEM",
+ "Lifecycle Email",
+ "Content Strategy",
+ "HubSpot",
+ "Looker")))
+ .section(Slot.SIDEBAR, RowsSection.builder("Languages", RowStyle.PLAIN)
+ .row("English", "Native")
+ .row("Tamil", "Native")
+ .row("German", "Intermediate")
+ .build())
+ .section(Slot.MAIN, EntriesSection.builder("Experience")
+ .entry("Marketing Manager",
+ "Ardent Systems, Bristol, UK",
+ "Mar 2021 - Present",
+ String.join("\n",
+ "Rebuilt the demand programme around three named"
+ + " segments, lifting qualified pipeline 44% in"
+ + " the first year.",
+ "Runs a team of four across content, lifecycle and"
+ + " events, and the agency relationship behind"
+ + " paid search.",
+ "Replaced a weekly spreadsheet with a Looker model the"
+ + " sales team reads without asking for it."))
+ .entry("Senior Marketing Executive",
+ "Halworth Digital, Bristol, UK",
+ "Sep 2018 - Feb 2021",
+ String.join("\n",
+ "Owned lifecycle email end to end, taking trial-to-paid"
+ + " conversion from 9% to 14%.",
+ "Launched the customer-story programme that still"
+ + " supplies the sales deck.",
+ "Ran competitor and win-loss research each quarter for"
+ + " the product team."))
+ .entry("Marketing Executive",
+ "Kite & Compass, Leeds, UK",
+ "Oct 2016 - Aug 2018",
+ String.join("\n",
+ "Planned and ran campaigns across search, social and"
+ + " trade press for six retail clients.",
+ "Built the reporting pack the agency used for every"
+ + " monthly review."))
+ .build())
+ .section(Slot.MAIN, new ParagraphSection("Achievements", String.join("\n",
+ "Grew organic sessions 60% in a year by rebuilding the site around"
+ + " search intent rather than the org chart.",
+ "Cut cost per qualified lead by a third by retiring two channels and"
+ + " funding the one that worked.",
+ "Named marketer of the year at Ardent in 2024.")))
+ .section(Slot.MAIN, new ParagraphSection("Certifications", String.join("\n",
+ "Google Analytics Individual Qualification",
+ "HubSpot Content Marketing Certification",
+ "Professional Certificate in Marketing, CIM")))
+ .build();
+ }
+
+ /**
+ * The packaged silhouette that stands in for a photograph.
+ *
+ * @return the portrait image data
+ */
+ private static DocumentImageData portrait() {
+ try (InputStream in = Objects.requireNonNull(
+ NavySidebarSampleData.class.getResourceAsStream(PORTRAIT),
+ "cv-portrait-placeholder.png missing from examples/src/main/resources/")) {
+ return DocumentImageData.fromBytes(in.readAllBytes());
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to read the sample portrait", e);
+ }
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/OrangeOpsSampleData.java b/examples/src/main/java/com/demcha/examples/support/OrangeOpsSampleData.java
new file mode 100644
index 000000000..a3c3d4ce8
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/OrangeOpsSampleData.java
@@ -0,0 +1,252 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvName;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.SkillGroup;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.data.Slot;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Shared sample data for the Orange Ops CV example.
+ *
+ *
The sample is the preset's reference content: a one-page CV with four
+ * contact items including one link, eleven skills, four achievement cards, a
+ * degree, four certifications, the opening prose, three roles, a four-metric
+ * strip and four closing lines.
+ *
+ *
Kept in lockstep with the qa module's {@code OrangeOpsFixtures} — the two
+ * modules cannot share a source file, so a content change here belongs there
+ * too.
+ */
+public final class OrangeOpsSampleData {
+
+ /** The break a body stacks its lines on. */
+ private static final String NEWLINE = String.valueOf((char) 10);
+
+ private OrangeOpsSampleData() {
+ }
+
+ /**
+ * The sample CV.
+ *
+ * @return the document the example renders
+ */
+ public static CvDocument sample() {
+ List placements = new ArrayList<>();
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, skills()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, achievements()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, education()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, certifications()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, profile()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, experience()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, metrics()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, additional()));
+ return new CvDocument(identity(), placements);
+ }
+
+ private static CvIdentity identity() {
+ return new CvIdentity(
+ CvName.of("Marcus", "Bennett"),
+ "Warehouse Operations Supervisor",
+ new Contact("+44 7700 900123",
+ "marcus.bennett@email.com",
+ "Birmingham, West Midlands, UK"),
+ List.of(new Link("LinkedIn", "https://www.linkedin.com/in/marcusbennett")),
+ Optional.empty());
+ }
+
+ /** The skills, as one flat group — the design draws no group names. */
+ private static SkillsSection skills() {
+ return new SkillsSection("KEY SKILLS", List.of(SkillGroup.ofNames("KEY SKILLS", List.of(
+ "Warehouse Operations Management",
+ "Team Leadership & Development",
+ "Inventory Control & Accuracy",
+ "Logistics & Freight Coordination",
+ "Order Fulfilment & Dispatch",
+ "KPI Monitoring & Reporting",
+ "Lean Process Improvement",
+ "Health & Safety Compliance",
+ "WMS & ERP Systems",
+ "Problem Solving & Decision Making",
+ "Stakeholder Communication"))));
+ }
+
+ /** The achievement cards: a mark, a title and a body each. */
+ private static EntriesSection achievements() {
+ return new EntriesSection("ACHIEVEMENTS", List.of(
+ CvEntry.builder("15% PRODUCTIVITY INCREASE")
+ .icon("achievement-productivity")
+ .body("Improved warehouse productivity by 15% through workflow "
+ + "redesign and performance management.")
+ .build(),
+ CvEntry.builder("99.2% INVENTORY ACCURACY")
+ .icon("achievement-accuracy")
+ .body("Maintained inventory accuracy above 99% for 12 "
+ + "consecutive months through cycle count discipline and "
+ + "process control.")
+ .build(),
+ CvEntry.builder("ZERO LOST TIME INCIDENTS")
+ .icon("achievement-safety")
+ .body("Led a safety-first culture resulting in zero lost time "
+ + "incidents over 24 months across operations.")
+ .build(),
+ CvEntry.builder("\u00a3280K COST SAVINGS")
+ .icon("achievement-savings")
+ .body("Delivered \u00a3280K in annual savings by reducing waste, "
+ + "lowering overtime and renegotiating carrier contracts.")
+ .build()));
+ }
+
+ /** The degree, its institution, place and years one line each. */
+ private static EntriesSection education() {
+ return new EntriesSection("EDUCATION", List.of(
+ CvEntry.builder("BSc (Hons) Logistics & Supply Chain Management")
+ .icon("graduation")
+ .body(String.join(NEWLINE,
+ "De Montfort University",
+ "Leicester, UK",
+ "2011 \u2013 2014"))
+ .build()));
+ }
+
+ /** The certifications: a title over its issuer. */
+ private static EntriesSection certifications() {
+ return new EntriesSection("CERTIFICATIONS", List.of(
+ CvEntry.builder("IOSH Managing Safely")
+ .subtitle("Institution of Occupational Safety and Health \u2013 2021")
+ .build(),
+ CvEntry.builder("Lean Six Sigma Yellow Belt")
+ .subtitle("The Knowledge Academy \u2013 2020")
+ .build(),
+ CvEntry.builder("Counterbalance Forklift Truck Instructor")
+ .subtitle("RTITB \u2013 2019")
+ .build(),
+ CvEntry.builder("First Aid at Work")
+ .subtitle("St John Ambulance \u2013 2019")
+ .build()));
+ }
+
+ /** The opening prose. */
+ private static ParagraphSection profile() {
+ return new ParagraphSection("PROFESSIONAL PROFILE",
+ "Results-driven Warehouse Operations Supervisor with 8+ "
+ + "years of experience leading high-performing teams and "
+ + "optimising warehouse performance in fast-paced "
+ + "distribution environments. Proven track record of "
+ + "improving operational efficiency, ensuring inventory "
+ + "accuracy, and delivering excellent service levels while "
+ + "maintaining a strong focus on health and safety. Adept "
+ + "at using data to drive decisions, streamline processes, "
+ + "and achieve measurable results.");
+ }
+
+ /** The roles held, each a headline over its bullets. */
+ private static EntriesSection experience() {
+ return new EntriesSection("WORK EXPERIENCE", List.of(
+ CvEntry.builder("Warehouse Operations Supervisor")
+ .subtitle("ExpressLink Distribution Ltd | Birmingham, UK")
+ .date("Mar 2021 \u2013 Present")
+ .body(String.join(NEWLINE,
+ "Oversee daily warehouse operations across a 150,000 sq "
+ + "ft facility, managing a team of 45 staff across inbound, "
+ + "storage, picking, packing and dispatch.",
+ "Ensure timely and accurate order fulfilment, achieving "
+ + "98%+ on-time delivery.",
+ "Monitor and maintain inventory accuracy through cycle "
+ + "counts, audits and investigation of variances.",
+ "Analyse KPIs and operational data to identify trends and "
+ + "implement improvements.",
+ "Coordinate with transport and customer service teams to "
+ + "resolve delivery issues and improve service levels.",
+ "Drive a culture of safety and accountability; deliver "
+ + "regular training and ensure full compliance with H&S "
+ + "regulations.",
+ "Lead continuous improvement initiatives using Lean "
+ + "principles to reduce waste and enhance productivity."))
+ .build(),
+ CvEntry.builder("Warehouse Team Leader")
+ .subtitle("Midland Gate Logistics | Coventry, UK")
+ .date("May 2017 \u2013 Feb 2021")
+ .body(String.join(NEWLINE,
+ "Led a team of 25 warehouse operatives across day-to-day "
+ + "operations.",
+ "Managed inbound receipts, putaway, picking and shipping "
+ + "activities.",
+ "Maintained accurate stock records and completed regular "
+ + "cycle counts.",
+ "Supported the implementation of a new WMS, improving "
+ + "stock visibility and reporting.",
+ "Reduced picking errors by 23% through training and "
+ + "process optimisation."))
+ .build(),
+ CvEntry.builder("Warehouse Coordinator")
+ .subtitle("TotalSupply Solutions | Northampton, UK")
+ .date("Jun 2015 \u2013 Apr 2017")
+ .body(String.join(NEWLINE,
+ "Coordinated daily operations and allocated tasks to "
+ + "warehouse staff.",
+ "Monitored stock levels and initiated replenishment to "
+ + "avoid stockouts.",
+ "Assisted with KPI reporting and performance reviews.",
+ "Ensured compliance with company policies and health & "
+ + "safety standards."))
+ .build()));
+ }
+
+ /**
+ * The metric strip. The parenthetical is part of the berth title: the
+ * preset splits it off and sets it smaller.
+ */
+ private static EntriesSection metrics() {
+ return new EntriesSection("KEY KPI SNAPSHOT (Recent 12 Months)", List.of(
+ CvEntry.builder("15%")
+ .icon("kpi-productivity")
+ .body("Productivity" + NEWLINE + "Increase")
+ .build(),
+ CvEntry.builder("99.2%")
+ .icon("kpi-accuracy")
+ .body("Inventory" + NEWLINE + "Accuracy")
+ .build(),
+ CvEntry.builder("98.4%")
+ .icon("kpi-delivery")
+ .body("On-Time" + NEWLINE + "Delivery")
+ .build(),
+ CvEntry.builder("0")
+ .icon("kpi-safety")
+ .body("Lost Time" + NEWLINE + "Incidents")
+ .build()));
+ }
+
+ /** The closing lines: a mark, a label and its value. */
+ private static EntriesSection additional() {
+ return new EntriesSection("ADDITIONAL INFORMATION", List.of(
+ CvEntry.builder("Systems:")
+ .icon("systems")
+ .body("SAP EWM, Manhattan WMS, Microsoft Dynamics, Excel "
+ + "(Advanced)")
+ .build(),
+ CvEntry.builder("Languages:")
+ .icon("languages")
+ .body("English (Native)")
+ .build(),
+ CvEntry.builder("Driving Licence:")
+ .icon("driving")
+ .body("Full UK Driving Licence")
+ .build(),
+ CvEntry.builder("Interests:")
+ .icon("interests")
+ .body("Strength training, Motorsport, Volunteering with local "
+ + "food banks")
+ .build()));
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/PaymentsInvoiceSampleData.java b/examples/src/main/java/com/demcha/examples/support/PaymentsInvoiceSampleData.java
new file mode 100644
index 000000000..3f786c57e
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/PaymentsInvoiceSampleData.java
@@ -0,0 +1,156 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.data.invoice.InvoiceBrand;
+import com.demcha.compose.document.templates.data.invoice.InvoiceContactBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoiceMasthead;
+import com.demcha.compose.document.templates.data.invoice.InvoiceNotesBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoicePaymentBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoiceRecipient;
+import com.demcha.compose.document.templates.data.invoice.InvoiceServiceLines;
+import com.demcha.compose.document.templates.data.invoice.InvoiceSummaryBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoiceTotalsBlock;
+import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceData;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shared sample data for the Payments invoice example.
+ *
+ *
Kept in lockstep with the qa module's {@code PaymentsInvoiceFixtures} — the
+ * two modules cannot share a source file, so a content change here belongs
+ * there too.
+ */
+public final class PaymentsInvoiceSampleData {
+
+ private PaymentsInvoiceSampleData() {
+ }
+
+ /**
+ * The sample invoice — seven metadata rows, two addressed parties, six
+ * marked service lines, seven payment fields, two summed rows and a
+ * two-line note.
+ *
+ * @return the document the example renders
+ */
+ public static StructuredInvoiceData sample() {
+ return new StructuredInvoiceData(
+ brand(), supplier(), masthead(), billTo(), shipTo(),
+ new InvoiceSummaryBlock("", "", ""),
+ serviceLines(), totals(), payment(), notes(), "GBP");
+ }
+
+ /** No logo: the wordmark is what a document without one falls back to. */
+ private static InvoiceBrand brand() {
+ return new InvoiceBrand(null, "Meridian", "", "", "", "");
+ }
+
+ private static InvoiceContactBlock supplier() {
+ return new InvoiceContactBlock(
+ "Meridian Payments Ltd.",
+ List.of("1 Harbour Exchange", "Canary Wharf", "London E14 9GE",
+ "United Kingdom"),
+ "", "", "",
+ "", "Company No. 09048900",
+ "", "VAT No. GB 123 4567 89");
+ }
+
+ private static InvoiceMasthead masthead() {
+ List entries = new ArrayList<>();
+ entries.add(new InvoiceMasthead.Entry("Invoice Number", "INV-2025-05-10472", false));
+ entries.add(new InvoiceMasthead.Entry("Invoice Date", "10 May 2025", false));
+ entries.add(new InvoiceMasthead.Entry("Billing Period", "1 - 30 April 2025", false));
+ entries.add(new InvoiceMasthead.Entry("Account ID", "acct_4Q8xTb2mKp", false));
+ entries.add(new InvoiceMasthead.Entry("Payment Terms", "Net 30", false));
+ entries.add(new InvoiceMasthead.Entry("Due Date", "14 June 2025", false));
+ entries.add(new InvoiceMasthead.Entry("Status", "UNPAID", true));
+ return new InvoiceMasthead("INVOICE", entries);
+ }
+
+ /** The subline is where a billed party's registration goes on this sheet. */
+ private static InvoiceRecipient billTo() {
+ return new InvoiceRecipient("BILL TO", "Northwind Ltd.",
+ "VAT No. GB 987 6543 21",
+ List.of("Attention: Finance Team", "42 Bridgewater Street",
+ "Manchester M15 4QT", "United Kingdom"),
+ "", "");
+ }
+
+ private static InvoiceRecipient shipTo() {
+ return new InvoiceRecipient("SHIP TO", "Northwind Ltd.", "",
+ List.of("Operations Centre", "7 Kingsway Park",
+ "Leeds LS12 6BD", "United Kingdom"),
+ "", "");
+ }
+
+ private static InvoiceServiceLines serviceLines() {
+ InvoiceServiceLines.Columns columns = new InvoiceServiceLines.Columns(
+ "", "DESCRIPTION", "", "QUANTITY", "UNIT PRICE", "AMOUNT", "VAT");
+ List lines = new ArrayList<>();
+ lines.add(line(1, "Subscription Billing",
+ "Subscription management and recurring billing",
+ "1", "250.00", "250.00", "20%", "card"));
+ lines.add(line(2, "Payment Processing",
+ "Online card payments processing (volume tier)",
+ "1", "1,180.00", "1,180.00", "20%", "card-settings"));
+ lines.add(line(3, "Fraud Screening",
+ "Advanced fraud screening and rules",
+ "1", "320.00", "320.00", "20%", "shield"));
+ lines.add(line(4, "Company Formation",
+ "Incorporation and compliance filings",
+ "1", "180.00", "180.00", "20%", "globe"));
+ lines.add(line(5, "In-Person Terminals",
+ "Terminal rental and firmware updates",
+ "4", "39.00", "156.00", "20%", "mobile"));
+ lines.add(line(6, "Priority Support",
+ "Named engineer, one-hour response",
+ "1", "100.00", "100.00", "20%", "headset"));
+ return new InvoiceServiceLines(columns, lines);
+ }
+
+ private static InvoiceServiceLines.Line line(int number, String title, String description,
+ String quantity, String unitPrice,
+ String amount, String vat, String icon) {
+ return new InvoiceServiceLines.Line(number, title, description, "",
+ decimal(quantity), "", decimal(unitPrice), decimal(amount), vat, icon);
+ }
+
+ private static InvoiceTotalsBlock totals() {
+ List rows = new ArrayList<>();
+ rows.add(new InvoiceTotalsBlock.Row("Subtotal (excl. VAT)", decimal("2,186.00")));
+ rows.add(new InvoiceTotalsBlock.Row("VAT at 20%", decimal("437.20")));
+ return new InvoiceTotalsBlock(rows, "TOTAL DUE", decimal("2,623.20"));
+ }
+
+ private static InvoicePaymentBlock payment() {
+ List fields = new ArrayList<>();
+ fields.add(new InvoicePaymentBlock.Field("Bank Name", "Harbour Bank plc"));
+ fields.add(new InvoicePaymentBlock.Field("Account Name", "Meridian Payments Ltd."));
+ fields.add(new InvoicePaymentBlock.Field("Sort Code", "40-02-50"));
+ fields.add(new InvoicePaymentBlock.Field("Account Number", "71295408"));
+ fields.add(new InvoicePaymentBlock.Field("IBAN", "GB29 HRBK 4002 5071 2954 08"));
+ fields.add(new InvoicePaymentBlock.Field("SWIFT / BIC", "HRBKGB2L"));
+ fields.add(new InvoicePaymentBlock.Field("Reference", "INV-2025-05-10472"));
+ return new InvoicePaymentBlock("PAYMENT DETAILS", fields,
+ "Please include the invoice number as payment reference.",
+ "Due by 14 June 2025",
+ "Payment due by 14 June 2025",
+ "",
+ "Questions? We're here to help.");
+ }
+
+ private static InvoiceNotesBlock notes() {
+ return new InvoiceNotesBlock("NOTES",
+ List.of("Thank you for your business. This invoice covers services and "
+ + "fees for the period stated above.",
+ "Late payment may incur interest at the statutory rate."),
+ "support@meridianpayments.example",
+ "+44 (0) 20 3966 1900");
+ }
+
+ /** A printed figure back to a number: the grouping comes off. */
+ private static BigDecimal decimal(String printed) {
+ return new BigDecimal(printed.replace(",", ""));
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/ProfessionalSidebarSampleData.java b/examples/src/main/java/com/demcha/examples/support/ProfessionalSidebarSampleData.java
new file mode 100644
index 000000000..75b1de94b
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/ProfessionalSidebarSampleData.java
@@ -0,0 +1,125 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+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.CvSkill;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.SkillGroup;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.data.Slot;
+
+import java.util.List;
+
+/**
+ * Sample data for the Professional Sidebar CV example.
+ *
+ *
Sized to the design: the preset draws a fixed one-page sheet, and a CV
+ * much longer than this one does not compose at all — the two columns are a
+ * single atomic row, so it raises {@code AtomicNodeTooLargeException}. The
+ * sidebar's one-line rows are sized to it too: a degree, skill or language
+ * longer than the narrow column measures overflows the row it is anchored in
+ * rather than wrapping inside it.
+ */
+public final class ProfessionalSidebarSampleData {
+
+ /** The en dash the design sets between dates. */
+ private static final String DASH = "–";
+
+ private ProfessionalSidebarSampleData() {
+ }
+
+ /**
+ * A backend engineer's one-page CV.
+ *
+ * @return the sample document
+ */
+ public static CvDocument sample() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("MARTA", "LINDQVIST")
+ .jobTitle("SENIOR BACKEND ENGINEER")
+ .contact(new Contact("+46 8 123 456 78",
+ "marta.l@example.com",
+ "Stockholm, Sweden"))
+ .link(new Link("linkedin.com/in/mlindqvist",
+ "https://linkedin.com/in/mlindqvist"))
+ .link(new Link("martalindqvist.dev",
+ "https://martalindqvist.dev"))
+ .build())
+ .section(Slot.MAIN, new ParagraphSection("PROFILE",
+ "Backend engineer with nine years on payment and identity systems,"
+ + " most of it owning services end to end: API design, the data"
+ + " model underneath, and the on-call rota that follows."
+ + " Comfortable in a team that ships small and often, and the"
+ + " one who writes the runbook nobody asked for."))
+ .section(Slot.SIDEBAR, SkillsSection.of("SKILLS",
+ new SkillGroup("Core", List.of(
+ CvSkill.of("Java", 0.9),
+ CvSkill.of("Kotlin", 0.72),
+ CvSkill.of("Spring Boot", 0.86),
+ CvSkill.of("PostgreSQL", 0.8),
+ CvSkill.of("Kafka", 0.68),
+ CvSkill.of("Kubernetes", 0.62),
+ CvSkill.of("Terraform", 0.55),
+ CvSkill.of("Observability", 0.74),
+ CvSkill.of("Threat Modelling", 0.6),
+ CvSkill.of("Mentoring", 0.78)))))
+ .section(Slot.SIDEBAR, EntriesSection.builder("EDUCATION")
+ .entry("MSc COMPUTER SCIENCE", "KTH Royal Institute",
+ "2014 " + DASH + " 2016", "")
+ .entry("BSc SOFTWARE DESIGN", "Uppsala University",
+ "2011 " + DASH + " 2014", "")
+ .build())
+ .section(Slot.SIDEBAR, SkillsSection.of("LANGUAGES",
+ new SkillGroup("Spoken", List.of(
+ CvSkill.of("Swedish", 1.0),
+ CvSkill.of("English", 1.0),
+ CvSkill.of("German", 0.6)))))
+ .section(Slot.MAIN, EntriesSection.builder("EXPERIENCE")
+ .entry("SENIOR BACKEND ENGINEER",
+ "Nordkassa | Stockholm, SE",
+ "2021 " + DASH + " Present",
+ String.join("\n",
+ "Led the split of the settlement monolith into four"
+ + " services with no customer-visible downtime.",
+ "Cut p99 authorisation latency from 780ms to 210ms by"
+ + " reworking the ledger write path.",
+ "Introduced contract tests between payments and risk,"
+ + " ending a class of release-day rollbacks.",
+ "Mentors three engineers; runs the internal design"
+ + " review."))
+ .entry("BACKEND ENGINEER",
+ "Vinge Identity | Gothenburg, SE",
+ "2018 " + DASH + " 2021",
+ String.join("\n",
+ "Built the OAuth2 and OIDC provider now serving eleven"
+ + " internal products.",
+ "Moved session storage to Redis with a migration that"
+ + " ran live for six weeks.",
+ "Wrote the audit pipeline the compliance team still"
+ + " reports from."))
+ .entry("SOFTWARE ENGINEER",
+ "Almgren Data | Uppsala, SE",
+ "2016 " + DASH + " 2018",
+ String.join("\n",
+ "Delivered ingest and reporting services for retail"
+ + " forecasting customers.",
+ "Replaced nightly batch reconciliation with a streaming"
+ + " job on Kafka."))
+ .build())
+ .section(Slot.MAIN, EntriesSection.builder("PROJECTS")
+ .entry("LEDGERKIT", "", "2024",
+ "Open-source double-entry ledger for JVM services, with a"
+ + " property-based test suite over the posting rules.")
+ .entry("ONCALL DIGEST", "", "2022",
+ "Weekly incident summariser that reads alert history and drafts"
+ + " the retrospective agenda.")
+ .build())
+ .section(Slot.MAIN, new ParagraphSection("REFERENCES",
+ "Available on request."))
+ .build();
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/SerifHeadlineSampleData.java b/examples/src/main/java/com/demcha/examples/support/SerifHeadlineSampleData.java
new file mode 100644
index 000000000..5c636db27
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/SerifHeadlineSampleData.java
@@ -0,0 +1,177 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvSkill;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+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.SkillsSection;
+
+import java.util.List;
+
+/**
+ * Sample data for the Serif Headline CV example.
+ *
+ *
Sized to the design: the preset draws a fixed one-page sheet, and a CV
+ * much longer than this one does not compose at all — the body is a single
+ * atomic row, so it raises {@code AtomicNodeTooLargeException}.
+ *
+ *
The project and achievement marks are this preset's own vocabulary —
+ * {@code cart}, {@code api}, {@code trophy}, {@code chart}, {@code rocket} —
+ * and each entry names the one it wants. The marks come in two colours:
+ * {@code cart} and {@code api} are drawn in the navy of the design's ink,
+ * the other three in its gold. This sample keeps a band to one colour, since
+ * mixing them inside one row reads as a mistake rather than a choice.
+ */
+public final class SerifHeadlineSampleData {
+
+ private static final String NEWLINE = String.valueOf((char) 10);
+
+ private SerifHeadlineSampleData() {
+ }
+
+ /**
+ * A backend engineer's one-page CV.
+ *
+ * @return the sample document
+ */
+ public static CvDocument sample() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Tomas", "Halvorsen")
+ .jobTitle("Platform Engineer")
+ .contact(new Contact("+47 22 12 34 56",
+ "tomas.h@example.com",
+ "Oslo, Norway"))
+ .link(new Link("linkedin.com/in/thalvorsen",
+ "https://www.linkedin.com/in/thalvorsen"))
+ .link(new Link("github.com/thalvorsen",
+ "https://github.com/thalvorsen"))
+ .build())
+ .section(new ParagraphSection("Summary",
+ "Platform engineer with seven years spent making other teams'"
+ + " deployments boring: build pipelines, the service"
+ + " templates behind them, and the observability that says"
+ + " whether a release went well. Writes the runbook before"
+ + " the incident."))
+ .section(EntriesSection.builder("Experience")
+ .entry(CvEntry.builder("Senior Platform Engineer")
+ .subtitle("Nordvik Systems")
+ .place("Oslo, NO")
+ .date("Feb 2022 - Present")
+ .body(List.of(
+ "Rebuilt the deployment pipeline around a golden path,"
+ + " cutting median lead time from two days to"
+ + " forty minutes.",
+ "Owns the Kubernetes platform four product teams ship"
+ + " onto, and the on-call rota that follows it.",
+ "Introduced service-level objectives the teams actually"
+ + " read, and retired the dashboards nobody did."))
+ .build())
+ .entry(CvEntry.builder("Platform Engineer")
+ .subtitle("Bergen Data")
+ .place("Bergen, NO")
+ .date("Aug 2019 - Jan 2022")
+ .body(List.of(
+ "Moved forty services off hand-rolled scripts onto a"
+ + " shared Terraform module set.",
+ "Built the log and metric pipeline the incident process"
+ + " still runs on.",
+ "Ran the migration to a single identity provider across"
+ + " every internal tool."))
+ .build())
+ .entry(CvEntry.builder("Backend Engineer")
+ .subtitle("Fjordline Software")
+ .place("Oslo, NO")
+ .date("Sep 2017 - Jul 2019")
+ .body(List.of(
+ "Delivered booking and payment services for a ferry"
+ + " operator's public API.",
+ "Replaced a nightly reconciliation batch with a"
+ + " streaming job."))
+ .build())
+ .build())
+ .section(EntriesSection.builder("Projects")
+ .entry(CvEntry.builder("Ledgerkit")
+ .subtitle("Java, PostgreSQL, Testcontainers")
+ .body("Open-source double-entry ledger for JVM services, with a"
+ + " property-based suite over the posting rules.")
+ .icon("chart")
+ .link("https://github.com/thalvorsen/ledgerkit")
+ .build())
+ .entry(CvEntry.builder("Runbook Digest")
+ .subtitle("Kotlin, Kafka, OpenSearch")
+ .body("Reads alert history and drafts the retrospective agenda"
+ + " before the meeting starts.")
+ .icon("rocket")
+ .link("https://github.com/thalvorsen/runbook-digest")
+ .build())
+ .build())
+ .section(EntriesSection.builder("Education")
+ .entry(CvEntry.builder("M.Sc. in Computer Science")
+ .subtitle("University of Oslo")
+ .date("2015 - 2017")
+ .place("Oslo, NO")
+ .build())
+ .entry(CvEntry.builder("B.Sc. in Informatics")
+ .subtitle("NTNU")
+ .date("2012 - 2015")
+ .place("Trondheim, NO")
+ .build())
+ .build())
+ .section(SkillsSection.builder("Skills")
+ .leveledGroup("Languages", List.of(
+ CvSkill.of("Java", 0.86),
+ CvSkill.of("Kotlin", 0.72),
+ CvSkill.of("Go", 0.48),
+ CvSkill.of("SQL", 0.8)))
+ .leveledGroup("Platform", List.of(
+ CvSkill.of("Kubernetes", 0.84),
+ CvSkill.of("Terraform", 0.78),
+ CvSkill.of("GitHub Actions", 0.75),
+ CvSkill.of("Argo CD", 0.6)))
+ .leveledGroup("Data", List.of(
+ CvSkill.of("PostgreSQL", 0.82),
+ CvSkill.of("Kafka", 0.7),
+ CvSkill.of("OpenSearch", 0.55)))
+ .leveledGroup("Practices", List.of(
+ CvSkill.of("Observability", 0.8),
+ CvSkill.of("Incident Response", 0.76),
+ CvSkill.of("Threat Modelling", 0.55)))
+ .build())
+ .section(new ParagraphSection("Soft Skills", String.join(NEWLINE,
+ "Mentoring | Facilitation",
+ "Writing | Pragmatism",
+ "Patience | Curiosity")))
+ .section(RowsSection.builder("Certifications", RowStyle.PLAIN)
+ .row("Certified Kubernetes", "Administrator (CNCF)")
+ .row("Terraform Associate", "(HashiCorp)")
+ .row("AWS Solutions Architect", "Associate (AWS)")
+ .row("Professional Cloud", "Architect (Google)")
+ .row("Site Reliability", "Foundations (Linux Foundation)")
+ .build())
+ .section(EntriesSection.builder("Achievements")
+ .entry(CvEntry.builder("Lead Time Cut by 96%")
+ .body("Took median deployment lead time from two days to forty"
+ + " minutes across four teams.")
+ .icon("chart")
+ .build())
+ .entry(CvEntry.builder("Zero-Downtime Migration")
+ .body("Moved the booking platform onto Kubernetes over six"
+ + " weeks without a customer-visible outage.")
+ .icon("rocket")
+ .build())
+ .entry(CvEntry.builder("Engineer of the Year 2024")
+ .body("Recognised for the platform work and for the mentoring"
+ + " that came with it.")
+ .icon("trophy")
+ .build())
+ .build())
+ .build();
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java
index 3c5f42cb4..fe0ea9c0a 100644
--- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java
+++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java
@@ -60,6 +60,16 @@ record Entry(String title, String description, List tags, String codeUrl
cv("cv-minimal-underlined-v2", "CvMinimalUnderlinedExample", "Minimal Underlined", "Single-column layout with underlined section titles and tight whitespace — minimalist reference shape.", "minimal");
cv("cv-mint-editorial-v2", "CvMintEditorialExample", "Mint Editorial", "Magazine-style editorial CV with mint accent palette and two-column body.", "editorial", "mint");
cv("cv-mint-editorial-v2-custom", "CvMintEditorialCustomExample", "Mint Editorial (custom band)", "The same preset with one colour changed through its Options — a kraft-paper masthead band, everything else left at the preset's defaults.", "editorial", "mint");
+ cv("cv-professional-sidebar-v2", "ProfessionalSidebarExample", "Professional Sidebar", "Navy monogram plate over a pale sidebar of contact marks, skill meters, an education rail and language ratings, beside a white column of profile, roles and projects.", "sidebar", "navy");
+ cv("cv-navy-sidebar-v2", "NavySidebarExample", "Navy Sidebar", "Navy plate with a ringed portrait, contact marks, degrees, skills and languages, beside a white column of summary, badged sections and roles strung on a timeline rail.", "sidebar", "navy", "portrait");
+ cv("cv-serif-headline-v2", "SerifHeadlineExample", "Serif Headline", "Volkhov masthead over a two-column body: roles on a timeline rail and marked project cards beside degrees and skill meters, closing with full-width certification and achievement bands.", "serif", "two-column");
+ cv("cv-charcoal-gold-v2", "CharcoalGoldExample", "Charcoal Gold", "Charcoal sidebar with a ringed photograph, rated skills and languages, beside a paper column with a two-tone name, a dated experience rail and paired credential columns.", "sidebar", "photo", "gold");
+ cv("cv-terracotta-rail-v2", "TerracottaRailExample", "Terracotta Rail", "Serif monogram over a terracotta rule, contact channels behind their marks and two bulleted lists, beside a letter-spaced masthead, roles on a ringed rail, a projects grid and the degrees.", "sidebar", "monogram", "terracotta");
+ cv("cv-teal-pulse-v2", "TealPulseExample", "Teal Pulse", "Clinical sheet in five bands: a heart crossed by a pulse beside the name, a contact strip on rules, competencies beside the summary and roles, and a three-column closing band over a tracked tagline.", "clinical", "badges", "teal");
+ cv("cv-slate-orange-v2", "SlateOrangeExample", "Slate Orange", "Full-bleed slate masthead with an orange monogram tile, over a sidebar of marked competencies, trophied achievements and rated languages beside a profile, a dated experience rail and a credentials footer.", "sidebar", "masthead", "orange");
+ cv("cv-violet-grid-v2", "VioletGridExample", "Violet Grid", "Single-column sheet in bands: a two-tone name beside the contact list, a six-up grid of marked skills on dotted rules, a tools strip, a dated timeline, tinted project tiles and a closing quotation.", "single-column", "grid", "violet");
+ cv("cv-orange-ops-v2", "OrangeOpsExample", "Orange Ops", "Operations sheet with a two-tone name over a slanted role bar and accent slashes, a contact strip on hairlines, and a sidebar of skills, achievement discs and credentials beside a profile, dated roles and a four-metric strip.", "sidebar", "metrics", "orange");
+ cv("cv-midnight-navy-v2", "MidnightNavyExample", "Midnight Navy", "Full-height navy plate carrying an outlined monogram, a tracked role line, metered skills and dotted languages, beside a paper column with the summary, roles on a rail, achievement discs and divided certification columns.", "sidebar", "monogram", "navy");
// ===== Templates / Cover Letter (v2 layered, paired 1:1 with CV) =====
// Registered directly: letter() points at the layered preset examples under
@@ -87,12 +97,22 @@ record Entry(String title, String description, List tags, String codeUrl
// ===== Templates / Invoice =====
invoice("invoice-cinematic", "InvoiceCinematicFileExample", "Cinematic Invoice", "Layered ModernInvoice preset with theme-driven layout, advanced tables, and totals.", "invoice", "cinematic");
+ invoice("invoice-consulting-v2", "v2/ConsultingInvoiceV2Example", "Consulting Invoice", "The ConsultingInvoice preset on the structured invoice model — brand lockup with the caller's logo, labelled masthead metadata, priced service lines with service periods, a totals stack and bank payment fields.", "invoice");
+ invoice("invoice-luma-studio-v2", "v2/LumaStudioInvoiceV2Example", "Luma Studio Invoice", "The LumaStudioInvoice preset on the structured invoice model — a cream sidebar carrying the brand lockup and its ornament, a billed-to / shipped-to pair, priced service lines with a VAT column, and the notes and bank details above a sign-off band.", "invoice");
+ invoice("invoice-payments-v2", "v2/PaymentsInvoiceV2Example", "Payments Invoice", "The PaymentsInvoice preset on the structured invoice model — a diagonal band crossing the masthead, a half-split issuer and metadata header, two addressed parties, marked service lines that repeat their header across pages, and a settlement row pairing bank details against the totals.", "invoice");
+ invoice("invoice-workspace-v2", "v2/WorkspaceInvoiceV2Example", "Workspace Invoice", "The WorkspaceInvoice preset on the structured invoice model — a brand masthead over an accent bar, a half-split issuer and metadata header, two addressed parties on discs, service lines whose marks sit on coloured tiles, and a settlement row above a closing band.", "invoice");
invoice("invoice-modern-v2", "v2/ModernInvoiceV2Example", "Modern Invoice", "The ModernInvoice preset composed straight from an InvoiceDocumentSpec — line items, totals and payment block driven by the BrandTheme rather than per-document styling.", "invoice");
+ invoice("invoice-classic-v2", "v2/ClassicInvoiceV2Example", "Classic Invoice", "The ClassicInvoice preset — letterhead header band, TOTAL DUE hero strip, BILL TO / FROM columns, and a dedicated Summary table after the line items.", "invoice");
// ===== Templates / Proposal =====
proposal("proposal-cinematic", "ProposalCinematicFileExample", "Cinematic Proposal", "Layered ModernProposal layout with cover panel, hero spread, and rich typography.", "proposal", "cinematic");
proposal("project-proposal-cinematic", "CinematicProposalFileExample", "Project Proposal (cinematic)", "End-to-end project proposal with mountain hero, scope panels, and pricing summary.", "proposal", "cinematic");
proposal("proposal-modern-v2", "v2/ModernProposalV2Example", "Modern Proposal", "The ModernProposal preset composed straight from its document spec — cover, scope sections and pricing table themed through BrandTheme.", "proposal");
+ proposal("proposal-editorial-v2", "v2/EditorialProposalV2Example", "Editorial Proposal", "The EditorialProposal preset — the same structured proposal document as Northline, set in a serif display face with an orange accent, a drawn brand mark and headings over accent rules.", "proposal");
+ proposal("proposal-northline-v2", "v2/NorthlineProposalV2Example", "Northline Proposal", "The NorthlineProposal preset on the structured proposal model — brand header, stacked title, glance card, goal cells, numbered scope, phase grid, investment table and signing card across two pages.", "proposal");
+
+ // ===== Templates / Receipt =====
+ receipt("receipt-modern", "ModernReceiptExample", "Modern Receipt", "A settled transfer confirmation on the layered receipt family — hero amount with a status chip, payer/beneficiary panel, dotted-leader detail rows, a status timeline, and a footer pinned to the page bottom with a verification QR code.", "receipt", "qr");
// ===== Templates / Schedule =====
schedule("weekly-schedule", "Weekly Schedule", "Multi-day weekly schedule with shift assignments, category fills, and repeated header.", "schedule", "table");
@@ -194,6 +214,7 @@ static String groupLabel(String category, String group) {
case "templates/invoice" -> "Invoice";
case "templates/proposal" -> "Proposal";
case "templates/schedule" -> "Schedule";
+ case "templates/receipt" -> "Payment Receipt";
case "features/lists" -> "Lists & Bullets";
case "features/tables" -> "Tables";
case "features/canvas" -> "Canvas / Free Placement";
@@ -256,6 +277,11 @@ private static void proposal(String id, String exampleClass, String title, Strin
EX_BASE + "/templates/proposal/" + exampleClass + ".java"));
}
+ private static void receipt(String id, String exampleClass, String title, String desc, String... tags) {
+ ENTRIES.put(id, entry(title, desc, withCategory("receipt", tags),
+ EX_BASE + "/templates/receipt/" + exampleClass + ".java"));
+ }
+
private static void schedule(String id, String title, String desc, String... tags) {
ENTRIES.put(id, entry(title, desc, withCategory("schedule", tags),
EX_BASE + "/templates/schedule/WeeklyScheduleFileExample.java"));
diff --git a/examples/src/main/java/com/demcha/examples/support/SlateOrangeSampleData.java b/examples/src/main/java/com/demcha/examples/support/SlateOrangeSampleData.java
new file mode 100644
index 000000000..00220b388
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/SlateOrangeSampleData.java
@@ -0,0 +1,207 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvName;
+import com.demcha.compose.document.templates.cv.data.CvSkill;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.SkillGroup;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.data.Slot;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Shared sample data for the Slate Orange CV example.
+ *
+ *
The sample is the preset's reference content: a one-page CV with four
+ * contact lines including a link, ten marked competencies, three
+ * achievements, three rated languages, three closing facts, a profile, four
+ * roles, a degree and four certifications.
+ *
+ *
Kept in lockstep with the qa module's {@code SlateOrangeFixtures} —
+ * the two modules cannot share a source file, so a content change here
+ * belongs there too.
+ */
+public final class SlateOrangeSampleData {
+
+ /** The break a body stacks its lines on. */
+ private static final String NEWLINE = String.valueOf((char) 10);
+
+ private SlateOrangeSampleData() {
+ }
+
+ /** The single-page reference CV. */
+ public static CvDocument sample() {
+ List placements = new ArrayList<>();
+ placements.add(new CvDocument.Placement(Slot.MAIN, specialisms()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, competencies()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, achievements()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, languages()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, facts()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, profile()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, experience()));
+ placements.add(new CvDocument.Placement(Slot.FOOTER, education()));
+ placements.add(new CvDocument.Placement(Slot.FOOTER, certifications()));
+ return new CvDocument(identity(), placements);
+ }
+
+ private static CvIdentity identity() {
+ return new CvIdentity(
+ CvName.of("DANIEL", "HARPER"),
+ "VERSATILE PROFESSIONAL",
+ new Contact("(555) 123-4567",
+ "daniel.harper@email.com",
+ "Austin, Texas \u2022 Open to Relocate"),
+ List.of(new Link("LinkedIn", "https://www.linkedin.com/in/danielharper")),
+ Optional.empty());
+ }
+
+ /** The strip under the role. Only the body is drawn; the title names the berth. */
+ private static ParagraphSection specialisms() {
+ return new ParagraphSection("SPECIALISMS", String.join(NEWLINE,
+ "OPERATIONS", "CUSTOMER SUCCESS", "COORDINATION"));
+ }
+
+ /** The competencies, each behind the mark its entry names. */
+ private static EntriesSection competencies() {
+ return new EntriesSection("CORE COMPETENCIES", List.of(
+ CvEntry.builder("Customer Service Excellence").icon("customer-service").build(),
+ CvEntry.builder("Operations & Process Support").icon("operations").build(),
+ CvEntry.builder("Project & Calendar Management").icon("calendar").build(),
+ CvEntry.builder("Data Management & Reporting").icon("reporting").build(),
+ CvEntry.builder("Communication & Stakeholders").icon("communication").build(),
+ CvEntry.builder("Problem Solving & Adaptability").icon("problem-solving").build(),
+ CvEntry.builder("Sales & Relationship Building").icon("sales").build(),
+ CvEntry.builder("Microsoft Office & Google Suite").icon("office-suite").build(),
+ CvEntry.builder("CRM Systems (Salesforce, HubSpot)").icon("crm").build(),
+ CvEntry.builder("Time Management & Prioritization").icon("time-management").build()));
+ }
+
+ /** The achievements: a bold title over its body, beside one shared trophy. */
+ private static EntriesSection achievements() {
+ return new EntriesSection("SELECTED ACHIEVEMENTS", List.of(
+ CvEntry.builder("Process Improvement")
+ .icon("achievement")
+ .body("Designed a new tracking system that reduced reporting time "
+ + "by 30% and improved accuracy.")
+ .build(),
+ CvEntry.builder("Customer Satisfaction")
+ .icon("achievement")
+ .body("Maintained a 96% customer satisfaction rating through "
+ + "responsive support and issue resolution.")
+ .build(),
+ CvEntry.builder("Revenue Contribution")
+ .icon("achievement")
+ .body("Consistently exceeded monthly sales goals by 15\u201320% "
+ + "through relationship building and consultative service.")
+ .build()));
+ }
+
+ /** The languages, each carrying both the rating and the word for it. */
+ private static SkillsSection languages() {
+ return new SkillsSection("LANGUAGES", List.of(new SkillGroup("LANGUAGES", List.of(
+ CvSkill.of("English", 1.0, "Native"),
+ CvSkill.of("Spanish", 0.8, "Professional Working"),
+ CvSkill.of("French", 0.4, "Basic")))));
+ }
+
+ /** The closing facts, on the same mark column at a wider pitch. */
+ private static EntriesSection facts() {
+ return new EntriesSection("ADDITIONAL INFORMATION", List.of(
+ CvEntry.builder("Availability: Full-time").icon("availability").build(),
+ CvEntry.builder("Willing to Relocate: Yes").icon("relocation").build(),
+ CvEntry.builder("Remote Work: Open to Hybrid/Remote").icon("remote-work").build()));
+ }
+
+ private static ParagraphSection profile() {
+ return new ParagraphSection("PROFESSIONAL PROFILE",
+ "Adaptable and resourceful professional with 6+ years of "
+ + "experience across operations, customer service, sales, and "
+ + "project support. Known for strong communication, "
+ + "organizational skills, and the ability to thrive in "
+ + "fast-paced environments. Adept at managing priorities, "
+ + "building relationships, and delivering results that drive "
+ + "efficiency, customer satisfaction, and business growth.");
+ }
+
+ /** The roles held, each a headline over its bullets. */
+ private static EntriesSection experience() {
+ return new EntriesSection("PROFESSIONAL EXPERIENCE", List.of(
+ CvEntry.builder("Operations Coordinator")
+ .subtitle("BrightStart Logistics, Austin, TX")
+ .date("Jan 2022 \u2013 Present")
+ .body(String.join(NEWLINE,
+ "Coordinate daily operations and scheduling for a team of "
+ + "12 across multiple sites.",
+ "Develop and maintain dashboards and reports to track KPIs "
+ + "and operational metrics.",
+ "Streamline communication between departments, reducing "
+ + "response time by 25%.",
+ "Manage vendor relationships and assist with contract and "
+ + "invoice reconciliation."))
+ .build(),
+ CvEntry.builder("Customer Support Specialist")
+ .subtitle("TechWave Solutions, Remote")
+ .date("Jun 2020 \u2013 Dec 2021")
+ .body(String.join(NEWLINE,
+ "Provided technical support to customers via phone, email, "
+ + "and live chat.",
+ "Resolved an average of 40+ inquiries daily with a "
+ + "first-contact resolution rate of 92%.",
+ "Created knowledge base articles and improved internal "
+ + "documentation.",
+ "Collaborated with product and engineering teams to "
+ + "escalate and resolve issues."))
+ .build(),
+ CvEntry.builder("Sales Associate")
+ .subtitle("Urban Outfitters, Austin, TX")
+ .date("Mar 2019 \u2013 May 2020")
+ .body(String.join(NEWLINE,
+ "Consistently exceeded sales targets by 15\u201320% through "
+ + "product knowledge and personalized customer service.",
+ "Built and maintained strong customer relationships, "
+ + "driving repeat business.",
+ "Merchandised floor displays and contributed to visual "
+ + "standards and promotions.",
+ "Recognized as \u201cTop Performer\u201d for Q4 2019."))
+ .build(),
+ CvEntry.builder("Administrative Assistant / Project Assistant")
+ .subtitle("Greenfield Nonprofit Initiative, Austin, TX")
+ .date("May 2018 \u2013 Feb 2019")
+ .body(String.join(NEWLINE,
+ "Supported project managers with scheduling, documentation, "
+ + "and meeting logistics.",
+ "Managed calendars, travel arrangements, and expense "
+ + "reports.",
+ "Maintained donor records and prepared reports for "
+ + "leadership and stakeholders.",
+ "Assisted with event planning and community outreach "
+ + "initiatives."))
+ .build()));
+ }
+
+ /** The degree; the design's third line is where and when, as one line. */
+ private static EntriesSection education() {
+ return new EntriesSection("EDUCATION", List.of(
+ CvEntry.builder("Bachelor of Arts in Communications")
+ .subtitle("University of Texas at Austin")
+ .place("Austin, TX \u2022 Graduated May 2018")
+ .build()));
+ }
+
+ /** The certifications, one bulleted line each. */
+ private static EntriesSection certifications() {
+ return new EntriesSection("CERTIFICATIONS & TRAINING", List.of(
+ CvEntry.builder("Google Project Management Certificate (Coursera) \u2013 2023").build(),
+ CvEntry.builder("HubSpot Customer Service Certification \u2013 2022").build(),
+ CvEntry.builder("Microsoft Excel: Advanced Formulas & Functions \u2013 2021").build(),
+ CvEntry.builder("ServSafe Food Handler Certification \u2013 2019").build()));
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/StructuredProposalSampleData.java b/examples/src/main/java/com/demcha/examples/support/StructuredProposalSampleData.java
new file mode 100644
index 000000000..0c315f917
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/StructuredProposalSampleData.java
@@ -0,0 +1,147 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.data.proposal.ProposalAcceptance;
+import com.demcha.compose.document.templates.data.proposal.ProposalBrand;
+import com.demcha.compose.document.templates.data.proposal.ProposalDeliverables;
+import com.demcha.compose.document.templates.data.proposal.ProposalGlance;
+import com.demcha.compose.document.templates.data.proposal.ProposalGoals;
+import com.demcha.compose.document.templates.data.proposal.ProposalInvestment;
+import com.demcha.compose.document.templates.data.proposal.ProposalMetaLine;
+import com.demcha.compose.document.templates.data.proposal.ProposalPhaseGrid;
+import com.demcha.compose.document.templates.data.proposal.ProposalScope;
+import com.demcha.compose.document.templates.data.proposal.ProposalSummaryBlock;
+import com.demcha.compose.document.templates.data.proposal.ProposalTermsBlock;
+import com.demcha.compose.document.templates.data.proposal.ProposalTitleLines;
+import com.demcha.compose.document.templates.data.proposal.StructuredProposalData;
+import com.demcha.compose.document.templates.data.proposal.StructuredProposalDocumentSpec;
+
+import java.util.List;
+
+/**
+ * Shared sample data for the structured proposal examples.
+ *
+ *
Kept in lockstep with the qa module's {@code NorthlineProposalFixtures}
+ * — the two modules cannot share a source file, so a content change here
+ * belongs there too.
+ *
+ *
The sample is the preset's reference content: a two-page
+ * brand-refresh proposal exercising every band — all four glance facts
+ * (one with the optional note), four goal cells, five scope rows, both
+ * deliverable columns, the four-phase grid, an investment table with
+ * ordinary, subtotal and optional rows plus the total band, four terms,
+ * and the three-field signing card.
+ */
+public final class StructuredProposalSampleData {
+
+ private StructuredProposalSampleData() {
+ }
+
+ public static StructuredProposalDocumentSpec sample() {
+ return StructuredProposalDocumentSpec.from(StructuredProposalData.builder()
+ .brand(new ProposalBrand("N", "NORTHLINE", "STUDIO", "PROPOSAL",
+ "northlinestudio.com", "NORTHLINE STUDIO"))
+ .title(new ProposalTitleLines("Proposal –", "Brand Refresh &",
+ "Website Redesign"))
+ .meta(new ProposalMetaLine("PREPARED FOR ASTERA HEALTH CO.",
+ "PREPARED BY NORTHLINE STUDIO", "25 AUGUST 2026"))
+ .executiveSummary(new ProposalSummaryBlock("EXECUTIVE SUMMARY",
+ "badge-summary", List.of(
+ "Thank you for the opportunity to partner with Astera Health Co. "
+ + "on your next chapter. This proposal outlines our approach to "
+ + "refreshing your brand identity and redesigning your website to "
+ + "better reflect your mission, elevate credibility, and deliver a "
+ + "seamless experience for the people you serve.",
+ "Our process blends strategic thinking with thoughtful design to "
+ + "create a cohesive brand system and a modern, conversion-focused "
+ + "website. The outcome will be a clear, confident presence that "
+ + "strengthens trust, improves engagement, and supports your "
+ + "growth goals.")))
+ .glance(new ProposalGlance("PROJECT AT A GLANCE", List.of(
+ new ProposalGlance.Fact("fact-duration", "PROJECT DURATION",
+ "6 weeks", null),
+ new ProposalGlance.Fact("fact-start", "START WINDOW",
+ "September 2026", null),
+ new ProposalGlance.Fact("fact-contact", "PRIMARY CONTACT",
+ "Project Lead", null),
+ new ProposalGlance.Fact("fact-validity", "PROPOSAL VALIDITY",
+ "14 days", "(until 8 September 2026)"))))
+ .goals(new ProposalGoals("PROJECT GOALS", "badge-goals", List.of(
+ new ProposalGoals.Goal("goal-brand",
+ "Strengthen brand credibility and visual consistency."),
+ new ProposalGoals.Goal("goal-usability",
+ "Improve website usability and drive meaningful action."),
+ new ProposalGoals.Goal("goal-message",
+ "Communicate your value clearly across all touchpoints."),
+ new ProposalGoals.Goal("goal-growth",
+ "Build a scalable digital foundation for future growth."))))
+ .scope(new ProposalScope("SCOPE OF WORK", "badge-scope", List.of(
+ new ProposalScope.Item("01", "Discovery & Research",
+ "Stakeholder interviews, brand audit, competitor review, and "
+ + "audience insights to inform strategy."),
+ new ProposalScope.Item("02", "Visual Identity Refinement",
+ "Refine logo system, color palette, typography, and visual "
+ + "guidelines for a cohesive, modern identity."),
+ new ProposalScope.Item("03", "Website UX/UI Design",
+ "Design a responsive, accessibility-minded website with "
+ + "intuitive navigation and clear conversion pathways."),
+ new ProposalScope.Item("04", "Content Structure & Messaging",
+ "Define site architecture, key messaging, and content "
+ + "hierarchy to communicate value and build trust."),
+ new ProposalScope.Item("05", "Launch Support",
+ "Developer handoff, QA support, training, and go-live "
+ + "assistance to ensure a smooth launch."))))
+ .deliverables(new ProposalDeliverables("DELIVERABLES", "badge-deliverables",
+ List.of("Brand strategy summary",
+ "Refined logo suite & brand guidelines",
+ "Color, typography & design system",
+ "Website UX wireframes",
+ "High-fidelity website designs"),
+ List.of("Responsive design for desktop, tablet & mobile",
+ "Content strategy & page templates",
+ "Developer-ready assets & specifications",
+ "Launch checklist & training session")))
+ .timeline(new ProposalPhaseGrid("TIMELINE", "badge-timeline",
+ List.of("PHASE", "FOCUS", "DURATION", "OUTPUT"), List.of(
+ new ProposalPhaseGrid.Phase("01", "Discover",
+ "Research, audit, insights, and strategy", "1 week",
+ "Discovery report & creative direction"),
+ new ProposalPhaseGrid.Phase("02", "Design",
+ "Brand refinement and website UX/UI design", "2 weeks",
+ "Design system & high-fidelity designs"),
+ new ProposalPhaseGrid.Phase("03", "Develop Prep",
+ "Content structure, developer handoff & asset preparation",
+ "1 week", "Specs, assets & content framework"),
+ new ProposalPhaseGrid.Phase("04", "Launch Support",
+ "QA, training & go-live assistance", "2 weeks",
+ "QA sign-off & successful launch"))))
+ .investment(new ProposalInvestment("INVESTMENT", "badge-investment",
+ "ITEM", "AMOUNT (GBP)", List.of(
+ new ProposalInvestment.Row("Discovery & Strategy", "£2,500",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Brand Identity Refinement", "£4,500",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Website UX/UI Design", "£7,000",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Content Structure & Messaging", "£2,000",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Launch Support", "£1,500",
+ ProposalInvestment.Role.NONE),
+ new ProposalInvestment.Row("Subtotal", "£17,500",
+ ProposalInvestment.Role.SUBTOTAL),
+ new ProposalInvestment.Row("Optional: 3 Months Post-Launch Support",
+ "£1,750", ProposalInvestment.Role.OPTIONAL)),
+ "TOTAL INVESTMENT", "£19,250"))
+ .terms(new ProposalTermsBlock("TERMS", "badge-terms", List.of(
+ "50% deposit is required to secure the project and schedule the "
+ + "kick-off.",
+ "Includes two rounds of revisions per major deliverable.",
+ "Final files and handoff delivered upon final payment.",
+ "Ownership of all final deliverables transfers to Astera Health Co. "
+ + "after full payment is received.")))
+ .acceptance(new ProposalAcceptance("ACCEPTANCE", "badge-acceptance",
+ "By signing below, you agree to the scope, timeline, investment, "
+ + "and terms outlined in this proposal.",
+ List.of("Accepted by:", "Signature:", "Date:")))
+ .build());
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/TealPulseSampleData.java b/examples/src/main/java/com/demcha/examples/support/TealPulseSampleData.java
new file mode 100644
index 000000000..1022015bd
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/TealPulseSampleData.java
@@ -0,0 +1,167 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvName;
+import com.demcha.compose.document.templates.cv.data.CvSkill;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.SkillGroup;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.data.Slot;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.OptionalDouble;
+
+/**
+ * Shared sample data for the Teal Pulse CV example.
+ *
+ *
The sample is the preset's reference content: a nurse's one-page CV with
+ * four contact channels including a link, twelve competencies, a summary,
+ * three roles with their highlights, a degree, four certifications, three
+ * facts and the closing tagline.
+ *
+ *
Kept in lockstep with the qa module's {@code TealPulseFixtures} — the
+ * two modules cannot share a source file, so a content change here belongs
+ * there too.
+ */
+public final class TealPulseSampleData {
+
+ /** The break an entry stacks its highlights on. */
+ private static final String NEWLINE = String.valueOf((char) 10);
+
+ /** The dash this sheet writes a date range with. */
+ private static final String EN_DASH = String.valueOf((char) 0x2013);
+
+ private TealPulseSampleData() {
+ }
+
+ /** The single-page reference CV. */
+ public static CvDocument sample() {
+ List placements = new ArrayList<>();
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, competencies()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, summary()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, experience()));
+ placements.add(new CvDocument.Placement(Slot.FOOTER, education()));
+ placements.add(new CvDocument.Placement(Slot.FOOTER, certifications()));
+ placements.add(new CvDocument.Placement(Slot.FOOTER, facts()));
+ placements.add(new CvDocument.Placement(Slot.FOOTER, tagline()));
+ return new CvDocument(identity(), placements);
+ }
+
+ private static CvIdentity identity() {
+ return new CvIdentity(
+ CvName.of("ISABELLA", "MOORE"),
+ "REGISTERED NURSE",
+ new Contact("+44 7700 900123", "isabella.moore@email.com",
+ "Manchester, United Kingdom"),
+ List.of(new Link("LinkedIn", "https://www.linkedin.com/in/isabellamoore-rn")),
+ Optional.empty());
+ }
+
+ private static SkillsSection competencies() {
+ List entries = new ArrayList<>();
+ for (String name : new String[] {
+ "Patient Assessment", "Care Planning", "Medication Administration",
+ "Clinical Documentation", "Infection Prevention", "IV Therapy",
+ "Wound Care", "Patient Education", "Safeguarding", "Team Collaboration",
+ "Discharge Coordination", "Electronic Health Records"}) {
+ entries.add(new CvSkill(name, OptionalDouble.empty()));
+ }
+ return new SkillsSection("CORE COMPETENCIES",
+ List.of(new SkillGroup("CORE COMPETENCIES", entries)));
+ }
+
+ private static ParagraphSection summary() {
+ return new ParagraphSection("PROFESSIONAL SUMMARY",
+ "Compassionate Registered Nurse with 7+ years of experience delivering "
+ + "high-quality patient care across acute medical, surgical, and "
+ + "community settings. Skilled in patient assessment, medication "
+ + "administration, multidisciplinary collaboration, discharge "
+ + "planning, and maintaining accurate clinical documentation. Known "
+ + "for calm decision-making, strong communication, and patient-centred "
+ + "care.");
+ }
+
+ private static EntriesSection experience() {
+ return new EntriesSection("PROFESSIONAL EXPERIENCE", List.of(
+ CvEntry.builder("Senior Staff Nurse")
+ .subtitle("Manchester Royal Infirmary, Manchester, UK")
+ .date("2021" + EN_DASH + "Present")
+ .body(String.join(NEWLINE,
+ "Deliver direct nursing care for adult patients on a busy "
+ + "acute medical ward.",
+ "Coordinate care plans with doctors, therapists, and support "
+ + "staff to improve outcomes.",
+ "Supervise junior nurses and support student placements during "
+ + "clinical rotations.",
+ "Improved discharge coordination and patient communication "
+ + "across the ward.",
+ "Maintain precise documentation and ensure compliance with "
+ + "clinical standards."))
+ .build(),
+ CvEntry.builder("Staff Nurse")
+ .subtitle("Salford General Hospital, Salford, UK")
+ .date("2018" + EN_DASH + "2021")
+ .body(String.join(NEWLINE,
+ "Provided nursing care across medical and surgical units.",
+ "Administered medications, monitored vital signs, and escalated "
+ + "deteriorating patients promptly.",
+ "Supported infection control procedures and safe discharge "
+ + "planning.",
+ "Built strong rapport with patients and families during "
+ + "treatment and recovery."))
+ .build(),
+ CvEntry.builder("Community Nurse")
+ .subtitle("NorthCare Community Health, Greater Manchester, UK")
+ .date("2016" + EN_DASH + "2018")
+ .body(String.join(NEWLINE,
+ "Delivered home-based nursing care and patient education.",
+ "Managed wound care, medication support, and follow-up visits "
+ + "for vulnerable patients.",
+ "Worked closely with GPs, social services, and families to "
+ + "coordinate holistic care."))
+ .build()));
+ }
+
+ private static EntriesSection education() {
+ return new EntriesSection("EDUCATION", List.of(
+ CvEntry.builder("BSc (Hons) Adult Nursing")
+ .subtitle("University of Manchester")
+ .date("2013" + EN_DASH + "2016")
+ .build()));
+ }
+
+ private static EntriesSection certifications() {
+ List entries = new ArrayList<>();
+ for (String name : new String[] {
+ "NMC Registered Nurse", "Immediate Life Support (ILS)",
+ "Venepuncture and Cannulation Certification", "Safeguarding Adults Level 3"}) {
+ entries.add(CvEntry.builder(name).build());
+ }
+ return new EntriesSection("CERTIFICATIONS", entries);
+ }
+
+ private static EntriesSection facts() {
+ return new EntriesSection("ADDITIONAL INFORMATION", List.of(
+ CvEntry.builder("Languages:")
+ .body("English (Native), Polish (Conversational)")
+ .build(),
+ CvEntry.builder("Right to Work:").body("United Kingdom").build(),
+ CvEntry.builder("Availability:").body("1 month notice").build()));
+ }
+
+ /**
+ * The closing line. Its title names the berth and is not drawn — only the
+ * body reaches the sheet.
+ */
+ private static ParagraphSection tagline() {
+ return new ParagraphSection("TAGLINE",
+ "COMPASSIONATE CARE. CLINICAL EXCELLENCE. BETTER OUTCOMES.");
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/TerracottaRailSampleData.java b/examples/src/main/java/com/demcha/examples/support/TerracottaRailSampleData.java
new file mode 100644
index 000000000..b4f2d7885
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/TerracottaRailSampleData.java
@@ -0,0 +1,207 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvName;
+import com.demcha.compose.document.templates.cv.data.CvSkill;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.SkillGroup;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.data.Slot;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.OptionalDouble;
+
+/**
+ * Shared sample data for the Terracotta Rail CV example.
+ *
+ *
The sample is the preset's reference content: an architect's one-page
+ * CV with a monogram, four contact rows including a link, two bulleted
+ * lists, two credentials, three facts behind their marks, a three-paragraph
+ * summary, three roles on the rail, three marked projects and two
+ * degrees.
+ *
+ *
Kept in lockstep with the qa module's {@code TerracottaRailFixtures} —
+ * the two modules cannot share a source file, so a content change here
+ * belongs there too.
+ */
+public final class TerracottaRailSampleData {
+
+ /** The break a fact stacks its values on. */
+ private static final String NEWLINE = String.valueOf((char) 10);
+
+ /** The dash this sheet writes a date range with. */
+ private static final String EN_DASH = String.valueOf((char) 0x2013);
+
+ private TerracottaRailSampleData() {
+ }
+
+ /** The single-page reference CV. */
+ public static CvDocument sample() {
+ List placements = new ArrayList<>();
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, competencies()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, software()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, certifications()));
+ placements.add(new CvDocument.Placement(Slot.SIDEBAR, facts()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, summary()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, experience()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, projects()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, education()));
+ return new CvDocument(identity(), placements);
+ }
+
+ private static CvIdentity identity() {
+ return new CvIdentity(
+ CvName.of("OLIVER", "BENNETT"),
+ "SENIOR ARCHITECT",
+ new Contact("+44 7700 900123", "oliver.bennett@email.com",
+ "Bristol, United Kingdom"),
+ List.of(new Link("LinkedIn",
+ "https://linkedin.com/in/oliverbennett-architect")),
+ Optional.empty());
+ }
+
+ private static SkillsSection competencies() {
+ return plainSkills("CORE COMPETENCIES",
+ "Architectural Design", "Concept Development", "Planning Applications",
+ "Technical Drawings", "Revit", "AutoCAD", "BIM Coordination",
+ "Design Presentations", "Project Delivery", "Stakeholder Management",
+ "Sustainable Design", "Building Regulations");
+ }
+
+ private static SkillsSection software() {
+ return plainSkills("SOFTWARE",
+ "Revit", "AutoCAD", "Rhino", "SketchUp", "Adobe InDesign", "Photoshop",
+ "Microsoft Office");
+ }
+
+ private static EntriesSection certifications() {
+ return new EntriesSection("CERTIFICATIONS", List.of(
+ CvEntry.builder("ARB Registered Architect").build(),
+ CvEntry.builder("RIBA Chartered Member").build()));
+ }
+
+ private static EntriesSection facts() {
+ return new EntriesSection("ADDITIONAL INFORMATION", List.of(
+ CvEntry.builder("Languages:")
+ .icon("globe")
+ .body("English (Native)," + NEWLINE + "Spanish (Conversational)")
+ .build(),
+ CvEntry.builder("Right to Work:")
+ .icon("badge")
+ .body("United Kingdom")
+ .build(),
+ CvEntry.builder("Availability:")
+ .icon("clock")
+ .body("1 month notice")
+ .build()));
+ }
+
+ private static ParagraphSection summary() {
+ return new ParagraphSection("PROFESSIONAL SUMMARY", String.join(NEWLINE,
+ "Senior Architect with over 8 years of experience delivering residential, "
+ + "mixed-use, and commercial projects across all RIBA work stages.",
+ "Proven expertise in design development, planning applications, and technical "
+ + "delivery with a strong focus on sustainability, quality, and user "
+ + "experience.",
+ "Collaborative communicator skilled in coordinating consultants, engaging "
+ + "stakeholders, and leading multidisciplinary teams to achieve "
+ + "successful outcomes."));
+ }
+
+ private static EntriesSection experience() {
+ return new EntriesSection("PROFESSIONAL EXPERIENCE", List.of(
+ CvEntry.builder("Senior Architect")
+ .subtitle("Northline Studio, Bristol, UK")
+ .date("2021" + EN_DASH + "Present")
+ .body(String.join(NEWLINE,
+ "Lead design packages from concept through to detailed design "
+ + "across mid-scale residential and mixed-use projects.",
+ "Coordinate structural, M&E, landscape and planning consultants "
+ + "to ensure integrated and buildable solutions.",
+ "Prepare and manage planning applications, design and access "
+ + "statements, and supporting documentation.",
+ "Present and communicate design proposals to clients and "
+ + "stakeholders, securing approvals and driving projects "
+ + "forward."))
+ .build(),
+ CvEntry.builder("Architect")
+ .subtitle("Urban Form Partners, Bath, UK")
+ .date("2018" + EN_DASH + "2021")
+ .body(String.join(NEWLINE,
+ "Developed concept and technical designs for a range of "
+ + "residential, commercial, and education projects.",
+ "Prepared coordinated drawing sets, schedules, and "
+ + "specifications to support planning and construction.",
+ "Supported project delivery on site, resolving design queries "
+ + "and ensuring quality outcomes.",
+ "Coordinated with contractors and consultants to maintain "
+ + "programme, budget and design intent."))
+ .build(),
+ CvEntry.builder("Part II Architectural Assistant")
+ .subtitle("Axis Design Workshop, Cardiff, UK")
+ .date("2016" + EN_DASH + "2018")
+ .body(String.join(NEWLINE,
+ "Supported senior architects across all RIBA stages on a "
+ + "variety of project types.",
+ "Produced CAD and Revit drawings, models, and visualisations to "
+ + "communicate design intent.",
+ "Undertook research, prepared presentation boards, and assisted "
+ + "with planning documentation."))
+ .build()));
+ }
+
+ private static EntriesSection projects() {
+ return new EntriesSection("SELECTED PROJECTS", List.of(
+ CvEntry.builder("Harbour Point")
+ .subtitle("Mixed-Use Regeneration")
+ .place("Bristol")
+ .icon("building")
+ .body("Mixed-use development delivering 142 homes, retail space and "
+ + "public realm improvements; secured planning consent and is "
+ + "now on site.")
+ .build(),
+ CvEntry.builder("The Assembly Hotel")
+ .subtitle("Boutique Hotel Refurbishment")
+ .place("Bath")
+ .icon("hotel")
+ .body("Sensitive refurbishment of listed building to create a "
+ + "36-bedroom boutique hotel; enhanced heritage features and "
+ + "guest experience.")
+ .build(),
+ CvEntry.builder("Elmwood Mews")
+ .subtitle("Residential Development")
+ .place("Clifton, Bristol")
+ .icon("house")
+ .body("Design of 28 high-quality homes within a sustainable masterplan; "
+ + "achieved high environmental standards and strong sales "
+ + "performance.")
+ .build()));
+ }
+
+ private static EntriesSection education() {
+ return new EntriesSection("EDUCATION", List.of(
+ CvEntry.builder("MArch Architecture")
+ .subtitle("University of Sheffield")
+ .date("2014" + EN_DASH + "2016")
+ .build(),
+ CvEntry.builder("BA (Hons) Architecture")
+ .subtitle("University of the West of England")
+ .date("2011" + EN_DASH + "2014")
+ .build()));
+ }
+
+ private static SkillsSection plainSkills(String title, String... names) {
+ List entries = new ArrayList<>();
+ for (String name : names) {
+ entries.add(new CvSkill(name, OptionalDouble.empty()));
+ }
+ return new SkillsSection(title, List.of(new SkillGroup(title, entries)));
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/VioletGridSampleData.java b/examples/src/main/java/com/demcha/examples/support/VioletGridSampleData.java
new file mode 100644
index 000000000..3266599a1
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/VioletGridSampleData.java
@@ -0,0 +1,224 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.core.identity.Contact;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvName;
+import com.demcha.compose.document.templates.cv.data.CvSkill;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.SkillGroup;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import com.demcha.compose.document.templates.cv.data.Slot;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Shared sample data for the Violet Grid CV example.
+ *
+ *
The sample is the preset's reference content: a one-page CV with five
+ * contact lines including two links, three opening lines, six marked skills,
+ * eleven tools, three roles, two projects, a degree, three rated languages
+ * and the closing quotation.
+ *
+ *
Kept in lockstep with the qa module's {@code VioletGridFixtures} — the
+ * two modules cannot share a source file, so a content change here belongs
+ * there too.
+ */
+public final class VioletGridSampleData {
+
+ /** The break a body stacks its lines on. */
+ private static final String NEWLINE = String.valueOf((char) 10);
+
+ private VioletGridSampleData() {
+ }
+
+ /** The single-page reference CV. */
+ public static CvDocument sample() {
+ List placements = new ArrayList<>();
+ placements.add(new CvDocument.Placement(Slot.MAIN, summary()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, skills()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, tools()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, experience()));
+ placements.add(new CvDocument.Placement(Slot.MAIN, projects()));
+ placements.add(new CvDocument.Placement(Slot.FOOTER, education()));
+ placements.add(new CvDocument.Placement(Slot.FOOTER, languages()));
+ placements.add(new CvDocument.Placement(Slot.FOOTER, quote()));
+ return new CvDocument(identity(), placements);
+ }
+
+ private static CvIdentity identity() {
+ return new CvIdentity(
+ CvName.of("SOFIA", "MARTINEZ"),
+ "UX / UI DESIGNER",
+ new Contact("+1 (415) 555-7842",
+ "sofia.martinez.design@gmail.com",
+ "San Francisco, CA, USA"),
+ List.of(
+ new Link("sofiamartinez.design", "https://sofiamartinez.design"),
+ new Link("LinkedIn", "https://www.linkedin.com/in/sofia-martinez-ux")),
+ Optional.empty());
+ }
+
+ /** The opening lines, set as written rather than wrapped. */
+ private static ParagraphSection summary() {
+ return new ParagraphSection("SUMMARY", String.join(NEWLINE,
+ "User-centered designer with 5+ years of experience "
+ + "crafting intuitive digital experiences for web and "
+ + "mobile products.",
+ "I combine research, design thinking, and visual design "
+ + "to solve complex problems and deliver accessible, "
+ + "elegant solutions.",
+ "Passionate about collaborating with cross-functional "
+ + "teams to create products that delight users and drive "
+ + "business impact."));
+ }
+
+ /** The six-up grid: a mark, a label and a description each. */
+ private static EntriesSection skills() {
+ return new EntriesSection("DESIGN SKILLS", List.of(
+ CvEntry.builder("UX RESEARCH")
+ .icon("ux-research")
+ .body("User interviews, surveys, personas, journey maps, "
+ + "competitive analysis")
+ .build(),
+ CvEntry.builder("INFORMATION ARCHITECTURE")
+ .icon("information-architecture")
+ .body("Sitemaps, user flows, card sorting, content strategy")
+ .build(),
+ CvEntry.builder("WIREFRAMING")
+ .icon("wireframing")
+ .body("Low to high-fidelity wireframes, layout design, "
+ + "interaction flows")
+ .build(),
+ CvEntry.builder("PROTOTYPING")
+ .icon("prototyping")
+ .body("Interactive prototypes, microinteractions, transitions")
+ .build(),
+ CvEntry.builder("USABILITY TESTING")
+ .icon("usability-testing")
+ .body("Test planning, usability tests, heuristic evaluation, "
+ + "insights & iteration")
+ .build(),
+ CvEntry.builder("DESIGN SYSTEMS")
+ .icon("design-systems")
+ .body("Component libraries, design tokens, pattern "
+ + "documentation")
+ .build()));
+ }
+
+ /** The tools strip — names without levels. */
+ private static SkillsSection tools() {
+ List entries = new ArrayList<>();
+ for (String name : new String[] {
+ "Figma", "FigJam", "Adobe XD", "Sketch", "Miro", "Notion", "Jira", "Slack", "Maze", "Hotjar", "Airtable"}) {
+ entries.add(CvSkill.of(name));
+ }
+ return new SkillsSection("TOOLS",
+ List.of(new SkillGroup("TOOLS", entries)));
+ }
+
+ /** The roles held, each a headline over its bullets. */
+ private static EntriesSection experience() {
+ return new EntriesSection("EXPERIENCE", List.of(
+ CvEntry.builder("Senior UX/UI Designer")
+ .subtitle("NovaFin (FinTech Startup)")
+ .place("San Francisco, CA")
+ .date("2022 - Present")
+ .body(String.join(NEWLINE,
+ "Led end-to-end design for a personal finance platform, "
+ + "from discovery to launch, improving task success rate by "
+ + "32%.",
+ "Conducted user research, built personas and journey "
+ + "maps, and translated insights into clear product "
+ + "opportunities.",
+ "Designed wireframes, prototypes, and high-fidelity "
+ + "interfaces for web and mobile using Figma.",
+ "Established and maintained a scalable design system, "
+ + "increasing design consistency and development speed.",
+ "Collaborated with product managers, engineers, and data "
+ + "analysts in an agile environment."))
+ .build(),
+ CvEntry.builder("UX Designer")
+ .subtitle("BrightHealth (HealthTech)")
+ .place("San Francisco, CA")
+ .date("2020 - 2022")
+ .body(String.join(NEWLINE,
+ "Redesigned patient portal experience, reducing support "
+ + "tickets by 28% and improving user satisfaction.",
+ "Planned and facilitated usability tests and synthesized "
+ + "findings to drive iterative improvements.",
+ "Partnered with engineers to ensure feasible solutions "
+ + "and pixel-perfect implementation.",
+ "Created reusable components and style guides to support "
+ + "a cohesive cross-platform experience."))
+ .build(),
+ CvEntry.builder("Junior UX/UI Designer")
+ .subtitle("Lumen Digital Agency")
+ .place("Austin, TX")
+ .date("2018 - 2020")
+ .body(String.join(NEWLINE,
+ "Supported discovery workshops, user flows, and "
+ + "wireframes for clients across e-commerce and SaaS.",
+ "Designed responsive websites and landing pages following "
+ + "best practices in accessibility and usability.",
+ "Collaborated with copywriters and developers to deliver "
+ + "high-quality, user-centered solutions."))
+ .build()));
+ }
+
+ /** The projects, each behind its tinted tile. */
+ private static EntriesSection projects() {
+ return new EntriesSection("SELECTED PROJECTS", List.of(
+ CvEntry.builder("NovaFin Mobile App")
+ .subtitle("Personal Finance Management")
+ .date("2023")
+ .icon("project-wallet")
+ .body("Led UX/UI design for budgeting, goal tracking, and "
+ + "insights features. Conducted user interviews and "
+ + "usability tests that informed a simplified navigation "
+ + "and data visualization. Resulted in 32% improvement in "
+ + "task success rate and 22% increase in weekly active "
+ + "users.")
+ .build(),
+ CvEntry.builder("BrightHealth Patient Portal")
+ .subtitle("Healthcare Platform Redesign")
+ .date("2021")
+ .icon("project-health")
+ .body("Redesigned key patient flows including appointment "
+ + "scheduling, test results, and messaging. Created a new "
+ + "design system and component library used across web and "
+ + "mobile. Reduced support tickets by 28%.")
+ .build()));
+ }
+
+ /** The degree, with the place and the years on its third line. */
+ private static EntriesSection education() {
+ return new EntriesSection("EDUCATION", List.of(
+ CvEntry.builder("Bachelor of Fine Arts in Graphic Design")
+ .subtitle("California College of the Arts")
+ .place("San Francisco, CA")
+ .date("2014 - 2018")
+ .icon("graduation")
+ .build()));
+ }
+
+ /** The languages, each carrying both the rating and the word for it. */
+ private static SkillsSection languages() {
+ return new SkillsSection("LANGUAGES", List.of(new SkillGroup("LANGUAGES", List.of(
+ CvSkill.of("English", 1.0, "Native"),
+ CvSkill.of("Spanish", 1.0, "Native"),
+ CvSkill.of("Portuguese", 0.6, "Conversational")))));
+ }
+
+ /** The closing line. Its title names the berth and is not drawn. */
+ private static ParagraphSection quote() {
+ return new ParagraphSection("QUOTE",
+ "I design with empathy, iterate with purpose, and "
+ + "collaborate to build products people love.");
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/WeeklyScheduleRenderer.java b/examples/src/main/java/com/demcha/examples/support/WeeklyScheduleRenderer.java
index 236c4a298..bd9910d3e 100644
--- a/examples/src/main/java/com/demcha/examples/support/WeeklyScheduleRenderer.java
+++ b/examples/src/main/java/com/demcha/examples/support/WeeklyScheduleRenderer.java
@@ -5,6 +5,7 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.output.DocumentPageZone;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentStroke;
@@ -336,13 +337,33 @@ public static Layout landscape() {
double dayColumn = 105.5;
return new Layout(
DocumentPageSize.of(PAGE_WIDTH, 472),
- new DocumentInsets(14, SIDE_MARGIN, 14, SIDE_MARGIN),
+ new DocumentInsets(BOTTOM_MARGIN, SIDE_MARGIN, BOTTOM_MARGIN, SIDE_MARGIN),
Math.floor((printable - DAYS_IN_WEEK * dayColumn) * 100) / 100,
dayColumn);
}
public double subColWidth() { return dayColWidth / 4.0; }
public int totalColumns() { return 1 + DAYS_IN_WEEK * 4; }
+
+ /** What the footer has to lay itself out inside. */
+ public double printableWidth() {
+ return pageSize.width() - margin.left() - margin.right();
+ }
+
+ /**
+ * Half the footer rule: the printable width less the seal and the gap
+ * on either side of it.
+ *
+ *
Equal weights already put the seal's slot on the centre
+ * line — that part needs no arithmetic. What this buys is that the
+ * rules fill the slots they are given: a shape narrower than its slot
+ * is drawn at the slot's left edge, so a fixed width leaves the drawn
+ * rule short on one side of the seal and short of the margin on the
+ * other, and the foot reads as lopsided however centred the seal is.
+ */
+ public double footerRuleWidth() {
+ return (printableWidth() - SEAL_WIDTH - 2 * SEAL_GAP) / 2;
+ }
}
// ───────────────────────── Constants ─────────────────────────────
@@ -354,6 +375,68 @@ public static Layout landscape() {
/** Left and right margin; the columns divide what is left. */
private static final double SIDE_MARGIN = 8;
+
+ /** Top and bottom margin; the footer zone has to clear the bottom one. */
+ private static final double BOTTOM_MARGIN = 14;
+
+ /** The seal that breaks the footer rule, and the air on either side of it. */
+ private static final double SEAL_WIDTH = 44;
+ private static final double SEAL_GAP = 8;
+
+ /** Times-Bold, as the seal is set: the metrics the seal's geometry reads. */
+ private static final double TIMES_BOLD_ASCENT = 0.683;
+ private static final double TIMES_BOLD_DESCENT = 0.217;
+ private static final double TIMES_BOLD_CAP_HEIGHT = 0.676;
+
+ /** The seal's letter, and the point of air the pill keeps above and below it. */
+ private static final double SEAL_LETTER_SIZE = 13;
+ private static final double SEAL_PADDING_Y = 1;
+
+ /**
+ * How far the seal's letter drops to sit level in its pill.
+ *
+ *
A line box is tall enough for a descender and a capital has none, so
+ * centring the box centres the unused space along with the letter and the
+ * letter rides high. Dropping the box by half that unused space — the cap
+ * height against what the ascent and descent leave — puts the ink on the
+ * pill's centre line. It scales with the letter, but not with the font:
+ * set the seal in something other than Times-Bold and these three metrics
+ * have to change with it.
+ */
+ private static final double SEAL_LETTER_DROP = SEAL_LETTER_SIZE
+ * (TIMES_BOLD_CAP_HEIGHT - (TIMES_BOLD_ASCENT - TIMES_BOLD_DESCENT)) / 2;
+
+ /** The pill: the letter's line box, plus its point of air top and bottom. */
+ private static final double SEAL_HEIGHT =
+ SEAL_LETTER_SIZE * (TIMES_BOLD_ASCENT + TIMES_BOLD_DESCENT) + 2 * SEAL_PADDING_Y;
+
+ /** The hairline the seal breaks, and the drop that lands it on the seal's centre line. */
+ private static final double FOOTER_RULE_THICKNESS = 0.6;
+ private static final double FOOTER_RULE_DROP = (SEAL_HEIGHT - FOOTER_RULE_THICKNESS) / 2;
+
+ /**
+ * The build line under the rule, set in Courier, and the air between the
+ * two. The depth reserved for it is the font's box rather than its metric
+ * descent, because it is ink, not the line box, that has to stay out of
+ * the margin, and a descender reaches past the line it is measured by.
+ */
+ private static final double COURIER_ASCENT = 0.629;
+ private static final double COURIER_BOX_DESCENT = 0.250;
+ private static final double BUILD_LINE_SIZE = 7.5;
+ private static final double FOOTER_GAP_Y = 6;
+
+ /**
+ * Height reserved at the foot of every page.
+ *
+ *
A footer zone is measured from the foot of the sheet, not
+ * from the bottom margin, and its content is laid out from the top of the
+ * band down. So the band has to be the furniture plus the margin it must
+ * not print into: at 34 — the furniture alone — the build line came within
+ * three millimetres of the paper edge, inside the dead zone of most office
+ * printers, and this board is printed.
+ */
+ private static final double FOOTER_ZONE_HEIGHT = SEAL_HEIGHT + FOOTER_GAP_Y
+ + BUILD_LINE_SIZE * (COURIER_ASCENT + COURIER_BOX_DESCENT) + BOTTOM_MARGIN;
private static final DayShift[] EMPTY_WEEK = {
DayShift.NONE, DayShift.NONE, DayShift.NONE, DayShift.NONE,
DayShift.NONE, DayShift.NONE, DayShift.NONE
@@ -404,6 +487,11 @@ public static void renderTo(Path outputFile,
Layout layout) throws Exception {
Objects.requireNonNull(outputFile, "outputFile");
Objects.requireNonNull(brandName, "brandName");
+ // The brand heads the board and its initial seals the foot, so a blank
+ // one is a board with nobody's name on it rather than a default.
+ if (brandName.isBlank()) {
+ throw new IllegalArgumentException("brandName must not be blank");
+ }
Objects.requireNonNull(weekStart, "weekStart");
Objects.requireNonNull(staff, "staff");
Objects.requireNonNull(week, "week");
@@ -431,6 +519,55 @@ public static void renderTo(Path outputFile,
.margin(layout.margin())
.create()) {
+ // The rule, its seal and the build line are page furniture, not
+ // the last thing on the board: as a zone they are drawn against
+ // the bottom margin, so a week that fills the sheet and a week
+ // that half fills it both foot the same way.
+ document.chrome().zone(DocumentPageZone.footer(FOOTER_ZONE_HEIGHT, page -> new SectionBuilder()
+ .name("FooterZone")
+ .spacing(6)
+ .addRow("FooterRule", row -> row
+ .spacing(SEAL_GAP)
+ // Equal weights on the two rules are what put the
+ // seal in the middle; the shapes then have to fill
+ // the slots the weights hand them.
+ .weights(layout.footerRuleWidth(), SEAL_WIDTH, layout.footerRuleWidth())
+ .addSection("LeftRule", section -> section
+ .padding(new DocumentInsets(FOOTER_RULE_DROP, 0, 0, 0))
+ .addShape(shape -> shape.size(layout.footerRuleWidth(), FOOTER_RULE_THICKNESS)
+ .fillColor(theme.brandAccent()).margin(DocumentInsets.zero())))
+ .addSection("Seal", section -> section
+ .stroke(DocumentStroke.of(theme.brandAccent(), 0.5))
+ .cornerRadius(9)
+ .padding(new DocumentInsets(SEAL_PADDING_Y + SEAL_LETTER_DROP, 0,
+ SEAL_PADDING_Y - SEAL_LETTER_DROP, 0))
+ .addParagraph(p -> p
+ .text(sealLetter(brandName))
+ .align(TextAlign.CENTER)
+ .textStyle(DocumentTextStyle.builder()
+ .fontName(FontName.TIMES_ROMAN)
+ .decoration(DocumentTextDecoration.BOLD)
+ .size(SEAL_LETTER_SIZE)
+ .color(theme.brandAccent())
+ .build())
+ .margin(DocumentInsets.zero())))
+ .addSection("RightRule", section -> section
+ .padding(new DocumentInsets(FOOTER_RULE_DROP, 0, 0, 0))
+ .addShape(shape -> shape.size(layout.footerRuleWidth(), FOOTER_RULE_THICKNESS)
+ .fillColor(theme.brandAccent()).margin(DocumentInsets.zero()))))
+ // Left, against the same margin the board's first column
+ // starts on: it is a credit, not part of the device above
+ // it, and centring gave it a weight it should not carry.
+ .addParagraph(p -> p
+ .text("Composed with GraphCompose — examples/.../WeeklyScheduleRenderer.java")
+ .textStyle(DocumentTextStyle.builder()
+ .fontName(FontName.COURIER)
+ .size(7.5)
+ .color(theme.muted())
+ .build())
+ .margin(DocumentInsets.zero()))
+ .build()));
+
document.pageFlow()
.name("WeeklyShiftSchedule")
.spacing(3)
@@ -509,47 +646,24 @@ public static void renderTo(Path outputFile,
}
})
- // Footer rule + seal.
- .addRow("FooterRule", row -> row
- .spacing(8)
- .weights(1, 0.12, 1)
- .addSection("LeftRule", section -> section
- .padding(new DocumentInsets(10, 0, 0, 0))
- .addShape(shape -> shape.size(292, 0.6).fillColor(theme.brandAccent()).margin(DocumentInsets.zero())))
- .addSection("Seal", section -> section
- .stroke(DocumentStroke.of(theme.brandAccent(), 0.5))
- .cornerRadius(9)
- .padding(new DocumentInsets(1, 0, 1, 0))
- .addParagraph(p -> p
- .text("S")
- .align(TextAlign.CENTER)
- .textStyle(DocumentTextStyle.builder()
- .fontName(FontName.TIMES_ROMAN)
- .decoration(DocumentTextDecoration.BOLD)
- .size(13)
- .color(theme.brandAccent())
- .build())
- .margin(DocumentInsets.zero())))
- .addSection("RightRule", section -> section
- .padding(new DocumentInsets(10, 0, 0, 0))
- .addShape(shape -> shape.size(292, 0.6).fillColor(theme.brandAccent()).margin(DocumentInsets.zero()))))
-
- .addSection("BuildFooter", section -> section
- .padding(new DocumentInsets(6, 0, 0, 0))
- .addParagraph(p -> p
- .text("Composed with GraphCompose — examples/.../WeeklyScheduleRenderer.java")
- .textStyle(DocumentTextStyle.builder()
- .fontName(FontName.COURIER)
- .size(7.5)
- .color(theme.muted())
- .build())
- .margin(DocumentInsets.zero())))
.build();
document.buildPdf();
}
}
+ /**
+ * The letter the footer seal carries: the board's own initial, so a
+ * schedule printed for one venue is not sealed with another's.
+ *
+ *
Read as a code point rather than a {@code char} so a brand opening on
+ * a character outside the basic plane is not sealed with half of it.
+ */
+ private static String sealLetter(String brandName) {
+ String trimmed = brandName.strip();
+ return String.valueOf(Character.toChars(trimmed.codePointAt(0))).toUpperCase(Locale.ROOT);
+ }
+
/**
* Split a day note like {@code "Bank Holiday Monday / Clean Crushed
* Ice Machine & Area"} into its slash-separated fragments so each
diff --git a/examples/src/main/java/com/demcha/examples/support/WorkspaceInvoiceSampleData.java b/examples/src/main/java/com/demcha/examples/support/WorkspaceInvoiceSampleData.java
new file mode 100644
index 000000000..9a569a11e
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/WorkspaceInvoiceSampleData.java
@@ -0,0 +1,141 @@
+package com.demcha.examples.support;
+
+import com.demcha.compose.document.templates.data.invoice.InvoiceBrand;
+import com.demcha.compose.document.templates.data.invoice.InvoiceContactBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoiceMasthead;
+import com.demcha.compose.document.templates.data.invoice.InvoiceNotesBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoicePaymentBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoiceRecipient;
+import com.demcha.compose.document.templates.data.invoice.InvoiceServiceLines;
+import com.demcha.compose.document.templates.data.invoice.InvoiceSummaryBlock;
+import com.demcha.compose.document.templates.data.invoice.InvoiceTotalsBlock;
+import com.demcha.compose.document.templates.data.invoice.StructuredInvoiceData;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shared sample data for the Workspace invoice example.
+ *
+ *
Kept in lockstep with the qa module's {@code WorkspaceInvoiceFixtures} —
+ * the two modules cannot share a source file, so a content change here belongs
+ * there too.
+ */
+public final class WorkspaceInvoiceSampleData {
+
+ private WorkspaceInvoiceSampleData() {
+ }
+
+ /**
+ * The sample invoice — six metadata rows, two addressed parties, four
+ * marked service lines, eight payment fields, two summed rows and a closing
+ * line with an address inside it.
+ *
+ * @return the document the example renders
+ */
+ public static StructuredInvoiceData sample() {
+ return new StructuredInvoiceData(
+ brand(), supplier(), masthead(), billTo(), shipTo(),
+ new InvoiceSummaryBlock("", "", ""),
+ serviceLines(), totals(), payment(), notes(), "USD");
+ }
+
+ /** No logo: the wordmark is what a document without one falls back to. */
+ private static InvoiceBrand brand() {
+ return new InvoiceBrand(null, "kestrel", "", "", "", "");
+ }
+
+ private static InvoiceContactBlock supplier() {
+ return new InvoiceContactBlock(
+ "Kestrel Collaboration, Inc.",
+ List.of("500 Howard Street, 6th Floor", "San Francisco, CA 94105",
+ "United States"),
+ "", "", "kestrel.example",
+ "", "",
+ "VAT ID:", "US 77-0560185");
+ }
+
+ private static InvoiceMasthead masthead() {
+ List entries = new ArrayList<>();
+ entries.add(new InvoiceMasthead.Entry("Invoice Number:", "INV-2024-0003521", true));
+ entries.add(new InvoiceMasthead.Entry("Invoice Date:", "27 May 2024", false));
+ entries.add(new InvoiceMasthead.Entry("Billing Period:", "1 - 31 May 2024", false));
+ entries.add(new InvoiceMasthead.Entry("Workspace ID:", "T04FQ9L2M", false));
+ entries.add(new InvoiceMasthead.Entry("Payment Terms:", "Net 30", false));
+ entries.add(new InvoiceMasthead.Entry("Due Date:", "26 June 2024", false));
+ return new InvoiceMasthead("INVOICE", entries);
+ }
+
+ /** The registration is the labelled pair printed under the address. */
+ private static InvoiceRecipient billTo() {
+ return new InvoiceRecipient("BILL TO", "Bright Future Ltd.", "",
+ List.of("45 King Street", "Manchester M2 7AZ", "United Kingdom"),
+ "", "", "VAT ID:", "GB 987 6543 21");
+ }
+
+ private static InvoiceRecipient shipTo() {
+ return new InvoiceRecipient("SHIP TO", "Bright Future Ltd.", "",
+ List.of("Unit 4, Riverside Park", "Leeds LS1 4AP", "United Kingdom"),
+ "", "", "", "");
+ }
+
+ private static InvoiceServiceLines serviceLines() {
+ // The currency is not in the labels: the preset names it once per money
+ // column, so a document states the code and nothing else.
+ InvoiceServiceLines.Columns columns = new InvoiceServiceLines.Columns(
+ "", "DESCRIPTION", "PLAN / SERVICE", "QTY", "UNIT PRICE", "AMOUNT", "");
+ List lines = new ArrayList<>();
+ lines.add(line(1, "Kestrel Business+", "Workspace subscription",
+ "Business+ Annual", "50", "Users", "8.75", "437.50", "grid"));
+ lines.add(line(2, "Enterprise Key Management", "Customer-managed encryption keys",
+ "Add-on", "1", "", "100.00", "100.00", "search"));
+ lines.add(line(3, "Priority Support", "Named engineer and response target",
+ "Standard", "1", "", "50.00", "50.00", "shield"));
+ lines.add(line(4, "Workflow Automation", "Templates and scheduled runs",
+ "Included", "1", "", "0.00", "0.00", "grid"));
+ return new InvoiceServiceLines(columns, lines);
+ }
+
+ private static InvoiceServiceLines.Line line(int number, String title, String description,
+ String plan, String quantity, String unit,
+ String unitPrice, String amount, String icon) {
+ return new InvoiceServiceLines.Line(number, title, description, plan,
+ new BigDecimal(quantity), unit, decimal(unitPrice), decimal(amount), "", icon);
+ }
+
+ private static InvoiceTotalsBlock totals() {
+ List rows = new ArrayList<>();
+ rows.add(new InvoiceTotalsBlock.Row("Subtotal", decimal("587.50")));
+ rows.add(new InvoiceTotalsBlock.Row("Tax (0%)", decimal("0.00")));
+ return new InvoiceTotalsBlock(rows, "TOTAL DUE", decimal("587.50"));
+ }
+
+ private static InvoicePaymentBlock payment() {
+ List fields = new ArrayList<>();
+ fields.add(new InvoicePaymentBlock.Field("Bank Name:", "Bay Union Bank, N.A."));
+ fields.add(new InvoicePaymentBlock.Field("Account Name:", "Kestrel Collaboration, Inc."));
+ fields.add(new InvoicePaymentBlock.Field("Account Number:", "000000123456789"));
+ fields.add(new InvoicePaymentBlock.Field("Routing (ABA):", "021000021"));
+ fields.add(new InvoicePaymentBlock.Field("SWIFT / BIC:", "BAYUUS33"));
+ fields.add(new InvoicePaymentBlock.Field("Currency:", "USD"));
+ fields.add(new InvoicePaymentBlock.Field("Reference:", "INV-2024-0003521"));
+ fields.add(new InvoicePaymentBlock.Field("Bank Address:", "1 Market Plaza, San Francisco"));
+ return new InvoicePaymentBlock("PAYMENT DETAILS", fields,
+ "Please include the invoice number in your payment reference.",
+ "PAYMENT DUE BY",
+ "26 June 2024",
+ "", "");
+ }
+
+ private static InvoiceNotesBlock notes() {
+ return new InvoiceNotesBlock("Thank you for using Kestrel.",
+ List.of("Questions about this invoice? Reach us at billing@kestrel.example "
+ + "and we will get back to you within one business day."),
+ "billing@kestrel.example", "");
+ }
+
+ private static BigDecimal decimal(String printed) {
+ return new BigDecimal(printed.replace(",", ""));
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CharcoalGoldExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CharcoalGoldExample.java
new file mode 100644
index 000000000..db78ef2e2
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CharcoalGoldExample.java
@@ -0,0 +1,57 @@
+package com.demcha.examples.templates.cv.v2;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.presets.CharcoalGold;
+import com.demcha.examples.support.CharcoalGoldSampleData;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+
+/**
+ * Renders the layered {@code cv.v2} Charcoal Gold preset against the
+ * project-manager sample.
+ *
+ *
Unlike its ported siblings this preset leaves the page to the caller,
+ * so the session sets A4 with no margin: both columns run to the paper edge
+ * and each carries its own padding.
The preset owns its page geometry — every length is a share of the
+ * design's own grid, and the navy plate is a page background sized by the same
+ * ratio the body row splits on — so the session starts unconfigured.
The preset owns its page geometry — every length is a share of the
+ * design's own grid — so the session starts unconfigured. What it does need is
+ * the display family: Orange Ops sets its name and headings in Oswald, which
+ * neither the templates artifact nor {@code graph-compose-fonts} carries, so
+ * registering it is the caller's job and this example is the caller. The two
+ * faces are the example module's own resources.
The preset leaves the page to the caller, so the session sets A4 with
+ * no margin: both columns run to the paper edge and each carries its own
+ * padding.
The preset pads the page flow itself, so the session margin stays at
+ * {@code ClassicInvoice.RECOMMENDED_MARGIN} (zero) and the page keeps its
+ * plain white background — the letterhead look of the preset.
The preset owns its page geometry — size, margins, the page fills and
+ * the footer band — so the session starts unconfigured; see
+ * {@code ConsultingInvoice.RECOMMENDED_MARGIN}. The brand logo is supplied
+ * through the data, the way a caller supplies its own.
The preset owns its page geometry — size, margins, the paper tint, the
+ * cream sidebar column and the sign-off band — so the session starts
+ * unconfigured. The lockup is drawn from the brand's monogram and wordmark,
+ * and the amounts take their mark from the data's currency code.
The preset owns its page geometry and its pagination — the table's header
+ * repeats and a continuation page reserves a deeper bottom margin — so the
+ * session starts unconfigured. The sample brings no logo, so the lockup box
+ * beside the title is filled with the brand's name as a wordmark.
The preset owns its page geometry and its pagination — the table's header
+ * repeats and a continuation page reserves a deeper bottom margin — so the
+ * session starts unconfigured. The sample brings no logo, so the lockup box
+ * beside the title is filled with the brand's name as a wordmark.
The same document shape the Northline example renders, in the serif
+ * editorial look: the preset owns its page geometry, so the session starts
+ * unconfigured.
The preset owns its page geometry — size, margins, the footer band
+ * and the page background — so the session starts unconfigured; see
+ * {@code NorthlineProposal.RECOMMENDED_MARGIN}.
The branding is the part worth reading: the theme
+ * ({@link BrandTheme#receiptModern()}) carries no brand colour at all, and
+ * the issuer's identity arrives through {@code ModernReceipt.Options} as a
+ * recolourable SVG mark plus one accent. Point those two at a different
+ * institution and the same preset renders that institution's
+ * confirmation.
+ */
+public final class ModernReceiptExample {
+
+ /** Classpath location of the fictional issuer's mark. */
+ private static final String MARK = "/brand/northwind-pay-mark.svg";
+
+ /** The fictional issuer's brand accent. */
+ private static final DocumentColor ACCENT = DocumentColor.rgb(23, 92, 211);
+
+ private ModernReceiptExample() {
+ }
+
+ /**
+ * @return absolute path of the rendered PDF
+ * @throws Exception if rendering fails
+ */
+ public static Path generate() throws Exception {
+ Path outputFile = ExampleOutputPaths.prepare("templates/receipt", "receipt-modern.pdf");
+ ReceiptDocumentSpec spec = ExampleDataFactory.sampleReceipt();
+ BrandTheme theme = BrandTheme.receiptModern();
+
+ // A symbol mark, not a wordmark: it renders inline beside the issuer's
+ // name, so it is sized to the name's cap height rather than to a
+ // wordmark's full width.
+ ModernReceipt.Options options = ModernReceipt.Options
+ .branded(SvgGlyph.fromResource(MARK), ACCENT)
+ .withLogoWidth(14)
+ .withLogoColor(ACCENT);
+ DocumentTemplate template = ModernReceipt.create(theme, options);
+
+ float m = (float) ModernReceipt.RECOMMENDED_MARGIN;
+ try (DocumentSession document = GraphCompose.document(outputFile)
+ .pageSize(DocumentPageSize.A4)
+ .pageBackground(theme.palette().mainFill())
+ .margin(m, m, m, m)
+ .create()) {
+ template.compose(document, spec);
+ document.buildPdf();
+ }
+ return outputFile;
+ }
+
+ /**
+ * @param args ignored
+ * @throws Exception if rendering fails
+ */
+ public static void main(String[] args) throws Exception {
+ System.out.println("Generated: " + generate());
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/templates/schedule/WeeklyScheduleFileExample.java b/examples/src/main/java/com/demcha/examples/templates/schedule/WeeklyScheduleFileExample.java
index 834f33377..e3eb621da 100644
--- a/examples/src/main/java/com/demcha/examples/templates/schedule/WeeklyScheduleFileExample.java
+++ b/examples/src/main/java/com/demcha/examples/templates/schedule/WeeklyScheduleFileExample.java
@@ -63,8 +63,8 @@ public final class WeeklyScheduleFileExample {
private static final List WEEK = List.of(
new DayPlan("Bank Holiday Monday / Clean Crushed Ice Machine & Area", 163, 35),
new DayPlan("Pianist 18:30 / Clean Crushed Ice Machine & Area", 21, 63),
- new DayPlan("TEAM DAY OUT / Motown GF / Pianist 18:30", 52, 74),
- new DayPlan("Ex Hire Terrace Dinner 13PAX / Pianist 18:30 / MGM meeting 3:30pm", 35, 75),
+ new DayPlan("TEAM DAY OUT / Soul GF / Pianist 18:30", 52, 74),
+ new DayPlan("Ex Hire Terrace Dinner 13PAX / Pianist 18:30 / Partner meeting 3:30pm", 35, 75),
new DayPlan("Pianist 19:00", 82, 78),
new DayPlan("Ex Hire FF Lunch 44PAX / Pianist 19:00", 131, 149),
new DayPlan("Masterclass 4PAX (2x2) / Line Check / Clean Cubed Ice Machine & Area", 130, 36)
diff --git a/examples/src/main/resources/brand/northwind-pay-mark.svg b/examples/src/main/resources/brand/northwind-pay-mark.svg
new file mode 100644
index 000000000..ba0d9a157
--- /dev/null
+++ b/examples/src/main/resources/brand/northwind-pay-mark.svg
@@ -0,0 +1,10 @@
+
diff --git a/examples/src/main/resources/consulting-invoice-logo.png b/examples/src/main/resources/consulting-invoice-logo.png
new file mode 100644
index 000000000..e1a37e806
Binary files /dev/null and b/examples/src/main/resources/consulting-invoice-logo.png differ
diff --git a/examples/src/main/resources/cv-portrait-placeholder.png b/examples/src/main/resources/cv-portrait-placeholder.png
new file mode 100644
index 000000000..8c58ba4d8
Binary files /dev/null and b/examples/src/main/resources/cv-portrait-placeholder.png differ
diff --git a/examples/src/main/resources/fonts/oswald/OFL.txt b/examples/src/main/resources/fonts/oswald/OFL.txt
new file mode 100644
index 000000000..9d0219e98
--- /dev/null
+++ b/examples/src/main/resources/fonts/oswald/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2016 The Oswald Project Authors (https://github.com/googlefonts/OswaldFont)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/examples/src/main/resources/fonts/oswald/Oswald-Regular.ttf b/examples/src/main/resources/fonts/oswald/Oswald-Regular.ttf
new file mode 100644
index 000000000..4a4373d1a
Binary files /dev/null and b/examples/src/main/resources/fonts/oswald/Oswald-Regular.ttf differ
diff --git a/examples/src/main/resources/fonts/oswald/Oswald-SemiBold.ttf b/examples/src/main/resources/fonts/oswald/Oswald-SemiBold.ttf
new file mode 100644
index 000000000..26aa8f272
Binary files /dev/null and b/examples/src/main/resources/fonts/oswald/Oswald-SemiBold.ttf differ
diff --git a/examples/src/test/java/com/demcha/examples/templates/cv/SidebarContactRowsFitTest.java b/examples/src/test/java/com/demcha/examples/templates/cv/SidebarContactRowsFitTest.java
new file mode 100644
index 000000000..cb70a57c0
--- /dev/null
+++ b/examples/src/test/java/com/demcha/examples/templates/cv/SidebarContactRowsFitTest.java
@@ -0,0 +1,84 @@
+package com.demcha.examples.templates.cv;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.snapshot.LayoutNodeSnapshot;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.presets.NavySidebar;
+import com.demcha.compose.document.templates.cv.presets.ProfessionalSidebar;
+import com.demcha.examples.support.NavySidebarSampleData;
+import com.demcha.examples.support.ProfessionalSidebarSampleData;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Holds the sidebar CV samples to the width of the column they are drawn in.
+ *
+ *
A contact channel in both of these presets is one paragraph — the mark
+ * and the value share a line — so a value wider than the sidebar's text
+ * column wraps, and the mark is left alone on the first line. Nothing in the
+ * promotion gates catches it: they measure the published template's own
+ * fixture, whose addresses fit, while the sample data here is written for
+ * the repository and can outgrow the column without a single test going
+ * red. That is exactly how it shipped once, spotted in the rendered preview
+ * rather than by a build.
+ *
+ *
So the guard is on the samples, and it is the shape of the row rather
+ * than the length of the string: a single-line channel is as tall as its
+ * mark — 8.8 to 11pt across the two designs — and a wrapped one is close to
+ * twice that. The bound sits between.