Skip to content
Open
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
80 changes: 80 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,86 @@
All notable changes to GraphCompose are documented here. Versions
follow semantic versioning; release dates are ISO 8601.

## v1.6.3 — in progress

Bug fix patch. Closes two independent hyperlink clickable-area
defects that surfaced on CV gallery presets and made the LinkedIn /
GitHub contact rows hijack each other's clicks (paragraph-level
link path) or drift past their visible text (span-level link path
through multi-space separators). **No public API change** — engine,
DSL, themes, templates, and backend records all stay
source-compatible with v1.6.2.

### Engine

- **Paragraph-level link annotations now hug rendered text.**
`PdfFixedLayoutBackend` used to emit a paragraph's `linkOptions`
as a single rectangle covering the entire fragment box
(`fragment.x()` + `fragment.width()`), ignoring `TextAlign.RIGHT`
/ `TextAlign.CENTER`. Stacked right-aligned contact paragraphs
(e.g. one per LinkedIn / GitHub icon row in Timeline Minimal /
Sidebar Portrait / Monogram Sidebar) therefore produced
full-column-wide rects that overlapped the empty alignment gap of
neighbouring rows — hovering over GitHub clicked the LinkedIn row.
The backend now emits one per-line rect tight to `line.width()`
positioned at the alignment-aware `lineX`, matching how
inline-span links already worked. Span-level link emission, table
/ shape / barcode payload links, and bookmark anchoring are
unchanged.
- **Glyph sanitizer preserves all author whitespace.**
`PdfFont.sanitizeForRender` used to collapse any run of consecutive
spaces into a single space, both for whitespace-only tokens (the
`" "` halves of a `" | "` separator) and for inter-word gaps
in spaced-caps strings (`spacedUpper("ARTEM DEMCHYSHYN")` produces
`"A R T E M D E M C H Y S H Y N"` with deliberate triple-spaces
between words). The collapse shrank the rendered glyph stream
under measurement, drifting inline-link rectangles ~8pt per
`" | "` separator past their visible labels and visually
merging spaced-caps titles back into a single run (`"A R T E M D E
M C H Y S H Y N"` — no word boundary). The sanitizer no longer
collapses adjacent spaces; newlines / NBSP / non-tab control
characters still resolve to a single space each, but author
whitespace is now preserved verbatim so wrap geometry,
link-rectangle emission, and `showText(...)` all see the same
string. Layout snapshot baselines for five CV presets and one
nested-list document widened to reflect the recovered whitespace —
the deliberate visual change is the bug fix.

### Templates

- **Boxed Sections projects render as title + indented description.**
The "Projects" module now renders each bullet-list or
`IndentedBlock` item as two stacked paragraphs — bullet plus bold
project name (with an optional tech-stack chunk in parentheses) on
the first line, then a hanging-indented description below aligned
to the project name (not the bullet). The previous single-line
rendering ran the project name and description together. Bullet
marker, hanging-indent, and surrounding modules are unchanged.
Example data in `ExampleDataFactory.sampleCvSpecV2` and
`PresetVisualGalleryTest` now ships tech-stack chunks (`"Java 21,
PDFBox, Maven, JMH"`) so the gallery PDFs reflect the new layout.

### Tests

- New regression in `PdfFixedLayoutBackendFeaturesTest` —
`shouldTightlyHugRightAlignedParagraphLinkRectangles` — stacks
three right-aligned link paragraphs and asserts each clickable
rect hugs its rendered label width (≤ 150pt), sits flush against
the inner right margin, and does not overlap the Y-band of
neighbouring rows.
- New regression in `PdfFixedLayoutBackendFeaturesTest` —
`shouldKeepCenteredInlineLinkRectanglesAlignedAcrossMultiSpaceSeparators`
— renders a centered contact line built with `" | "` separators
and asserts the three resulting link rectangles preserve
left-to-right order with non-overlapping X ranges and a sane
per-separator gap (5..40pt), pinning the bug where collapsed
whitespace pushed later rects past the line.
- New regression in `PdfFontSanitizerTest` —
`sanitizeForRender_preservesWhitespaceOnlyTokensVerbatim` — pins
the whitespace-only short-circuit so render width stays in
lockstep with `getTextWidth` for tokenised contact-line
separators.

## v1.6.2 — 2026-05-20

