Skip to content

Commit 9db7a88

Browse files
authored
v1.6.3: tight link rects, preserved whitespace, two-line Projects layout
1 parent a8493f8 commit 9db7a88

21 files changed

Lines changed: 619 additions & 170 deletions

File tree

CHANGELOG.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,86 @@
33
All notable changes to GraphCompose are documented here. Versions
44
follow semantic versioning; release dates are ISO 8601.
55

6+
## v1.6.3 — in progress
7+
8+
Bug fix patch. Closes two independent hyperlink clickable-area
9+
defects that surfaced on CV gallery presets and made the LinkedIn /
10+
GitHub contact rows hijack each other's clicks (paragraph-level
11+
link path) or drift past their visible text (span-level link path
12+
through multi-space separators). **No public API change** — engine,
13+
DSL, themes, templates, and backend records all stay
14+
source-compatible with v1.6.2.
15+
16+
### Engine
17+
18+
- **Paragraph-level link annotations now hug rendered text.**
19+
`PdfFixedLayoutBackend` used to emit a paragraph's `linkOptions`
20+
as a single rectangle covering the entire fragment box
21+
(`fragment.x()` + `fragment.width()`), ignoring `TextAlign.RIGHT`
22+
/ `TextAlign.CENTER`. Stacked right-aligned contact paragraphs
23+
(e.g. one per LinkedIn / GitHub icon row in Timeline Minimal /
24+
Sidebar Portrait / Monogram Sidebar) therefore produced
25+
full-column-wide rects that overlapped the empty alignment gap of
26+
neighbouring rows — hovering over GitHub clicked the LinkedIn row.
27+
The backend now emits one per-line rect tight to `line.width()`
28+
positioned at the alignment-aware `lineX`, matching how
29+
inline-span links already worked. Span-level link emission, table
30+
/ shape / barcode payload links, and bookmark anchoring are
31+
unchanged.
32+
- **Glyph sanitizer preserves all author whitespace.**
33+
`PdfFont.sanitizeForRender` used to collapse any run of consecutive
34+
spaces into a single space, both for whitespace-only tokens (the
35+
`" "` halves of a `" | "` separator) and for inter-word gaps
36+
in spaced-caps strings (`spacedUpper("ARTEM DEMCHYSHYN")` produces
37+
`"A R T E M D E M C H Y S H Y N"` with deliberate triple-spaces
38+
between words). The collapse shrank the rendered glyph stream
39+
under measurement, drifting inline-link rectangles ~8pt per
40+
`" | "` separator past their visible labels and visually
41+
merging spaced-caps titles back into a single run (`"A R T E M D E
42+
M C H Y S H Y N"` — no word boundary). The sanitizer no longer
43+
collapses adjacent spaces; newlines / NBSP / non-tab control
44+
characters still resolve to a single space each, but author
45+
whitespace is now preserved verbatim so wrap geometry,
46+
link-rectangle emission, and `showText(...)` all see the same
47+
string. Layout snapshot baselines for five CV presets and one
48+
nested-list document widened to reflect the recovered whitespace —
49+
the deliberate visual change is the bug fix.
50+
51+
### Templates
52+
53+
- **Boxed Sections projects render as title + indented description.**
54+
The "Projects" module now renders each bullet-list or
55+
`IndentedBlock` item as two stacked paragraphs — bullet plus bold
56+
project name (with an optional tech-stack chunk in parentheses) on
57+
the first line, then a hanging-indented description below aligned
58+
to the project name (not the bullet). The previous single-line
59+
rendering ran the project name and description together. Bullet
60+
marker, hanging-indent, and surrounding modules are unchanged.
61+
Example data in `ExampleDataFactory.sampleCvSpecV2` and
62+
`PresetVisualGalleryTest` now ships tech-stack chunks (`"Java 21,
63+
PDFBox, Maven, JMH"`) so the gallery PDFs reflect the new layout.
64+
65+
### Tests
66+
67+
- New regression in `PdfFixedLayoutBackendFeaturesTest`
68+
`shouldTightlyHugRightAlignedParagraphLinkRectangles` — stacks
69+
three right-aligned link paragraphs and asserts each clickable
70+
rect hugs its rendered label width (≤ 150pt), sits flush against
71+
the inner right margin, and does not overlap the Y-band of
72+
neighbouring rows.
73+
- New regression in `PdfFixedLayoutBackendFeaturesTest`
74+
`shouldKeepCenteredInlineLinkRectanglesAlignedAcrossMultiSpaceSeparators`
75+
— renders a centered contact line built with `" | "` separators
76+
and asserts the three resulting link rectangles preserve
77+
left-to-right order with non-overlapping X ranges and a sane
78+
per-separator gap (5..40pt), pinning the bug where collapsed
79+
whitespace pushed later rects past the line.
80+
- New regression in `PdfFontSanitizerTest`
81+
`sanitizeForRender_preservesWhitespaceOnlyTokensVerbatim` — pins
82+
the whitespace-only short-circuit so render width stays in
83+
lockstep with `getTextWidth` for tokenised contact-line
84+
separators.
85+
686
## v1.6.2 — 2026-05-20
787

