From 95c00fb49c7e107a2c5336326b105f474e591630 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 24 Jul 2026 08:22:23 +0100 Subject: [PATCH] feat(templates): render inline links in CV project-card titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EngineeringResume and SidebarPortrait hand-roll their project cards outside ProjectRenderer and emitted the card title as a flat styled run, so a [label](url) in a project title stayed literal there — unlike entry titles, project rows, and body text, which already render it as a link. Route both title runs through MarkdownInline.append so the link syntax becomes a clickable hyperlink, keeping the trailing " (stack)" run. Plain titles render byte-identically, so CV visual parity is unchanged. --- CHANGELOG.md | 5 + .../cv/components/ProjectTitleLinkTest.java | 141 ++++++++++++++++++ .../cv/presets/EngineeringResume.java | 3 +- .../templates/cv/presets/SidebarPortrait.java | 2 +- 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/components/ProjectTitleLinkTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 577c24f4..0eba9453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ follow semantic versioning; release dates are ISO 8601. the combined subtitle+date meta line of MintEditorial/SidebarPortrait experience entries — the link's label text is shown instead. Titles without inline-Markdown syntax render exactly as before, so the change is backward-compatible. +- **Project-card titles accept inline `[text](url)` links in the `EngineeringResume` + and `SidebarPortrait` presets.** Their hand-rolled project cards previously emitted + the title as flat styled text, so a `[label](url)` there stayed literal; it now runs + through the same link-aware path as project rows, entry titles, and body text, with + the trailing `" (stack)"` run preserved. Plain titles render exactly as before. - The fixed-layout PPTX backend ships as `@Beta` (Experimental) in its first release: the `document.backend.fixed.pptx` packages and the `DocumentSession` PPTX convenience methods (`toPptxBytes`, `writePptx`, diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ProjectTitleLinkTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ProjectTitleLinkTest.java new file mode 100644 index 00000000..20a15193 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/components/ProjectTitleLinkTest.java @@ -0,0 +1,141 @@ +package com.demcha.compose.document.templates.cv.components; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ExternalLinkTarget; +import com.demcha.compose.document.node.InlineRun; +import com.demcha.compose.document.node.InlineTextRun; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.RowStyle; +import com.demcha.compose.document.templates.cv.data.RowsSection; +import com.demcha.compose.document.templates.cv.presets.EngineeringResume; +import com.demcha.compose.document.templates.cv.presets.SidebarPortrait; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end wiring for inline-link CV project-card titles: a project row whose + * label carries {@code [label](url)} markdown renders as a clickable link in the + * two presets that hand-roll their project card outside the shared + * {@code ProjectRenderer} — {@code EngineeringResume} (bordered card in the main + * column) and {@code SidebarPortrait} (stacked row in the main column). Plain + * labels stay plain and the trailing {@code " (stack)"} run is preserved, so the + * change is backward-compatible. + * + *

The {@link com.demcha.compose.document.templates.core.text.MarkdownInline} + * link primitive and {@link ProjectLabel} split are unit-tested separately; this + * locks the preset wiring so a refactor cannot silently revert a project title to + * a flat styled run (the state before this change), which would drop the link.

+ */ +class ProjectTitleLinkTest { + + private static CvDocument docWithProject(String label) { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Test", "User").jobTitle("Engineer") + .contact("+1 555 0100", "user@example.com", "City").build()) + .section(RowsSection.builder("Projects", RowStyle.BULLETED_STACKED) + .row(label, "Did meaningful work.") + .build()) + .build(); + } + + /** Collects every inline text run in the composed document. */ + private static List textRuns(DocumentTemplate template, + CvDocument doc) { + try (DocumentSession document = GraphCompose.document() + .pageSize(DocumentPageSize.A4).margin(28, 28, 28, 28).create()) { + template.compose(document, doc); + List paragraphs = new ArrayList<>(); + collectParagraphs(document.roots(), paragraphs); + List runs = new ArrayList<>(); + for (ParagraphNode paragraph : paragraphs) { + for (InlineRun run : paragraph.inlineRuns()) { + if (run instanceof InlineTextRun text) { + runs.add(text); + } + } + } + return runs; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static void collectParagraphs(List nodes, List out) { + for (DocumentNode node : nodes) { + if (node instanceof ParagraphNode paragraph) { + out.add(paragraph); + } + collectParagraphs(node.children(), out); + } + } + + private static boolean isExternalLink(InlineTextRun run) { + return run.linkTarget() instanceof ExternalLinkTarget; + } + + private static String uri(InlineTextRun run) { + return ((ExternalLinkTarget) run.linkTarget()).options().uri(); + } + + /** + * EngineeringResume's bordered project card: a link in the project title + * reaches the output, and the trailing {@code " (stack)"} run is preserved. + */ + @Test + void engineeringResumeProjectTitleBecomesLink() { + List runs = textRuns(EngineeringResume.create(), + docWithProject("[Acme Corp](https://acme.example) (Java, PDFBox)")); + assertThat(runs).filteredOn(ProjectTitleLinkTest::isExternalLink) + .anySatisfy(run -> { + assertThat(run.text()).isEqualTo("Acme Corp"); + assertThat(uri(run)).isEqualTo("https://acme.example"); + }); + assertThat(runs).anySatisfy(run -> + assertThat(run.text()).contains("Java, PDFBox")); + } + + /** + * SidebarPortrait's stacked project row: a link in the project title reaches + * the output, and the trailing {@code " (stack)"} run is preserved. + */ + @Test + void sidebarPortraitProjectTitleBecomesLink() { + List runs = textRuns(SidebarPortrait.create(), + docWithProject("[Acme Corp](https://acme.example) (Java, PDFBox)")); + assertThat(runs).filteredOn(ProjectTitleLinkTest::isExternalLink) + .anySatisfy(run -> { + assertThat(run.text()).isEqualTo("Acme Corp"); + assertThat(uri(run)).isEqualTo("https://acme.example"); + }); + assertThat(runs).anySatisfy(run -> + assertThat(run.text()).contains("Java, PDFBox")); + } + + /** + * Backward-compatible: a plain project title is not rendered as a link. The + * header contact block may render its own email/website links, so the control + * asserts specifically that the plain project title run is not linked. + */ + @Test + void plainProjectTitleIsNotLink() { + assertThat(projectTitleLinked(EngineeringResume.create())).isFalse(); + assertThat(projectTitleLinked(SidebarPortrait.create())).isFalse(); + } + + private static boolean projectTitleLinked(DocumentTemplate template) { + return textRuns(template, docWithProject("Acme Corp (Java, PDFBox)")).stream() + .filter(ProjectTitleLinkTest::isExternalLink) + .anyMatch(run -> run.text().equalsIgnoreCase("Acme Corp")); + } +} diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java index 7a7b09fb..40a190c8 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java @@ -477,7 +477,8 @@ private void addProjects(SectionBuilder parent, CvSection section) { .lineSpacing(1.06) .margin(DocumentInsets.zero()) .rich(rich -> { - rich.style(label.title(), + MarkdownInline.append(rich, + label.title(), projectTitleStyle()); if (!label.stack().isBlank()) { rich.style(" (" + label.stack() diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java index 35af1348..e57acc44 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java @@ -754,7 +754,7 @@ private void addProjectsList(SectionBuilder section, .lineSpacing(1.2) .margin(DocumentInsets.top(topMargin)) .rich(rich -> { - rich.style(label.title(), titleStyle); + MarkdownInline.append(rich, label.title(), titleStyle); if (!label.stack().isBlank()) { rich.style(" (" + label.stack() + ")", contextStyle);