diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f1b14c..d9d4c4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ follow semantic versioning; release dates are ISO 8601. template surface, including its isolated theme tokens, visual regression baselines, and reusable `Subheadline` / `SectionHeader.flatSpacedCaps` widget support. +- Added the **Mint Editorial** template set: a two-page, two-column + editorial CV preset `MintEditorial` (centred spaced-caps masthead with + a full-width mint accent rule; sidebar contact / interests / education / + expertise / skill-bars / social beside a profile / experience / awards / + references main column) and its paired `MintEditorialLetter`, both on + `CvTheme.mintEditorial()` and with visual regression baselines. +- Added two reusable `cv/v2/widgets`: `SkillBar` (data-driven proficiency + bar — spaced-caps label above a track with a level-positioned marker; + no bar when the level is absent) and `IconTextRow` (inline icon + text + row, optionally a single click target), with `WidgetSmokeTest` coverage. +- Added optional proficiency levels to `SkillGroup` via the new + `CvSkill` record and `SkillsSection.Builder.leveledGroup(...)`. Fully + backward-compatible: name-only skills carry no level and every existing + name-based renderer is unaffected. +- Added `MintEditorial.Options` (and a matching `MintEditorialLetter.Options`) + — an additive masthead colour API (accent, rule, name, and an optional + full-width page-1 header band) whose defaults reproduce the stock render + exactly, so the committed look and the parity baselines are unchanged. ### Public API diff --git a/docs/templates/v2-layered/authoring-presets.md b/docs/templates/v2-layered/authoring-presets.md index b932bda4..dd73154c 100644 --- a/docs/templates/v2-layered/authoring-presets.md +++ b/docs/templates/v2-layered/authoring-presets.md @@ -107,6 +107,24 @@ in `com.demcha.compose.document.templates.widgets`. | `SectionHeader.upperRule(host, title, theme, titleStyle, ruleColor, ruleWidth)` | Uppercase label with short rule below | | `SectionHeader.spacedCapsRule(host, title, theme, titleStyle, ruleColor, ruleWidth, ruleThickness, ruleMargin)` | Spaced-caps label with short rule below | +### `SkillBar` — data-driven proficiency bar + +| Variant | Visual | +|---|---| +| `SkillBar.render(host, skill, trackWidth, theme)` | Spaced-caps skill label above a thin track with a level-positioned marker; renders the label with **no bar** when `skill.level()` is absent | + +Reads the level from `CvSkill.level()` (`[0, 1]`); used by the Mint +Editorial skills sidebar. + +### `IconTextRow` — inline icon + text row + +| Variant | Visual | +|---|---| +| `IconTextRow.render(host, icon, iconSize, text, style, link, margin)` | A glyph image followed by a label on one baseline; the whole row is a single click target when a `link` is supplied | + +Used for the icon-led contact and social rows in sidebar CV layouts +(Mint Editorial). + ### Higher-order CV widgets | Widget | Visual | diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMintEditorialCustomExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMintEditorialCustomExample.java new file mode 100644 index 00000000..fd2a9e79 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/CvMintEditorialCustomExample.java @@ -0,0 +1,61 @@ +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.style.DocumentColor; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.v2.data.CvDocument; +import com.demcha.compose.document.templates.cv.v2.presets.MintEditorial; +import com.demcha.examples.support.ExampleDataFactory; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Renders the Mint Editorial CV against the rich "Rose Harris" showcase + * dataset with a single custom colour via + * {@link MintEditorial.Options} — a soft kraft-paper header band that fills + * the whole masthead zone from the top page edge down to the mint rule. Only + * {@code headerBandColor} is set; everything else stays default, so the dark + * name, mint tagline, and mint full-width rule are unchanged and read cleanly + * on the light tan band. This demonstrates the colour-customisation API; the + * default-coloured render lives in {@code CvMintEditorialExample} and is left + * untouched. + * + *

Output: + * {@code examples/target/generated-pdfs/templates/cv/cv-mint-editorial-v2-custom.pdf}.

