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

## v1.6.4 — Planned

Bug fix + structured-block patch. Adds two new public Block types —
`WorkHistoryBlock` and `EducationBlock` — that let template authors
declare work-history and education entries with explicit (title,
organisation, date, description) / (degree, institution, year,
details) fields instead of relying on the legacy
`MultiParagraphBlock` pipe-separated string parser. Also closes a
Boxed Sections layout defect that bundled the date and description
into the right-aligned date column for any author-supplied line that
used an em-dash (`" — "`), en-dash (`" – "`), or contained
prose-shaped content the parser misread as a date. **No public API
break** — the sealed `Block` permit list grows from six to eight,
existing `MultiParagraphBlock` work-history strings continue to
parse, and the deprecated parser path stays in place for backward
compatibility.

### Templates — new structured blocks

- **`WorkHistoryBlock`.** New public record block carrying a list of
`Item(title, organisation, date, description)` entries. The
`BoxedSections` preset renders each item as a structured row:
title bold on the left, date right-aligned on the same row,
organisation italic on the next line under the title, and
description as a full-width paragraph beneath. Other presets fall
back to a single concatenated paragraph per item. Authors who use
`WorkHistoryBlock` bypass the legacy
`BoxedSections#parseWorkEntry` heuristic parser entirely.
- **`EducationBlock`.** New public record block carrying a list of
`Item(degree, institution, year, details)` entries. Renders with
the same structured layout as `WorkHistoryBlock` (degree bold
left, year right, institution italic, details paragraph) so
Education & Certifications sections visually match Professional
Experience.
- **Sample data migrated.** `ExampleDataFactory.sampleCvSpecV2` now
uses `WorkHistoryBlock` for Professional Experience and
`EducationBlock` for Education & Certifications. The legacy
`MultiParagraphBlock` pattern remains supported and is exercised
by `PresetLayoutSnapshotTest` / `PresetVisualParityTest` to lock
the backward-compat path.

### Templates — parser robustness (legacy path)

- **`parseWorkEntry` accepts em-dash and en-dash.** Used to split
the post-pipe segment on ASCII `" - "` only; now tries `" — "`,
`" – "`, and `" - "` in order, mirroring `splitHeading`. Authors
who typed `"*2024-Present* — Led reusable document flows."` saw
the whole tail collapse into the date column — this no longer
happens.
- **`parseWorkEntry` rejects prose dressed up as a date.** The
loose `looksLikeDate` check accepted any string containing a
year and a hyphen anywhere, which caused education lines like
`"... | 2019. First-class honours. Specialisation ..."` to
parse as work entries (the hyphen inside `"First-class"` was
enough to satisfy the heuristic). Parser now rejects post-pipe
segments that contain sentence-ending punctuation (`.`, `:`,
`;`) when no explicit date / description separator was found,
letting these lines fall back to plain paragraph rendering.
Marked `@Deprecated` with a `@deprecated` Javadoc pointing
callers to `WorkHistoryBlock` / `EducationBlock`.
- **`parseProjectItem`** picks up the same em-dash / en-dash /
ASCII separator set so future Project items typed with em-dash
don't regress into "title only" rendering.

### Tests

- `BlockTest.blockSealingPermitsAllEightVariants` updated for the
two new permitted block types.
- `PresetVisualGalleryTest.sampleSpec` migrated to
`WorkHistoryBlock` so the visible "primary example" exercises the
new structured shape.
- `PresetLayoutSnapshotTest` intentionally retained on
`MultiParagraphBlock` to lock the legacy parser's behaviour.

## v1.6.3 — 2026-05-22

Bug fix patch. Closes two independent hyperlink clickable-area
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.demcha.examples.support;

