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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **`CvIdentity` carries an optional portrait.** A CV design with a photograph in it
had nowhere to put one: the identity record held the name, the title, the contact
triple and the links, and a preset that wanted a face had to ship a silhouette of its
own. `CvIdentity` now carries `Optional<DocumentImageData> portrait` — the image
itself, because a photograph is caller-supplied content rather than template chrome —
reachable through `CvIdentity.Builder.portrait(...)`. The four- and three-argument
constructors are kept explicitly, so existing calls compile and link unchanged; only a
record deconstruction pattern over `CvIdentity` sees the extra component. A document
carrying a portrait still renders through every preset in the family — the ones with
nowhere to draw it ignore it.

- **A structured invoice document model.** The invoice family's data layer knew one
shape — an invoice as pre-formatted display strings, with one address block per
party, line items whose quantity and money are already rendered, and a flat list of
Expand Down Expand Up @@ -36,6 +47,24 @@ follow semantic versioning; release dates are ISO 8601.

### Templates

- **A portrait CV preset: `NavySidebar`.** A one-page A4 CV in two columns on Lato —
a navy plate carrying a ringed portrait, the contact channels behind their marks, the
degrees, the skills and the languages, beside a white column of the name, the summary,
the roles held on a timeline rail with a filled marker at each one, and the
achievements and certifications behind badged headings. Ships as
`cv.presets.NavySidebar` on the existing `CvDocument` model, porting the rendered
layout of the published standalone `navy-sidebar-cv` template. Like its sibling it
owns its page and holds one: the two columns are a single atomic row, so a CV longer
than the sheet raises `AtomicNodeTooLargeException` rather than flowing or dropping
entries. Sections reach their berth by title rather than by `Slot`; languages are a
`RowsSection` because this design writes the proficiency out — "Native", "Advanced" —
which a levelled skill could not carry back. The photograph comes from the new
`CvIdentity.portrait()`; an identity without one draws the ring around an empty navy
disc. Guarded by a smoke test (including the missing portrait, the capitals this
design imposes, the one-page limit and the fields it has no place for), an exact
layout snapshot and a pixel-parity gate; the examples showcase gains
`cv-navy-sidebar-v2`.

- **The first CV preset that owns its page: `ProfessionalSidebar`.** A one-page CV
in two columns on the Barlow&nbsp;Condensed / Lato pair — a navy monogram plate over a
pale sidebar carrying the contact channels, meter-bar skills, an education rail with
Expand Down
Binary file added assets/readme/examples/cv-navy-sidebar-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-professional-sidebar-v2.pdf
Binary file not shown.
12 changes: 6 additions & 6 deletions docs/templates/v2-layered/using-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,12 @@ whole or the paginator raises `AtomicNodeTooLargeException`. Lifting a
cap without teaching the preset to pick its own page boundaries turns a
CV that silently lost an entry into one that fails to render.

`ProfessionalSidebar.create()` is the fourth composition for a fixed
amount of content and the one that caps nothing: it reproduces a
specific one-page sheet, so a CV longer than that sheet raises
`AtomicNodeTooLargeException` instead of losing an entry to a cap. Size
the document to it — roughly five or six roles with three or four
highlights each, alongside the sidebar blocks.
`ProfessionalSidebar.create()` and `NavySidebar.create()` are
compositions for a fixed amount of content that cap nothing: each
reproduces a specific one-page sheet, so a CV longer than that sheet
raises `AtomicNodeTooLargeException` instead of losing an entry to a
cap. Size the document to them — roughly five or six roles with three
or four highlights each, alongside the sidebar blocks.