+ */ +public final class CvMintEditorialCustomExample { + + private CvMintEditorialCustomExample() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare( + "templates/cv", "cv-mint-editorial-v2-custom.pdf"); + CvDocument doc = ExampleDataFactory.mintEditorialShowcaseCv(); + + // Set ONLY the header band colour — a soft warm kraft-paper tan. Every + // other knob stays default: dark ink name, mint tagline, mint + // full-width rule. The dark name reads cleanly on the light tan band. + MintEditorial.Options options = MintEditorial.Options.builder() + .headerBandColor(DocumentColor.rgb(228, 217, 198)) // kraft-paper tan band + .build(); + DocumentTemplate template = MintEditorial.create(options); + + float m = (float) MintEditorial.RECOMMENDED_MARGIN; + try (DocumentSession document = GraphCompose.document(outputFile) + .pageSize(DocumentPageSize.A4) + .margin(m, m, m, m) + .create()) { + template.compose(document, doc); + document.buildPdf(); + } + return outputFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/MintEditorialLetter.java b/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/MintEditorialLetter.java index bd404f49..5e356fcf 100644 --- a/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/MintEditorialLetter.java +++ b/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/MintEditorialLetter.java @@ -2,7 +2,13 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.ClipPolicy; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentTextDecoration; @@ -11,6 +17,7 @@ import com.demcha.compose.document.templates.coverletter.v2.components.LetterBody; import com.demcha.compose.document.templates.coverletter.v2.data.CoverLetterDocument; import com.demcha.compose.document.templates.cv.v2.components.CvTextStyles; +import com.demcha.compose.document.templates.cv.v2.components.TextOrnaments; import com.demcha.compose.document.templates.cv.v2.data.CvIdentity; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; import com.demcha.compose.document.templates.cv.v2.widgets.Headline; @@ -30,6 +37,11 @@ * CV, so the CV and the letter read as one matched set. The CV's two-column * sidebar grids are a CV-body concern and are intentionally not part of the * letter.

+ * + *

The same {@link Options} colour knobs as the paired + * {@link com.demcha.compose.document.templates.cv.v2.presets.MintEditorial} + * preset recolour the letter masthead (accent, rule, name, optional header + * band), with identical defaults so the matched set stays in sync.

*/ public final class MintEditorialLetter { @@ -42,33 +54,140 @@ public final class MintEditorialLetter { /** Recommended symmetric page margin (in points). Matches the CV preset. */ public static final double RECOMMENDED_MARGIN = 48.0; + // Banded-masthead canvas geometry — mirrors the CV preset so the matched + // set's masthead is identical, and reproduces the letter's own DEFAULT + // (bandless) masthead positions so the banded render only adds a fill + // behind an otherwise-unchanged masthead. + + /** Canvas flow footprint (points) — matches the CV masthead footprint. */ + private static final double MASTHEAD_CANVAS_HEIGHT = 143.76; + + /** Canvas-local y (points) of the masthead name — matches the default top. */ + private static final double MASTHEAD_NAME_Y = 48.0; + + /** Canvas-local y (points) of the masthead tagline — matches the default top. */ + private static final double MASTHEAD_TAGLINE_Y = 87.4; + + /** Canvas-local y (points) of the masthead rule — matches the default top. */ + private static final double MASTHEAD_RULE_Y = 123.76; + private MintEditorialLetter() { } - /** Builds the letter with its Mint Editorial theme. */ + /** Builds the letter with its Mint Editorial theme and default colours. */ public static DocumentTemplate create() { - return create(CvTheme.mintEditorial()); + return create(CvTheme.mintEditorial(), Options.defaults()); } /** - * Builds the letter with a caller-supplied theme (share the paired CV's - * theme instance for a guaranteed visual match). + * Builds the letter with a caller-supplied theme and default colours + * (share the paired CV's theme instance for a guaranteed visual match). */ public static DocumentTemplate create(CvTheme theme) { + return create(theme, Options.defaults()); + } + + /** Builds the letter with its Mint Editorial theme and explicit colours. */ + public static DocumentTemplate create(Options options) { + return create(CvTheme.mintEditorial(), options); + } + + /** + * Builds the letter with a caller-supplied theme and explicit colour + * {@link Options}. Pass the same {@code Options} as the paired CV preset + * so the recoloured masthead matches. + */ + public static DocumentTemplate create(CvTheme theme, + Options options) { Objects.requireNonNull(theme, "theme"); - return new Template(theme); + Objects.requireNonNull(options, "options"); + return new Template(theme, options); + } + + /** + * Mint Editorial letter masthead colour knobs — same shape and defaults as + * {@code MintEditorial.Options}. Every {@code null} field reproduces the + * stock render. + * + * @param accentColor mint accent for the tagline; {@code null} → + * {@code theme.palette().banner()} + * @param ruleColor masthead rule colour; {@code null} → the resolved + * {@code accentColor} + * @param nameColor masthead name colour; {@code null} → + * {@code theme.palette().ink()} + * @param headerBandColor optional full-width band behind the masthead; + * {@code null} → no band + */ + public record Options(DocumentColor accentColor, + DocumentColor ruleColor, + DocumentColor nameColor, + DocumentColor headerBandColor) { + + public static Options defaults() { + return new Options(null, null, null, null); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private DocumentColor accentColor; + private DocumentColor ruleColor; + private DocumentColor nameColor; + private DocumentColor headerBandColor; + + private Builder() { + } + + public Builder accentColor(DocumentColor value) { + this.accentColor = value; + return this; + } + + public Builder ruleColor(DocumentColor value) { + this.ruleColor = value; + return this; + } + + public Builder nameColor(DocumentColor value) { + this.nameColor = value; + return this; + } + + public Builder headerBandColor(DocumentColor value) { + this.headerBandColor = value; + return this; + } + + public Options build() { + return new Options(accentColor, ruleColor, nameColor, + headerBandColor); + } + } } private static final class Template implements DocumentTemplate { private final CvTheme theme; private final DocumentColor accent; + private final DocumentColor ruleColor; + private final DocumentColor nameColor; + private final DocumentColor headerBandColor; - Template(CvTheme theme) { + Template(CvTheme theme, Options options) { this.theme = theme; - // Same accent source as the paired CV preset — the palette - // banner slot carries the mint accent. - this.accent = theme.palette().banner(); + // Same accent source + Options defaults as the paired CV preset. + this.accent = options.accentColor() != null + ? options.accentColor() + : theme.palette().banner(); + this.ruleColor = options.ruleColor() != null + ? options.ruleColor() + : this.accent; + this.nameColor = options.nameColor() != null + ? options.nameColor() + : theme.palette().ink(); + this.headerBandColor = options.headerBandColor(); } @Override @@ -97,13 +216,19 @@ public void compose(DocumentSession document, CoverLetterDocument doc) { .spacing(theme.spacing().pageFlowSpacing()); flow.addSection("CoverLetterV2MintEditorialHeader", - section -> addMasthead(section, doc.identity())); - flow.addLine(line -> line - .name("CoverLetterV2MintEditorialHeaderRule") - .horizontal(pageWidth) - .color(accent) - .thickness(theme.spacing().accentRuleWidth()) - .margin(new DocumentInsets(8, -ruleBleed, 14, -ruleBleed))); + section -> addMasthead(section, doc.identity(), pageWidth, + ruleBleed)); + if (headerBandColor == null) { + // Stock masthead rule. In banded mode the rule is drawn flush at + // the band's bottom edge inside the header canvas, so the + // separate flow rule is suppressed (no doubled line). + flow.addLine(line -> line + .name("CoverLetterV2MintEditorialHeaderRule") + .horizontal(pageWidth) + .color(ruleColor) + .thickness(theme.spacing().accentRuleWidth()) + .margin(new DocumentInsets(8, -ruleBleed, 14, -ruleBleed))); + } flow.addSection("CoverLetterV2MintEditorialBody", host -> LetterBody.render(host, doc, theme)); @@ -113,17 +238,93 @@ public void compose(DocumentSession document, CoverLetterDocument doc) { /** * Centred spaced-caps name + mint accent tagline — the identical - * masthead the {@code MintEditorial} CV preset renders, so the - * matched set never forks the header treatment. + * masthead the {@code MintEditorial} CV preset renders, so the matched + * set never forks the header treatment. With default colours and no + * band the output is byte-identical to the pre-Options render. */ - private void addMasthead(SectionBuilder section, CvIdentity identity) { - Headline.spacedCentered(section, identity.name(), theme); + private void addMasthead(SectionBuilder section, CvIdentity identity, + double pageWidth, double ruleBleed) { + if (headerBandColor != null) { + addBandedMasthead(section, identity, pageWidth, ruleBleed); + } else { + addPlainMasthead(section, identity); + } + } + + private void addPlainMasthead(SectionBuilder section, CvIdentity identity) { + // Style-override variant so only the name colour can change. + Headline.render(section, identity.name(), theme, + TextAlign.CENTER, true, mastheadNameStyle()); String jobTitle = identity.jobTitle(); if (jobTitle != null && !jobTitle.isBlank()) { Subheadline.centeredSpacedCaps(section, jobTitle, taglineStyle()); } } + /** + * Banded masthead: the whole masthead zone is one + * {@code CanvasLayerNode} (controlled absolute placement), mirroring the + * paired CV preset. The band fills the canvas (page top edge → rule), + * the canvas is bled to the page edges via negative margins, and the + * rule sits flush at the band's bottom edge. The canvas reserves only + * {@value #MASTHEAD_CANVAS_HEIGHT}pt of flow, so the band adds no extra + * flow height. See {@code MintEditorial.addBandedHeader} for the full + * rationale (including why the band fills the canvas rather than + * overflowing a child upward). + */ + private void addBandedMasthead(SectionBuilder section, + CvIdentity identity, double pageWidth, + double ruleBleed) { + double canvasH = MASTHEAD_CANVAS_HEIGHT; + double ruleThickness = theme.spacing().accentRuleWidth(); + double bandHeight = MASTHEAD_RULE_Y + ruleThickness; + + DocumentNode band = new ShapeBuilder() + .name("CoverLetterV2MintEditorialHeaderBand") + .size(pageWidth, bandHeight) + .fillColor(headerBandColor) + .build(); + DocumentNode rule = new ShapeBuilder() + .name("CoverLetterV2MintEditorialHeaderRule") + .size(pageWidth, ruleThickness) + .fillColor(ruleColor) + .build(); + ParagraphNode name = new ParagraphBuilder() + .name("CoverLetterV2MintEditorialHeaderName") + .text(TextOrnaments.spacedUpper(identity.name().full())) + .textStyle(mastheadNameStyle()) + .align(TextAlign.CENTER) + .build(); + String jobTitle = identity.jobTitle(); + ParagraphNode tagline = jobTitle != null && !jobTitle.isBlank() + ? new ParagraphBuilder() + .name("CoverLetterV2MintEditorialHeaderTagline") + .text(TextOrnaments.spacedUpper(jobTitle)) + .textStyle(taglineStyle()) + .align(TextAlign.CENTER) + .build() + : null; + + section.addCanvas(pageWidth, canvasH, canvas -> { + canvas.name("CoverLetterV2MintEditorialHeaderCanvas") + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .margin(new DocumentInsets(-ruleBleed, -ruleBleed, 0, + -ruleBleed)) + .position(band, 0.0, 0.0) + .position(rule, 0.0, MASTHEAD_RULE_Y) + .position(name, 0.0, MASTHEAD_NAME_Y); + if (tagline != null) { + canvas.position(tagline, 0.0, MASTHEAD_TAGLINE_Y); + } + }); + } + + private DocumentTextStyle mastheadNameStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + theme.typography().sizeHeadline(), + DocumentTextDecoration.DEFAULT, nameColor); + } + private DocumentTextStyle taglineStyle() { return CvTextStyles.of(theme.typography().headlineFont(), theme.typography().sizeContact(), diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java index 4f455289..053422e1 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java @@ -1,12 +1,17 @@ package com.demcha.compose.document.templates.cv.v2.presets; import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; import com.demcha.compose.document.dsl.ParagraphBuilder; import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; import com.demcha.compose.document.image.DocumentImageData; import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.ShapeNode; import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.ClipPolicy; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentStroke; @@ -172,6 +177,35 @@ public final class MintEditorial { /** Expertise badge edge length (points). */ private static final double BADGE_SIZE = 36.0; + // Banded-masthead canvas geometry. These values reproduce the DEFAULT + // (bandless) masthead flow positions exactly — name baseline, tagline + // baseline, and rule y — measured from the stock render at the canonical + // 48pt page margin. The canvas top is bled to the page top edge (y=0) via a + // negative margin, so a canvas-local y equals the page-absolute top edge in + // points; the constants therefore equal the default page-absolute tops. The + // band fills the canvas from y=0 down to the rule's bottom, and the masthead + // (name/tagline/rule) renders at the same positions whether or not a band is + // present — the band only adds a tan fill behind it. + // + // These are package-private (not private) so MintEditorialSmokeTest can + // assert them against the live default masthead positions — see its + // band_constants_match_default_masthead guard, which fails if a future + // typography / spacing / margin change moves the masthead and silently + // invalidates these hand-measured coordinates. + + /** Canvas flow footprint (points) — sized so the page-1 row starts at the + * same y as the default render. */ + static final double MASTHEAD_CANVAS_HEIGHT = 143.76; + + /** Canvas-local y (points) of the masthead name — matches the default top. */ + static final double MASTHEAD_NAME_Y = 48.0; + + /** Canvas-local y (points) of the masthead tagline — matches the default top. */ + static final double MASTHEAD_TAGLINE_Y = 87.4; + + /** Canvas-local y (points) of the masthead rule — matches the default top. */ + static final double MASTHEAD_RULE_Y = 123.76; + private static final String ICON_ROOT = "/templates/cv/mint-editorial/icons/"; private static final Map ICON_CACHE = new ConcurrentHashMap<>(); @@ -195,30 +229,133 @@ public final class MintEditorial { private MintEditorial() { } - /** Builds the preset with its Mint Editorial theme. */ + /** Builds the preset with its Mint Editorial theme and default colours. */ public static DocumentTemplate create() { - return create(CvTheme.mintEditorial()); + return create(CvTheme.mintEditorial(), Options.defaults()); } /** - * Builds the preset with a caller-supplied theme (share the paired - * cover letter's theme instance for a guaranteed visual match). + * Builds the preset with a caller-supplied theme and default colours + * (share the paired cover letter's theme instance for a guaranteed + * visual match). */ public static DocumentTemplate create(CvTheme theme) { + return create(theme, Options.defaults()); + } + + /** + * Builds the preset with its Mint Editorial theme and explicit colour + * {@link Options}. + */ + public static DocumentTemplate create(Options options) { + return create(CvTheme.mintEditorial(), options); + } + + /** + * Builds the preset with a caller-supplied theme and explicit colour + * {@link Options}. Use this to recolour the masthead (accent, rule, + * name, optional header band) without forking the theme. + */ + public static DocumentTemplate create(CvTheme theme, + Options options) { Objects.requireNonNull(theme, "theme"); - return new Template(theme); + Objects.requireNonNull(options, "options"); + return new Template(theme, options); + } + + /** + * Mint Editorial masthead colour knobs. Every {@code null} field falls + * back to a default that reproduces the stock render exactly, so an + * {@link #defaults()} instance leaves the committed look unchanged. + * + * @param accentColor mint accent used for the centred tagline and + * every spaced-caps section heading; {@code null} + * → {@code theme.palette().banner()} + * @param ruleColor full-width masthead rule colour, independent of + * the accent; {@code null} → the resolved + * {@code accentColor} (so unset = today's look) + * @param nameColor masthead name text colour; {@code null} → + * {@code theme.palette().ink()} + * @param headerBandColor optional full-page-width colour band painted + * behind the masthead on page 1 only; + * {@code null} → no band (white header) + */ + public record Options(DocumentColor accentColor, + DocumentColor ruleColor, + DocumentColor nameColor, + DocumentColor headerBandColor) { + + public static Options defaults() { + return new Options(null, null, null, null); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private DocumentColor accentColor; + private DocumentColor ruleColor; + private DocumentColor nameColor; + private DocumentColor headerBandColor; + + private Builder() { + } + + public Builder accentColor(DocumentColor value) { + this.accentColor = value; + return this; + } + + public Builder ruleColor(DocumentColor value) { + this.ruleColor = value; + return this; + } + + public Builder nameColor(DocumentColor value) { + this.nameColor = value; + return this; + } + + public Builder headerBandColor(DocumentColor value) { + this.headerBandColor = value; + return this; + } + + public Options build() { + return new Options(accentColor, ruleColor, nameColor, + headerBandColor); + } + } } private static final class Template implements DocumentTemplate { private final CvTheme theme; + /** Accent for the tagline + section headings (defaults to mint). */ private final DocumentColor accent; - - Template(CvTheme theme) { + /** Masthead rule colour (defaults to the accent). */ + private final DocumentColor ruleColor; + /** Masthead name colour (defaults to ink). */ + private final DocumentColor nameColor; + /** Optional page-1 header band; null = no band (white header). */ + private final DocumentColor headerBandColor; + + Template(CvTheme theme, Options options) { this.theme = theme; // Mint carries its accent in the palette banner slot — single - // source shared with the paired cover letter. - this.accent = theme.palette().banner(); + // source shared with the paired cover letter. Each Options knob + // defaults to the value that reproduces the stock render. + this.accent = options.accentColor() != null + ? options.accentColor() + : theme.palette().banner(); + this.ruleColor = options.ruleColor() != null + ? options.ruleColor() + : this.accent; + this.nameColor = options.nameColor() != null + ? options.nameColor() + : theme.palette().ink(); + this.headerBandColor = options.headerBandColor(); } @Override @@ -268,18 +405,25 @@ public void compose(DocumentSession document, CvDocument doc) { List experiencePage2 = experienceEntries.stream() .skip(EXPERIENCE_PAGE_ONE).toList(); - document.dsl() + PageFlowBuilder flow = document.dsl() .pageFlow() .name("CvV2MintEditorialRoot") .spacing(theme.spacing().pageFlowSpacing()) .addSection("CvV2MintEditorialHeader", - section -> addHeader(section, identity)) - .addLine(line -> line - .name("CvV2MintEditorialHeaderRule") - .horizontal(pageWidth) - .color(accent) - .thickness(theme.spacing().accentRuleWidth()) - .margin(new DocumentInsets(8, -ruleBleed, 14, -ruleBleed))) + section -> addHeader(section, identity, pageWidth, ruleBleed)); + if (headerBandColor == null) { + // Stock masthead rule, full page width. When a band is present + // the rule is drawn flush at the band's bottom edge inside the + // header canvas instead (see addBandedHeader), so the separate + // flow rule is suppressed to avoid a doubled line. + flow.addLine(line -> line + .name("CvV2MintEditorialHeaderRule") + .horizontal(pageWidth) + .color(ruleColor) + .thickness(theme.spacing().accentRuleWidth()) + .margin(new DocumentInsets(8, -ruleBleed, 14, -ruleBleed))); + } + flow .addRow("CvV2MintEditorialPageOne", row -> { row.spacing(COLUMN_GAP).weights(SIDEBAR_WEIGHT, MAIN_WEIGHT); row.addSection("CvV2MintEditorialPageOneSidebar", sidebar -> { @@ -314,17 +458,121 @@ public void compose(DocumentSession document, CvDocument doc) { // -- Header -------------------------------------------------------- - private void addHeader(SectionBuilder section, CvIdentity identity) { - // Headline.spacedCentered sets the section's spacing + padding; - // it renders the centred spaced-caps name as the page's loudest - // element. The tagline follows in the mint accent. - Headline.spacedCentered(section, identity.name(), theme); + private void addHeader(SectionBuilder section, CvIdentity identity, + double pageWidth, double ruleBleed) { + if (headerBandColor != null) { + addBandedHeader(section, identity, pageWidth, ruleBleed); + } else { + addPlainHeader(section, identity); + } + } + + /** + * Stock masthead: centred spaced-caps name (in {@code nameColor}) over + * a centred accent tagline, no background band. When + * {@code nameColor == theme.palette().ink()} the explicit style equals + * {@code theme.headlineStyle()}, so the default render is byte-identical + * to the pre-Options output. + */ + private void addPlainHeader(SectionBuilder section, CvIdentity identity) { + // Render the name through Headline's style-override variant so only + // the colour can change — alignment, spaced-caps transform, font and + // size all stay exactly as Headline.spacedCentered produced them. + Headline.render(section, identity.name(), theme, + TextAlign.CENTER, true, mastheadNameStyle()); String jobTitle = identity.jobTitle(); if (jobTitle != null && !jobTitle.isBlank()) { Subheadline.centeredSpacedCaps(section, jobTitle, taglineStyle()); } } + /** + * Banded masthead (page 1 only): the whole masthead zone is one + * {@code CanvasLayerNode} (controlled absolute placement, the v1.6 + * free-canvas primitive) with {@link ClipPolicy#OVERFLOW_VISIBLE}. The + * canvas reserves only {@value #MASTHEAD_CANVAS_HEIGHT}pt in the flow — + * its declared height — so the masthead footprint stays small and the + * dense page-1 row still fits, keeping the document at two pages. + * + *

Inside the canvas (origin top-left, y down):

+ *
    + *
  • the band rectangle is positioned at {@code (-ruleBleed, + * -ruleBleed)} and sized {@code pageWidth × (ruleBleed + + * canvasHeight)}, so — because overflow is visible — it bleeds up + * to the top page edge, out to both side edges (full width), and + * down to the canvas bottom;
  • + *
  • a thin rule sits flush at the band's bottom edge, reading as the + * band's underline (the separate flow rule is suppressed in this + * mode, see {@code compose});
  • + *
  • the centred name (in {@code nameColor}) and tagline (in + * {@code accentColor}) render on top.
  • + *
+ * + *

The band consumes no extra flow height because the canvas footprint + * is fixed and the overflow draws outside it. Page 2 is untouched: the + * header canvas is the first page-flow child, so it lives on page 1 + * only — no {@code pageBackgrounds} (which would repeat on page 2).

+ */ + private void addBandedHeader(SectionBuilder section, CvIdentity identity, + double pageWidth, double ruleBleed) { + double canvasH = MASTHEAD_CANVAS_HEIGHT; + double ruleThickness = theme.spacing().accentRuleWidth(); + // Band runs from the top page edge down to the rule's bottom edge. + double bandHeight = MASTHEAD_RULE_Y + ruleThickness; + + // The masthead (name / tagline / rule) is positioned at the EXACT + // default flow coordinates, so the banded and bandless renders share + // identical masthead geometry — only the tan fill behind it is new. + // The band fills the canvas from y=0 to the rule bottom; no child + // overflows the canvas upward (which would overdraw the row beneath + // on the PDF backend) — instead the canvas itself is bled to the page + // edges via negative margins. + DocumentNode band = new ShapeBuilder() + .name("CvV2MintEditorialHeaderBand") + .size(pageWidth, bandHeight) + .fillColor(headerBandColor) + .build(); + DocumentNode rule = new ShapeBuilder() + .name("CvV2MintEditorialHeaderRule") + .size(pageWidth, ruleThickness) + .fillColor(ruleColor) + .build(); + ParagraphNode name = new ParagraphBuilder() + .name("CvV2MintEditorialHeaderName") + .text(TextOrnaments.spacedUpper(identity.name().full())) + .textStyle(mastheadNameStyle()) + .align(TextAlign.CENTER) + .build(); + String jobTitle = identity.jobTitle(); + boolean hasTagline = jobTitle != null && !jobTitle.isBlank(); + ParagraphNode tagline = hasTagline + ? new ParagraphBuilder() + .name("CvV2MintEditorialHeaderTagline") + .text(TextOrnaments.spacedUpper(jobTitle)) + .textStyle(taglineStyle()) + .align(TextAlign.CENTER) + .build() + : null; + + section.addCanvas(pageWidth, canvasH, canvas -> { + canvas.name("CvV2MintEditorialHeaderCanvas") + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + // Bleed the whole canvas to the top + side page edges so + // the band reaches y=0 and both side edges. + .margin(new DocumentInsets(-ruleBleed, -ruleBleed, 0, + -ruleBleed)) + // Band fills the masthead zone from the page top edge to + // the rule's bottom; rule + name + tagline sit at their + // default flow positions on top. + .position(band, 0.0, 0.0) + .position(rule, 0.0, MASTHEAD_RULE_Y) + .position(name, 0.0, MASTHEAD_NAME_Y); + if (tagline != null) { + canvas.position(tagline, 0.0, MASTHEAD_TAGLINE_Y); + } + }); + } + // -- Sidebar: Contact --------------------------------------------- private void addContact(SectionBuilder section, CvIdentity identity) { @@ -513,6 +761,23 @@ private void addProfile(SectionBuilder section, CvSection profile) { // -- Main: Experience --------------------------------------------- + /** + * Renders the Experience block with Mint's bespoke entry layout: + * a spaced-caps job title, a single {@code subtitle | date} meta line, + * a prose paragraph, and — uniquely for this preset — any trailing + * markdown bullet lines as a real bullet list. + * + *

This is rendered locally rather than through the shared + * {@code EntryCompactRenderer} / {@code RichParagraphRenderer} because + * Mint's entry shape does not match those renderers: the title is + * transformed to letter-spaced uppercase, the subtitle and date are + * fused into one {@code "Company | Location | 2010 - Present"} meta line + * (the shared renderers keep them as separate title-row columns), and + * the body is split into prose + highlight bullets via + * {@link #splitBody(String)} — a transform no shared renderer performs. + * Bodies with no bullet lines (the canonical sample) take the plain + * single-paragraph path and are unaffected.

+ */ private void addExperience(SectionBuilder section, String title, List entries) { if (entries.isEmpty()) { @@ -750,6 +1015,18 @@ private DocumentTableCell gridCell(String text, DocumentTableStyle style) { // -- Style factories ---------------------------------------------- + /** + * Masthead name style. With the default {@code nameColor} (ink) this is + * identical to {@code theme.headlineStyle()} — headline font, headline + * size, default decoration — so the stock render is unchanged; only the + * colour differs when a caller overrides {@code nameColor}. + */ + private DocumentTextStyle mastheadNameStyle() { + return CvTextStyles.of(theme.typography().headlineFont(), + theme.typography().sizeHeadline(), + DocumentTextDecoration.DEFAULT, nameColor); + } + private DocumentTextStyle taglineStyle() { return CvTextStyles.of(theme.typography().headlineFont(), theme.typography().sizeContact(), @@ -837,6 +1114,12 @@ private static List entriesOf(CvSection section) { * {@code "* "}). Bodies with no bullet lines yield the whole text as * prose and an empty bullet list, so the canonical sample renders * exactly as before. + * + *

Preset-local because the shared CV body renderers + * ({@code RichParagraphRenderer}, {@code EntryCompactRenderer}) treat the + * entry body as one rich paragraph and never break trailing bullet lines + * out into a list. This split is what lets a Mint experience entry show a + * paragraph followed by highlight bullets (see {@link Template#addExperience}).

*/ private static BodyParts splitBody(String body) { if (body == null || body.isBlank()) { diff --git a/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2VisualParityTest.java index bb796752..c61ba18c 100644 --- a/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2VisualParityTest.java +++ b/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2VisualParityTest.java @@ -114,7 +114,10 @@ private static Stream presets() { (Supplier>) MonogramSidebarLetter::create), Arguments.of("timeline_minimal", TimelineMinimalLetter.RECOMMENDED_MARGIN, - (Supplier>) TimelineMinimalLetter::create)); + (Supplier>) TimelineMinimalLetter::create), + Arguments.of("mint-editorial-letter", + MintEditorialLetter.RECOMMENDED_MARGIN, + (Supplier>) MintEditorialLetter::create)); } /** diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java index addd9e78..0b6220be 100644 --- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java @@ -131,7 +131,10 @@ private static Stream presets() { (Supplier>) MonogramSidebar::create), Arguments.of("sidebar_portrait", SidebarPortrait.RECOMMENDED_MARGIN, - (Supplier>) SidebarPortrait::create)); + (Supplier>) SidebarPortrait::create), + Arguments.of("mint_editorial", + MintEditorial.RECOMMENDED_MARGIN, + (Supplier>) MintEditorial::create)); } /** diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java index ffa43d51..3901fcf1 100644 --- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java @@ -4,6 +4,7 @@ import com.demcha.compose.document.api.DocumentPageSize; import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.templates.api.DocumentTemplate; import com.demcha.compose.document.templates.cv.v2.data.CvDocument; import com.demcha.compose.document.templates.cv.v2.data.CvIdentity; @@ -14,11 +15,13 @@ import com.demcha.compose.document.templates.cv.v2.data.RowsSection; import com.demcha.compose.document.templates.cv.v2.data.SkillsSection; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; +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; +import static org.assertj.core.api.Assertions.within; /** * Smoke test for the v2 Mint Editorial preset. Covers stable identity, @@ -64,6 +67,101 @@ void custom_theme_factory_renders() throws Exception { } } + @Test + void custom_colour_options_render_two_pages() throws Exception { + // Dark header band + white name + a contrasting rule and accent — + // exercises every Options knob at once. Still a clean two-page render. + MintEditorial.Options options = MintEditorial.Options.builder() + .headerBandColor(DocumentColor.rgb(24, 24, 24)) + .nameColor(DocumentColor.WHITE) + .ruleColor(DocumentColor.rgb(220, 120, 90)) + .accentColor(DocumentColor.rgb(139, 207, 190)) + .build(); + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(48, 48, 48, 48) + .create()) { + MintEditorial.create(options).compose(session, fullDocument()); + assertThat(session.roots()).isNotEmpty(); + assertThat(session.layoutGraph().totalPages()).isEqualTo(2); + } + } + + @Test + void default_options_equal_no_options() { + // The default Options factory must leave the stock surface identity + // intact (and, by the parity gate, the stock render). + DocumentTemplate withDefaults = + MintEditorial.create(MintEditorial.Options.defaults()); + assertThat(withDefaults.id()).isEqualTo("mint-editorial"); + assertThat(withDefaults.displayName()).isEqualTo("Mint Editorial"); + } + + @Test + void band_constants_match_default_masthead() throws Exception { + // Guard: the banded masthead reuses hand-measured MASTHEAD_* constants + // to place the name/tagline/rule at the SAME positions the default + // (bandless) flow produces. This test ties MASTHEAD_RULE_Y to the real + // default rule y, so any future typography / margin / spacing change + // that moves the masthead fails here and signals the constants must be + // re-measured. + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(48, 48, 48, 48) + .create()) { + MintEditorial.create().compose(session, fullDocument()); + LayoutGraph layout = session.layoutGraph(); + PlacedFragment rule = layout.fragments().stream() + .filter(f -> f.pageIndex() == 0) + .filter(f -> f.path().contains("CvV2MintEditorialHeaderRule")) + .findFirst() + .orElseThrow(() -> new AssertionError( + "default masthead rule fragment not found")); + // PlacedFragment.y is the PDF bottom-left origin (y grows up); + // convert to the top-down page-edge coordinate the constant uses. + double pageHeight = session.canvas().height(); + double ruleTop = pageHeight - (rule.y() + rule.height()); + assertThat(ruleTop) + .as("default rule top must equal MASTHEAD_RULE_Y (re-measure " + + "the MASTHEAD_* band constants if this drifts)") + .isCloseTo(MintEditorial.MASTHEAD_RULE_Y, within(0.5)); + } + } + + @Test + void banded_and_bandless_place_first_row_identically() throws Exception { + // Complementary guard: the band must not shift the body. The first + // page-1 content row must start at the same y with and without a band. + double bandless = firstPageOneRowTop(MintEditorial.create()); + double banded = firstPageOneRowTop(MintEditorial.create( + MintEditorial.Options.builder() + .headerBandColor(DocumentColor.rgb(228, 217, 198)) + .build())); + assertThat(banded) + .as("banded masthead must place the first row at the same y as " + + "the bandless masthead") + .isCloseTo(bandless, within(0.5)); + } + + private static double firstPageOneRowTop(DocumentTemplate template) + throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(48, 48, 48, 48) + .create()) { + template.compose(session, fullDocument()); + LayoutGraph layout = session.layoutGraph(); + double pageHeight = session.canvas().height(); + return layout.fragments().stream() + .filter(f -> f.pageIndex() == 0) + .filter(f -> f.path().contains("CvV2MintEditorialPageOne")) + .mapToDouble(f -> pageHeight - (f.y() + f.height())) + .min() + .orElseThrow(() -> new AssertionError( + "page-1 row fragments not found")); + } + } + @Test void renders_with_awards_and_references_grids() throws Exception { try (DocumentSession session = GraphCompose.document() diff --git a/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png b/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png new file mode 100644 index 00000000..58d297b6 Binary files /dev/null and b/src/test/resources/visual-baselines/coverletter-v2-layered/mint-editorial-letter-page-0.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png b/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png new file mode 100644 index 00000000..6d16b68d Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-0.png differ diff --git a/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png b/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png new file mode 100644 index 00000000..f747e92b Binary files /dev/null and b/src/test/resources/visual-baselines/cv-v2-layered/mint_editorial-page-1.png differ