diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66e4d4248..757b26c36 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 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
@@ -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 Condensed / Lato pair — a navy monogram plate over a
pale sidebar carrying the contact channels, meter-bar skills, an education rail with
diff --git a/assets/readme/examples/cv-navy-sidebar-v2.pdf b/assets/readme/examples/cv-navy-sidebar-v2.pdf
new file mode 100644
index 000000000..1ba8f2fcd
Binary files /dev/null and b/assets/readme/examples/cv-navy-sidebar-v2.pdf differ
diff --git a/assets/readme/examples/cv-professional-sidebar-v2.pdf b/assets/readme/examples/cv-professional-sidebar-v2.pdf
index 2abddb69b..e739b2937 100644
Binary files a/assets/readme/examples/cv-professional-sidebar-v2.pdf and b/assets/readme/examples/cv-professional-sidebar-v2.pdf differ
diff --git a/docs/templates/v2-layered/using-templates.md b/docs/templates/v2-layered/using-templates.md
index 9017f5517..e040f106b 100644
--- a/docs/templates/v2-layered/using-templates.md
+++ b/docs/templates/v2-layered/using-templates.md
@@ -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
diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
index d9c144721..706465084 100644
--- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
+++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
@@ -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;
@@ -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)
diff --git a/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java b/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java
new file mode 100644
index 000000000..712d5dcdf
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java
@@ -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.
+ *
+ *
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}.
+ *
+ *
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.
+ */
+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);
+ }
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/support/ProfessionalSidebarSampleData.java b/examples/src/main/java/com/demcha/examples/support/ProfessionalSidebarSampleData.java
index 39c18438e..75b1de94b 100644
--- a/examples/src/main/java/com/demcha/examples/support/ProfessionalSidebarSampleData.java
+++ b/examples/src/main/java/com/demcha/examples/support/ProfessionalSidebarSampleData.java
@@ -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"))
diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java
index b63c554c0..62fc7aafa 100644
--- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java
+++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java
@@ -61,6 +61,7 @@ record Entry(String title, String description, List 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
diff --git a/examples/src/main/java/com/demcha/examples/templates/cv/v2/NavySidebarExample.java b/examples/src/main/java/com/demcha/examples/templates/cv/v2/NavySidebarExample.java
new file mode 100644
index 000000000..25a43f09e
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/templates/cv/v2/NavySidebarExample.java
@@ -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.
+ *
+ *
The preset owns its page — A4 with no margin, the navy plate painted as
+ * a page background — so the session starts unconfigured.
+ */
+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 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());
+ }
+}
diff --git a/examples/src/main/resources/cv-portrait-placeholder.png b/examples/src/main/resources/cv-portrait-placeholder.png
new file mode 100644
index 000000000..8c58ba4d8
Binary files /dev/null and b/examples/src/main/resources/cv-portrait-placeholder.png differ
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvIdentityPortraitTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvIdentityPortraitTest.java
new file mode 100644
index 000000000..4a275da64
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvIdentityPortraitTest.java
@@ -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==");
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java
new file mode 100644
index 000000000..923ebec22
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java
@@ -0,0 +1,166 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+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.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+
+import javax.imageio.ImageIO;
+
+/**
+ * The canonical fixture for {@link NavySidebar} — the document the design was
+ * drawn around, and the one both gates measure.
+ *
+ *
One fixture feeds the layout snapshot and the pixel baseline, so a
+ * change that moves the sheet moves both together instead of leaving one
+ * gate measuring a document the other no longer renders.
+ *
+ *
The portrait is drawn here rather than committed: a photograph in the
+ * repository would be a licensed asset guarding a layout, and what the gates
+ * need is only that the disc has something in it. It is deterministic, so
+ * the pixel baseline is stable.
+ */
+public final class NavySidebarFixtures {
+
+ private NavySidebarFixtures() {
+ }
+
+ /**
+ * The canonical CV: a portrait, four contact channels, two degrees, eight
+ * skills, three languages, three roles, three achievements and three
+ * certifications — one page.
+ *
+ * @return the fixture document
+ */
+ public static CvDocument canonicalCv() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Olivia", "Martinez")
+ .jobTitle("Marketing Manager")
+ .contact(new Contact("+1 (555) 123-4567",
+ "your.email@gmail.com",
+ "New York, NY, USA"))
+ .link(new Link("linkedin.com/in/yourname",
+ "https://linkedin.com/in/yourname"))
+ .portrait(portrait())
+ .build())
+ .section(Slot.MAIN, new ParagraphSection("SUMMARY",
+ "Results-driven marketing professional with 5+ years of experience"
+ + " developing and executing data-driven marketing strategies"
+ + " that drive brand growth, increase engagement, and deliver"
+ + " measurable results. Skilled in digital marketing, market"
+ + " research, and cross-functional collaboration."))
+ .section(Slot.SIDEBAR, EntriesSection.builder("Education")
+ .entry("Master of Science in Marketing", "New York University",
+ "2020 - 2022", "New York, NY")
+ .entry("Bachelor of Business Administration", "University of California",
+ "2016 - 2020", "Los Angeles, CA")
+ .build())
+ .section(Slot.SIDEBAR, SkillsSection.of("Skills", SkillGroup.of("Core",
+ "Digital Marketing",
+ "Data Analysis",
+ "Project Management",
+ "SEO / SEM",
+ "Microsoft Office Suite",
+ "Google Analytics",
+ "Social Media Strategy",
+ "Content Marketing")))
+ .section(Slot.SIDEBAR, RowsSection.builder("Languages", RowStyle.PLAIN)
+ .row("English", "Native")
+ .row("Spanish", "Advanced")
+ .row("French", "Intermediate")
+ .build())
+ .section(Slot.MAIN, EntriesSection.builder("Experience")
+ .entry("Marketing Manager",
+ "BrightWave Solutions, New York, NY",
+ "Jan 2022 - Present",
+ String.join("\n",
+ "Develop and implement integrated marketing campaigns"
+ + " that increased brand awareness by 35% and"
+ + " lead generation by 50%.",
+ "Manage a team of 4 marketing specialists and"
+ + " collaborate with sales, product, and design"
+ + " teams to align strategies.",
+ "Analyze campaign performance using Google Analytics"
+ + " and other tools to optimize ROI and"
+ + " reporting."))
+ .entry("Marketing Specialist",
+ "BrightWave Solutions, New York, NY",
+ "Jun 2020 - Dec 2021",
+ String.join("\n",
+ "Executed digital marketing campaigns across SEO, PPC,"
+ + " email, and social media channels.",
+ "Conducted market research and competitor analysis to"
+ + " identify opportunities and inform strategy.",
+ "Created engaging content for blogs, newsletters, and"
+ + " social media that increased engagement by"
+ + " 40%."))
+ .entry("Marketing Intern",
+ "BrightWave Solutions, New York, NY",
+ "Jan 2020 - May 2020",
+ String.join("\n",
+ "Assisted in planning and executing marketing"
+ + " initiatives and events.",
+ "Supported content creation and social media"
+ + " management.",
+ "Analyzed data and prepared reports to support the"
+ + " marketing team."))
+ .build())
+ .section(Slot.MAIN, new ParagraphSection("Achievements", String.join("\n",
+ "Increased website traffic by 60% within one year through SEO and"
+ + " content strategy.",
+ "Launched a successful email marketing campaign with a 25% conversion"
+ + " rate.",
+ "Received \u201cEmployee of the Year\u201d award in 2023 for outstanding"
+ + " performance.")))
+ .section(Slot.MAIN, new ParagraphSection("Certifications", String.join("\n",
+ "Google Analytics Individual Qualification (GAIQ)",
+ "HubSpot Content Marketing Certification",
+ "Facebook Blueprint Certification")))
+ .build();
+ }
+
+ /**
+ * A flat two-tone silhouette, drawn at fixture time.
+ *
+ * @return the portrait image data
+ */
+ static DocumentImageData portrait() {
+ BufferedImage image = new BufferedImage(512, 512, BufferedImage.TYPE_INT_RGB);
+ Graphics2D g = image.createGraphics();
+ try {
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+ RenderingHints.VALUE_ANTIALIAS_ON);
+ g.setColor(new Color(222, 226, 231));
+ g.fillRect(0, 0, 512, 512);
+ g.setColor(new Color(150, 160, 173));
+ g.fillOval(171, 102, 169, 169);
+ g.fillOval(67, 307, 379, 379);
+ } finally {
+ g.dispose();
+ }
+ ByteArrayOutputStream png = new ByteArrayOutputStream();
+ try {
+ ImageIO.write(image, "png", png);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to draw the fixture portrait", e);
+ }
+ return DocumentImageData.fromBytes(png.toByteArray());
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarLayoutSnapshotTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarLayoutSnapshotTest.java
new file mode 100644
index 000000000..5af914f79
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarLayoutSnapshotTest.java
@@ -0,0 +1,29 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.templates.TemplateTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Exact layout snapshot gate for {@link NavySidebar} - freezes the resolved
+ * geometry of the canonical one-page CV, which is what catches a shift the
+ * pixel budget is too coarse to see.
+ *
+ *
Refresh with {@code -Dgraphcompose.updateSnapshots=true} after a
+ * deliberate layout change, and commit the JSON with the change.
+ */
+class NavySidebarLayoutSnapshotTest {
+
+ @Test
+ void canonicalCvMatchesLayoutSnapshot() throws Exception {
+ try (DocumentSession session = GraphCompose.document().create()) {
+ NavySidebar.create().compose(session, NavySidebarFixtures.canonicalCv());
+ assertThat(session.layoutSnapshot().totalPages()).isEqualTo(1);
+ TemplateTestSupport.assertCanonicalSnapshot(
+ session, "navy_sidebar_layout", "cv-v2");
+ }
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java
new file mode 100644
index 000000000..58322a979
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java
@@ -0,0 +1,185 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.exceptions.AtomicNodeTooLargeException;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+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.CvSkill;
+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 org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Smoke test for {@link NavySidebar} — proves the preset renders a
+ * {@link CvDocument} end-to-end with its packaged marks and the portrait
+ * from the identity, composes without one, and pins the limits the class
+ * documents: the sheet holds one page, and the fields this design has no
+ * place for are not drawn.
+ */
+class NavySidebarSmokeTest {
+
+ private static byte[] render(CvDocument doc) throws Exception {
+ // The preset owns its page geometry, so the session starts unconfigured.
+ try (DocumentSession session = GraphCompose.document().create()) {
+ NavySidebar.create().compose(session, doc);
+ assertThat(session.roots()).isNotEmpty();
+ byte[] pdfBytes = session.toPdfBytes();
+ assertThat(pdfBytes).isNotEmpty();
+ return pdfBytes;
+ }
+ }
+
+ private static String textOf(byte[] pdfBytes) throws Exception {
+ try (PDDocument document = Loader.loadPDF(pdfBytes)) {
+ return new PDFTextStripper().getText(document);
+ }
+ }
+
+ @Test
+ void exposesStableIdentity() {
+ DocumentTemplate template = NavySidebar.create();
+ assertThat(template.id()).isEqualTo(NavySidebar.ID);
+ assertThat(template.displayName()).isEqualTo(NavySidebar.DISPLAY_NAME);
+ }
+
+ @Test
+ void canonicalRenderCarriesTheUntrackedText() throws Exception {
+ // The headings, the name and the role are set letter by letter to
+ // reach their tracking, so they arrive on the text layer spaced out.
+ // What is asserted here is the text drawn as written.
+ String text = textOf(render(NavySidebarFixtures.canonicalCv()));
+ assertThat(text)
+ .contains("your.email@gmail.com")
+ .contains("New York, NY, USA")
+ .contains("linkedin.com/in/yourname")
+ .contains("Results-driven marketing professional")
+ .contains("New York University")
+ .contains("Digital Marketing")
+ .contains("Native")
+ .contains("BrightWave Solutions, New York, NY")
+ .contains("Increased website traffic by 60%")
+ .contains("HubSpot Content Marketing Certification");
+ }
+
+ @Test
+ void rendersOneCanonicalPage() throws Exception {
+ try (PDDocument document =
+ Loader.loadPDF(render(NavySidebarFixtures.canonicalCv()))) {
+ assertThat(document.getNumberOfPages()).isEqualTo(1);
+ }
+ }
+
+ @Test
+ void rendersWithoutAPortraitOrAJobTitle() throws Exception {
+ // No photograph: the ring is drawn around an empty navy disc rather
+ // than left as a hole in the column. And no role: that line is
+ // written letter by letter to reach its tracking, so a blank one
+ // reaches the engine as a paragraph holding no runs at all.
+ String text = textOf(render(CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name("Ada", "Lovelace")
+ .contact(new Contact("+44 20 7946 0000", "empty@example.com",
+ "London, UK"))
+ .build())
+ .build()));
+ assertThat(text).contains("empty@example.com");
+ }
+
+ @Test
+ void degreesAndTitlesAreDrawnInCapitals() throws Exception {
+ // This design upper-cases for the author — the name, the role, each
+ // degree and every heading — so a document written in title case
+ // still reads as the sheet it is.
+ String text = textOf(render(CvDocument.builder()
+ .identity(identity())
+ .section(EntriesSection.builder("Education")
+ .entry("Master of Science", "Some University", "2019", "Berlin")
+ .build())
+ .build()));
+ assertThat(text).contains("MASTER OF SCIENCE").doesNotContain("Master of Science");
+ }
+
+ @Test
+ void drawsNothingTheDesignHasNoPlaceFor() throws Exception {
+ // A berth is filled once, and this sheet has nowhere to put a skill
+ // level, a group category or the summary's own title.
+ String text = textOf(render(CvDocument.builder()
+ .identity(identity())
+ .section(new ParagraphSection("Summary title here", "The opening paragraph."))
+ .section(SkillsSection.of("Skills",
+ new SkillGroup("Category name here", List.of(CvSkill.of("Java", 0.8)))))
+ .section(new ParagraphSection("Achievements", "Shipped a thing."))
+ .section(new ParagraphSection("More achievements", "Second block here."))
+ .build()));
+ assertThat(text)
+ .contains("The opening paragraph.")
+ .contains("Java")
+ .contains("Shipped a thing.")
+ .doesNotContain("Summary title here")
+ .doesNotContain("Category name here")
+ .doesNotContain("Second block here.");
+ }
+
+ @Test
+ void writesTheProficiencyTheDocumentCarries() throws Exception {
+ // Languages are rows rather than levelled skills precisely so the
+ // words survive; the row style is not read.
+ String text = textOf(render(CvDocument.builder()
+ .identity(identity())
+ .section(RowsSection.builder("Languages", RowStyle.BULLETED_STACKED)
+ .row("Portuguese", "Conversational")
+ .build())
+ .build()));
+ assertThat(text).contains("Portuguese").contains("Conversational");
+ }
+
+ @Test
+ void refusesACvLongerThanTheSheet() {
+ // The sheet holds one page: the two columns are a single row, and a
+ // row is atomic. A CV past that raises rather than flowing.
+ EntriesSection.Builder experience = EntriesSection.builder("Experience");
+ for (int i = 0; i < 14; i++) {
+ experience.entry("Role " + i, "Employer " + i + ", City", "2010 - 2011",
+ String.join("\n",
+ "Did a substantial thing that took a full line of text here.",
+ "Did a second substantial thing, also a full line of text.",
+ "And a third, so each role occupies real vertical space."));
+ }
+ CvDocument doc = CvDocument.builder()
+ .identity(identity())
+ .section(experience.build())
+ .build();
+ try (DocumentSession session = GraphCompose.document().create()) {
+ NavySidebar.create().compose(session, doc);
+ assertThatThrownBy(session::toPdfBytes)
+ .isInstanceOf(AtomicNodeTooLargeException.class)
+ .hasMessageContaining("PageGrid");
+ }
+ }
+
+ private static CvIdentity identity() {
+ return CvIdentity.builder()
+ .name("Ada", "Lovelace")
+ .jobTitle("Analyst")
+ .contact(new Contact("+44 20 7946 0000", "empty@example.com", "London, UK"))
+ .link(new Link("linkedin.com/in/ada", "https://linkedin.com/in/ada"))
+ .portrait(NavySidebarFixtures.portrait())
+ .build();
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarVisualParityTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarVisualParityTest.java
new file mode 100644
index 000000000..e5dc3d6e1
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarVisualParityTest.java
@@ -0,0 +1,41 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.testing.visual.PdfVisualRegression;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Path;
+
+/**
+ * Pixel-diff visual parity gate for {@link NavySidebar} - the canonical
+ * one-page CV against a checked-in baseline.
+ *
+ *
Re-blessing baselines - after a deliberate visual
+ * change, re-run with {@code -Dgraphcompose.visual.approve=true} and commit
+ * the updated PNG with the change. Baselines live under
+ * {@code src/test/resources/visual-baselines/cv-v2-layered/}.
The preset owns its page: it sets A4 with no margin and paints the navy
+ * plate as a page background, so the column reaches the foot of the sheet
+ * whatever the sidebar holds.
+ *
+ *
One page, and what happens past it
+ *
+ *
This sheet holds one page of content. The two columns are a single row,
+ * and a row is atomic — it cannot be split — so a CV longer than the sheet
+ * does not flow onto a second page: composing it raises
+ * {@code AtomicNodeTooLargeException}, naming the node and the height it
+ * needed. It draws no cap of its own, because a CV that quietly loses a job
+ * is worse than one that refuses to compose. {@link TimelineMinimal} is the
+ * preset in this package that splits its own columns across pages.
+ *
+ *
How a document reaches its berth
+ *
+ *
The columns are fixed, so the preset assigns each section a berth by
+ * title rather than reading {@link
+ * com.demcha.compose.document.templates.cv.data.Slot}: summary, experience,
+ * achievements and certifications to the main column; education, skills and
+ * languages to the sidebar. A section whose title matches no berth is not
+ * drawn, and a berth with no section takes its leading hairline with it.
+ *
+ *
Each berth wants a particular shape. The summary, the achievements and
+ * the certifications are {@link ParagraphSection}s, and the two lists are
+ * drawn one bullet per line of the body. Experience and education are
+ * {@link EntriesSection}s. Languages are a {@link RowsSection}, because this
+ * design writes the proficiency out — "Native", "Advanced" — which a
+ * levelled skill could not carry back; its {@code RowStyle} is not read,
+ * since the rows are drawn to this design rather than to a family style.
+ *
+ *
Three things are drawn in capitals whatever the document writes: the
+ * name, the role, and each degree. Headings are upper-cased too, and set
+ * with the tracking this design uses throughout.
+ *
+ *
Content this sheet has no place for is not drawn: the summary block's
+ * own title, since it is the one block with no heading; a skill's level,
+ * since there are no meters here; and a {@code SkillGroup}'s category. A
+ * berth is filled by the first section that names it, so a second section
+ * naming the same berth is not drawn either. An education entry uses all
+ * four of its fields — the degree, the institution, the body as the campus
+ * line, and the years.
+ *
+ *
The portrait
+ *
+ *
The photograph comes from {@code CvIdentity.portrait()}. An identity
+ * without one draws the ring around an empty navy disc rather than leaving a
+ * hole in the column.
+ *
+ *
The contact rows carry no link targets — the design draws the channels
+ * as text, and the port keeps that. The packaged set has one network mark,
+ * so every link the identity carries is drawn behind it whatever the network
+ * is; a document that lists more than one profile should say which is which
+ * in the label.
+ *
+ *
A channel is one paragraph, the mark and the value sharing a line, so a
+ * value wider than the sidebar's text column wraps under the mark and leaves
+ * it alone on the first line. The column is about 124pt, which at this type
+ * size is a little over twenty characters — long addresses want shortening
+ * rather than a wider column.
+ *
+ *
Fonts
+ *
+ *
The sheet is set in Lato. The templates artifact does not carry it —
+ * register it on the session, or the engine substitutes and the measured
+ * geometry here no longer matches the type sitting in it.
+ */
+public final class NavySidebar {
+
+ /** Stable identifier of this preset. */
+ public static final String ID = "navy-sidebar";
+
+ /** Human-readable name of this preset. */
+ public static final String DISPLAY_NAME = "Navy Sidebar";
+
+ /**
+ * The margin this preset expects: none. It sets the page margin itself,
+ * because the navy plate runs to the paper edge.
+ */
+ public static final double RECOMMENDED_MARGIN = 0.0;
+
+ private static final List SUMMARY_KEYS =
+ List.of("summary", "profile", "about");
+ private static final List EXPERIENCE_KEYS =
+ List.of("experience", "employment", "work history", "career");
+ private static final List ACHIEVEMENT_KEYS =
+ List.of("achievements", "achievement", "awards");
+ private static final List CERTIFICATION_KEYS =
+ List.of("certifications", "certification", "licences", "licenses");
+ private static final List EDUCATION_KEYS =
+ List.of("education", "qualifications");
+ private static final List SKILL_KEYS =
+ List.of("skills", "expertise", "competencies");
+ private static final List LANGUAGE_KEYS =
+ List.of("languages", "language");
+
+ private NavySidebar() {
+ }
+
+ /**
+ * Creates the template.
+ *
+ * @return a template composing a {@link CvDocument} onto its own page
+ */
+ public static DocumentTemplate create() {
+ return new Template();
+ }
+
+ private record Template() implements DocumentTemplate {
+
+ @Override
+ public String id() {
+ return ID;
+ }
+
+ @Override
+ public String displayName() {
+ return DISPLAY_NAME;
+ }
+
+ @Override
+ public void compose(DocumentSession document, CvDocument doc) {
+ Objects.requireNonNull(document, "document");
+ Objects.requireNonNull(doc, "doc");
+ List sections = doc.sections();
+
+ document.pageSize(PAGE)
+ .margin(DocumentInsets.zero())
+ // The plate is a page background rather than a section
+ // fill: a section is only as tall as what it holds, and
+ // the navy column has to reach the foot of the sheet
+ // whatever the sidebar carries.
+ .pageBackgrounds(List.of(
+ PageBackgroundFill.leftColumn(SIDEBAR_WEIGHT, NAVY)));
+
+ EntriesSection education = berth(sections, EntriesSection.class, EDUCATION_KEYS);
+ SkillsSection skills = berth(sections, SkillsSection.class, SKILL_KEYS);
+ RowsSection languages = berth(sections, RowsSection.class, LANGUAGE_KEYS);
+ ParagraphSection summary = berth(sections, ParagraphSection.class, SUMMARY_KEYS);
+ EntriesSection experience = berth(sections, EntriesSection.class, EXPERIENCE_KEYS);
+ ParagraphSection achievements =
+ berth(sections, ParagraphSection.class, ACHIEVEMENT_KEYS);
+ ParagraphSection certifications =
+ berth(sections, ParagraphSection.class, CERTIFICATION_KEYS);
+
+ document.pageFlow(page -> page
+ .name("NavySidebarCv")
+ .padding(DocumentInsets.zero())
+ // spacing(0): every gap in this design is authored on the
+ // node that owns it, so a flow gap would add to all of
+ // them.
+ .spacing(0)
+ .addRow("PageGrid", row -> {
+ row.spacing(0);
+ row.weights(SIDEBAR_WEIGHT, MAIN_WEIGHT);
+ row.addSection("Sidebar", aside ->
+ NavySidebarAside.compose(aside, doc.identity(),
+ education, skills, languages));
+ row.addSection("Main", main ->
+ NavySidebarMain.compose(main, doc.identity(),
+ summary, experience, achievements, certifications));
+ }));
+ }
+
+ /**
+ * The first section of the wanted shape whose title names this berth,
+ * or {@code null} when the document fills no such berth.
+ */
+ private static T berth(List sections,
+ Class type,
+ List keys) {
+ for (CvSection section : sections) {
+ if (!type.isInstance(section)) {
+ continue;
+ }
+ for (String key : keys) {
+ if (SectionLookup.titleContains(section.title(), key)) {
+ return type.cast(section);
+ }
+ }
+ }
+ return null;
+ }
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java
new file mode 100644
index 000000000..face6f2b9
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java
@@ -0,0 +1,295 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.document.dsl.EllipseBuilder;
+import com.demcha.compose.document.dsl.ImageBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.InlineImageAlignment;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.templates.core.identity.Link;
+import com.demcha.compose.document.templates.cv.components.SectionLookup;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.CvRow;
+import com.demcha.compose.document.templates.cv.data.CvSkill;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+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 java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.AVATAR_DIAMETER;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.AVATAR_RING;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.AVATAR_RING_WIDTH;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.AVATAR_TO_HEADING;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.BODY_SIZE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.BULLET_LEADING;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.CONTACT_ROW_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.EDU_DEGREE_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.EDU_ENTRY_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.EDU_LINE_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.LANGUAGE_ROW_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.LANGUAGE_VALUE_X;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.LIST_BULLET_DIAMETER;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.NAVY;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.PAGE_MARGIN;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_BAND_HEIGHT;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_INNER_WIDTH;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_PAD_TOP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_PAD_X;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_STRONG;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_TEXT;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SKILL_ITEM_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.compact;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.sidebarBody;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.style;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarWidgets.sidebarHeading;
+
+/**
+ * The navy left column: the ringed portrait, the contact channels, the
+ * degrees, the skills and the languages.
+ */
+final class NavySidebarAside {
+
+ /**
+ * The contact heading. It is not read from the document because the
+ * channels are not a section — they come off the identity, which carries
+ * no title of its own.
+ */
+ static final String CONTACT_HEADING = "Contact";
+
+ private NavySidebarAside() {
+ }
+
+ static void compose(SectionBuilder section,
+ CvIdentity identity,
+ EntriesSection education,
+ SkillsSection skills,
+ RowsSection languages) {
+ section.spacing(0)
+ .padding(new DocumentInsets(
+ SIDEBAR_PAD_TOP, SIDEBAR_PAD_X, PAGE_MARGIN, SIDEBAR_PAD_X));
+ section.addSection("Avatar", inner -> {
+ inner.padding(new DocumentInsets(0, 0, AVATAR_TO_HEADING, 0));
+ renderPortrait(inner, identity);
+ });
+ section.addSection("Contact", inner -> renderContact(inner, identity));
+ if (SectionLookup.hasContent(education)) {
+ section.addSection("Education", inner -> renderEducation(inner, education));
+ }
+ if (SectionLookup.hasContent(skills)) {
+ section.addSection("Skills", inner -> renderSkills(inner, skills));
+ }
+ if (SectionLookup.hasContent(languages)) {
+ section.addSection("Languages", inner -> renderLanguages(inner, languages));
+ }
+ }
+
+ // -- portrait --------------------------------------------------------
+
+ /**
+ * The photograph, clipped to a disc inside a pale ring. An identity
+ * carrying no portrait draws the ring around a navy disc, which is the
+ * same shape with nothing in it rather than a hole in the column.
+ */
+ private static void renderPortrait(SectionBuilder section, CvIdentity identity) {
+ DocumentNode circle = identity.portrait()
+ .map(NavySidebarAside::clippedPhoto)
+ .orElseGet(NavySidebarAside::emptyDisc);
+ section.addContainer(ring -> ring
+ .name("AvatarRing")
+ .circle(AVATAR_DIAMETER + 2.0 * AVATAR_RING_WIDTH)
+ .fillColor(AVATAR_RING)
+ .center(circle));
+ }
+
+ private static DocumentNode clippedPhoto(DocumentImageData image) {
+ return new ShapeContainerBuilder()
+ .name("AvatarCircle")
+ .circle(AVATAR_DIAMETER)
+ .clipPolicy(ClipPolicy.CLIP_PATH)
+ .fillColor(NAVY)
+ // size(), not fitToBounds(): the disc clips, so the photo is
+ // drawn to fill it and whatever falls outside is cut away.
+ .center(new ImageBuilder()
+ .name("AvatarPhoto")
+ .source(image)
+ .size(AVATAR_DIAMETER, AVATAR_DIAMETER)
+ .build())
+ .build();
+ }
+
+ /**
+ * The disc with no photograph in it. It is an ellipse rather than the
+ * same clipping container holding nothing, because a shape container
+ * with no layer is rejected — the clip needs something to cut.
+ */
+ private static DocumentNode emptyDisc() {
+ return new EllipseBuilder()
+ .name("AvatarCircle")
+ .circle(AVATAR_DIAMETER)
+ .fillColor(NAVY)
+ .build();
+ }
+
+ // -- contact ---------------------------------------------------------
+
+ /**
+ * The contact channels, in the order the design sets them: phone, email,
+ * address, then whatever links the identity carries.
+ *
+ *
The rows carry no link targets. The design draws them as text, and
+ * the promotion keeps that — a clickable channel would be a change to
+ * the sheet rather than a port of it.
+ */
+ private static void renderContact(SectionBuilder section, CvIdentity identity) {
+ sidebarHeading(section, CONTACT_HEADING, true);
+ for (Channel channel : channels(identity)) {
+ renderChannel(section, channel);
+ }
+ }
+
+ private static void renderChannel(SectionBuilder section, Channel channel) {
+ double size = NavySidebarIcons.size(channel.token());
+ section.addParagraph(p -> p
+ .name("Contact_" + compact(channel.token()))
+ .textStyle(sidebarBody())
+ .inlineImage(NavySidebarIcons.image(channel.token()), size, size,
+ InlineImageAlignment.CENTER, 0.0, null)
+ // The gap between the mark and the value is set as spaces
+ // rather than an indent: the row is one paragraph, so the
+ // mark and the text share a baseline and wrap together.
+ .inlineText(" " + channel.value(), sidebarBody())
+ .margin(new DocumentInsets(0, 0, CONTACT_ROW_GAP, 0)));
+ }
+
+ private static List channels(CvIdentity identity) {
+ List channels = new ArrayList<>();
+ channels.add(new Channel(NavySidebarIcons.PHONE, identity.contact().phone()));
+ channels.add(new Channel(NavySidebarIcons.EMAIL, identity.contact().email()));
+ channels.add(new Channel(NavySidebarIcons.LOCATION, identity.contact().address()));
+ for (Link link : identity.links()) {
+ channels.add(new Channel(NavySidebarIcons.LINKEDIN, link.label()));
+ }
+ return channels;
+ }
+
+ /**
+ * A contact row. The packaged set has one network mark, so every link
+ * takes it — this design ships no globe.
+ */
+ private record Channel(String token, String value) {
+ }
+
+ // -- education -------------------------------------------------------
+
+ /**
+ * Each degree as four lines: the qualification in capitals, the
+ * institution, where it was, and the years.
+ */
+ private static void renderEducation(SectionBuilder section, EntriesSection education) {
+ sidebarHeading(section, education.title(), false);
+ for (CvEntry entry : education.entries()) {
+ section.addParagraph(p -> p
+ .name("Degree_" + compact(entry.title()))
+ .text(entry.title().toUpperCase(Locale.ROOT))
+ .textStyle(style(BODY_SIZE, SIDEBAR_STRONG, DocumentTextDecoration.BOLD))
+ .lineSpacing(BULLET_LEADING)
+ .margin(new DocumentInsets(0, 0, EDU_DEGREE_GAP, 0)));
+ line(section, "Institution_" + compact(entry.title()), entry.subtitle(),
+ EDU_LINE_GAP);
+ line(section, "Campus_" + compact(entry.title()), entry.body(), EDU_LINE_GAP);
+ line(section, "Years_" + compact(entry.title()), entry.date(), EDU_ENTRY_GAP);
+ }
+ }
+
+ private static void line(SectionBuilder section, String name, String text, double gapBelow) {
+ section.addParagraph(p -> p
+ .name(name)
+ .text(text)
+ .textStyle(sidebarBody())
+ .margin(new DocumentInsets(0, 0, gapBelow, 0)));
+ }
+
+ // -- skills ----------------------------------------------------------
+
+ /**
+ * The skills as a plain bullet list. This design draws no meters, so a
+ * level the document carries is not read here.
+ */
+ private static void renderSkills(SectionBuilder section, SkillsSection skills) {
+ sidebarHeading(section, skills.title(), false);
+ section.addList(list -> list
+ .name("SkillsList")
+ .bullet()
+ .items(names(skills))
+ .textStyle(sidebarBody())
+ .lineSpacing(BULLET_LEADING)
+ .itemSpacing(SKILL_ITEM_GAP)
+ .margin(DocumentInsets.zero()));
+ }
+
+ /**
+ * The section's skills as one list of names. This design sets no group
+ * headings, so a document that groups its skills gets them in the order
+ * the groups were declared.
+ */
+ private static List names(SkillsSection section) {
+ List out = new ArrayList<>();
+ for (SkillGroup group : section.groups()) {
+ for (CvSkill skill : group.entries()) {
+ out.add(skill.name());
+ }
+ }
+ return out;
+ }
+
+ // -- languages -------------------------------------------------------
+
+ /**
+ * Language rows: a dotted label at the left, the proficiency anchored at
+ * a fixed offset behind a dash so the second column lines up whatever
+ * the names measure.
+ *
+ *
The rows are {@link CvRow}s rather than levelled skills because
+ * this design writes the proficiency out — "Native", "Advanced" — and a
+ * number could not carry those words back.
+ */
+ private static void renderLanguages(SectionBuilder section, RowsSection languages) {
+ sidebarHeading(section, languages.title(), false);
+ // The row gap is section spacing rather than a margin on each band:
+ // the bands are shape containers, and a container's margin would sit
+ // inside the clip rather than between the rows.
+ section.spacing(LANGUAGE_ROW_GAP);
+ for (CvRow row : languages.rows()) {
+ DocumentNode label = new ParagraphBuilder()
+ .name("Language_" + compact(row.label()))
+ .textStyle(sidebarBody())
+ .dot(LIST_BULLET_DIAMETER, SIDEBAR_TEXT)
+ .inlineText(" " + row.label(), sidebarBody())
+ .margin(DocumentInsets.zero())
+ .build();
+ DocumentNode value = new ParagraphBuilder()
+ .name("Proficiency_" + compact(row.label()))
+ .text("– " + row.body())
+ .textStyle(sidebarBody())
+ .margin(DocumentInsets.zero())
+ .build();
+ section.addContainer(band -> band
+ .name("LanguageRow_" + compact(row.label()))
+ .rectangle(SIDEBAR_INNER_WIDTH, SIDEBAR_BAND_HEIGHT)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .centerLeft(label)
+ .position(value, LANGUAGE_VALUE_X, 0, LayerAlign.CENTER_LEFT));
+ }
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarIcons.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarIcons.java
new file mode 100644
index 000000000..eb0888509
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarIcons.java
@@ -0,0 +1,85 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.document.image.DocumentImageData;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Classpath loader for the Navy Sidebar CV marks — the four contact glyphs
+ * in the sidebar and the three section badges in the main column.
+ *
+ *
They ship inside the templates artifact under
+ * {@code templates/cv/navy-sidebar/icons/} as PNG, each at the point size the
+ * design draws it at: a pin needs more height than an envelope to read as the
+ * same weight, and the badge glyphs are set larger than the contact ones
+ * because they sit inside a filled disc.
+ */
+final class NavySidebarIcons {
+
+ static final String PHONE = "phone";
+ static final String EMAIL = "email";
+ static final String LOCATION = "location";
+ static final String LINKEDIN = "linkedin";
+ static final String BRIEFCASE = "briefcase";
+ static final String TROPHY = "trophy";
+ static final String CERTIFICATE = "certificate";
+
+ private static final String ICON_ROOT = "/templates/cv/navy-sidebar/icons/";
+
+ private static final Map SIZES = Map.of(
+ PHONE, 10.0,
+ EMAIL, 10.0,
+ LOCATION, 11.0,
+ LINKEDIN, 10.5,
+ BRIEFCASE, 11.3,
+ TROPHY, 11.3,
+ CERTIFICATE, 11.3);
+
+ private static final Map CACHE = new ConcurrentHashMap<>();
+
+ private NavySidebarIcons() {
+ }
+
+ /**
+ * The drawn size of one mark.
+ *
+ * @param token one of the constants on this class
+ * @return the size in points
+ * @throws IllegalArgumentException when the token names no packaged mark
+ */
+ static double size(String token) {
+ Double size = SIZES.get(token);
+ if (size == null) {
+ throw new IllegalArgumentException("Unknown navy sidebar CV icon token: " + token);
+ }
+ return size;
+ }
+
+ /**
+ * Reads one mark, caching the decoded image per token so a CV that
+ * repeats a channel reads the file once per JVM.
+ *
+ * @param token one of the constants on this class
+ * @return the image data
+ */
+ static DocumentImageData image(String token) {
+ return CACHE.computeIfAbsent(token, NavySidebarIcons::read);
+ }
+
+ private static DocumentImageData read(String token) {
+ String resourcePath = ICON_ROOT + token + ".png";
+ try (InputStream input = NavySidebarIcons.class.getResourceAsStream(resourcePath)) {
+ if (input == null) {
+ throw new IllegalStateException("Missing navy sidebar CV icon: " + resourcePath);
+ }
+ return DocumentImageData.fromBytes(input.readAllBytes());
+ } catch (IOException e) {
+ throw new UncheckedIOException(
+ "Failed to read navy sidebar CV icon: " + resourcePath, e);
+ }
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarMain.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarMain.java
new file mode 100644
index 000000000..2b266fbcd
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarMain.java
@@ -0,0 +1,276 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.document.dsl.EllipseBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.core.text.MarkdownInline;
+import com.demcha.compose.document.templates.cv.components.SectionLookup;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
+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 java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.ACCENT;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.BODY;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.BODY_LEADING;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.BODY_SIZE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.BULLET_ITEM_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.BULLET_LEADING;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.DATE_OVERFLOW;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.ENTRY_EMPLOYER_TO_BULLETS;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.ENTRY_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.ENTRY_HEAD_BAND_HEIGHT;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.ENTRY_TEXT_INSET;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.ENTRY_TITLE_TO_EMPLOYER;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.ENTRY_WIDTH;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.HEADER_TO_BODY;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.INK;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.JOB_TITLE_SIZE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.MAIN_CONTENT_WIDTH;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.MAIN_PAD_TOP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.MAIN_RULE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.MAIN_RULE_THICKNESS;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.MARKER_DIAMETER;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.NAME_SIZE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.NAME_TO_ROLE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.NAVY;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.PAGE_MARGIN;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.RAIL;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.RAIL_MARGIN_LEFT;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.RAIL_WIDTH;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.ROLE_SIZE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.ROLE_TO_SUMMARY;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.RULE_GAP_ABOVE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.RULE_GAP_BELOW;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SUMMARY_RULE_GAP_BELOW;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SUMMARY_SIZE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.TITLE_OVERFLOW;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.TROUGH;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.body;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.compact;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.style;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.tracked;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarWidgets.indentedList;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarWidgets.sectionHeader;
+
+/**
+ * The white right column: the name and role, the summary, then the roles
+ * held on a timeline rail, the achievements and the certifications, each
+ * opened by a hairline and a badged heading.
+ */
+final class NavySidebarMain {
+
+ private NavySidebarMain() {
+ }
+
+ static void compose(SectionBuilder section,
+ CvIdentity identity,
+ ParagraphSection summary,
+ EntriesSection experience,
+ ParagraphSection achievements,
+ ParagraphSection certifications) {
+ section.spacing(0)
+ .padding(new DocumentInsets(MAIN_PAD_TOP, PAGE_MARGIN, PAGE_MARGIN, TROUGH));
+ section.addSection("Identity", inner -> renderIdentity(inner, identity));
+ if (SectionLookup.hasContent(summary)) {
+ section.addSection("Summary", inner -> renderSummary(inner, summary));
+ }
+ if (SectionLookup.hasContent(experience)) {
+ rule(section, SUMMARY_RULE_GAP_BELOW);
+ section.addSection("Experience", inner -> renderExperience(inner, experience));
+ }
+ if (SectionLookup.hasContent(achievements)) {
+ rule(section, RULE_GAP_BELOW);
+ section.addSection("Achievements", inner ->
+ renderBulletBlock(inner, NavySidebarIcons.TROPHY, achievements,
+ "AchievementsList"));
+ }
+ if (SectionLookup.hasContent(certifications)) {
+ rule(section, RULE_GAP_BELOW);
+ section.addSection("Certifications", inner ->
+ renderBulletBlock(inner, NavySidebarIcons.CERTIFICATE, certifications,
+ "CertificationsList"));
+ }
+ }
+
+ /**
+ * The hairline that opens a block. It belongs to the block below it, so
+ * a block the document does not fill takes its rule with it instead of
+ * leaving one hanging. The gap below is authored per rule: the one that
+ * opens the experience block is set four points wider than the two that
+ * follow it.
+ *
+ * @param section the main column
+ * @param gapBelow the gap between the rule and the block it opens
+ */
+ private static void rule(SectionBuilder section, double gapBelow) {
+ section.addLine(line -> line
+ .name("SectionRule")
+ .horizontal(MAIN_CONTENT_WIDTH)
+ .thickness(MAIN_RULE_THICKNESS)
+ .color(MAIN_RULE)
+ .margin(new DocumentInsets(RULE_GAP_ABOVE, 0, gapBelow, 0)));
+ }
+
+ // -- identity --------------------------------------------------------
+
+ private static void renderIdentity(SectionBuilder section, CvIdentity identity) {
+ DocumentTextStyle nameStyle = style(NAME_SIZE, INK, DocumentTextDecoration.DEFAULT);
+ section.addParagraph(p -> p
+ .name("Name")
+ .text(identity.name().full().toUpperCase(Locale.ROOT))
+ .textStyle(nameStyle)
+ .align(TextAlign.LEFT)
+ .margin(new DocumentInsets(0, 0, NAME_TO_ROLE, 0)));
+
+ DocumentTextStyle roleStyle = style(ROLE_SIZE, ACCENT, DocumentTextDecoration.BOLD);
+ section.addParagraph(p -> {
+ p.name("Role");
+ p.textStyle(roleStyle);
+ tracked(p, identity.jobTitle().toUpperCase(Locale.ROOT), roleStyle);
+ p.align(TextAlign.LEFT);
+ p.margin(new DocumentInsets(0, 0, ROLE_TO_SUMMARY, 0));
+ });
+ }
+
+ // -- summary ---------------------------------------------------------
+
+ /**
+ * The opening paragraph. It is the one block this design gives no
+ * heading, so the section's title is not drawn.
+ */
+ private static void renderSummary(SectionBuilder section, ParagraphSection summary) {
+ section.addParagraph(p -> p
+ .name("Summary")
+ .text(summary.body())
+ .textStyle(style(SUMMARY_SIZE, BODY, DocumentTextDecoration.DEFAULT))
+ .lineSpacing(BODY_LEADING)
+ .margin(DocumentInsets.zero()));
+ }
+
+ // -- experience ------------------------------------------------------
+
+ /**
+ * The roles held, strung on a vertical rail with a filled marker at each
+ * one.
+ *
+ *
The rail is the section's left accent, which runs the full height
+ * of what the section holds — so the last role's body is composed in a
+ * second section outside the rail, and the line stops at the last
+ * marker instead of running past it to the foot of the block.
+ */
+ private static void renderExperience(SectionBuilder section, EntriesSection experience) {
+ sectionHeader(section, NavySidebarIcons.BRIEFCASE, experience.title());
+ List entries = experience.entries();
+ int last = entries.size() - 1;
+ section.addSection("ExperienceRail", rail -> {
+ rail.spacing(0);
+ // The rail starts where the first marker's band does, which is
+ // half a title's overhang below the heading.
+ rail.margin(new DocumentInsets(
+ HEADER_TO_BODY + TITLE_OVERFLOW, 0, 0, RAIL_MARGIN_LEFT));
+ rail.accentLeft(RAIL, RAIL_WIDTH);
+ for (int i = 0; i < entries.size(); i++) {
+ renderEntryHead(rail, entries.get(i));
+ if (i < last) {
+ renderEntryBody(rail, entries.get(i), ENTRY_GAP + TITLE_OVERFLOW);
+ }
+ }
+ });
+ section.addSection("ExperienceTail", tail -> {
+ tail.spacing(0);
+ tail.margin(new DocumentInsets(0, 0, 0, RAIL_MARGIN_LEFT));
+ renderEntryBody(tail, entries.get(last), 0);
+ });
+ }
+
+ /**
+ * The marker, the position in capitals and the dates, all on the
+ * marker's axis: the band is as tall as the marker, and the two pieces
+ * of type are pulled up by half their own overhang to centre on it.
+ */
+ private static void renderEntryHead(SectionBuilder rail, CvEntry entry) {
+ DocumentNode marker = new EllipseBuilder()
+ .name("Marker_" + compact(entry.title()))
+ .circle(MARKER_DIAMETER)
+ .fillColor(NAVY)
+ .build();
+ DocumentNode title = new ParagraphBuilder()
+ .name("JobTitle_" + compact(entry.title()))
+ .text(entry.title().toUpperCase(Locale.ROOT))
+ .textStyle(style(JOB_TITLE_SIZE, INK, DocumentTextDecoration.BOLD))
+ .margin(DocumentInsets.zero())
+ .build();
+ DocumentNode dates = new ParagraphBuilder()
+ .name("JobDates_" + compact(entry.title()))
+ .text(entry.date())
+ .textStyle(body())
+ .align(TextAlign.RIGHT)
+ .margin(DocumentInsets.zero())
+ .build();
+ rail.addContainer(head -> head
+ .name("EntryHead_" + compact(entry.title()))
+ .rectangle(ENTRY_WIDTH, ENTRY_HEAD_BAND_HEIGHT)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(marker, -MARKER_DIAMETER / 2.0, 0, LayerAlign.CENTER_LEFT)
+ .position(title, ENTRY_TEXT_INSET, -TITLE_OVERFLOW, LayerAlign.CENTER_LEFT)
+ .position(dates, 0, -DATE_OVERFLOW, LayerAlign.CENTER_RIGHT));
+ }
+
+ /** The employer in accent, then one bullet per line of the entry body. */
+ private static void renderEntryBody(SectionBuilder rail, CvEntry entry, double gapBelow) {
+ rail.addParagraph(p -> p
+ .name("Employer_" + compact(entry.title()))
+ .text(entry.subtitle())
+ .textStyle(style(BODY_SIZE, ACCENT, DocumentTextDecoration.DEFAULT))
+ .margin(new DocumentInsets(
+ ENTRY_TITLE_TO_EMPLOYER + TITLE_OVERFLOW, 0,
+ ENTRY_EMPLOYER_TO_BULLETS, ENTRY_TEXT_INSET)));
+ List highlights = lines(entry.body());
+ if (!highlights.isEmpty()) {
+ rail.addList(list -> list
+ .name("Highlights_" + compact(entry.title()))
+ .bullet()
+ .items(highlights)
+ .textStyle(body())
+ .lineSpacing(BULLET_LEADING)
+ .itemSpacing(BULLET_ITEM_GAP)
+ .margin(new DocumentInsets(0, 0, gapBelow, ENTRY_TEXT_INSET)));
+ }
+ }
+
+ // -- closing blocks --------------------------------------------------
+
+ /** A badged heading over an indented bullet list, one bullet per line. */
+ private static void renderBulletBlock(SectionBuilder section, String iconToken,
+ ParagraphSection block, String listName) {
+ sectionHeader(section, iconToken, block.title());
+ indentedList(section, listName, lines(block.body()));
+ }
+
+ /**
+ * One bullet per non-blank line — the family's way of carrying a list in
+ * a field the model types as a single string.
+ */
+ private static List lines(String body) {
+ List out = new ArrayList<>();
+ for (String line : body.split("\\R")) {
+ String clean = MarkdownInline.plainText(line).trim();
+ if (!clean.isBlank()) {
+ out.add(clean);
+ }
+ }
+ return out;
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarStyles.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarStyles.java
new file mode 100644
index 000000000..0584c1372
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarStyles.java
@@ -0,0 +1,192 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.templates.core.text.TextStyles;
+import com.demcha.compose.font.FontName;
+
+/**
+ * The measured geometry, palette and type scale of the Navy Sidebar CV.
+ *
+ *
The bands in this design are sized from the type they hold rather than
+ * chosen: an entry head is as tall as its marker, and the title and the dates
+ * set in it are taller than that, so each is pushed up by half the difference
+ * to sit on the marker's axis. Those overflows are computed here so the two
+ * ends of the band cannot drift apart.
+ */
+final class NavySidebarStyles {
+
+ private NavySidebarStyles() {
+ }
+
+ // -- page ------------------------------------------------------------
+
+ static final DocumentPageSize PAGE = DocumentPageSize.A4;
+ static final double PAGE_WIDTH = PAGE.width();
+
+ static final double SIDEBAR_WEIGHT = 0.297;
+ static final double MAIN_WEIGHT = 1.0 - SIDEBAR_WEIGHT;
+
+ static final double SIDEBAR_PAD_X = 26.5;
+ static final double PAGE_MARGIN = 36.5;
+
+ /** The gutter between the navy plate and the main column's text. */
+ static final double TROUGH = 25.4;
+
+ static final double SIDEBAR_WIDTH = PAGE_WIDTH * SIDEBAR_WEIGHT;
+ static final double MAIN_WIDTH = PAGE_WIDTH * MAIN_WEIGHT;
+ static final double SIDEBAR_INNER_WIDTH = SIDEBAR_WIDTH - 2.0 * SIDEBAR_PAD_X;
+ static final double MAIN_CONTENT_WIDTH = MAIN_WIDTH - TROUGH - PAGE_MARGIN;
+
+ // -- ornaments -------------------------------------------------------
+
+ /** The portrait fills the sidebar's text column exactly. */
+ static final double AVATAR_DIAMETER = SIDEBAR_INNER_WIDTH;
+ static final double AVATAR_RING_WIDTH = 1.6;
+
+ static final double BADGE_DIAMETER = 22.6;
+ static final double BADGE_GAP = 7.3;
+ static final double SECTION_INDENT = BADGE_DIAMETER + BADGE_GAP;
+
+ static final double MARKER_DIAMETER = 6.8;
+ static final double RAIL_WIDTH = 0.8;
+ static final double RAIL_X = BADGE_DIAMETER / 2.0;
+ static final double RAIL_MARGIN_LEFT = RAIL_X - RAIL_WIDTH / 2.0;
+ static final double ENTRY_TEXT_INSET = SECTION_INDENT - RAIL_MARGIN_LEFT;
+ static final double ENTRY_WIDTH = MAIN_CONTENT_WIDTH - RAIL_MARGIN_LEFT;
+
+ static final double SIDEBAR_RULE_THICKNESS = 0.7;
+ static final double MAIN_RULE_THICKNESS = 0.7;
+ static final double LIST_BULLET_DIAMETER = 2.4;
+
+ // -- type ------------------------------------------------------------
+
+ static final FontName HEADING_FONT = FontName.LATO;
+ static final FontName BODY_FONT = FontName.LATO;
+
+ static final double BODY_SIZE = 9.0;
+ static final double SUMMARY_SIZE = 10.5;
+ static final double NAME_SIZE = 30.0;
+ static final double ROLE_SIZE = 13.5;
+ static final double HEADING_SIZE = 12.0;
+ static final double JOB_TITLE_SIZE = 11.5;
+ static final double BODY_LEADING = 1.28;
+ static final double BULLET_LEADING = 1.35;
+
+ static final double TRACKING_EM = 0.17;
+
+ /**
+ * The advance of a space in Lato, as a fraction of type size.
+ * {@link #tracked} sizes its spacer runs against this.
+ */
+ static final double SPACE_ADVANCE_EM = 0.25;
+
+ // -- bands -----------------------------------------------------------
+
+ static final double HEADING_BAND_HEIGHT = BADGE_DIAMETER;
+ static final double JOB_TITLE_BAND_HEIGHT = JOB_TITLE_SIZE * BULLET_LEADING;
+ static final double ENTRY_HEAD_BAND_HEIGHT = MARKER_DIAMETER;
+ static final double DATE_BAND_HEIGHT = BODY_SIZE * BULLET_LEADING;
+ static final double SIDEBAR_BAND_HEIGHT = BODY_SIZE * BULLET_LEADING;
+
+ /** How far the job title rises above the marker band it is set on. */
+ static final double TITLE_OVERFLOW =
+ (JOB_TITLE_BAND_HEIGHT - ENTRY_HEAD_BAND_HEIGHT) / 2.0;
+
+ /** The same, for the dates set at the other end of that band. */
+ static final double DATE_OVERFLOW =
+ (DATE_BAND_HEIGHT - ENTRY_HEAD_BAND_HEIGHT) / 2.0;
+
+ static final double LANGUAGE_LABEL_WEIGHT = 0.41;
+ static final double LANGUAGE_VALUE_X = SIDEBAR_INNER_WIDTH * LANGUAGE_LABEL_WEIGHT;
+
+ // -- vertical rhythm -------------------------------------------------
+
+ static final double SIDEBAR_PAD_TOP = 25.0;
+ static final double AVATAR_TO_HEADING = 22.0;
+ static final double SIDEBAR_BLOCK_GAP = 18.0;
+ static final double HEADING_TO_RULE = 7.0;
+ static final double RULE_TO_BODY = 10.0;
+ static final double CONTACT_ROW_GAP = 10.5;
+ static final double EDU_DEGREE_GAP = 4.0;
+ static final double EDU_LINE_GAP = 1.5;
+ static final double EDU_ENTRY_GAP = 13.0;
+ static final double SKILL_ITEM_GAP = 2.4;
+ static final double LANGUAGE_ROW_GAP = 6.0;
+
+ static final double MAIN_PAD_TOP = 30.0;
+ static final double NAME_TO_ROLE = 6.0;
+ static final double ROLE_TO_SUMMARY = 16.0;
+ static final double RULE_GAP_ABOVE = 16.0;
+ static final double RULE_GAP_BELOW = 11.0;
+ static final double SUMMARY_RULE_GAP_BELOW = 15.0;
+ static final double HEADER_TO_BODY = 11.0;
+ static final double ENTRY_TITLE_TO_EMPLOYER = 3.0;
+ static final double ENTRY_EMPLOYER_TO_BULLETS = 7.0;
+ static final double ENTRY_GAP = 22.0;
+ static final double BULLET_ITEM_GAP = 6.1;
+ static final double PLAIN_ITEM_GAP = 5.1;
+
+ // -- palette ---------------------------------------------------------
+
+ static final DocumentColor NAVY = DocumentColor.rgb(32, 44, 59);
+ static final DocumentColor INK = DocumentColor.rgb(30, 40, 51);
+ static final DocumentColor ACCENT = DocumentColor.rgb(43, 84, 149);
+ static final DocumentColor BODY = DocumentColor.rgb(83, 87, 92);
+ static final DocumentColor SIDEBAR_STRONG = DocumentColor.WHITE;
+ static final DocumentColor SIDEBAR_TEXT = DocumentColor.rgb(233, 235, 238);
+ static final DocumentColor SIDEBAR_RULE = DocumentColor.rgba(255, 255, 255, 115);
+ static final DocumentColor MAIN_RULE = DocumentColor.rgb(228, 230, 233);
+ static final DocumentColor RAIL = DocumentColor.rgb(110, 116, 123);
+ static final DocumentColor AVATAR_RING = DocumentColor.rgb(226, 228, 231);
+
+ // -- styles ----------------------------------------------------------
+
+ static DocumentTextStyle body() {
+ return style(BODY_SIZE, BODY, DocumentTextDecoration.DEFAULT);
+ }
+
+ static DocumentTextStyle sidebarBody() {
+ return style(BODY_SIZE, SIDEBAR_TEXT, DocumentTextDecoration.DEFAULT);
+ }
+
+ /**
+ * Both faces are Lato, so the weight is what the decoration selects; the
+ * split is kept because the design names a heading face and a body face,
+ * and a document set in two families would want it back.
+ */
+ static DocumentTextStyle style(double size, DocumentColor color,
+ DocumentTextDecoration decoration) {
+ boolean bold = decoration == DocumentTextDecoration.BOLD
+ || decoration == DocumentTextDecoration.BOLD_ITALIC;
+ return TextStyles.of(bold ? HEADING_FONT : BODY_FONT, size, decoration, color);
+ }
+
+ /**
+ * Writes text letter by letter with a sized space between each pair,
+ * which is how this design gets its letter-spacing: a text style carries
+ * no tracking, so the gap is a run of its own whose type size is chosen
+ * to advance by {@link #TRACKING_EM} of the surrounding size.
+ *
+ * @param paragraph the paragraph being built
+ * @param text the text to space out
+ * @param style the style of the letters
+ */
+ static void tracked(ParagraphBuilder paragraph, String text, DocumentTextStyle style) {
+ DocumentTextStyle spacer = style.withSize(TRACKING_EM * style.size() / SPACE_ADVANCE_EM);
+ for (int i = 0; i < text.length(); i++) {
+ paragraph.inlineText(String.valueOf(text.charAt(i)), style);
+ if (i < text.length() - 1) {
+ paragraph.inlineText(" ", spacer);
+ }
+ }
+ }
+
+ /** Strips a title down to the letters and digits a node name can carry. */
+ static String compact(String text) {
+ return text == null ? "" : text.replaceAll("[^A-Za-z0-9]", "");
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarWidgets.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarWidgets.java
new file mode 100644
index 000000000..aa0b78c5f
--- /dev/null
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarWidgets.java
@@ -0,0 +1,133 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.document.dsl.ImageBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+
+import java.util.List;
+import java.util.Locale;
+
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.BADGE_DIAMETER;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.BULLET_LEADING;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.HEADER_TO_BODY;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.HEADING_BAND_HEIGHT;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.HEADING_SIZE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.HEADING_TO_RULE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.INK;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.MAIN_CONTENT_WIDTH;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.NAVY;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.PLAIN_ITEM_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.RULE_TO_BODY;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SECTION_INDENT;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_BLOCK_GAP;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_INNER_WIDTH;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_RULE;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_RULE_THICKNESS;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_STRONG;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.body;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.compact;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.style;
+import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.tracked;
+
+/**
+ * The two headings this design uses — tracked capitals over a hairline in
+ * the navy sidebar, tracked capitals beside a filled badge in the main
+ * column — and the indented bullet list the closing sections are set in.
+ */
+final class NavySidebarWidgets {
+
+ private NavySidebarWidgets() {
+ }
+
+ /**
+ * A sidebar heading: tracked white capitals over a translucent rule the
+ * width of the sidebar's text column.
+ *
+ * @param section the sidebar column
+ * @param title the heading text, drawn in capitals
+ * @param first true for the block directly under the portrait, which
+ * takes no gap above
+ */
+ static void sidebarHeading(SectionBuilder section, String title, boolean first) {
+ DocumentTextStyle headingStyle =
+ style(HEADING_SIZE, SIDEBAR_STRONG, DocumentTextDecoration.BOLD);
+ section.addParagraph(p -> {
+ p.name("SidebarHeading_" + compact(title));
+ p.textStyle(headingStyle);
+ tracked(p, title.toUpperCase(Locale.ROOT), headingStyle);
+ p.margin(new DocumentInsets(first ? 0 : SIDEBAR_BLOCK_GAP, 0, HEADING_TO_RULE, 0));
+ });
+ section.addLine(line -> line
+ .name("SidebarRule_" + compact(title))
+ .horizontal(SIDEBAR_INNER_WIDTH)
+ .thickness(SIDEBAR_RULE_THICKNESS)
+ .color(SIDEBAR_RULE)
+ .margin(new DocumentInsets(0, 0, RULE_TO_BODY, 0)));
+ }
+
+ /**
+ * A main-column heading: the section's mark inside a navy disc, with
+ * tracked capitals on the disc's axis.
+ *
+ *
It is a container rather than a row because the badge and the words
+ * are centred against each other; a row of sections would top-align
+ * them.
+ *
+ * @param section the main column
+ * @param iconToken the packaged mark for this section
+ * @param title the heading text, drawn in capitals
+ */
+ static void sectionHeader(SectionBuilder section, String iconToken, String title) {
+ DocumentNode badge = new ShapeContainerBuilder()
+ .name("Badge_" + compact(title))
+ .circle(BADGE_DIAMETER)
+ .clipPolicy(ClipPolicy.CLIP_PATH)
+ .fillColor(NAVY)
+ .center(new ImageBuilder()
+ .name("BadgeGlyph_" + compact(title))
+ .source(NavySidebarIcons.image(iconToken))
+ .size(NavySidebarIcons.size(iconToken), NavySidebarIcons.size(iconToken))
+ .build())
+ .build();
+
+ DocumentTextStyle headingStyle = style(HEADING_SIZE, INK, DocumentTextDecoration.BOLD);
+ ParagraphBuilder heading = new ParagraphBuilder()
+ .name("SectionTitle_" + compact(title))
+ .textStyle(headingStyle);
+ tracked(heading, title.toUpperCase(Locale.ROOT), headingStyle);
+ DocumentNode headingNode = heading.margin(DocumentInsets.zero()).build();
+
+ section.addContainer(band -> band
+ .name("SectionHeader_" + compact(title))
+ .rectangle(MAIN_CONTENT_WIDTH, HEADING_BAND_HEIGHT)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .centerLeft(badge)
+ .position(headingNode, SECTION_INDENT, 0, LayerAlign.CENTER_LEFT));
+ }
+
+ /**
+ * A bullet list indented to clear the badge, so its text starts on the
+ * same axis as the heading beside it.
+ *
+ * @param section the main column
+ * @param name node name of the list
+ * @param items one bullet per element
+ */
+ static void indentedList(SectionBuilder section, String name, List items) {
+ section.addList(list -> list
+ .name(name)
+ .bullet()
+ .items(items)
+ .textStyle(body())
+ .lineSpacing(BULLET_LEADING)
+ .itemSpacing(PLAIN_ITEM_GAP)
+ .margin(new DocumentInsets(HEADER_TO_BODY, 0, 0, SECTION_INDENT)));
+ }
+}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ProfessionalSidebar.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ProfessionalSidebar.java
index 2dcca81e6..15d297a22 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ProfessionalSidebar.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/ProfessionalSidebar.java
@@ -82,6 +82,12 @@
* phone and email rows carry {@code tel:} and {@code mailto:} targets built
* from the value.
*
+ *
A channel is one paragraph, the mark and the value sharing a line, so a
+ * value wider than the sidebar's text column wraps under the mark and leaves
+ * it alone on the first line. The column is about 107pt, which at this type
+ * size is a little over twenty characters — long addresses want shortening
+ * rather than a wider column.
+ *
*
Fonts
*
*
The sheet is set in Barlow Condensed and Lato. The templates artifact
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/package-info.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/package-info.java
index 64a490a00..5b6771bc0 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/package-info.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/package-info.java
@@ -21,11 +21,11 @@
* fed a different theme.
*
*
The package holds a second shape as well. A preset that reproduces a
- * specific drawn sheet — {@code ProfessionalSidebar} is the one here — takes
- * no {@code BrandTheme} and calls no shared renderer: its geometry, palette
- * and type scale are measured constants of its own, and it sets its own page.
- * The data model is the same {@code CvDocument}; what differs is that the
- * cosmetics cannot be swapped, because moving them would stop it being the
- * sheet it reproduces.
+ * specific drawn sheet — {@code ProfessionalSidebar} and {@code NavySidebar}
+ * are the ones here — takes no {@code BrandTheme} and calls no shared
+ * renderer: its geometry, palette and type scale are measured constants of
+ * its own, and it sets its own page. The data model is the same
+ * {@code CvDocument}; what differs is that the cosmetics cannot be swapped,
+ * because moving them would stop it being the sheet it reproduces.
*/
package com.demcha.compose.document.templates.cv.presets;
diff --git a/templates/src/main/resources/templates/cv/navy-sidebar/icons/briefcase.png b/templates/src/main/resources/templates/cv/navy-sidebar/icons/briefcase.png
new file mode 100644
index 000000000..98d131523
Binary files /dev/null and b/templates/src/main/resources/templates/cv/navy-sidebar/icons/briefcase.png differ
diff --git a/templates/src/main/resources/templates/cv/navy-sidebar/icons/certificate.png b/templates/src/main/resources/templates/cv/navy-sidebar/icons/certificate.png
new file mode 100644
index 000000000..c3da29ef5
Binary files /dev/null and b/templates/src/main/resources/templates/cv/navy-sidebar/icons/certificate.png differ
diff --git a/templates/src/main/resources/templates/cv/navy-sidebar/icons/email.png b/templates/src/main/resources/templates/cv/navy-sidebar/icons/email.png
new file mode 100644
index 000000000..29bf4216f
Binary files /dev/null and b/templates/src/main/resources/templates/cv/navy-sidebar/icons/email.png differ
diff --git a/templates/src/main/resources/templates/cv/navy-sidebar/icons/linkedin.png b/templates/src/main/resources/templates/cv/navy-sidebar/icons/linkedin.png
new file mode 100644
index 000000000..a77bf7bf8
Binary files /dev/null and b/templates/src/main/resources/templates/cv/navy-sidebar/icons/linkedin.png differ
diff --git a/templates/src/main/resources/templates/cv/navy-sidebar/icons/location.png b/templates/src/main/resources/templates/cv/navy-sidebar/icons/location.png
new file mode 100644
index 000000000..0d58db247
Binary files /dev/null and b/templates/src/main/resources/templates/cv/navy-sidebar/icons/location.png differ
diff --git a/templates/src/main/resources/templates/cv/navy-sidebar/icons/phone.png b/templates/src/main/resources/templates/cv/navy-sidebar/icons/phone.png
new file mode 100644
index 000000000..749c4c181
Binary files /dev/null and b/templates/src/main/resources/templates/cv/navy-sidebar/icons/phone.png differ
diff --git a/templates/src/main/resources/templates/cv/navy-sidebar/icons/trophy.png b/templates/src/main/resources/templates/cv/navy-sidebar/icons/trophy.png
new file mode 100644
index 000000000..80e7964ff
Binary files /dev/null and b/templates/src/main/resources/templates/cv/navy-sidebar/icons/trophy.png differ