If the document's length is the author's rather than the template's,
pick a preset that paginates — `TimelineMinimal` splits its own columns
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
import com.demcha.examples.templates.cv.v2.CvPanelExample;
import com.demcha.examples.templates.cv.v2.CvSidebarPortraitExample;
import com.demcha.examples.templates.cv.v2.CvTimelineMinimalExample;
import com.demcha.examples.templates.cv.v2.NavySidebarExample;
import com.demcha.examples.templates.cv.v2.ProfessionalSidebarExample;
import com.demcha.examples.templates.invoice.ClassicInvoiceV2Example;
import com.demcha.examples.templates.invoice.ConsultingInvoiceV2Example;
Expand Down Expand Up @@ -146,6 +147,7 @@ public static void main(String[] args) throws Exception {
System.out.println("Generated: " + CvPanelExample.generate());
System.out.println("Generated: " + CvSidebarPortraitExample.generate());
System.out.println("Generated: " + CvTimelineMinimalExample.generate());
System.out.println("Generated: " + NavySidebarExample.generate());
System.out.println("Generated: " + ProfessionalSidebarExample.generate());

// Cover letters (v2 layered — 15 paired letters, one per CV preset)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.demcha.examples.support;

import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.templates.core.identity.Contact;
import com.demcha.compose.document.templates.core.identity.Link;
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.EntriesSection;
import com.demcha.compose.document.templates.cv.data.ParagraphSection;
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.data.SkillGroup;
import com.demcha.compose.document.templates.cv.data.SkillsSection;
import com.demcha.compose.document.templates.cv.data.Slot;

import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.Objects;

/**
* Sample data for the Navy Sidebar CV example.
*
* <p>Sized to the design: the preset draws a fixed one-page sheet, and a CV
* much longer than this one does not compose at all — the two columns are a
* single atomic row, so it raises {@code AtomicNodeTooLargeException}.</p>
*
* <p>The portrait is a neutral silhouette rather than a photograph, because
* the example ships in the repository; a real CV passes the candidate's own
* image to {@code CvIdentity.Builder.portrait(...)} the same way.</p>
*/
public final class NavySidebarSampleData {

private static final String PORTRAIT = "/cv-portrait-placeholder.png";

private NavySidebarSampleData() {
}

/**
* A marketing manager's one-page CV.
*
* @return the sample document
*/
public static CvDocument sample() {
return CvDocument.builder()
.identity(CvIdentity.builder()
.name("Priya", "Raghavan")
.jobTitle("Marketing Manager")
.contact(new Contact("+44 20 7946 0812",
"priya.r@example.com",
"Bristol, United Kingdom"))
.link(new Link("linkedin.com/in/praghavan",
"https://linkedin.com/in/praghavan"))
.portrait(portrait())
.build())
.section(Slot.MAIN, new ParagraphSection("Summary",
"Marketing manager with eight years in B2B software, most of it"
+ " running the demand side end to end: positioning, the"
+ " campaigns that carry it, and the reporting that says"
+ " whether it worked. Happiest with a small team and a"
+ " short feedback loop."))
.section(Slot.SIDEBAR, EntriesSection.builder("Education")
.entry("MSc Marketing Analytics", "University of Bristol",
"2015 - 2016", "Bristol, UK")
.entry("BA Business Management", "University of Leeds",
"2012 - 2015", "Leeds, UK")
.build())
.section(Slot.SIDEBAR, SkillsSection.of("Skills", SkillGroup.of("Core",
"Positioning",
"Demand Generation",
"Marketing Analytics",
"SEO / SEM",
"Lifecycle Email",
"Content Strategy",
"HubSpot",
"Looker")))
.section(Slot.SIDEBAR, RowsSection.builder("Languages", RowStyle.PLAIN)
.row("English", "Native")
.row("Tamil", "Native")
.row("German", "Intermediate")
.build())
.section(Slot.MAIN, EntriesSection.builder("Experience")
.entry("Marketing Manager",
"Ardent Systems, Bristol, UK",
"Mar 2021 - Present",
String.join("\n",
"Rebuilt the demand programme around three named"
+ " segments, lifting qualified pipeline 44% in"
+ " the first year.",
"Runs a team of four across content, lifecycle and"
+ " events, and the agency relationship behind"
+ " paid search.",
"Replaced a weekly spreadsheet with a Looker model the"
+ " sales team reads without asking for it."))
.entry("Senior Marketing Executive",
"Halworth Digital, Bristol, UK",
"Sep 2018 - Feb 2021",
String.join("\n",
"Owned lifecycle email end to end, taking trial-to-paid"
+ " conversion from 9% to 14%.",
"Launched the customer-story programme that still"
+ " supplies the sales deck.",
"Ran competitor and win-loss research each quarter for"
+ " the product team."))
.entry("Marketing Executive",
"Kite & Compass, Leeds, UK",
"Oct 2016 - Aug 2018",
String.join("\n",
"Planned and ran campaigns across search, social and"
+ " trade press for six retail clients.",
"Built the reporting pack the agency used for every"
+ " monthly review."))
.build())
.section(Slot.MAIN, new ParagraphSection("Achievements", String.join("\n",
"Grew organic sessions 60% in a year by rebuilding the site around"
+ " search intent rather than the org chart.",
"Cut cost per qualified lead by a third by retiring two channels and"
+ " funding the one that worked.",
"Named marketer of the year at Ardent in 2024.")))
.section(Slot.MAIN, new ParagraphSection("Certifications", String.join("\n",
"Google Analytics Individual Qualification",
"HubSpot Content Marketing Certification",
"Professional Certificate in Marketing, CIM")))
.build();
}

/**
* The packaged silhouette that stands in for a photograph.
*
* @return the portrait image data
*/
private static DocumentImageData portrait() {
try (InputStream in = Objects.requireNonNull(
NavySidebarSampleData.class.getResourceAsStream(PORTRAIT),
"cv-portrait-placeholder.png missing from examples/src/main/resources/")) {
return DocumentImageData.fromBytes(in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException("Failed to read the sample portrait", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public static CvDocument sample() {
.name("MARTA", "LINDQVIST")
.jobTitle("SENIOR BACKEND ENGINEER")
.contact(new Contact("+46 8 123 456 78",
"marta.lindqvist@example.com",
"marta.l@example.com",
"Stockholm, Sweden"))
.link(new Link("linkedin.com/in/mlindqvist",
"https://linkedin.com/in/mlindqvist"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ record Entry(String title, String description, List<String> tags, String codeUrl
cv("cv-mint-editorial-v2", "CvMintEditorialExample", "Mint Editorial", "Magazine-style editorial CV with mint accent palette and two-column body.", "editorial", "mint");
cv("cv-mint-editorial-v2-custom", "CvMintEditorialCustomExample", "Mint Editorial (custom band)", "The same preset with one colour changed through its Options — a kraft-paper masthead band, everything else left at the preset's defaults.", "editorial", "mint");
cv("cv-professional-sidebar-v2", "ProfessionalSidebarExample", "Professional Sidebar", "Navy monogram plate over a pale sidebar of contact marks, skill meters, an education rail and language ratings, beside a white column of profile, roles and projects.", "sidebar", "navy");
cv("cv-navy-sidebar-v2", "NavySidebarExample", "Navy Sidebar", "Navy plate with a ringed portrait, contact marks, degrees, skills and languages, beside a white column of summary, badged sections and roles strung on a timeline rail.", "sidebar", "navy", "portrait");

// ===== Templates / Cover Letter (v2 layered, paired 1:1 with CV) =====
// Registered directly: letter() points at the layered preset examples under
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.demcha.examples.templates.cv.v2;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.cv.data.CvDocument;
import com.demcha.compose.document.templates.cv.presets.NavySidebar;
import com.demcha.examples.support.ExampleOutputPaths;
import com.demcha.examples.support.NavySidebarSampleData;

import java.nio.file.Path;

/**
* Renders the layered {@code cv.v2} Navy Sidebar preset against the
* marketing-manager sample.
*
* <p>Output:
* {@code examples/target/generated-pdfs/templates/cv/cv-navy-sidebar-v2.pdf}.</p>
*
* <p>The preset owns its page — A4 with no margin, the navy plate painted as
* a page background — so the session starts unconfigured.</p>
*/
public final class NavySidebarExample {

private NavySidebarExample() {
}

/**
* @return absolute path of the rendered PDF
* @throws Exception if rendering fails
*/
public static Path generate() throws Exception {
Path outputFile = ExampleOutputPaths.prepare(
"templates/cv", "cv-navy-sidebar-v2.pdf");
CvDocument doc = NavySidebarSampleData.sample();
DocumentTemplate<CvDocument> template = NavySidebar.create();

try (DocumentSession document = GraphCompose.document(outputFile).create()) {
template.compose(document, doc);
document.buildPdf();
}
return outputFile;
}

/**
* @param args ignored
* @throws Exception if rendering fails
*/
public static void main(String[] args) throws Exception {
System.out.println("Generated: " + generate());
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.demcha.compose.document.templates.cv.data;

import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.templates.core.identity.Contact;
import com.demcha.compose.document.templates.core.identity.Link;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Pins the compatibility promise made when {@link CvIdentity} grew a
* portrait: the constructors that predate the component are still there and
* still mean what they meant, so a caller written against the four-argument
* form keeps compiling and linking.
*/
class CvIdentityPortraitTest {

private static final Contact CONTACT =
new Contact("+44 20 7946 0000", "ada@example.com", "London, UK");

@Test
void theConstructorThatPredatesThePortraitLeavesItEmpty() {
CvIdentity identity = new CvIdentity(
CvName.of("Ada", "Lovelace"), "Analyst", CONTACT,
List.of(new Link("site", "https://example.com")));
assertThat(identity.portrait()).isEmpty();
assertThat(identity.jobTitle()).isEqualTo("Analyst");
}

@Test
void theConstructorThatPredatesTheJobTitleLeavesBothEmpty() {
CvIdentity identity = new CvIdentity(
CvName.of("Ada", "Lovelace"), CONTACT, List.of());
assertThat(identity.portrait()).isEmpty();
assertThat(identity.jobTitle()).isEmpty();
}

@Test
void aNullPortraitNormalizesToAbsent() {
CvIdentity identity = new CvIdentity(
CvName.of("Ada", "Lovelace"), "Analyst", CONTACT, List.of(), null);
assertThat(identity.portrait()).isEmpty();
}

@Test
void theBuilderCarriesThePortraitThrough() {
DocumentImageData image = DocumentImageData.fromBytes(onePixelPng());
CvIdentity identity = CvIdentity.builder()
.name("Ada", "Lovelace")
.contact(CONTACT)
.portrait(image)
.build();
assertThat(identity.portrait()).isEqualTo(Optional.of(image));
}

@Test
void theBuilderTreatsANullPortraitAsNone() {
CvIdentity identity = CvIdentity.builder()
.name("Ada", "Lovelace")
.contact(CONTACT)
.portrait(null)
.build();
assertThat(identity.portrait()).isEmpty();
}

/** The smallest valid PNG: one opaque pixel. */
private static byte[] onePixelPng() {
return java.util.Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmM"
+ "IQAAAABJRU5ErkJggg==");
}
}
Loading
Loading