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