From e15b9dc5b039a61271605ed0795a9701ce04e716 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 30 Aug 2026 23:46:47 +0100
Subject: [PATCH] feat(templates): make the Navy Sidebar contact channels
clickable
---
CHANGELOG.md | 12 ++--
assets/readme/examples/cv-navy-sidebar-v2.pdf | Bin 33670 -> 34375 bytes
.../cv/presets/NavySidebarSmokeTest.java | 32 +++++++++++
.../templates/cv/presets/NavySidebar.java | 10 ++--
.../cv/presets/NavySidebarAside.java | 54 +++++++++++++-----
5 files changed, 84 insertions(+), 24 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f2d0d97d..33772dcd5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,10 +60,14 @@ follow semantic versioning; release dates are ISO 8601.
`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. Guarded by a smoke test (including 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`.
+ disc. 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
diff --git a/assets/readme/examples/cv-navy-sidebar-v2.pdf b/assets/readme/examples/cv-navy-sidebar-v2.pdf
index 1ba8f2fcddeffaad539905b79dd61176eadf3de2..5ebdbace0f9f726095f61d846ca779a581c919b1 100644
GIT binary patch
delta 2649
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
T*wSGL1OP`yn3&K{xFG%oGb=C|
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java
index 58322a979..96b6c30ce 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java
@@ -17,9 +17,14 @@
import com.demcha.compose.document.templates.cv.data.SkillsSection;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
+import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -51,6 +56,21 @@ private static String textOf(byte[] pdfBytes) throws Exception {
}
}
+ private static List linkUris(byte[] pdfBytes) throws Exception {
+ List uris = new ArrayList<>();
+ try (PDDocument document = Loader.loadPDF(pdfBytes)) {
+ for (PDPage page : document.getPages()) {
+ for (PDAnnotation annotation : page.getAnnotations()) {
+ if (annotation instanceof PDAnnotationLink link
+ && link.getAction() instanceof PDActionURI action) {
+ uris.add(action.getURI());
+ }
+ }
+ }
+ }
+ return uris;
+ }
+
@Test
void exposesStableIdentity() {
DocumentTemplate template = NavySidebar.create();
@@ -77,6 +97,18 @@ void canonicalRenderCarriesTheUntrackedText() throws Exception {
.contains("HubSpot Content Marketing Certification");
}
+ @Test
+ void channelsReachThePdfAsLinks() throws Exception {
+ // The dial and mail targets are built from the values, so nothing in
+ // the document carries them and nothing but the PDF can show them.
+ // They are annotations, so they move no pixel and no layout node —
+ // which is why the parity gates cannot see them either.
+ assertThat(linkUris(render(NavySidebarFixtures.canonicalCv())))
+ .contains("tel:+15551234567",
+ "mailto:your.email@gmail.com",
+ "https://linkedin.com/in/yourname");
+ }
+
@Test
void rendersOneCanonicalPage() throws Exception {
try (PDDocument document =
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java
index de980283a..91f970705 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java
@@ -75,11 +75,11 @@
* without one draws the ring around an empty navy disc rather than leaving a
* hole in the column.
*
- * The contact rows carry no link targets — the design draws the channels
- * as text, and the port keeps that. The packaged set has one network mark,
- * so every link the identity carries is drawn behind it whatever the network
- * is; a document that lists more than one profile should say which is which
- * in the label.
+ * The phone, the email and each link are reachable from the PDF, with the
+ * {@code tel:} and {@code mailto:} targets built from the values. The
+ * packaged set has one network mark, so every link the identity carries is
+ * drawn behind it whatever the network is; a document that lists more than
+ * one profile should say which is which in the label.
*
* A channel is one paragraph, the mark and the value sharing a line, so a
* value wider than the sidebar's text column wraps under the mark and leaves
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java
index face6f2b9..47dad59df 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java
@@ -6,6 +6,7 @@
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.dsl.ShapeContainerBuilder;
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.InlineImageAlignment;
import com.demcha.compose.document.node.LayerAlign;
@@ -147,9 +148,11 @@ private static DocumentNode emptyDisc() {
* The contact channels, in the order the design sets them: phone, email,
* address, then whatever links the identity carries.
*
- *
The rows carry no link targets. The design draws them as text, and
- * the promotion keeps that — a clickable channel would be a change to
- * the sheet rather than a port of it.
+ * The phone, the email and each link are reachable from the PDF: the
+ * dial and mail targets are built from the values, so a document does
+ * not carry the same address twice. The annotations sit beside the
+ * content stream and draw nothing, which is why a clickable channel is
+ * the same ink as a plain one.
*/
private static void renderContact(SectionBuilder section, CvIdentity identity) {
sidebarHeading(section, CONTACT_HEADING, true);
@@ -160,34 +163,55 @@ private static void renderContact(SectionBuilder section, CvIdentity identity) {
private static void renderChannel(SectionBuilder section, Channel channel) {
double size = NavySidebarIcons.size(channel.token());
- section.addParagraph(p -> p
+ DocumentLinkOptions link = channel.href() == null
+ ? null
+ : new DocumentLinkOptions(channel.href());
+ ParagraphBuilder row = new ParagraphBuilder()
.name("Contact_" + compact(channel.token()))
.textStyle(sidebarBody())
.inlineImage(NavySidebarIcons.image(channel.token()), size, size,
- InlineImageAlignment.CENTER, 0.0, null)
- // The gap between the mark and the value is set as spaces
- // rather than an indent: the row is one paragraph, so the
- // mark and the text share a baseline and wrap together.
- .inlineText(" " + channel.value(), sidebarBody())
- .margin(new DocumentInsets(0, 0, CONTACT_ROW_GAP, 0)));
+ InlineImageAlignment.CENTER, 0.0, link);
+ // The gap between the mark and the value is set as spaces rather than
+ // an indent: the row is one paragraph, so the mark and the text share
+ // a baseline and wrap together.
+ if (link == null) {
+ row.inlineText(" " + channel.value(), sidebarBody());
+ } else {
+ row.inlineText(" " + channel.value(), sidebarBody(), link);
+ }
+ section.add(row.margin(new DocumentInsets(0, 0, CONTACT_ROW_GAP, 0)).build());
}
private static List channels(CvIdentity identity) {
List channels = new ArrayList<>();
- channels.add(new Channel(NavySidebarIcons.PHONE, identity.contact().phone()));
- channels.add(new Channel(NavySidebarIcons.EMAIL, identity.contact().email()));
- channels.add(new Channel(NavySidebarIcons.LOCATION, identity.contact().address()));
+ String phone = identity.contact().phone();
+ channels.add(new Channel(NavySidebarIcons.PHONE, phone, telUri(phone)));
+ String email = identity.contact().email();
+ channels.add(new Channel(NavySidebarIcons.EMAIL, email, "mailto:" + email));
+ channels.add(new Channel(NavySidebarIcons.LOCATION,
+ identity.contact().address(), null));
for (Link link : identity.links()) {
- channels.add(new Channel(NavySidebarIcons.LINKEDIN, link.label()));
+ channels.add(new Channel(NavySidebarIcons.LINKEDIN, link.label(), link.url()));
}
return channels;
}
+ /**
+ * The dial target for a phone number: its digits, keeping a leading
+ * {@code +} so an international number stays international.
+ */
+ private static String telUri(String phone) {
+ String digits = phone.replaceAll("[^0-9]", "");
+ return digits.isEmpty()
+ ? null
+ : "tel:" + (phone.trim().startsWith("+") ? "+" : "") + digits;
+ }
+
/**
* A contact row. The packaged set has one network mark, so every link
* takes it — this design ships no globe.
*/
- private record Channel(String token, String value) {
+ private record Channel(String token, String value, String href) {
}
// -- education -------------------------------------------------------