Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified assets/readme/examples/cv-navy-sidebar-v2.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,6 +56,21 @@ private static String textOf(byte[] pdfBytes) throws Exception {
}
}

private static List<String> linkUris(byte[] pdfBytes) throws Exception {
List<String> 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<CvDocument> template = NavySidebar.create();
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@
* without one draws the ring around an empty navy disc rather than leaving a
* hole in the column.</p>
*
* <p>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.</p>
* <p>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.</p>
*
* <p>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>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.</p>
* <p>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.</p>
*/
private static void renderContact(SectionBuilder section, CvIdentity identity) {
sidebarHeading(section, CONTACT_HEADING, true);
Expand All @@ -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<Channel> channels(CvIdentity identity) {
List<Channel> 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 -------------------------------------------------------
Expand Down
Loading