From 065ff888e536362607574c02819064fe44020a6f Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 31 Aug 2026 14:22:42 +0100
Subject: [PATCH] feat(templates): link every title Terracotta Rail draws
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Only a project title could point somewhere. A role, a degree and a credential
are just as often things a reader wants to open — a company, a campus, a
registry entry — and the model already carries the target on every entry.
Each of them is now a link when its entry carries CvEntry.link(). It costs the
layout nothing, because a link is an annotation rather than ink: both gates
pass on the baselines recorded before this change, which is the proof.
The closing facts stay plain. Their bold line is a label for the values under
it — "Languages:", "Availability:" — rather than the name of something a
reader could open.
---
CHANGELOG.md | 7 +--
.../cv/presets/TerracottaRailSmokeTest.java | 40 +++++++++++++++++
.../templates/cv/presets/TerracottaRail.java | 12 ++++--
.../cv/presets/TerracottaRailAside.java | 7 +--
.../cv/presets/TerracottaRailMain.java | 5 ++-
.../cv/presets/TerracottaRailWidgets.java | 43 ++++++++++++++-----
6 files changed, 93 insertions(+), 21 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f04c042f6..cef198cb3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -96,7 +96,8 @@ follow semantic versioning; release dates are ISO 8601.
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,
- a project title is a link when its entry carries one, and the monogram is drawn from
+ 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
@@ -111,8 +112,8 @@ follow semantic versioning; release dates are ISO 8601.
`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, the four contact rows sharing one axis and the one-page limit), an exact
- layout snapshot and a pixel-parity gate;
+ 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
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailSmokeTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailSmokeTest.java
index 1cbf9fc03..86332096f 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailSmokeTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailSmokeTest.java
@@ -173,6 +173,46 @@ void everyContactRowStartsOnTheSameAxis() throws Exception {
}
}
+ @Test
+ void everyTitleBecomesALinkWhenItsEntryCarriesOne() throws Exception {
+ // A role, a degree and a credential, each pointing somewhere. None of
+ // it moves a pixel or a layout node, so this is the only thing that
+ // would notice the annotations going missing.
+ List placements = new ArrayList<>();
+ for (CvDocument.Placement placement : TerracottaRailFixtures.canonicalCv().placements()) {
+ placements.add(switch (placement.section().title()) {
+ case "PROFESSIONAL EXPERIENCE" -> new CvDocument.Placement(Slot.MAIN,
+ new EntriesSection("PROFESSIONAL EXPERIENCE", List.of(
+ CvEntry.builder("Senior Architect")
+ .subtitle("Northline Studio, Bristol, UK")
+ .date("2021")
+ .link("https://example.test/northline")
+ .body("Lead design packages.")
+ .build())));
+ case "EDUCATION" -> new CvDocument.Placement(Slot.MAIN,
+ new EntriesSection("EDUCATION", List.of(
+ CvEntry.builder("MArch Architecture")
+ .subtitle("University of Sheffield")
+ .date("2014")
+ .link("https://example.test/sheffield")
+ .build())));
+ case "CERTIFICATIONS" -> new CvDocument.Placement(Slot.SIDEBAR,
+ new EntriesSection("CERTIFICATIONS", List.of(
+ CvEntry.builder("ARB Registered Architect")
+ .link("https://example.test/arb")
+ .build())));
+ default -> placement;
+ });
+ }
+
+ List targets = linkTargets(
+ render(new CvDocument(TerracottaRailFixtures.identity(), placements)));
+ assertThat(targets)
+ .contains("https://example.test/northline")
+ .contains("https://example.test/sheffield")
+ .contains("https://example.test/arb");
+ }
+
@Test
void aProjectTitleBecomesALinkWhenItsEntryCarriesOne() throws Exception {
EntriesSection linked = new EntriesSection("SELECTED PROJECTS", List.of(
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRail.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRail.java
index 06dd1b06e..2dc44a148 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRail.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRail.java
@@ -72,9 +72,15 @@
* {@code CvEntry.icon()}, from this preset's own vocabulary —
* {@code globe}, {@code badge}, {@code clock}, {@code building},
* {@code hotel}, {@code house} — and an unknown token is reported as a data
- * error naming the set. An entry with no token is drawn without a mark, and
- * a project title becomes a link when its entry carries
- * {@code CvEntry.link()}.
+ * error naming the set. An entry with no token is drawn without a mark.
+ *
+ * Every title the preset draws — a role, a project, a degree, a
+ * credential — becomes a link when its entry carries {@code CvEntry.link()}.
+ * It costs the layout nothing, because a link is an annotation rather than
+ * ink, so a linked title and a plain one are the same line. The closing facts
+ * are the exception: their bold line is a label for the values under it —
+ * "Languages:", "Availability:" — rather than the name of something a reader
+ * could open.
*
* The email, the phone and each link are reachable from the PDF, with the
* {@code mailto:} and {@code tel:} targets built from the values. A link is
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailAside.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailAside.java
index b9a8aa33e..50e82e123 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailAside.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailAside.java
@@ -213,12 +213,12 @@ private static void renderBulletedSkills(SectionBuilder side, SkillsSection sect
String name = prefix + "_" + index++;
if (discBullet) {
bulletLine(block, name, p -> p.dot(3.0, ACCENT), skill.name(),
- INK, DETAIL_SIZE);
+ INK, DETAIL_SIZE, null);
} else {
bulletLine(block, name,
p -> inlineIcon(p, TerracottaRailIcons.SQUARE,
TerracottaRailIcons.BULLET_SIZE),
- skill.name(), INK, DETAIL_SIZE);
+ skill.name(), INK, DETAIL_SIZE, null);
}
}
}
@@ -232,8 +232,9 @@ private static void renderCertifications(SectionBuilder side, EntriesSection sec
headingWithDash(block, section.title(), SIDEBAR_HEADING_SPACER, SIDEBAR_DASH_WIDTH);
List entries = section.entries();
for (int index = 0; index < entries.size(); index++) {
+ CvEntry entry = entries.get(index);
bulletLine(block, "Certification_" + index, p -> p.dot(3.0, ACCENT),
- entries.get(index).title(), INK, DETAIL_SIZE);
+ entry.title(), INK, DETAIL_SIZE, entry.link());
}
});
}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailMain.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailMain.java
index 985c12538..c9bc38fc4 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailMain.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailMain.java
@@ -172,7 +172,7 @@ private static void renderExperience(SectionBuilder main, EntriesSection experie
body.padding(0f, 0f, last ? 0f : (float) ENTRY_GAP, (float) ENTRY_INDENT);
railedLine(body, "Role", index,
titleAndDate("RoleTable_" + index, entry.title(), entry.date(),
- ENTRY_WIDTH, ROLE_PERIOD_SHARE));
+ ENTRY_WIDTH, ROLE_PERIOD_SHARE, entry.link()));
body.addParagraph(p -> p
.name("Employer_" + index)
.text(entry.subtitle())
@@ -301,7 +301,8 @@ private static void renderEducation(SectionBuilder main, EntriesSection educatio
(float) ENTRY_INDENT);
railedLine(body, "Edu", index,
titleAndDate("EduTable_" + index, entry.title(), entry.date(),
- EDUCATION_ENTRY_WIDTH, EDUCATION_PERIOD_SHARE));
+ EDUCATION_ENTRY_WIDTH, EDUCATION_PERIOD_SHARE,
+ entry.link()));
body.addParagraph(p -> p
.name("Institution_" + index)
.text(entry.subtitle())
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailWidgets.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailWidgets.java
index 590883f1a..4a70b3b2c 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailWidgets.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/TerracottaRailWidgets.java
@@ -5,6 +5,7 @@
import com.demcha.compose.document.dsl.RowBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.dsl.TableBuilder;
+import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.LayerAlign;
@@ -194,10 +195,30 @@ static void railedLine(SectionBuilder body, String prefix, int index, DocumentNo
* — it spans the cell and its columns lay out. Both cells hold a
* paragraph, because a table inside a row cell draws only leaf
* content.
+ *
+ * The title carries the entry's link when it has one. That costs the
+ * layout nothing — a link is an annotation rather than ink — so a linked
+ * title and a plain one are the same line.
+ *
+ * @param name the node name this line's parts are built from
+ * @param title the entry's title
+ * @param date the date, set flush right
+ * @param width the line's width
+ * @param dateShare how much of that width the date takes
+ * @param link the target the title points at, blank for none
+ * @return the line node
*/
static DocumentNode titleAndDate(String name, String title, String date,
- double width, double dateShare) {
+ double width, double dateShare, String link) {
double dateColumn = width * dateShare;
+ ParagraphBuilder titleCell = new ParagraphBuilder()
+ .name(name + "_Title")
+ .text(title)
+ .lineSpacing(0)
+ .textStyle(text(ITEM_TITLE_SIZE, INK, true));
+ if (link != null && !link.isBlank()) {
+ titleCell.link(new DocumentLinkOptions(link));
+ }
return new TableBuilder()
.name(name)
.width(width)
@@ -209,12 +230,7 @@ static DocumentNode titleAndDate(String name, String title, String date,
.stroke(NO_BORDER)
.build())
.rowCells(
- DocumentTableCell.node(new ParagraphBuilder()
- .name(name + "_Title")
- .text(title)
- .lineSpacing(0)
- .textStyle(text(ITEM_TITLE_SIZE, INK, true))
- .build()),
+ DocumentTableCell.node(titleCell.build()),
DocumentTableCell.node(new ParagraphBuilder()
.name(name + "_Period")
.text(date)
@@ -246,14 +262,21 @@ static void layeredRow(SectionBuilder parent, String name, double marginTop,
.layer(layer.build(), LayerAlign.TOP_LEFT, 0));
}
- /** A bullet line: a mark, a gap, and the item beside it. */
+ /**
+ * A bullet line: a mark, a gap, and the item beside it — carrying a link
+ * when one is given.
+ */
static void bulletLine(SectionBuilder block, String name, Consumer mark,
- String item, DocumentColor color, double size) {
+ String item, DocumentColor color, double size, String link) {
block.addParagraph(p -> {
p.name(name);
mark.accept(p);
p.inlineText(" ");
- p.inlineText(item, text(size, color, false));
+ if (link == null || link.isBlank()) {
+ p.inlineText(item, text(size, color, false));
+ } else {
+ p.inlineText(item, text(size, color, false), new DocumentLinkOptions(link));
+ }
p.margin(0f, 0f, (float) BULLET_ROW_GAP, 0f);
});
}