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 1ba8f2fcd..5ebdbace0 100644 Binary files a/assets/readme/examples/cv-navy-sidebar-v2.pdf and b/assets/readme/examples/cv-navy-sidebar-v2.pdf differ 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 -------------------------------------------------------