import com.demcha.compose.document.templates.blocks.BulletListBlock;
import com.demcha.compose.document.templates.blocks.EducationBlock;
import com.demcha.compose.document.templates.blocks.IndentedBlock;
import com.demcha.compose.document.templates.blocks.KeyValueBlock;
import com.demcha.compose.document.templates.blocks.MultiParagraphBlock;
import com.demcha.compose.document.templates.blocks.ParagraphBlock;
import com.demcha.compose.document.templates.blocks.WorkHistoryBlock;
import com.demcha.compose.document.templates.coverletter.spec.CoverLetterHeader;
import com.demcha.compose.document.templates.coverletter.spec.CoverLetterSpec;
import com.demcha.compose.document.templates.cv.spec.CvHeader;
Expand Down Expand Up @@ -286,16 +288,32 @@ public static CvSpec sampleCvSpecV2() {
"**Distribution:** Maven Central, Sonatype OSSRH, GPG signing, "
+ "JitPack, semantic versioning discipline"))))
.module(CvModule.of("Education & Certifications",
new MultiParagraphBlock(List.of(
"**MSc Computer Science** - University of Manchester | 2021. "
+ "Distinction. Thesis: *Composable layout primitives for "
+ "deterministic document rendering*.",
"**BSc Software Engineering** - Imperial College London | 2019. "
+ "First-class honours. Specialisation in compilers and "
+ "static analysis.",
"**Oracle Java Certification** - Professional track | 2023. "
+ "Java 17 platform deep-dive: records, sealed types, "
+ "pattern matching, virtual threads."))))
// Preferred: structured EducationBlock with
// explicit (degree, institution, year, details)
// fields. BoxedSections renders each item with
// the same structured layout as Professional
// Experience — degree bold left, year right,
// institution italic on the next line, and
// details as a full-width paragraph below.
new EducationBlock(List.of(
new EducationBlock.Item(
"MSc Computer Science",
"University of Manchester",
"2021",
"Distinction. Thesis: *Composable layout primitives "
+ "for deterministic document rendering*."),
new EducationBlock.Item(
"BSc Software Engineering",
"Imperial College London",
"2019",
"First-class honours. Specialisation in compilers and "
+ "static analysis."),
new EducationBlock.Item(
"Oracle Java Certification",
"Professional track",
"2023",
"Java 17 platform deep-dive: records, sealed types, "
+ "pattern matching, virtual threads.")))))
.module(CvModule.of("Projects",
new BulletListBlock(List.of(
"**GraphCompose (Java 21, PDFBox, Maven, JMH)** - "
Expand All @@ -316,23 +334,40 @@ public static CvSpec sampleCvSpecV2() {
+ "GraphCompose: cinematic covers, pull quotes, "
+ "multi-column flow, sidebar callouts."))))
.module(CvModule.of("Professional Experience",
new MultiParagraphBlock(List.of(
"**Senior Platform Engineer**, Northwind Systems | "
+ "*2024-Present* - Led the reusable document-generation "
+ "platform serving billing, hiring, and reporting flows "
+ "across **8 product teams**. Reduced template "
+ "maintenance time by **70%** by retiring per-team "
+ "PDF scripts in favour of one canonical engine.",
"**Software Engineer**, BrightLeaf Labs | *2021-2024* - Built "
+ "backend services and production document rendering "
+ "pipelines processing **2M+ documents per month**. "
+ "Drove the migration from iText to a custom layout "
+ "engine, eliminating licensing risk and cutting "
+ "p99 render latency from 1.4s to 380ms.",
"**Backend Engineer**, Helix Print Co | *2019-2021* - "
+ "Maintained a high-volume invoice-printing service "
+ "(15M PDFs/year) and authored the compliance test "
+ "harness that gated every template change."))))
// Preferred: structured WorkHistoryBlock with
// explicit (title, organisation, date,
// description) fields. BoxedSections renders
// each item as a structured row (title bold
// left, date right, organisation italic on the
// next line, description full-width below)
// without falling back to the legacy
// pipe-separated string parser.
new WorkHistoryBlock(List.of(
new WorkHistoryBlock.Item(
"Senior Platform Engineer",
"Northwind Systems",
"2024-Present",
"Led the reusable document-generation platform serving "
+ "billing, hiring, and reporting flows across "
+ "**8 product teams**. Reduced template maintenance "
+ "time by **70%** by retiring per-team PDF scripts "
+ "in favour of one canonical engine."),
new WorkHistoryBlock.Item(
"Software Engineer",
"BrightLeaf Labs",
"2021-2024",
"Built backend services and production document rendering "
+ "pipelines processing **2M+ documents per month**. "
+ "Drove the migration from iText to a custom layout "
+ "engine, eliminating licensing risk and cutting "
+ "p99 render latency from 1.4s to 380ms."),
new WorkHistoryBlock.Item(
"Backend Engineer",
"Helix Print Co",
"2019-2021",
"Maintained a high-volume invoice-printing service "
+ "(15M PDFs/year) and authored the compliance test "
+ "harness that gated every template change.")))))
.module(CvModule.of("Additional Information",
new KeyValueBlock(List.of(
new KeyValueBlock.Entry("Languages",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
*
* <p>The sealed permit list is intentionally exhaustive: every body
* shape that a CV / cover-letter / invoice / proposal preset can
* declare today is one of the six concrete records. To add a new body
* shape, extend the {@code permits} list and update the Module
* declare today is one of the eight concrete records. To add a new
* body shape, extend the {@code permits} list and update the Module
* composer to handle the new variant.</p>
*
* <p>Block records are immutable and safe to reuse across documents.</p>
Expand All @@ -23,5 +23,7 @@ public sealed interface Block
NumberedListBlock,
IndentedBlock,
KeyValueBlock,
MultiParagraphBlock {
MultiParagraphBlock,
WorkHistoryBlock,
EducationBlock {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.demcha.compose.document.templates.blocks;

import java.util.List;
import java.util.Objects;

/**
* A {@link Block} that captures a stack of education / certification
* entries with each field (degree, institution, year, details)
* supplied separately so presets can place them precisely without
* re-parsing a concatenated source string.
*
* <p>This is the <strong>preferred shape</strong> for "Education",
* "Education &amp; Certifications", "Qualifications" or any module
* whose body is a list of degree / course entries. The
* {@code BoxedSections} preset renders each {@link Item} with the
* same structured layout as {@code WorkHistoryBlock}: degree bold on
* the left, year right-aligned on the same row, institution italic
* on the next line under the degree, and details as a full-width
* paragraph beneath. Other presets fall back to a single inline
* paragraph per item.</p>
*
* <p><strong>Legacy alternative.</strong> Authors may still pass
* education as a {@link MultiParagraphBlock} of pipe-separated
* strings — e.g.
* {@code "**Degree** - Institution | Year. Details..."} — and the
* legacy parser tries to interpret them. Prefer
* {@code EducationBlock} in new code: the structured fields are
* explicit, do not depend on the parser's separator and date
* heuristics (which over-trigger on prose containing stray hyphens
* like "First-class"), and survive copy-paste from spreadsheets
* without quoting concerns.</p>
*
* @param items education entries in source order, most-recent-first
* by convention (must not be null; may be empty;
* individual items must not be null)
*/
public record EducationBlock(List<Item> items) implements Block {

/**
* Compact constructor that defensively copies the supplied list and
* validates that no item reference is null.
*
* @throws NullPointerException if {@code items} or any element is
* null
*/
public EducationBlock {
Objects.requireNonNull(items, "items");
items = List.copyOf(items);
}

/**
* One row in an education stack. All four fields are required
* non-null strings but may be blank — a blank {@code institution}
* collapses the subtitle line, a blank {@code details} collapses
* the body paragraph, and a blank {@code year} renders the degree
* row without a right-aligned year column.
*
* @param degree degree / qualification name, e.g.
* {@code "MSc Computer Science"} or
* {@code "Oracle Java Certification"}
* @param institution awarding institution, e.g.
* {@code "University of Manchester"} or
* {@code "Professional track"}
* @param year year or year range, e.g. {@code "2021"},
* {@code "2018-2021"}
* @param details additional details (honours, thesis,
* specialisation, course content); free prose,
* may contain inline markdown
* ({@code **bold**}, {@code *italic*})
*/
public record Item(String degree, String institution, String year, String details) {

/**
* Compact constructor: rejects null fields. Use empty strings
* for absent values rather than null.
*/
public Item {
Objects.requireNonNull(degree, "degree");
Objects.requireNonNull(institution, "institution");
Objects.requireNonNull(year, "year");
Objects.requireNonNull(details, "details");
}
}
}
Loading