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 -------------------------------------------------------