888
Robustness patch. Closes four engine defects surfaced while building

examples/src/main/java/com/demcha/examples/support/ExampleDataFactory.java

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -298,19 +298,23 @@ public static CvSpec sampleCvSpecV2() {
298298
+ "pattern matching, virtual threads."))))
299299
.module(CvModule.of("Projects",
300300
new BulletListBlock(List.of(
301-
"**GraphCompose** - Declarative Java PDF layout engine. "
302-
+ "Semantic DSL, slot-based templates, snapshot testing. "
303-
+ "Powers production CV / invoice / proposal pipelines "
304-
+ "for hiring tools and billing systems. *(Open source)*",
305-
"**Template Studio** - Internal tool for evaluating CV, proposal, "
306-
+ "and invoice output across 14 design presets. PNG "
301+
"**GraphCompose (Java 21, PDFBox, Maven, JMH)** - "
302+
+ "Declarative Java PDF layout engine. Semantic DSL, "
303+
+ "slot-based templates, snapshot testing. Powers "
304+
+ "production CV / invoice / proposal pipelines for "
305+
+ "hiring tools and billing systems. *(Open source)*",
306+
"**Template Studio (Kotlin, Compose Desktop, PDFBox PNG diff)** - "
307+
+ "Internal tool for evaluating CV, proposal, and "
308+
+ "invoice output across 14 design presets. PNG "
307309
+ "diffing, side-by-side layout, baseline freezing.",
308-
"**LayoutLint** - Static analyser that flags fragile authoring "
309-
+ "patterns (deeply nested rows, untyped offsets, "
310-
+ "implicit page breaks) before they ship to production.",
311-
"**ChromeForge** - Editorial-magazine document toolkit built on "
312-
+ "GraphCompose: cinematic covers, pull quotes, multi-"
313-
+ "column flow, sidebar callouts."))))
310+
"**LayoutLint (Java 21, JavaParser, Spoon)** - Static analyser "
311+
+ "that flags fragile authoring patterns (deeply "
312+
+ "nested rows, untyped offsets, implicit page "
313+
+ "breaks) before they ship to production.",
314+
"**ChromeForge (Java, GraphCompose, Pandoc bridge)** - "
315+
+ "Editorial-magazine document toolkit built on "
316+
+ "GraphCompose: cinematic covers, pull quotes, "
317+
+ "multi-column flow, sidebar callouts."))))
314318
.module(CvModule.of("Professional Experience",
315319
new MultiParagraphBlock(List.of(
316320
"**Senior Platform Engineer**, Northwind Systems | "

src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,10 +327,14 @@ private void finishRenderedFragment(PlacedFragment fragment,
327327
boolean guideLines,
328328
Map<String, Map<Integer, PdfGuideLinesRenderer.Bounds>> ownerBounds) throws Exception {
329329
if (payload instanceof ParagraphFragmentPayload paragraphPayload) {
330-
addParagraphSpanLinks(fragment, paragraphPayload, environment);
330+
addParagraphLinks(fragment, paragraphPayload, environment);
331331
}
332332
if (payload instanceof PdfSemanticFragmentPayload semanticPayload) {
333-
if (semanticPayload.linkOptions() != null) {
333+
// Paragraph-level link emission is handled above with per-line
334+
// rects tight to the rendered text (alignment-aware). Other
335+
// semantic payloads (shapes, table rows) still use the full
336+
// fragment rect as their clickable area.
337+
if (semanticPayload.linkOptions() != null && !(payload instanceof ParagraphFragmentPayload)) {
334338
PdfLinkAnnotationWriter.addUriLink(
335339
environment.document().getPage(fragment.pageIndex()),
336340
new PdfLinkAnnotationWriter.PlacedPdfRect(fragment.x(), fragment.y(), fragment.width(), fragment.height()),
@@ -345,9 +349,10 @@ private void finishRenderedFragment(PlacedFragment fragment,
345349
}
346350
}
347351

348-
private void addParagraphSpanLinks(PlacedFragment fragment,
349-
ParagraphFragmentPayload payload,
350-
PdfRenderEnvironment environment) throws Exception {
352+
private void addParagraphLinks(PlacedFragment fragment,
353+
ParagraphFragmentPayload payload,
354+
PdfRenderEnvironment environment) throws Exception {
355+
var paragraphLink = payload.linkOptions();
351356
double innerX = fragment.x() + payload.padding().left();
352357
double innerWidth = Math.max(0.0, fragment.width() - payload.padding().horizontal());
353358
double contentTop = fragment.y() + fragment.height() - payload.padding().top();
@@ -362,6 +367,23 @@ private void addParagraphSpanLinks(PlacedFragment fragment,
362367
case CENTER -> innerX + (innerWidth - line.width()) / 2.0;
363368
case LEFT -> innerX;
364369
};
370+
371+
// Paragraph-level link covers each rendered line tightly. Without
372+
// this, right- or center-aligned paragraphs leaked clickable area
373+
// across the empty alignment gap, so neighbouring contact rows
374+
// (LinkedIn / GitHub icon paragraphs) hijacked each other's
375+
// clicks.
376+
if (paragraphLink != null && line.width() > 0.0) {
377+
PdfLinkAnnotationWriter.addUriLink(
378+
environment.document().getPage(fragment.pageIndex()),
379+
new PdfLinkAnnotationWriter.PlacedPdfRect(
380+
lineX,
381+
lineTop - resolvedLineHeight,
382+
line.width(),
383+
resolvedLineHeight),
384+
paragraphLink);
385+
}
386+
365387
double spanX = lineX;
366388
for (ParagraphSpan span : line.spans()) {
367389
if (span.linkOptions() != null && span.width() > 0.0) {

src/main/java/com/demcha/compose/document/templates/cv/presets/BoxedSections.java

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,32 @@ private void addSectionBanner(SectionBuilder section, String title) {
185185
private void addModuleBody(SectionBuilder section, CvModule module) {
186186
section.spacing(4)
187187
.padding(new DocumentInsets(4, 4, 0, 4));
188+
// Projects render with a dedicated two-line layout — bold
189+
// project name (with optional tech stack in parens) on the
190+
// first line behind a bullet, then a hanging-indented
191+
// description below — instead of the flat single-line bullet
192+
// used for general bullet lists. Matches the canonical CV
193+
// visual where "what the project is" stands apart from "what
194+
// it did". Honours both shapes the data layer ships: a
195+
// {@link BulletListBlock} with "**Name (tech)** - Description"
196+
// strings and an {@link IndentedBlock} with separate title /
197+
// body fields.
198+
if (isProjectsModule(module.title())) {
199+
if (module.body() instanceof BulletListBlock projects) {
200+
for (String item : projects.items()) {
201+
renderProjectItem(section, parseProjectItem(safe(item).trim()));
202+
}
203+
return;
204+
}
205+
if (module.body() instanceof IndentedBlock indented) {
206+
for (IndentedBlock.Item item : indented.items()) {
207+
renderProjectItem(section,
208+
new ProjectParts(safe(item.title()).trim(),
209+
safe(item.body()).trim()));
210+
}
211+
return;
212+
}
213+
}
188214
renderBody(section, module.body());
189215
}
190216

@@ -266,6 +292,54 @@ private void renderBulletItem(SectionBuilder section, String rawLine) {
266292
.rich(rich -> appendMarkdown(rich, text, base)));
267293
}
268294

295+
/**
296+
* Renders one project entry as two stacked paragraphs:
297+
*
298+
* <pre>
299+
* • <b>Name</b> (tech stack)
300+
* Description text wrapped under the title, hanging-indented
301+
* so it lines up with the project name (not the bullet).
302+
* </pre>
303+
*
304+
* <p>Input format: {@code "**Name (tech)** - Description"}.
305+
* Both halves are optional — a project without a description
306+
* renders only the title line; a project without bold markers
307+
* around the name is treated as plain title text.</p>
308+
*/
309+
private void renderProjectItem(SectionBuilder section, ProjectParts parts) {
310+
if (parts.name().isBlank() && parts.description().isBlank()) {
311+
return;
312+
}
313+
DocumentTextStyle base = style(BODY_FONT, 8.6,
314+
DocumentTextDecoration.DEFAULT, INK);
315+
DocumentTextStyle nameStyle = style(BODY_FONT, 8.6,
316+
DocumentTextDecoration.BOLD, INK);
317+
318+
section.addParagraph(paragraph -> paragraph
319+
.textStyle(base)
320+
.lineSpacing(1.4)
321+
.align(TextAlign.LEFT)
322+
.margin(DocumentInsets.top(2))
323+
.bulletOffset("• ")
324+
.indentStrategy(DocumentTextIndent.ALL_LINES)
325+
.rich(rich -> appendMarkdown(rich, parts.name(), nameStyle)));
326+
327+
if (parts.description().isBlank()) {
328+
return;
329+
}
330+
// Two-space prefix matches the bullet+space width inside the
331+
// hanging-indent computation, so the description's first
332+
// glyph sits under the project name rather than the bullet.
333+
section.addParagraph(paragraph -> paragraph
334+
.textStyle(base)
335+
.lineSpacing(1.4)
336+
.align(TextAlign.LEFT)
337+
.margin(DocumentInsets.zero())
338+
.bulletOffset(" ")
339+
.indentStrategy(DocumentTextIndent.ALL_LINES)
340+
.rich(rich -> appendMarkdown(rich, parts.description(), base)));
341+
}
342+
269343
private void renderWorkEntry(SectionBuilder section, WorkEntry entry) {
270344
DocumentTextStyle positionStyle = style(BODY_FONT, 9.2,
271345
DocumentTextDecoration.BOLD, INK);
@@ -448,6 +522,30 @@ private static String stripBasicMarkdown(String value) {
448522
.replace("_", "");
449523
}
450524

525+
private static boolean isProjectsModule(String title) {
526+
if (title == null) {
527+
return false;
528+
}
529+
String normalized = title.toLowerCase(Locale.ROOT).trim();
530+
return normalized.equals("projects") || normalized.startsWith("projects ");
531+
}
532+
533+
private static ProjectParts parseProjectItem(String item) {
534+
// Split on " - " (space-hyphen-space, mirroring WorkEntry parsing)
535+
// so an em-dash or hyphen inside the description is not eaten.
536+
// Falls back to "title only" when no separator is present.
537+
int sepIndex = item.indexOf(" - ");
538+
if (sepIndex <= 0) {
539+
return new ProjectParts(item.trim(), "");
540+
}
541+
String name = item.substring(0, sepIndex).trim();
542+
String description = item.substring(sepIndex + 3).trim();
543+
return new ProjectParts(name, description);
544+
}
545+
546+
private record ProjectParts(String name, String description) {
547+
}
548+
451549
private static String spacedUpper(String value) {
452550
String upper = safe(value).toUpperCase(Locale.ROOT);
453551
StringBuilder builder = new StringBuilder();

src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,19 @@ public double getTextWidthNoSanitize(TextStyle style, String text) {
180180
}
181181

182182
private @NotNull String textSanitizer(String text) {
183+
// v1.6.3: preserve author-supplied whitespace verbatim. The
184+
// previous implementation collapsed any run of resulting spaces
185+
// (original + converted) into one, but downstream geometry
186+
// (`PdfFont.getTextWidth`, paragraph layout, link-rect emission)
187+
// measures against the input as written. The collapse therefore
188+
// shrank the rendered string under measurement, drifting
189+
// link annotations away from their glyphs and visually merging
190+
// author-spaced strings like `spacedUpper("ARTEM DEMCHYSHYN")`
191+
// (which inserts deliberate triple-spaces between words).
192+
// Newlines / NBSP / non-tab control chars still resolve to a
193+
// single space each \u2014 they no longer collapse adjacent author
194+
// spaces.
183195
StringBuilder sanitized = new StringBuilder(text.length());
184-
boolean previousSpace = false;
185196
for (int offset = 0; offset < text.length(); ) {
186197
int codePoint = text.codePointAt(offset);
187198
offset += Character.charCount(codePoint);
@@ -191,16 +202,7 @@ public double getTextWidthNoSanitize(TextStyle style, String text) {
191202
default -> Character.isISOControl(codePoint) && codePoint != '\t' ? ' ' : codePoint;
192203
};
193204

194-
if (resolved == ' ') {
195-
if (!previousSpace) {
196-
sanitized.append(' ');
197-
previousSpace = true;
198-
}
199-
continue;
200-
}
201-
202205
sanitized.appendCodePoint(resolved);
203-
previousSpace = false;
204206
}
205207

206208
return sanitized.toString();

0 commit comments

Comments
 (0)