Robustness patch. Closes four engine defects surfaced while building
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,19 +298,23 @@ public static CvSpec sampleCvSpecV2() {
+ "pattern matching, virtual threads."))))
.module(CvModule.of("Projects",
new BulletListBlock(List.of(
"**GraphCompose** - Declarative Java PDF layout engine. "
+ "Semantic DSL, slot-based templates, snapshot testing. "
+ "Powers production CV / invoice / proposal pipelines "
+ "for hiring tools and billing systems. *(Open source)*",
"**Template Studio** - Internal tool for evaluating CV, proposal, "
+ "and invoice output across 14 design presets. PNG "
"**GraphCompose (Java 21, PDFBox, Maven, JMH)** - "
+ "Declarative Java PDF layout engine. Semantic DSL, "
+ "slot-based templates, snapshot testing. Powers "
+ "production CV / invoice / proposal pipelines for "
+ "hiring tools and billing systems. *(Open source)*",
"**Template Studio (Kotlin, Compose Desktop, PDFBox PNG diff)** - "
+ "Internal tool for evaluating CV, proposal, and "
+ "invoice output across 14 design presets. PNG "
+ "diffing, side-by-side layout, baseline freezing.",
"**LayoutLint** - Static analyser that flags fragile authoring "
+ "patterns (deeply nested rows, untyped offsets, "
+ "implicit page breaks) before they ship to production.",
"**ChromeForge** - Editorial-magazine document toolkit built on "
+ "GraphCompose: cinematic covers, pull quotes, multi-"
+ "column flow, sidebar callouts."))))
"**LayoutLint (Java 21, JavaParser, Spoon)** - Static analyser "
+ "that flags fragile authoring patterns (deeply "
+ "nested rows, untyped offsets, implicit page "
+ "breaks) before they ship to production.",
"**ChromeForge (Java, GraphCompose, Pandoc bridge)** - "
+ "Editorial-magazine document toolkit built on "
+ "GraphCompose: cinematic covers, pull quotes, "
+ "multi-column flow, sidebar callouts."))))
.module(CvModule.of("Professional Experience",
new MultiParagraphBlock(List.of(
"**Senior Platform Engineer**, Northwind Systems | "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,14 @@ private void finishRenderedFragment(PlacedFragment fragment,
boolean guideLines,
Map<String, Map<Integer, PdfGuideLinesRenderer.Bounds>> ownerBounds) throws Exception {
if (payload instanceof ParagraphFragmentPayload paragraphPayload) {
addParagraphSpanLinks(fragment, paragraphPayload, environment);
addParagraphLinks(fragment, paragraphPayload, environment);
}
if (payload instanceof PdfSemanticFragmentPayload semanticPayload) {
if (semanticPayload.linkOptions() != null) {
// Paragraph-level link emission is handled above with per-line
// rects tight to the rendered text (alignment-aware). Other
// semantic payloads (shapes, table rows) still use the full
// fragment rect as their clickable area.
if (semanticPayload.linkOptions() != null && !(payload instanceof ParagraphFragmentPayload)) {
PdfLinkAnnotationWriter.addUriLink(
environment.document().getPage(fragment.pageIndex()),
new PdfLinkAnnotationWriter.PlacedPdfRect(fragment.x(), fragment.y(), fragment.width(), fragment.height()),
Expand All @@ -345,9 +349,10 @@ private void finishRenderedFragment(PlacedFragment fragment,
}
}

private void addParagraphSpanLinks(PlacedFragment fragment,
ParagraphFragmentPayload payload,
PdfRenderEnvironment environment) throws Exception {
private void addParagraphLinks(PlacedFragment fragment,
ParagraphFragmentPayload payload,
PdfRenderEnvironment environment) throws Exception {
var paragraphLink = payload.linkOptions();
double innerX = fragment.x() + payload.padding().left();
double innerWidth = Math.max(0.0, fragment.width() - payload.padding().horizontal());
double contentTop = fragment.y() + fragment.height() - payload.padding().top();
Expand All @@ -362,6 +367,23 @@ private void addParagraphSpanLinks(PlacedFragment fragment,
case CENTER -> innerX + (innerWidth - line.width()) / 2.0;
case LEFT -> innerX;
};

// Paragraph-level link covers each rendered line tightly. Without
// this, right- or center-aligned paragraphs leaked clickable area
// across the empty alignment gap, so neighbouring contact rows
// (LinkedIn / GitHub icon paragraphs) hijacked each other's
// clicks.
if (paragraphLink != null && line.width() > 0.0) {
PdfLinkAnnotationWriter.addUriLink(
environment.document().getPage(fragment.pageIndex()),
new PdfLinkAnnotationWriter.PlacedPdfRect(
lineX,
lineTop - resolvedLineHeight,
line.width(),
resolvedLineHeight),
paragraphLink);
}

double spanX = lineX;
for (ParagraphSpan span : line.spans()) {
if (span.linkOptions() != null && span.width() > 0.0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,32 @@ private void addSectionBanner(SectionBuilder section, String title) {
private void addModuleBody(SectionBuilder section, CvModule module) {
section.spacing(4)
.padding(new DocumentInsets(4, 4, 0, 4));
// Projects render with a dedicated two-line layout — bold
// project name (with optional tech stack in parens) on the
// first line behind a bullet, then a hanging-indented
// description below — instead of the flat single-line bullet
// used for general bullet lists. Matches the canonical CV
// visual where "what the project is" stands apart from "what
// it did". Honours both shapes the data layer ships: a
// {@link BulletListBlock} with "**Name (tech)** - Description"
// strings and an {@link IndentedBlock} with separate title /
// body fields.
if (isProjectsModule(module.title())) {
if (module.body() instanceof BulletListBlock projects) {
for (String item : projects.items()) {
renderProjectItem(section, parseProjectItem(safe(item).trim()));
}
return;
}
if (module.body() instanceof IndentedBlock indented) {
for (IndentedBlock.Item item : indented.items()) {
renderProjectItem(section,
new ProjectParts(safe(item.title()).trim(),
safe(item.body()).trim()));
}
return;
}
}
renderBody(section, module.body());
}

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

/**
* Renders one project entry as two stacked paragraphs:
*
* <pre>
* • <b>Name</b> (tech stack)
* Description text wrapped under the title, hanging-indented
* so it lines up with the project name (not the bullet).
* </pre>
*
* <p>Input format: {@code "**Name (tech)** - Description"}.
* Both halves are optional — a project without a description
* renders only the title line; a project without bold markers
* around the name is treated as plain title text.</p>
*/
private void renderProjectItem(SectionBuilder section, ProjectParts parts) {
if (parts.name().isBlank() && parts.description().isBlank()) {
return;
}
DocumentTextStyle base = style(BODY_FONT, 8.6,
DocumentTextDecoration.DEFAULT, INK);
DocumentTextStyle nameStyle = style(BODY_FONT, 8.6,
DocumentTextDecoration.BOLD, INK);

section.addParagraph(paragraph -> paragraph
.textStyle(base)
.lineSpacing(1.4)
.align(TextAlign.LEFT)
.margin(DocumentInsets.top(2))
.bulletOffset("• ")
.indentStrategy(DocumentTextIndent.ALL_LINES)
.rich(rich -> appendMarkdown(rich, parts.name(), nameStyle)));

if (parts.description().isBlank()) {
return;
}
// Two-space prefix matches the bullet+space width inside the
// hanging-indent computation, so the description's first
// glyph sits under the project name rather than the bullet.
section.addParagraph(paragraph -> paragraph
.textStyle(base)
.lineSpacing(1.4)
.align(TextAlign.LEFT)
.margin(DocumentInsets.zero())
.bulletOffset(" ")
.indentStrategy(DocumentTextIndent.ALL_LINES)
.rich(rich -> appendMarkdown(rich, parts.description(), base)));
}

private void renderWorkEntry(SectionBuilder section, WorkEntry entry) {
DocumentTextStyle positionStyle = style(BODY_FONT, 9.2,
DocumentTextDecoration.BOLD, INK);
Expand Down Expand Up @@ -448,6 +522,30 @@ private static String stripBasicMarkdown(String value) {
.replace("_", "");
}

private static boolean isProjectsModule(String title) {
if (title == null) {
return false;
}
String normalized = title.toLowerCase(Locale.ROOT).trim();
return normalized.equals("projects") || normalized.startsWith("projects ");
}

private static ProjectParts parseProjectItem(String item) {
// Split on " - " (space-hyphen-space, mirroring WorkEntry parsing)
// so an em-dash or hyphen inside the description is not eaten.
// Falls back to "title only" when no separator is present.
int sepIndex = item.indexOf(" - ");
if (sepIndex <= 0) {
return new ProjectParts(item.trim(), "");
}
String name = item.substring(0, sepIndex).trim();
String description = item.substring(sepIndex + 3).trim();
return new ProjectParts(name, description);
}

private record ProjectParts(String name, String description) {
}

private static String spacedUpper(String value) {
String upper = safe(value).toUpperCase(Locale.ROOT);
StringBuilder builder = new StringBuilder();
Expand Down
22 changes: 12 additions & 10 deletions src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,19 @@ public double getTextWidthNoSanitize(TextStyle style, String text) {
}

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

if (resolved == ' ') {
if (!previousSpace) {
sanitized.append(' ');
previousSpace = true;
}
continue;
}

sanitized.appendCodePoint(resolved);
previousSpace = false;
}

return sanitized.toString();
Expand Down
Loading