Skip to content

Commit 1e15ae3

Browse files
authored
feat(templates): render inline links in CV entry titles and subtitles (#434)
CV project rows and body text already render inline [label](url) Markdown as clickable links, but experience/education entry titles were emitted as plain text, so a [name](url) title showed up literally. Route entry titles and subtitles through link-aware MarkdownInline helpers across every CV preset: the shared EntryRenderer / EntryCompactRenderer and the four presets that hand-roll their titles (MonogramSidebar, SidebarPortrait, EngineeringResume, MintEditorial). Add appendTransformed (transforms the visible label but never the URL) so upper-cased and letter-spaced titles keep their link, appendUpperCased as a delegate, and appendIfPresent for prefixed subtitles. TimelineMinimal's fused excerpt and the fused subtitle+date meta lines strip to the label text. Plain titles render byte-identically; snapshots and visual parity are unchanged.
1 parent 1aa0954 commit 1e15ae3

11 files changed

Lines changed: 378 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ follow semantic versioning; release dates are ISO 8601.
77

88
### Public API
99

10+
- **CV entry titles and subtitles accept inline `[text](url)` links.** In the layered
11+
CV presets, a `[label](url)` in an experience/education entry title or subtitle now
12+
renders as a clickable hyperlink — the same inline-Markdown convention already used
13+
for project rows and body text. Upper-cased and letter-spaced preset titles keep the
14+
link too, with the visible label styled and the URL preserved. Where a preset fuses a
15+
line that cannot carry a clickable link — the single-line TimelineMinimal excerpt, and
16+
the combined subtitle+date meta line of MintEditorial/SidebarPortrait experience
17+
entries — the link's label text is shown instead. Titles without inline-Markdown
18+
syntax render exactly as before, so the change is backward-compatible.
1019
- The fixed-layout PPTX backend ships as `@Beta` (Experimental) in its first
1120
release: the `document.backend.fixed.pptx` packages and the
1221
`DocumentSession` PPTX convenience methods (`toPptxBytes`, `writePptx`,
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package com.demcha.compose.document.templates.cv.components;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentPageSize;
5+
import com.demcha.compose.document.api.DocumentSession;
6+
import com.demcha.compose.document.node.DocumentNode;
7+
import com.demcha.compose.document.node.ExternalLinkTarget;
8+
import com.demcha.compose.document.node.InlineRun;
9+
import com.demcha.compose.document.node.InlineTextRun;
10+
import com.demcha.compose.document.node.ParagraphNode;
11+
import com.demcha.compose.document.templates.api.DocumentTemplate;
12+
import com.demcha.compose.document.templates.cv.data.CvDocument;
13+
import com.demcha.compose.document.templates.cv.data.CvIdentity;
14+
import com.demcha.compose.document.templates.cv.data.EntriesSection;
15+
import com.demcha.compose.document.templates.cv.presets.BlueBanner;
16+
import com.demcha.compose.document.templates.cv.presets.EngineeringResume;
17+
import com.demcha.compose.document.templates.cv.presets.ModernProfessional;
18+
import com.demcha.compose.document.templates.cv.presets.MonogramSidebar;
19+
import org.junit.jupiter.api.Test;
20+
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
24+
import static org.assertj.core.api.Assertions.assertThat;
25+
26+
/**
27+
* End-to-end wiring for inline-link CV entry titles and subtitles: an entry
28+
* whose title or subtitle carries {@code [label](url)} markdown renders as a
29+
* clickable link across the shared renderers — the canonical two-column
30+
* {@code EntryRenderer} (via {@code ModernProfessional}) and the compact
31+
* upper-cased {@code EntryCompactRenderer} (via {@code BlueBanner}). Plain
32+
* titles stay plain, so the change is backward-compatible.
33+
*
34+
* <p>The {@link com.demcha.compose.document.templates.core.text.MarkdownInline}
35+
* link/transform primitives are unit-tested separately; this locks the preset
36+
* wiring so a refactor cannot silently revert a title to stripped plain text.</p>
37+
*/
38+
class EntryTitleLinkTest {
39+
40+
private static CvDocument docWith(String title, String subtitle) {
41+
return CvDocument.builder()
42+
.identity(CvIdentity.builder()
43+
.name("Test", "User").jobTitle("Engineer")
44+
.contact("+1 555 0100", "user@example.com", "City").build())
45+
.section(EntriesSection.builder("Professional Experience")
46+
.entry(title, subtitle, "2020-2024", "Built things.")
47+
.build())
48+
.build();
49+
}
50+
51+
/** Collects every external-link run in the composed document. */
52+
private static List<InlineTextRun> linkRuns(DocumentTemplate<CvDocument> template,
53+
CvDocument doc) {
54+
try (DocumentSession document = GraphCompose.document()
55+
.pageSize(DocumentPageSize.A4).margin(28, 28, 28, 28).create()) {
56+
template.compose(document, doc);
57+
List<ParagraphNode> paragraphs = new ArrayList<>();
58+
collectParagraphs(document.roots(), paragraphs);
59+
List<InlineTextRun> links = new ArrayList<>();
60+
for (ParagraphNode paragraph : paragraphs) {
61+
for (InlineRun run : paragraph.inlineRuns()) {
62+
if (run instanceof InlineTextRun text
63+
&& text.linkTarget() instanceof ExternalLinkTarget) {
64+
links.add(text);
65+
}
66+
}
67+
}
68+
return links;
69+
} catch (Exception e) {
70+
throw new RuntimeException(e);
71+
}
72+
}
73+
74+
private static void collectParagraphs(List<DocumentNode> nodes, List<ParagraphNode> out) {
75+
for (DocumentNode node : nodes) {
76+
if (node instanceof ParagraphNode paragraph) {
77+
out.add(paragraph);
78+
}
79+
collectParagraphs(node.children(), out);
80+
}
81+
}
82+
83+
private static String uri(InlineTextRun run) {
84+
return ((ExternalLinkTarget) run.linkTarget()).options().uri();
85+
}
86+
87+
/** Canonical EntryRenderer: a link in the entry title reaches the output. */
88+
@Test
89+
void entryRendererTitleBecomesLink() {
90+
List<InlineTextRun> links = linkRuns(ModernProfessional.create(),
91+
docWith("[Acme Corp](https://acme.example)", "Senior Engineer"));
92+
assertThat(links).anySatisfy(run -> {
93+
assertThat(run.text()).isEqualTo("Acme Corp");
94+
assertThat(uri(run)).isEqualTo("https://acme.example");
95+
});
96+
}
97+
98+
/** Canonical EntryRenderer: the subtitle is link-aware too. */
99+
@Test
100+
void entryRendererSubtitleBecomesLink() {
101+
List<InlineTextRun> links = linkRuns(ModernProfessional.create(),
102+
docWith("Senior Engineer", "[Acme Corp](https://acme.example)"));
103+
assertThat(links).anySatisfy(run ->
104+
assertThat(uri(run)).isEqualTo("https://acme.example"));
105+
}
106+
107+
/** Compact upper-cased path: the link survives and its label is upper-cased, url intact. */
108+
@Test
109+
void compactUpperCasedTitleBecomesUpperCasedLink() {
110+
List<InlineTextRun> links = linkRuns(BlueBanner.create(),
111+
docWith("[Acme Corp](https://acme.example)", "Senior Engineer"));
112+
assertThat(links).anySatisfy(run -> {
113+
assertThat(run.text()).isEqualTo("ACME CORP");
114+
assertThat(uri(run)).isEqualTo("https://acme.example");
115+
});
116+
}
117+
118+
/** Hand-rolled per-preset path (upper-cased title built outside the shared renderers). */
119+
@Test
120+
void handRolledPresetTitleBecomesLink() {
121+
List<InlineTextRun> links = linkRuns(MonogramSidebar.create(),
122+
docWith("[Acme Corp](https://acme.example)", "Senior Engineer"));
123+
assertThat(links).anySatisfy(run -> {
124+
assertThat(run.text()).isEqualTo("ACME CORP");
125+
assertThat(uri(run)).isEqualTo("https://acme.example");
126+
});
127+
}
128+
129+
/** Hand-rolled EngineeringResume role title (non-upper-cased) is link-aware. */
130+
@Test
131+
void engineeringResumeRoleTitleBecomesLink() {
132+
List<InlineTextRun> links = linkRuns(EngineeringResume.create(),
133+
docWith("[Acme Corp](https://acme.example)", "Senior Engineer"));
134+
assertThat(links).anySatisfy(run -> {
135+
assertThat(run.text()).isEqualTo("Acme Corp");
136+
assertThat(uri(run)).isEqualTo("https://acme.example");
137+
});
138+
}
139+
140+
/**
141+
* Backward-compatible: a plain title/subtitle is not rendered as a link. The
142+
* header contact block may render its own email/website links, so the control
143+
* asserts specifically that the plain entry title and subtitle are not linked.
144+
*/
145+
@Test
146+
void plainTitleAndSubtitleAreNotLinks() {
147+
assertThat(titleOrSubtitleLinked(ModernProfessional.create())).isFalse();
148+
assertThat(titleOrSubtitleLinked(BlueBanner.create())).isFalse();
149+
}
150+
151+
private static boolean titleOrSubtitleLinked(DocumentTemplate<CvDocument> template) {
152+
return linkRuns(template, docWith("Senior Engineer", "Acme Corp")).stream()
153+
.anyMatch(run -> run.text().equalsIgnoreCase("Senior Engineer")
154+
|| run.text().equalsIgnoreCase("Acme Corp"));
155+
}
156+
}

templates/src/main/java/com/demcha/compose/document/templates/core/text/MarkdownInline.java

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import com.demcha.compose.document.node.InlineTextRun;
66
import com.demcha.compose.document.style.DocumentTextStyle;
77

8+
import java.util.Locale;
9+
import java.util.function.UnaryOperator;
810
import java.util.regex.Matcher;
911
import java.util.regex.Pattern;
1012

@@ -92,6 +94,60 @@ public static void appendTrimmed(RichText rich, String text,
9294
append(rich, text == null ? "" : text.trim(), baseStyle);
9395
}
9496

97+
/**
98+
* Expands inline markdown links while applying {@code displayTransform} to the
99+
* <em>visible</em> text only: each plain segment and each {@code [label](url)}
100+
* link label is passed through the transform, but the link's {@code url} is left
101+
* untouched so the hyperlink still resolves. Plain segments are first reduced to
102+
* their {@link #plainText(String) plain-text projection}, so a decorative
103+
* transform (upper-casing, letter-spacing) never has to cope with emphasis
104+
* markers. Used by renderers that display a stylised title (caps, spaced caps)
105+
* yet still want {@code [name](url)} to render as a clickable link.
106+
*
107+
* @param rich target rich-text builder
108+
* @param text source string; null treated as empty
109+
* @param baseStyle style applied to the transformed runs
110+
* @param displayTransform transform applied to visible text (never the url)
111+
*/
112+
public static void appendTransformed(RichText rich, String text,
113+
DocumentTextStyle baseStyle,
114+
UnaryOperator<String> displayTransform) {
115+
if (text == null || text.isEmpty()) {
116+
return;
117+
}
118+
Matcher matcher = LINK_PATTERN.matcher(text);
119+
int cursor = 0;
120+
while (matcher.find()) {
121+
appendTransformedSegment(rich, text.substring(cursor, matcher.start()),
122+
baseStyle, displayTransform);
123+
rich.link(displayTransform.apply(matcher.group(1)), matcher.group(2));
124+
cursor = matcher.end();
125+
}
126+
appendTransformedSegment(rich, text.substring(cursor), baseStyle, displayTransform);
127+
}
128+
129+
/**
130+
* Convenience {@link #appendTransformed} that upper-cases the visible text
131+
* (link labels and plain segments) while preserving each link's url.
132+
*
133+
* @param rich target rich-text builder
134+
* @param text source string; null treated as empty
135+
* @param baseStyle style applied to the upper-cased runs
136+
*/
137+
public static void appendUpperCased(RichText rich, String text,
138+
DocumentTextStyle baseStyle) {
139+
appendTransformed(rich, text, baseStyle, s -> s.toUpperCase(Locale.ROOT));
140+
}
141+
142+
private static void appendTransformedSegment(RichText rich, String segment,
143+
DocumentTextStyle baseStyle,
144+
UnaryOperator<String> displayTransform) {
145+
String clean = plainText(segment);
146+
if (!clean.isEmpty()) {
147+
rich.style(displayTransform.apply(clean), baseStyle);
148+
}
149+
}
150+
95151
/**
96152
* Appends {@code prefix + plainText(value)} only when the
97153
* plain-text projection is non-blank. Used by renderers that
@@ -112,6 +168,28 @@ public static void appendPlainIfPresent(RichText rich, String prefix,
112168
}
113169
}
114170

171+
/**
172+
* Link-aware counterpart of {@link #appendPlainIfPresent}: when the
173+
* plain-text projection of {@code value} is non-blank, appends {@code prefix}
174+
* as a plain run and then {@code value} through
175+
* {@link #append(RichText, String, DocumentTextStyle)}, so inline
176+
* {@code [label](url)} in the supplementary segment renders as a clickable
177+
* link instead of being flattened to text.
178+
*
179+
* @param rich target rich-text builder
180+
* @param prefix separator prepended before the value (never a link)
181+
* @param value source string; null treated as empty
182+
* @param style style applied to the prefix and the value's plain runs
183+
*/
184+
public static void appendIfPresent(RichText rich, String prefix,
185+
String value,
186+
DocumentTextStyle style) {
187+
if (!plainText(value).isBlank()) {
188+
rich.style(prefix, style);
189+
append(rich, value, style);
190+
}
191+
}
192+
115193
/**
116194
* Returns a plain-text projection of {@code value} with inline
117195
* Markdown syntax removed: {@code [label](url)} collapses to

templates/src/main/java/com/demcha/compose/document/templates/cv/components/EntryCompactRenderer.java

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@
33
import com.demcha.compose.document.templates.core.text.MarkdownInline;
44
import com.demcha.compose.document.templates.core.text.RichParagraphRenderer;
55

6+
import com.demcha.compose.document.dsl.RichText;
67
import com.demcha.compose.document.dsl.SectionBuilder;
78
import com.demcha.compose.document.node.TextAlign;
89
import com.demcha.compose.document.style.DocumentInsets;
910
import com.demcha.compose.document.style.DocumentTextStyle;
1011
import com.demcha.compose.document.templates.cv.data.CvEntry;
1112

12-
import java.util.Locale;
13-
1413
/**
1514
* Compact entry renderer for editorial/card/rail presets where title,
1615
* subtitle, and date are packed tighter than the canonical
@@ -59,11 +58,11 @@ public static void twoColumnTitleDateBody(SectionBuilder host,
5958
.addSection("Title", titleColumn -> titleColumn
6059
.padding(DocumentInsets.zero())
6160
.addParagraph(paragraph -> paragraph
62-
.text(formattedTitle(entry.title(),
63-
uppercaseTitle))
6461
.textStyle(titleStyle)
6562
.align(TextAlign.LEFT)
66-
.margin(DocumentInsets.zero())))
63+
.margin(DocumentInsets.zero())
64+
.rich(rich -> richTitle(rich, entry.title(),
65+
titleStyle, uppercaseTitle))))
6766
.addSection("Date", dateColumn -> dateColumn
6867
.padding(DocumentInsets.zero())
6968
.addParagraph(paragraph -> paragraph
@@ -74,10 +73,10 @@ public static void twoColumnTitleDateBody(SectionBuilder host,
7473

7574
if (!entry.subtitle().isBlank()) {
7675
host.addParagraph(paragraph -> paragraph
77-
.text(MarkdownInline.plainText(entry.subtitle()))
7876
.textStyle(subtitleStyle)
7977
.align(TextAlign.LEFT)
80-
.margin(subtitleMargin));
78+
.margin(subtitleMargin)
79+
.rich(rich -> MarkdownInline.append(rich, entry.subtitle(), subtitleStyle)));
8180
}
8281
RichParagraphRenderer.render(host, entry.body(), bodyStyle,
8382
bodyLineSpacing, bodyMargin);
@@ -106,9 +105,8 @@ public static void slashMeta(SectionBuilder host,
106105
.align(TextAlign.LEFT)
107106
.margin(margin)
108107
.rich(rich -> {
109-
rich.style(MarkdownInline.plainText(entry.title()),
110-
titleStyle);
111-
MarkdownInline.appendPlainIfPresent(rich, " / ",
108+
MarkdownInline.append(rich, entry.title(), titleStyle);
109+
MarkdownInline.appendIfPresent(rich, " / ",
112110
entry.subtitle(), metaStyle);
113111
MarkdownInline.appendPlainIfPresent(rich, " / ",
114112
entry.date(), metaStyle);
@@ -140,9 +138,8 @@ public static void slashSubtitleDate(SectionBuilder host,
140138
.align(TextAlign.LEFT)
141139
.margin(margin)
142140
.rich(rich -> {
143-
rich.style(MarkdownInline.plainText(entry.title()),
144-
titleStyle);
145-
MarkdownInline.appendPlainIfPresent(rich, " / ",
141+
MarkdownInline.append(rich, entry.title(), titleStyle);
142+
MarkdownInline.appendIfPresent(rich, " / ",
146143
entry.subtitle(), subtitleStyle);
147144
MarkdownInline.appendPlainIfPresent(rich, " / ",
148145
entry.date(), dateStyle);
@@ -187,8 +184,7 @@ public static void titleDateBody(SectionBuilder host,
187184
.align(TextAlign.LEFT)
188185
.margin(headerMargin)
189186
.rich(rich -> {
190-
rich.style(formattedTitle(entry.title(), uppercaseTitle),
191-
titleStyle);
187+
richTitle(rich, entry.title(), titleStyle, uppercaseTitle);
192188
if (!entry.date().isBlank()) {
193189
rich.style(datePrefix, titleStyle);
194190
rich.style(MarkdownInline.plainText(entry.date()),
@@ -197,10 +193,10 @@ public static void titleDateBody(SectionBuilder host,
197193
}));
198194
if (!entry.subtitle().isBlank()) {
199195
host.addParagraph(paragraph -> paragraph
200-
.text(MarkdownInline.plainText(entry.subtitle()))
201196
.textStyle(subtitleStyle)
202197
.align(TextAlign.LEFT)
203-
.margin(subtitleMargin));
198+
.margin(subtitleMargin)
199+
.rich(rich -> MarkdownInline.append(rich, entry.subtitle(), subtitleStyle)));
204200
}
205201
RichParagraphRenderer.render(host, entry.body(), bodyStyle,
206202
bodyLineSpacing, bodyMargin);
@@ -242,9 +238,8 @@ public static void titleSubtitleDateBody(SectionBuilder host,
242238
.align(TextAlign.LEFT)
243239
.margin(headerMargin)
244240
.rich(rich -> {
245-
rich.style(MarkdownInline.plainText(entry.title()),
246-
titleStyle);
247-
MarkdownInline.appendPlainIfPresent(rich, subtitlePrefix,
241+
MarkdownInline.append(rich, entry.title(), titleStyle);
242+
MarkdownInline.appendIfPresent(rich, subtitlePrefix,
248243
entry.subtitle(), subtitleStyle);
249244
MarkdownInline.appendPlainIfPresent(rich, datePrefix,
250245
entry.date(), dateStyle);
@@ -253,8 +248,18 @@ public static void titleSubtitleDateBody(SectionBuilder host,
253248
bodyLineSpacing, bodyMargin);
254249
}
255250

256-
private static String formattedTitle(String title, boolean uppercase) {
257-
String clean = MarkdownInline.plainText(title);
258-
return uppercase ? clean.toUpperCase(Locale.ROOT) : clean;
251+
/**
252+
* Appends the entry title to {@code rich}, expanding inline markdown so a
253+
* {@code [name](url)} title renders as a clickable link. When
254+
* {@code uppercase} is set the visible label is upper-cased while the link
255+
* URL is preserved.
256+
*/
257+
private static void richTitle(RichText rich, String title,
258+
DocumentTextStyle style, boolean uppercase) {
259+
if (uppercase) {
260+
MarkdownInline.appendUpperCased(rich, title, style);
261+
} else {
262+
MarkdownInline.append(rich, title, style);
263+
}
259264
}
260265
}

0 commit comments

Comments
 (0)