diff --git a/CHANGELOG.md b/CHANGELOG.md index 29d33a281..e3bdd4ebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **`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 @@ -59,6 +68,17 @@ follow semantic versioning; release dates are ISO 8601. ### Templates +- **`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 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 diff --git a/assets/readme/examples/cv-serif-headline-v2.pdf b/assets/readme/examples/cv-serif-headline-v2.pdf index 7d7ba3937..044840db1 100644 Binary files a/assets/readme/examples/cv-serif-headline-v2.pdf and b/assets/readme/examples/cv-serif-headline-v2.pdf differ diff --git a/examples/src/main/java/com/demcha/examples/support/SerifHeadlineSampleData.java b/examples/src/main/java/com/demcha/examples/support/SerifHeadlineSampleData.java index 309d6afde..5c636db27 100644 --- a/examples/src/main/java/com/demcha/examples/support/SerifHeadlineSampleData.java +++ b/examples/src/main/java/com/demcha/examples/support/SerifHeadlineSampleData.java @@ -23,7 +23,10 @@ * *

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.

+ * 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 { @@ -98,13 +101,15 @@ public static CvDocument sample() { .subtitle("Java, PostgreSQL, Testcontainers") .body("Open-source double-entry ledger for JVM services, with a" + " property-based suite over the posting rules.") - .icon("api") + .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("chart") + .icon("rocket") + .link("https://github.com/thalvorsen/runbook-digest") .build()) .build()) .section(EntriesSection.builder("Education") diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java index 8b3b933bc..dff48fb20 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java @@ -8,38 +8,49 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Pins the compatibility promise made when {@link CvEntry} grew a location - * and a mark: the four-argument constructor that predates them is still - * there and still means what it meant, so a caller written against it keeps - * compiling and linking. + * Pins the compatibility promise made each time {@link CvEntry} grew: the + * constructors that predate the location, the mark and the link are still + * there and still mean what they meant, so a caller written against any of + * them keeps compiling and linking. */ class CvEntryPlaceAndIconTest { @Test - void theConstructorThatPredatesThemLeavesBothBlank() { + void theConstructorThatPredatesThemLeavesThemAllBlank() { CvEntry entry = new CvEntry("Engineer", "Acme", "2021", "Did the work."); assertThat(entry.place()).isEmpty(); assertThat(entry.icon()).isEmpty(); + assertThat(entry.link()).isEmpty(); assertThat(entry.subtitle()).isEqualTo("Acme"); } + @Test + void theConstructorThatPredatesTheLinkLeavesItBlank() { + CvEntry entry = new CvEntry("Engineer", "Acme", "2021", "Body", "Berlin", "cart"); + assertThat(entry.link()).isEmpty(); + assertThat(entry.place()).isEqualTo("Berlin"); + assertThat(entry.icon()).isEqualTo("cart"); + } + @Test void nullsNormalizeToBlank() { - CvEntry entry = new CvEntry("Engineer", "Acme", "2021", "Body", null, null); + CvEntry entry = new CvEntry("Engineer", "Acme", "2021", "Body", null, null, null); assertThat(entry.place()).isEmpty(); assertThat(entry.icon()).isEmpty(); + assertThat(entry.link()).isEmpty(); } @Test void theOriginalFieldsStillRejectNull() { - assertThatThrownBy(() -> new CvEntry("Engineer", null, "2021", "Body", "Berlin", "cart")) + assertThatThrownBy(() -> + new CvEntry("Engineer", null, "2021", "Body", "Berlin", "cart", "")) .isInstanceOf(NullPointerException.class) .hasMessageContaining("subtitle"); } @Test void aBlankTitleIsStillRejected() { - assertThatThrownBy(() -> new CvEntry(" ", "Acme", "2021", "Body", "", "")) + assertThatThrownBy(() -> new CvEntry(" ", "Acme", "2021", "Body", "", "", "")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("title"); } @@ -52,9 +63,11 @@ void theBuilderCarriesEveryFieldThrough() { .body("An open-source ledger.") .place("Remote") .icon("cart") + .link("https://example.com/ledgerkit") .build(); assertThat(entry).isEqualTo(new CvEntry("Ledgerkit", "Java, PostgreSQL", "2024", - "An open-source ledger.", "Remote", "cart")); + "An open-source ledger.", "Remote", "cart", + "https://example.com/ledgerkit")); } @Test @@ -65,6 +78,7 @@ void theBuilderLeavesWhatItIsNotToldBlank() { assertThat(entry.body()).isEmpty(); assertThat(entry.place()).isEmpty(); assertThat(entry.icon()).isEmpty(); + assertThat(entry.link()).isEmpty(); } @Test @@ -85,9 +99,11 @@ void theBuilderTreatsNullsAsBlank() { .body((String) null) .place(null) .icon(null) + .link(null) .build(); assertThat(entry.subtitle()).isEmpty(); assertThat(entry.body()).isEmpty(); assertThat(entry.place()).isEmpty(); + assertThat(entry.link()).isEmpty(); } } diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineSmokeTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineSmokeTest.java index a90b665b4..e6b51769b 100644 --- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineSmokeTest.java +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineSmokeTest.java @@ -139,6 +139,43 @@ void rejectsAnUnknownEntryMarkByName() { } } + @Test + void anEntryWithALinkReachesThePdfAsOne() throws Exception { + // Every title this preset draws is a link when its entry carries + // one. It is an annotation, so it moves no pixel and no layout node + // — which is why neither parity gate can see it. + byte[] pdf = render(CvDocument.builder() + .identity(identity()) + .section(EntriesSection.builder("Projects") + .entry(CvEntry.builder("Ledgerkit") + .subtitle("Java") + .body("A ledger.") + .icon("api") + .link("https://example.com/ledgerkit") + .build()) + .build()) + .section(EntriesSection.builder("Experience") + .entry(CvEntry.builder("Engineer") + .subtitle("Acme") + .date("2024") + .body("Shipped a thing.") + .link("https://acme.example.com") + .build()) + .build()) + .section(EntriesSection.builder("Achievements") + .entry(CvEntry.builder("Engineer of the Year") + .body("For the platform work.") + .icon("trophy") + .link("https://example.com/award") + .build()) + .build()) + .build()); + assertThat(linkUris(pdf)) + .contains("https://example.com/ledgerkit", + "https://acme.example.com", + "https://example.com/award"); + } + @Test void drawsAnEntryWithoutAMark() throws Exception { // No token, no plate — the card is drawn, just unmarked. diff --git a/qa/src/test/resources/layout-snapshots/canonical-templates/cv-v2/serif_headline_layout.json b/qa/src/test/resources/layout-snapshots/canonical-templates/cv-v2/serif_headline_layout.json index c427e8259..e124848d1 100644 --- a/qa/src/test/resources/layout-snapshots/canonical-templates/cv-v2/serif_headline_layout.json +++ b/qa/src/test/resources/layout-snapshots/canonical-templates/cv-v2/serif_headline_layout.json @@ -1705,15 +1705,15 @@ "computedY" : 218.426, "placementX" : 31.682, "placementY" : 218.426, - "placementWidth" : 168.307, + "placementWidth" : 161.798, "placementHeight" : 52.675, "startPage" : 0, "endPage" : 0, - "contentWidth" : 168.307, + "contentWidth" : 161.798, "contentHeight" : 52.675, "margin" : { "top" : 0.0, - "right" : 171.488, + "right" : 177.301, "bottom" : 0.0, "left" : 0.0 }, @@ -1795,11 +1795,11 @@ "computedY" : 218.426, "placementX" : 64.818, "placementY" : 218.426, - "placementWidth" : 135.172, + "placementWidth" : 128.662, "placementHeight" : 27.484, "startPage" : 0, "endPage" : 0, - "contentWidth" : 135.172, + "contentWidth" : 128.662, "contentHeight" : 27.484, "margin" : { "top" : 0.0, @@ -1825,15 +1825,15 @@ "computedY" : 218.426, "placementX" : 203.17, "placementY" : 218.426, - "placementWidth" : 167.99, + "placementWidth" : 164.738, "placementHeight" : 52.675, "startPage" : 0, "endPage" : 0, - "contentWidth" : 167.99, + "contentWidth" : 164.738, "contentHeight" : 52.675, "margin" : { "top" : 0.0, - "right" : 0.0, + "right" : 5.813, "bottom" : 0.0, "left" : 171.488 }, @@ -1915,11 +1915,11 @@ "computedY" : 218.426, "placementX" : 244.444, "placementY" : 218.426, - "placementWidth" : 126.716, + "placementWidth" : 123.464, "placementHeight" : 27.484, "startPage" : 0, "endPage" : 0, - "contentWidth" : 126.716, + "contentWidth" : 123.464, "contentHeight" : 27.484, "margin" : { "top" : 0.0, @@ -5193,7 +5193,7 @@ "contentHeight" : 26.204, "margin" : { "top" : 0.0, - "right" : 425.529, + "right" : 431.342, "bottom" : 0.0, "left" : 0.0 }, @@ -5283,7 +5283,7 @@ "contentHeight" : 26.204, "margin" : { "top" : 0.0, - "right" : 319.147, + "right" : 324.96, "bottom" : 0.0, "left" : 106.382 }, @@ -5373,7 +5373,7 @@ "contentHeight" : 26.204, "margin" : { "top" : 0.0, - "right" : 212.765, + "right" : 218.578, "bottom" : 0.0, "left" : 212.765 }, @@ -5463,7 +5463,7 @@ "contentHeight" : 26.204, "margin" : { "top" : 0.0, - "right" : 106.382, + "right" : 112.196, "bottom" : 0.0, "left" : 319.147 }, @@ -5553,7 +5553,7 @@ "contentHeight" : 26.204, "margin" : { "top" : 0.0, - "right" : 0.0, + "right" : 5.813, "bottom" : 0.0, "left" : 425.529 }, @@ -6513,7 +6513,7 @@ "contentHeight" : 36.181, "margin" : { "top" : 0.0, - "right" : 354.608, + "right" : 360.421, "bottom" : 0.0, "left" : 0.0 }, @@ -6595,15 +6595,15 @@ "computedY" : 43.938, "placementX" : 208.986, "placementY" : 43.938, - "placementWidth" : 173.765, + "placementWidth" : 166.571, "placementHeight" : 36.181, "startPage" : 0, "endPage" : 0, - "contentWidth" : 173.765, + "contentWidth" : 166.571, "contentHeight" : 36.181, "margin" : { "top" : 0.0, - "right" : 177.304, + "right" : 183.117, "bottom" : 0.0, "left" : 177.304 }, @@ -6655,11 +6655,11 @@ "computedY" : 43.938, "placementX" : 249.097, "placementY" : 43.938, - "placementWidth" : 133.653, + "placementWidth" : 126.46, "placementHeight" : 17.849, "startPage" : 0, "endPage" : 0, - "contentWidth" : 133.653, + "contentWidth" : 126.46, "contentHeight" : 17.849, "margin" : { "top" : 0.0, @@ -6693,7 +6693,7 @@ "contentHeight" : 36.181, "margin" : { "top" : 0.0, - "right" : 0.0, + "right" : 5.813, "bottom" : 0.0, "left" : 354.608 }, diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/serif_headline-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/serif_headline-page-0.png index efab7573d..b9dab2b90 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/serif_headline-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/serif_headline-page-0.png differ diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java index 025805f15..91a27c9f9 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java @@ -12,8 +12,9 @@ *

Blank fields are honoured: a blank {@code date} omits the date * column, a blank {@code subtitle} drops the italic line, a blank * {@code body} drops the description paragraph, a blank {@code place} - * drops the location, and a blank {@code icon} leaves the entry unmarked. - * A preset draws only what it has somewhere to put.

+ * drops the location, a blank {@code icon} leaves the entry unmarked, and a + * blank {@code link} leaves its title plain. A preset draws only what it has + * somewhere to put.

* * @param title bold heading on the left (job title, degree) * @param subtitle italic subtitle on the line below (employer, @@ -32,14 +33,18 @@ * set — so a token means something only to the preset that * declares it, and the presets that draw no marks ignore it; * blank when absent + * @param link where this entry points — a repository, a case study, a + * company. The presets that honour it make the title a link + * in the rendered PDF, which costs the layout nothing: a + * link is an annotation, not ink. Blank when absent */ public record CvEntry(String title, String subtitle, String date, String body, - String place, String icon) { + String place, String icon, String link) { /** * Validates that the four original fields are non-null and that - * {@code title} is non-blank, treating a null {@code place} or - * {@code icon} as absent. + * {@code title} is non-blank, treating a null {@code place}, + * {@code icon} or {@code link} as absent. */ public CvEntry { Objects.requireNonNull(title, "title"); @@ -48,11 +53,27 @@ public record CvEntry(String title, String subtitle, String date, String body, Objects.requireNonNull(body, "body"); place = place == null ? "" : place; icon = icon == null ? "" : icon; + link = link == null ? "" : link; if (title.isBlank()) { throw new IllegalArgumentException("title must not be blank"); } } + /** + * Backward-compatible constructor for callers that predate the link. + * + * @param title bold heading on the left + * @param subtitle subtitle on the line below; blank collapses it + * @param date date column next to the title; blank removes it + * @param body prose paragraph beneath the subtitle + * @param place where this happened; blank when absent + * @param icon the mark a preset draws for this entry; blank when absent + */ + public CvEntry(String title, String subtitle, String date, String body, + String place, String icon) { + this(title, subtitle, date, body, place, icon, ""); + } + /** * Backward-compatible constructor for callers that predate the location * and the mark. The entry simply carries neither. @@ -63,7 +84,7 @@ public record CvEntry(String title, String subtitle, String date, String body, * @param body prose paragraph beneath the subtitle */ public CvEntry(String title, String subtitle, String date, String body) { - this(title, subtitle, date, body, "", ""); + this(title, subtitle, date, body, "", "", ""); } /** @@ -91,6 +112,7 @@ public static final class Builder { private String body = ""; private String place = ""; private String icon = ""; + private String link = ""; private Builder(String title) { this.title = title; @@ -164,13 +186,25 @@ public Builder icon(String value) { return this; } + /** + * Sets where this entry points. The presets that honour it make the + * title a link. + * + * @param value the target URL; null becomes blank + * @return this builder for chaining + */ + public Builder link(String value) { + this.link = value == null ? "" : value; + return this; + } + /** * Builds the immutable {@link CvEntry}. * * @return the assembled entry */ public CvEntry build() { - return new CvEntry(title, subtitle, date, body, place, icon); + return new CvEntry(title, subtitle, date, body, place, icon, link); } } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadline.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadline.java index 9db112b75..b4c37d4d3 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadline.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadline.java @@ -39,6 +39,11 @@ * type subtracted; setting the pixel numbers directly would push every block * a few points too far apart.

* + *

One measure is deliberately not the drawing's: a band column keeps a + * gutter at its right edge, so its text stops short of the hairline instead + * of running into it. In the published sheet a line that happens to fill its + * column touches the rule.

+ * *

One page, and what happens past it

* *

This sheet holds one page of content. The body is a single row, and a @@ -84,6 +89,17 @@ * with no token is drawn without a mark. The certification medal is chrome * rather than data: every plate takes the same one.

* + *

The marks come in two colours, which is worth knowing before choosing + * one: {@code cart} and {@code api} are drawn in the design's navy ink, and + * {@code trophy}, {@code chart} and {@code rocket} in its gold. Nothing + * stops a document mixing them, but a band that does reads as a mistake + * rather than a choice.

+ * + *

Every title this preset draws — a role, a project, a degree, an + * achievement — becomes a link when its entry carries {@code CvEntry.link()}. + * A link is an annotation rather than ink, so a linked title and a plain one + * are the same sheet.

+ * *

The employer's city and the campus come from {@code CvEntry.place()}, * because this design sets them beside the employer and the years in their * own colour rather than inside them.

diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineAside.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineAside.java index 76d1c2710..a5dddadf7 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineAside.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineAside.java @@ -105,13 +105,15 @@ private static void renderEducation(SectionBuilder section, EntriesSection educa for (int i = 0; i < entries.size(); i++) { CvEntry entry = entries.get(i); boolean first = i == 0; - section.addParagraph(p -> p - .name("Degree_" + compact(entry.title())) - .text(entry.title()) - .textStyle(style(ITEM_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)) - .lineSpacing(TIGHT_LEADING) - .margin(new DocumentInsets( - first ? HEADING_TO_EDUCATION : 0, 0, DEGREE_TO_INSTITUTION, 0))); + section.addParagraph(p -> { + p.name("Degree_" + compact(entry.title())) + .textStyle(style(ITEM_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)) + .lineSpacing(TIGHT_LEADING); + SerifHeadlineText.title(p, entry, + style(ITEM_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)); + p.margin(new DocumentInsets( + first ? HEADING_TO_EDUCATION : 0, 0, DEGREE_TO_INSTITUTION, 0)); + }); section.addParagraph(p -> p .name("Institution_" + compact(entry.title())) .text(entry.subtitle()) diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineClosing.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineClosing.java index cd9aed9f1..5a7e5f024 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineClosing.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineClosing.java @@ -120,12 +120,14 @@ private static BandColumn achievementColumn(CvEntry achievement, int index) { // title it stands beside and the two centre on each other. .padding(new DocumentInsets( ACHIEVEMENT_TEXT_DROP, 0, 0, lead + ACHIEVEMENT_GLYPH_TO_TEXT)); - column.addParagraph(p -> p - .name("AchievementTitle_" + compact(achievement.title())) - .text(achievement.title()) - .textStyle(style(ITEM_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)) - .lineSpacing(TIGHT_LEADING) - .margin(new DocumentInsets(0, 0, ACHIEVEMENT_TITLE_TO_BODY, 0))); + column.addParagraph(p -> { + p.name("AchievementTitle_" + compact(achievement.title())) + .textStyle(style(ITEM_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)) + .lineSpacing(TIGHT_LEADING); + SerifHeadlineText.title(p, achievement, + style(ITEM_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)); + p.margin(new DocumentInsets(0, 0, ACHIEVEMENT_TITLE_TO_BODY, 0)); + }); column.addParagraph(p -> p .name("AchievementBody_" + compact(achievement.title())) .text(achievement.body()) diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineMain.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineMain.java index 17bf94e21..f030d47a2 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineMain.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineMain.java @@ -132,13 +132,13 @@ private static void renderEntryHead(SectionBuilder rail, CvEntry entry) { .fillColor(INK) .margin(DocumentInsets.zero()) .build(); - DocumentNode title = new ParagraphBuilder() + ParagraphBuilder titleText = new ParagraphBuilder() .name("JobTitle_" + compact(entry.title())) - .text(entry.title()) .textStyle(style(JOB_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)) - .lineSpacing(TIGHT_LEADING) - .margin(DocumentInsets.zero()) - .build(); + .lineSpacing(TIGHT_LEADING); + SerifHeadlineText.title(titleText, entry, + style(JOB_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)); + DocumentNode title = titleText.margin(DocumentInsets.zero()).build(); DocumentNode dates = new ParagraphBuilder() .name("JobDates_" + compact(entry.title())) .text(entry.date()) @@ -226,12 +226,14 @@ private static BandColumn projectColumn(CvEntry project, int index) { column.name("ProjectCard_" + compact(project.title())) .spacing(0) .padding(new DocumentInsets(0, 0, 0, lead + PROJECT_GLYPH_TO_TEXT)); - column.addParagraph(p -> p - .name("ProjectTitle_" + compact(project.title())) - .text(project.title()) - .textStyle(style(ITEM_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)) - .lineSpacing(TIGHT_LEADING) - .margin(new DocumentInsets(0, 0, CARD_TITLE_TO_TECH, 0))); + column.addParagraph(p -> { + p.name("ProjectTitle_" + compact(project.title())) + .textStyle(style(ITEM_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)) + .lineSpacing(TIGHT_LEADING); + SerifHeadlineText.title(p, project, + style(ITEM_TITLE_SIZE, INK, DocumentTextDecoration.BOLD)); + p.margin(new DocumentInsets(0, 0, CARD_TITLE_TO_TECH, 0)); + }); column.addParagraph(p -> p .name("ProjectStack_" + compact(project.title())) .text(project.subtitle()) diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineStyles.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineStyles.java index e24543155..c5246072f 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineStyles.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineStyles.java @@ -245,6 +245,18 @@ static double titleOffset(double leftSurfaceDistance) { /** The plate hangs a little left of its column, into the gutter. */ static final double PLATE_HANG = h(-4); static final double GLYPH_CLEARANCE = h(10); + + /** + * The space a band column leaves at its right edge, so its text stops + * short of the hairline rather than running into it. + * + *

This is the one measure on the sheet that is not the published + * design's: there, a column's text runs to the separator, and a line + * that happens to fill the column touches it. The gutter matches + * {@link #GLYPH_CLEARANCE}, the space the next column's mark already + * keeps on the other side, so the rule sits centred in white.

+ */ + static final double COLUMN_TAIL_GUTTER = GLYPH_CLEARANCE; static final double CARD_TITLE_TO_TECH = gap(15, BLANK_TITLE, BLANK_SMALL); static final double CARD_TECH_TO_BODY = gap(12, BLANK_SMALL, BLANK_SMALL); static final double CARD_BODY_LEADING = v(18.5) / SMALL_SIZE; @@ -379,10 +391,27 @@ private static double capAdvanceEm(char glyph) { */ static DocumentInsets columnInsets(double bandWidth, double columnWidth, int index, double dx, double naturalWidth) { + return columnInsets(bandWidth, columnWidth, index, dx, naturalWidth, 0.0); + } + + /** + * The same, with a gutter kept at the column's right edge. + * + * @param bandWidth the band's full width + * @param columnWidth one column's share of it + * @param index which column, from zero + * @param dx an extra offset for a mark that hangs left + * @param naturalWidth the child's own width when it has one, else zero + * @param tailGutter space to leave at the right edge + * @return the insets to set on that layer + */ + static DocumentInsets columnInsets(double bandWidth, double columnWidth, + int index, double dx, double naturalWidth, + double tailGutter) { double left = index * columnWidth + dx; double right = naturalWidth > 0.0 ? Math.max(0.0, bandWidth - left - naturalWidth) - : Math.max(0.0, bandWidth - left - (columnWidth - dx)); + : Math.max(0.0, bandWidth - left - (columnWidth - dx) + tailGutter); return new DocumentInsets(0, right, 0, left); } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineText.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineText.java index 89f46eab2..a30914a0c 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineText.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineText.java @@ -1,11 +1,15 @@ package com.demcha.compose.document.templates.cv.presets; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.templates.core.text.MarkdownInline; +import com.demcha.compose.document.templates.cv.data.CvEntry; import java.util.ArrayList; import java.util.List; -/** Turning a model field into the lines this sheet draws. */ +/** Turning model fields into the runs and lines this sheet draws. */ final class SerifHeadlineText { private SerifHeadlineText() { @@ -28,4 +32,24 @@ static List lines(String body) { } return out; } + + /** + * Writes an entry's title into a paragraph, as a link when the entry + * carries one. + * + *

The link is the same ink either way — an annotation draws nothing + * and lays out nothing — so a linked title and a plain one are the same + * sheet.

+ * + * @param paragraph the paragraph being built + * @param entry the entry whose title and link to write + * @param style the style of the title + */ + static void title(ParagraphBuilder paragraph, CvEntry entry, DocumentTextStyle style) { + if (entry.link().isBlank()) { + paragraph.inlineText(entry.title(), style); + } else { + paragraph.inlineText(entry.title(), style, new DocumentLinkOptions(entry.link())); + } + } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineWidgets.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineWidgets.java index 14e4827ff..8dd32fb0e 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineWidgets.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SerifHeadlineWidgets.java @@ -25,6 +25,7 @@ import static com.demcha.compose.document.templates.cv.presets.SerifHeadlineStyles.RULE; import static com.demcha.compose.document.templates.cv.presets.SerifHeadlineStyles.TAIL_GAP; import static com.demcha.compose.document.templates.cv.presets.SerifHeadlineStyles.TIGHT_LEADING; +import static com.demcha.compose.document.templates.cv.presets.SerifHeadlineStyles.COLUMN_TAIL_GUTTER; import static com.demcha.compose.document.templates.cv.presets.SerifHeadlineStyles.columnInsets; import static com.demcha.compose.document.templates.cv.presets.SerifHeadlineStyles.compact; import static com.demcha.compose.document.templates.cv.presets.SerifHeadlineStyles.dashOffset; @@ -184,8 +185,12 @@ static void columnBand(SectionBuilder section, String name, double bandWidth, .padding(DocumentInsets.zero()) .margin(new DocumentInsets(gapAbove, 0, 0, 0)); for (int i = 0; i < columns; i++) { + // The text keeps a gutter at its right edge; the marks and + // the separators do not, since they are placed on the column + // boundary rather than filling to it. stack.layer(parts.get(i).text() - .margin(columnInsets(bandWidth, columnWidth, i, 0.0, 0.0)) + .margin(columnInsets(bandWidth, columnWidth, i, 0.0, 0.0, + COLUMN_TAIL_GUTTER)) .build(), LayerAlign.TOP_LEFT); } for (int i = 0; i < columns; i++) {