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. + * + *

Output: + * {@code examples/target/generated-pdfs/templates/cv/cv-navy-sidebar-v2.pdf}.

+ * + *

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/}.

+ */ +class NavySidebarVisualParityTest { + + private static final Path BASELINE_ROOT = Path.of( + "src", "test", "resources", "visual-baselines", "cv-v2-layered"); + + private static final long PIXEL_DIFF_BUDGET = 50_000L; + private static final int PER_PIXEL_TOLERANCE = 8; + + @Test + void rendersWithinPixelDiffBudget() throws Exception { + byte[] pdfBytes; + try (DocumentSession document = GraphCompose.document().create()) { + NavySidebar.create().compose(document, NavySidebarFixtures.canonicalCv()); + pdfBytes = document.toPdfBytes(); + } + + PdfVisualRegression.standard() + .baselineRoot(BASELINE_ROOT) + .perPixelTolerance(PER_PIXEL_TOLERANCE) + .mismatchedPixelBudget(PIXEL_DIFF_BUDGET) + .assertMatchesBaseline("navy_sidebar", pdfBytes); + } +} diff --git a/qa/src/test/resources/layout-snapshots/canonical-templates/cv-v2/navy_sidebar_layout.json b/qa/src/test/resources/layout-snapshots/canonical-templates/cv-v2/navy_sidebar_layout.json new file mode 100644 index 000000000..fe432c744 --- /dev/null +++ b/qa/src/test/resources/layout-snapshots/canonical-templates/cv-v2/navy_sidebar_layout.json @@ -0,0 +1,2627 @@ +{ + "formatVersion" : "2.0", + "canvas" : { + "pageWidth" : 595.276, + "pageHeight" : 841.89, + "innerWidth" : 595.276, + "innerHeight" : 841.89, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, + "totalPages" : 1, + "nodes" : [ { + "path" : "NavySidebarCv[0]", + "entityName" : "NavySidebarCv", + "entityKind" : "ContainerNode", + "parentPath" : null, + "childIndex" : 0, + "depth" : 1, + "layer" : 1, + "computedX" : 0.0, + "computedY" : 1.125, + "placementX" : 0.0, + "placementY" : 1.125, + "placementWidth" : 595.276, + "placementHeight" : 840.765, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 595.276, + "contentHeight" : 840.765, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]", + "entityName" : "PageGrid", + "entityKind" : "RowNode", + "parentPath" : "NavySidebarCv[0]", + "childIndex" : 0, + "depth" : 2, + "layer" : 2, + "computedX" : 0.0, + "computedY" : 1.125, + "placementX" : 0.0, + "placementY" : 1.125, + "placementWidth" : 595.276, + "placementHeight" : 840.765, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 595.276, + "contentHeight" : 840.765, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]", + "entityName" : "Sidebar", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]", + "childIndex" : 0, + "depth" : 3, + "layer" : 3, + "computedX" : 0.0, + "computedY" : 48.043, + "placementX" : 0.0, + "placementY" : 48.043, + "placementWidth" : 176.797, + "placementHeight" : 793.847, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 176.797, + "contentHeight" : 793.847, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 25.0, + "right" : 26.5, + "bottom" : 36.5, + "left" : 26.5 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Avatar[0]", + "entityName" : "Avatar", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 26.5, + "computedY" : 667.893, + "placementX" : 26.5, + "placementY" : 667.893, + "placementWidth" : 123.797, + "placementHeight" : 148.997, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 148.997, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 22.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Avatar[0]/AvatarRing[0]", + "entityName" : "AvatarRing", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Avatar[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 689.893, + "placementX" : 26.5, + "placementY" : 689.893, + "placementWidth" : 126.997, + "placementHeight" : 126.997, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 126.997, + "contentHeight" : 126.997, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Avatar[0]/AvatarRing[0]/AvatarCircle[0]", + "entityName" : "AvatarCircle", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Avatar[0]/AvatarRing[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 28.1, + "computedY" : 691.493, + "placementX" : 28.1, + "placementY" : 691.493, + "placementWidth" : 123.797, + "placementHeight" : 123.797, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 123.797, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Avatar[0]/AvatarRing[0]/AvatarCircle[0]/AvatarPhoto[0]", + "entityName" : "AvatarPhoto", + "entityKind" : "ImageNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Avatar[0]/AvatarRing[0]/AvatarCircle[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 28.1, + "computedY" : 691.493, + "placementX" : 28.1, + "placementY" : 691.493, + "placementWidth" : 123.797, + "placementHeight" : 123.797, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 123.797, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]", + "entityName" : "Contact", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]", + "childIndex" : 1, + "depth" : 4, + "layer" : 4, + "computedX" : 26.5, + "computedY" : 550.093, + "placementX" : 26.5, + "placementY" : 550.093, + "placementWidth" : 123.797, + "placementHeight" : 117.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 117.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]/SidebarHeading_Contact[0]", + "entityName" : "SidebarHeading_Contact", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 653.493, + "placementX" : 26.5, + "placementY" : 653.493, + "placementWidth" : 69.473, + "placementHeight" : 14.4, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 69.473, + "contentHeight" : 14.4, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 7.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]/SidebarRule_Contact[1]", + "entityName" : "SidebarRule_Contact", + "entityKind" : "LineNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 645.493, + "placementX" : 26.5, + "placementY" : 645.493, + "placementWidth" : 123.797, + "placementHeight" : 1.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 1.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 10.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]/Contact_phone[2]", + "entityName" : "Contact_phone", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]", + "childIndex" : 2, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 624.693, + "placementX" : 26.5, + "placementY" : 624.693, + "placementWidth" : 92.314, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 92.314, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 10.5, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]/Contact_email[3]", + "entityName" : "Contact_email", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]", + "childIndex" : 3, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 603.393, + "placementX" : 26.5, + "placementY" : 603.393, + "placementWidth" : 105.31, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 105.31, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 10.5, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]/Contact_location[4]", + "entityName" : "Contact_location", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]", + "childIndex" : 4, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 581.893, + "placementX" : 26.5, + "placementY" : 581.893, + "placementWidth" : 96.275, + "placementHeight" : 11.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 96.275, + "contentHeight" : 11.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 10.5, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]/Contact_linkedin[5]", + "entityName" : "Contact_linkedin", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Contact[1]", + "childIndex" : 5, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 560.593, + "placementX" : 26.5, + "placementY" : 560.593, + "placementWidth" : 122.217, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 122.217, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 10.5, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "entityName" : "Education", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]", + "childIndex" : 2, + "depth" : 4, + "layer" : 4, + "computedX" : 26.5, + "computedY" : 348.993, + "placementX" : 26.5, + "placementY" : 348.993, + "placementWidth" : 123.797, + "placementHeight" : 201.1, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 201.1, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/SidebarHeading_Education[0]", + "entityName" : "SidebarHeading_Education", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 517.693, + "placementX" : 26.5, + "placementY" : 517.693, + "placementWidth" : 86.807, + "placementHeight" : 14.4, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 86.807, + "contentHeight" : 14.4, + "margin" : { + "top" : 18.0, + "right" : 0.0, + "bottom" : 7.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/SidebarRule_Education[1]", + "entityName" : "SidebarRule_Education", + "entityKind" : "LineNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 509.693, + "placementX" : 26.5, + "placementY" : 509.693, + "placementWidth" : 123.797, + "placementHeight" : 1.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 1.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 10.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/Degree_MasterofScienceinMarketing[2]", + "entityName" : "Degree_MasterofScienceinMarketing", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 2, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 476.743, + "placementX" : 26.5, + "placementY" : 476.743, + "placementWidth" : 101.421, + "placementHeight" : 22.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 101.421, + "contentHeight" : 22.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 4.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/Institution_MasterofScienceinMarketing[3]", + "entityName" : "Institution_MasterofScienceinMarketing", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 3, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 461.943, + "placementX" : 26.5, + "placementY" : 461.943, + "placementWidth" : 82.287, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 82.287, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 1.5, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/Campus_MasterofScienceinMarketing[4]", + "entityName" : "Campus_MasterofScienceinMarketing", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 4, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 449.643, + "placementX" : 26.5, + "placementY" : 449.643, + "placementWidth" : 56.412, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 56.412, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 1.5, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/Years_MasterofScienceinMarketing[5]", + "entityName" : "Years_MasterofScienceinMarketing", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 5, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 437.343, + "placementX" : 26.5, + "placementY" : 437.343, + "placementWidth" : 49.716, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 49.716, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 13.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/Degree_BachelorofBusinessAdministration[6]", + "entityName" : "Degree_BachelorofBusinessAdministration", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 6, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 401.393, + "placementX" : 26.5, + "placementY" : 401.393, + "placementWidth" : 106.965, + "placementHeight" : 22.95, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 106.965, + "contentHeight" : 22.95, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 4.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/Institution_BachelorofBusinessAdministration[7]", + "entityName" : "Institution_BachelorofBusinessAdministration", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 7, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 386.593, + "placementX" : 26.5, + "placementY" : 386.593, + "placementWidth" : 91.242, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 91.242, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 1.5, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/Campus_BachelorofBusinessAdministration[8]", + "entityName" : "Campus_BachelorofBusinessAdministration", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 8, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 374.293, + "placementX" : 26.5, + "placementY" : 374.293, + "placementWidth" : 63.702, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 63.702, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 1.5, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]/Years_BachelorofBusinessAdministration[9]", + "entityName" : "Years_BachelorofBusinessAdministration", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Education[2]", + "childIndex" : 9, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 361.993, + "placementX" : 26.5, + "placementY" : 361.993, + "placementWidth" : 49.716, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 49.716, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 13.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Skills[3]", + "entityName" : "Skills", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]", + "childIndex" : 3, + "depth" : 4, + "layer" : 4, + "computedX" : 26.5, + "computedY" : 195.393, + "placementX" : 26.5, + "placementY" : 195.393, + "placementWidth" : 123.797, + "placementHeight" : 153.6, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 153.6, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Skills[3]/SidebarHeading_Skills[0]", + "entityName" : "SidebarHeading_Skills", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Skills[3]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 316.593, + "placementX" : 26.5, + "placementY" : 316.593, + "placementWidth" : 47.366, + "placementHeight" : 14.4, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 47.366, + "contentHeight" : 14.4, + "margin" : { + "top" : 18.0, + "right" : 0.0, + "bottom" : 7.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Skills[3]/SidebarRule_Skills[1]", + "entityName" : "SidebarRule_Skills", + "entityKind" : "LineNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Skills[3]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 308.593, + "placementX" : 26.5, + "placementY" : 308.593, + "placementWidth" : 123.797, + "placementHeight" : 1.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 1.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 10.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Skills[3]/SkillsList[2]", + "entityName" : "SkillsList", + "entityKind" : "ListNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Skills[3]", + "childIndex" : 2, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 195.393, + "placementX" : 26.5, + "placementY" : 195.393, + "placementWidth" : 95.517, + "placementHeight" : 103.2, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 95.517, + "contentHeight" : 103.2, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]", + "entityName" : "Languages", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]", + "childIndex" : 4, + "depth" : 4, + "layer" : 4, + "computedX" : 26.5, + "computedY" : 84.543, + "placementX" : 26.5, + "placementY" : 84.543, + "placementWidth" : 123.797, + "placementHeight" : 110.85, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 110.85, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/SidebarHeading_Languages[0]", + "entityName" : "SidebarHeading_Languages", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 162.993, + "placementX" : 26.5, + "placementY" : 162.993, + "placementWidth" : 87.863, + "placementHeight" : 14.4, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 87.863, + "contentHeight" : 14.4, + "margin" : { + "top" : 18.0, + "right" : 0.0, + "bottom" : 7.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/SidebarRule_Languages[1]", + "entityName" : "SidebarRule_Languages", + "entityKind" : "LineNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 148.993, + "placementX" : 26.5, + "placementY" : 148.993, + "placementWidth" : 123.797, + "placementHeight" : 1.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 1.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 10.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_English[2]", + "entityName" : "LanguageRow_English", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]", + "childIndex" : 2, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 120.843, + "placementX" : 26.5, + "placementY" : 120.843, + "placementWidth" : 123.797, + "placementHeight" : 12.15, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 12.15, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_English[2]/Language_English[0]", + "entityName" : "Language_English", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_English[2]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 26.5, + "computedY" : 121.518, + "placementX" : 26.5, + "placementY" : 121.518, + "placementWidth" : 35.115, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 35.115, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_English[2]/Proficiency_English[1]", + "entityName" : "Proficiency_English", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_English[2]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 77.257, + "computedY" : 121.518, + "placementX" : 77.257, + "placementY" : 121.518, + "placementWidth" : 38.268, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 38.268, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_Spanish[3]", + "entityName" : "LanguageRow_Spanish", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]", + "childIndex" : 3, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 102.693, + "placementX" : 26.5, + "placementY" : 102.693, + "placementWidth" : 123.797, + "placementHeight" : 12.15, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 12.15, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_Spanish[3]/Language_Spanish[0]", + "entityName" : "Language_Spanish", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_Spanish[3]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 26.5, + "computedY" : 103.368, + "placementX" : 26.5, + "placementY" : 103.368, + "placementWidth" : 37.518, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 37.518, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_Spanish[3]/Proficiency_Spanish[1]", + "entityName" : "Proficiency_Spanish", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_Spanish[3]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 77.257, + "computedY" : 103.368, + "placementX" : 77.257, + "placementY" : 103.368, + "placementWidth" : 51.498, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 51.498, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_French[4]", + "entityName" : "LanguageRow_French", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]", + "childIndex" : 4, + "depth" : 5, + "layer" : 5, + "computedX" : 26.5, + "computedY" : 84.543, + "placementX" : 26.5, + "placementY" : 84.543, + "placementWidth" : 123.797, + "placementHeight" : 12.15, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 123.797, + "contentHeight" : 12.15, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_French[4]/Language_French[0]", + "entityName" : "Language_French", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_French[4]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 26.5, + "computedY" : 85.218, + "placementX" : 26.5, + "placementY" : 85.218, + "placementWidth" : 34.476, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 34.476, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_French[4]/Proficiency_French[1]", + "entityName" : "Proficiency_French", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Sidebar[0]/Languages[4]/LanguageRow_French[4]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 77.257, + "computedY" : 85.218, + "placementX" : 77.257, + "placementY" : 85.218, + "placementWidth" : 62.748, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 62.748, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]", + "entityName" : "Main", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]", + "childIndex" : 1, + "depth" : 3, + "layer" : 3, + "computedX" : 176.797, + "computedY" : 1.125, + "placementX" : 176.797, + "placementY" : 1.125, + "placementWidth" : 418.479, + "placementHeight" : 840.765, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 418.479, + "contentHeight" : 840.765, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 30.0, + "right" : 36.5, + "bottom" : 36.5, + "left" : 25.4 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Identity[0]", + "entityName" : "Identity", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]", + "childIndex" : 0, + "depth" : 4, + "layer" : 4, + "computedX" : 202.197, + "computedY" : 737.69, + "placementX" : 202.197, + "placementY" : 737.69, + "placementWidth" : 255.99, + "placementHeight" : 74.2, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 255.99, + "contentHeight" : 74.2, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Identity[0]/Name[0]", + "entityName" : "Name", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Identity[0]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 202.197, + "computedY" : 775.89, + "placementX" : 202.197, + "placementY" : 775.89, + "placementWidth" : 255.99, + "placementHeight" : 36.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 255.99, + "contentHeight" : 36.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 6.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Identity[0]/Role[1]", + "entityName" : "Role", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Identity[0]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 202.197, + "computedY" : 753.69, + "placementX" : 202.197, + "placementY" : 753.69, + "placementWidth" : 187.31, + "placementHeight" : 16.2, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 187.31, + "contentHeight" : 16.2, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 16.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Summary[1]", + "entityName" : "Summary", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]", + "childIndex" : 1, + "depth" : 4, + "layer" : 4, + "computedX" : 202.197, + "computedY" : 683.45, + "placementX" : 202.197, + "placementY" : 683.45, + "placementWidth" : 346.217, + "placementHeight" : 54.24, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 346.217, + "contentHeight" : 54.24, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Summary[1]/Summary[0]", + "entityName" : "Summary", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Summary[1]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 202.197, + "computedY" : 683.45, + "placementX" : 202.197, + "placementY" : 683.45, + "placementWidth" : 346.217, + "placementHeight" : 54.24, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 346.217, + "contentHeight" : 54.24, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/SectionRule[2]", + "entityName" : "SectionRule", + "entityKind" : "LineNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]", + "childIndex" : 2, + "depth" : 4, + "layer" : 4, + "computedX" : 202.197, + "computedY" : 666.45, + "placementX" : 202.197, + "placementY" : 666.45, + "placementWidth" : 356.579, + "placementHeight" : 1.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 356.579, + "contentHeight" : 1.0, + "margin" : { + "top" : 16.0, + "right" : 0.0, + "bottom" : 15.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]", + "entityName" : "Experience", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]", + "childIndex" : 3, + "depth" : 4, + "layer" : 4, + "computedX" : 202.197, + "computedY" : 258.175, + "placementX" : 202.197, + "placementY" : 258.175, + "placementWidth" : 356.579, + "placementHeight" : 393.275, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 356.579, + "contentHeight" : 393.275, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/SectionHeader_Experience[0]", + "entityName" : "SectionHeader_Experience", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 202.197, + "computedY" : 628.85, + "placementX" : 202.197, + "placementY" : 628.85, + "placementWidth" : 356.579, + "placementHeight" : 22.6, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 356.579, + "contentHeight" : 22.6, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/SectionHeader_Experience[0]/Badge_Experience[0]", + "entityName" : "Badge_Experience", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/SectionHeader_Experience[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 202.197, + "computedY" : 628.85, + "placementX" : 202.197, + "placementY" : 628.85, + "placementWidth" : 22.6, + "placementHeight" : 22.6, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 22.6, + "contentHeight" : 22.6, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/SectionHeader_Experience[0]/Badge_Experience[0]/BadgeGlyph_Experience[0]", + "entityName" : "BadgeGlyph_Experience", + "entityKind" : "ImageNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/SectionHeader_Experience[0]/Badge_Experience[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 207.847, + "computedY" : 634.5, + "placementX" : 207.847, + "placementY" : 634.5, + "placementWidth" : 11.3, + "placementHeight" : 11.3, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 11.3, + "contentHeight" : 11.3, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/SectionHeader_Experience[0]/SectionTitle_Experience[1]", + "entityName" : "SectionTitle_Experience", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/SectionHeader_Experience[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 232.097, + "computedY" : 632.95, + "placementX" : 232.097, + "placementY" : 632.95, + "placementWidth" : 89.402, + "placementHeight" : 14.4, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 89.402, + "contentHeight" : 14.4, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]", + "entityName" : "ExperienceRail", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 213.097, + "computedY" : 327.937, + "placementX" : 213.097, + "placementY" : 327.937, + "placementWidth" : 345.679, + "placementHeight" : 285.55, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 345.679, + "contentHeight" : 285.55, + "margin" : { + "top" : 15.363, + "right" : 0.0, + "bottom" : 0.0, + "left" : 10.9 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingManager[0]", + "entityName" : "EntryHead_MarketingManager", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 213.097, + "computedY" : 606.687, + "placementX" : 213.097, + "placementY" : 606.687, + "placementWidth" : 345.679, + "placementHeight" : 6.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 345.679, + "contentHeight" : 6.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingManager[0]/Marker_MarketingManager[0]", + "entityName" : "Marker_MarketingManager", + "entityKind" : "EllipseNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingManager[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 209.697, + "computedY" : 606.687, + "placementX" : 209.697, + "placementY" : 606.687, + "placementWidth" : 6.8, + "placementHeight" : 6.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 6.8, + "contentHeight" : 6.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingManager[0]/JobTitle_MarketingManager[1]", + "entityName" : "JobTitle_MarketingManager", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingManager[0]", + "childIndex" : 1, + "depth" : 7, + "layer" : 7, + "computedX" : 232.097, + "computedY" : 604.05, + "placementX" : 232.097, + "placementY" : 604.05, + "placementWidth" : 129.157, + "placementHeight" : 13.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 129.157, + "contentHeight" : 13.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingManager[0]/JobDates_MarketingManager[2]", + "entityName" : "JobDates_MarketingManager", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingManager[0]", + "childIndex" : 2, + "depth" : 7, + "layer" : 7, + "computedX" : 213.097, + "computedY" : 605.362, + "placementX" : 213.097, + "placementY" : 605.362, + "placementWidth" : 345.679, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 345.679, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/Employer_MarketingManager[1]", + "entityName" : "Employer_MarketingManager", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 232.097, + "computedY" : 588.525, + "placementX" : 232.097, + "placementY" : 588.525, + "placementWidth" : 146.997, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 146.997, + "contentHeight" : 10.8, + "margin" : { + "top" : 7.363, + "right" : 0.0, + "bottom" : 7.0, + "left" : 19.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/Highlights_MarketingManager[2]", + "entityName" : "Highlights_MarketingManager", + "entityKind" : "ListNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]", + "childIndex" : 2, + "depth" : 6, + "layer" : 6, + "computedX" : 232.097, + "computedY" : 500.475, + "placementX" : 232.097, + "placementY" : 500.475, + "placementWidth" : 313.803, + "placementHeight" : 81.05, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 313.803, + "contentHeight" : 81.05, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 26.363, + "left" : 19.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingSpecialist[3]", + "entityName" : "EntryHead_MarketingSpecialist", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]", + "childIndex" : 3, + "depth" : 6, + "layer" : 6, + "computedX" : 213.097, + "computedY" : 467.312, + "placementX" : 213.097, + "placementY" : 467.312, + "placementWidth" : 345.679, + "placementHeight" : 6.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 345.679, + "contentHeight" : 6.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingSpecialist[3]/Marker_MarketingSpecialist[0]", + "entityName" : "Marker_MarketingSpecialist", + "entityKind" : "EllipseNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingSpecialist[3]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 209.697, + "computedY" : 467.312, + "placementX" : 209.697, + "placementY" : 467.312, + "placementWidth" : 6.8, + "placementHeight" : 6.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 6.8, + "contentHeight" : 6.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingSpecialist[3]/JobTitle_MarketingSpecialist[1]", + "entityName" : "JobTitle_MarketingSpecialist", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingSpecialist[3]", + "childIndex" : 1, + "depth" : 7, + "layer" : 7, + "computedX" : 232.097, + "computedY" : 464.675, + "placementX" : 232.097, + "placementY" : 464.675, + "placementWidth" : 132.756, + "placementHeight" : 13.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 132.756, + "contentHeight" : 13.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingSpecialist[3]/JobDates_MarketingSpecialist[2]", + "entityName" : "JobDates_MarketingSpecialist", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingSpecialist[3]", + "childIndex" : 2, + "depth" : 7, + "layer" : 7, + "computedX" : 213.097, + "computedY" : 465.987, + "placementX" : 213.097, + "placementY" : 465.987, + "placementWidth" : 345.679, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 345.679, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/Employer_MarketingSpecialist[4]", + "entityName" : "Employer_MarketingSpecialist", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]", + "childIndex" : 4, + "depth" : 6, + "layer" : 6, + "computedX" : 232.097, + "computedY" : 449.15, + "placementX" : 232.097, + "placementY" : 449.15, + "placementWidth" : 146.997, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 146.997, + "contentHeight" : 10.8, + "margin" : { + "top" : 7.363, + "right" : 0.0, + "bottom" : 7.0, + "left" : 19.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/Highlights_MarketingSpecialist[5]", + "entityName" : "Highlights_MarketingSpecialist", + "entityKind" : "ListNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]", + "childIndex" : 5, + "depth" : 6, + "layer" : 6, + "computedX" : 232.097, + "computedY" : 361.1, + "placementX" : 232.097, + "placementY" : 361.1, + "placementWidth" : 324.765, + "placementHeight" : 81.05, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 324.765, + "contentHeight" : 81.05, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 26.363, + "left" : 19.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingIntern[6]", + "entityName" : "EntryHead_MarketingIntern", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]", + "childIndex" : 6, + "depth" : 6, + "layer" : 6, + "computedX" : 213.097, + "computedY" : 327.937, + "placementX" : 213.097, + "placementY" : 327.937, + "placementWidth" : 345.679, + "placementHeight" : 6.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 345.679, + "contentHeight" : 6.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingIntern[6]/Marker_MarketingIntern[0]", + "entityName" : "Marker_MarketingIntern", + "entityKind" : "EllipseNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingIntern[6]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 209.697, + "computedY" : 327.937, + "placementX" : 209.697, + "placementY" : 327.937, + "placementWidth" : 6.8, + "placementHeight" : 6.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 6.8, + "contentHeight" : 6.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingIntern[6]/JobTitle_MarketingIntern[1]", + "entityName" : "JobTitle_MarketingIntern", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingIntern[6]", + "childIndex" : 1, + "depth" : 7, + "layer" : 7, + "computedX" : 232.097, + "computedY" : 325.3, + "placementX" : 232.097, + "placementY" : 325.3, + "placementWidth" : 113.137, + "placementHeight" : 13.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 113.137, + "contentHeight" : 13.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingIntern[6]/JobDates_MarketingIntern[2]", + "entityName" : "JobDates_MarketingIntern", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceRail[1]/EntryHead_MarketingIntern[6]", + "childIndex" : 2, + "depth" : 7, + "layer" : 7, + "computedX" : 213.097, + "computedY" : 326.612, + "placementX" : 213.097, + "placementY" : 326.612, + "placementWidth" : 345.679, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 345.679, + "contentHeight" : 10.8, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceTail[2]", + "entityName" : "ExperienceTail", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]", + "childIndex" : 2, + "depth" : 5, + "layer" : 5, + "computedX" : 213.097, + "computedY" : 258.175, + "placementX" : 213.097, + "placementY" : 258.175, + "placementWidth" : 293.005, + "placementHeight" : 69.762, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 293.005, + "contentHeight" : 69.762, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 10.9 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceTail[2]/Employer_MarketingIntern[0]", + "entityName" : "Employer_MarketingIntern", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceTail[2]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 232.097, + "computedY" : 309.775, + "placementX" : 232.097, + "placementY" : 309.775, + "placementWidth" : 146.997, + "placementHeight" : 10.8, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 146.997, + "contentHeight" : 10.8, + "margin" : { + "top" : 7.363, + "right" : 0.0, + "bottom" : 7.0, + "left" : 19.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceTail[2]/Highlights_MarketingIntern[1]", + "entityName" : "Highlights_MarketingIntern", + "entityKind" : "ListNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Experience[3]/ExperienceTail[2]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 232.097, + "computedY" : 258.175, + "placementX" : 232.097, + "placementY" : 258.175, + "placementWidth" : 274.005, + "placementHeight" : 44.6, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 274.005, + "contentHeight" : 44.6, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 19.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/SectionRule[4]", + "entityName" : "SectionRule", + "entityKind" : "LineNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]", + "childIndex" : 4, + "depth" : 4, + "layer" : 4, + "computedX" : 202.197, + "computedY" : 241.175, + "placementX" : 202.197, + "placementY" : 241.175, + "placementWidth" : 356.579, + "placementHeight" : 1.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 356.579, + "contentHeight" : 1.0, + "margin" : { + "top" : 16.0, + "right" : 0.0, + "bottom" : 11.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]", + "entityName" : "Achievements", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]", + "childIndex" : 5, + "depth" : 4, + "layer" : 4, + "computedX" : 202.197, + "computedY" : 141.825, + "placementX" : 202.197, + "placementY" : 141.825, + "placementWidth" : 356.579, + "placementHeight" : 88.35, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 356.579, + "contentHeight" : 88.35, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]/SectionHeader_Achievements[0]", + "entityName" : "SectionHeader_Achievements", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 202.197, + "computedY" : 207.575, + "placementX" : 202.197, + "placementY" : 207.575, + "placementWidth" : 356.579, + "placementHeight" : 22.6, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 356.579, + "contentHeight" : 22.6, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]/SectionHeader_Achievements[0]/Badge_Achievements[0]", + "entityName" : "Badge_Achievements", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]/SectionHeader_Achievements[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 202.197, + "computedY" : 207.575, + "placementX" : 202.197, + "placementY" : 207.575, + "placementWidth" : 22.6, + "placementHeight" : 22.6, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 22.6, + "contentHeight" : 22.6, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]/SectionHeader_Achievements[0]/Badge_Achievements[0]/BadgeGlyph_Achievements[0]", + "entityName" : "BadgeGlyph_Achievements", + "entityKind" : "ImageNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]/SectionHeader_Achievements[0]/Badge_Achievements[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 207.847, + "computedY" : 213.225, + "placementX" : 207.847, + "placementY" : 213.225, + "placementWidth" : 11.3, + "placementHeight" : 11.3, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 11.3, + "contentHeight" : 11.3, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]/SectionHeader_Achievements[0]/SectionTitle_Achievements[1]", + "entityName" : "SectionTitle_Achievements", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]/SectionHeader_Achievements[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 232.097, + "computedY" : 211.675, + "placementX" : 232.097, + "placementY" : 211.675, + "placementWidth" : 114.332, + "placementHeight" : 14.4, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 114.332, + "contentHeight" : 14.4, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]/AchievementsList[1]", + "entityName" : "AchievementsList", + "entityKind" : "ListNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Achievements[5]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 232.097, + "computedY" : 141.825, + "placementX" : 232.097, + "placementY" : 141.825, + "placementWidth" : 315.126, + "placementHeight" : 54.75, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 315.126, + "contentHeight" : 54.75, + "margin" : { + "top" : 11.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 29.9 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/SectionRule[6]", + "entityName" : "SectionRule", + "entityKind" : "LineNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]", + "childIndex" : 6, + "depth" : 4, + "layer" : 4, + "computedX" : 202.197, + "computedY" : 124.825, + "placementX" : 202.197, + "placementY" : 124.825, + "placementWidth" : 356.579, + "placementHeight" : 1.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 356.579, + "contentHeight" : 1.0, + "margin" : { + "top" : 16.0, + "right" : 0.0, + "bottom" : 11.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]", + "entityName" : "Certifications", + "entityKind" : "SectionNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]", + "childIndex" : 7, + "depth" : 4, + "layer" : 4, + "computedX" : 202.197, + "computedY" : 37.625, + "placementX" : 202.197, + "placementY" : 37.625, + "placementWidth" : 356.579, + "placementHeight" : 76.2, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 356.579, + "contentHeight" : 76.2, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]/SectionHeader_Certifications[0]", + "entityName" : "SectionHeader_Certifications", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]", + "childIndex" : 0, + "depth" : 5, + "layer" : 5, + "computedX" : 202.197, + "computedY" : 91.225, + "placementX" : 202.197, + "placementY" : 91.225, + "placementWidth" : 356.579, + "placementHeight" : 22.6, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 356.579, + "contentHeight" : 22.6, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]/SectionHeader_Certifications[0]/Badge_Certifications[0]", + "entityName" : "Badge_Certifications", + "entityKind" : "ShapeContainerNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]/SectionHeader_Certifications[0]", + "childIndex" : 0, + "depth" : 6, + "layer" : 6, + "computedX" : 202.197, + "computedY" : 91.225, + "placementX" : 202.197, + "placementY" : 91.225, + "placementWidth" : 22.6, + "placementHeight" : 22.6, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 22.6, + "contentHeight" : 22.6, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]/SectionHeader_Certifications[0]/Badge_Certifications[0]/BadgeGlyph_Certifications[0]", + "entityName" : "BadgeGlyph_Certifications", + "entityKind" : "ImageNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]/SectionHeader_Certifications[0]/Badge_Certifications[0]", + "childIndex" : 0, + "depth" : 7, + "layer" : 7, + "computedX" : 207.847, + "computedY" : 96.875, + "placementX" : 207.847, + "placementY" : 96.875, + "placementWidth" : 11.3, + "placementHeight" : 11.3, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 11.3, + "contentHeight" : 11.3, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]/SectionHeader_Certifications[0]/SectionTitle_Certifications[1]", + "entityName" : "SectionTitle_Certifications", + "entityKind" : "ParagraphNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]/SectionHeader_Certifications[0]", + "childIndex" : 1, + "depth" : 6, + "layer" : 6, + "computedX" : 232.097, + "computedY" : 95.325, + "placementX" : 232.097, + "placementY" : 95.325, + "placementWidth" : 122.041, + "placementHeight" : 14.4, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 122.041, + "contentHeight" : 14.4, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]/CertificationsList[1]", + "entityName" : "CertificationsList", + "entityKind" : "ListNode", + "parentPath" : "NavySidebarCv[0]/PageGrid[0]/Main[1]/Certifications[7]", + "childIndex" : 1, + "depth" : 5, + "layer" : 5, + "computedX" : 232.097, + "computedY" : 37.625, + "placementX" : 232.097, + "placementY" : 37.625, + "placementWidth" : 197.037, + "placementHeight" : 42.6, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 197.037, + "contentHeight" : 42.6, + "margin" : { + "top" : 11.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 29.9 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + } ] +} diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/navy_sidebar-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/navy_sidebar-page-0.png new file mode 100644 index 000000000..efef4bc28 Binary files /dev/null and b/qa/src/test/resources/visual-baselines/cv-v2-layered/navy_sidebar-page-0.png differ diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvIdentity.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvIdentity.java index 4ae2135bd..e5ff45f27 100644 --- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvIdentity.java +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvIdentity.java @@ -1,5 +1,6 @@ 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 com.demcha.compose.document.templates.core.identity.PartyIdentity; @@ -7,6 +8,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; /** * Top-of-document identity block — name, optional professional title, @@ -24,21 +26,41 @@ * that have a subtitle line; blank when absent * @param contact phone / email / address — all three required * @param links ordered list of optional outbound links; never null + * @param portrait optional photograph of the candidate, drawn by the presets + * that have somewhere to put one; empty otherwise */ public record CvIdentity(CvName name, String jobTitle, - Contact contact, List links) + Contact contact, List links, + Optional portrait) implements PartyIdentity { /** * Validates that {@code name} and {@code contact} are non-null, - * trims a null {@code jobTitle} to empty, and defensively copies - * {@code links} (null becomes an empty list). + * trims a null {@code jobTitle} to empty, defensively copies + * {@code links} (null becomes an empty list), and treats a null + * {@code portrait} as absent. */ public CvIdentity { Objects.requireNonNull(name, "name"); jobTitle = jobTitle == null ? "" : jobTitle.trim(); Objects.requireNonNull(contact, "contact"); links = links == null ? List.of() : List.copyOf(links); + portrait = portrait == null ? Optional.empty() : portrait; + } + + /** + * Backward-compatible constructor for callers that predate the + * optional portrait. The identity simply carries no photograph. + * + * @param name structured name with first / last required and an + * optional middle component + * @param jobTitle optional professional title; blank when absent + * @param contact phone / email / address — all three required + * @param links ordered list of optional outbound links; never null + */ + public CvIdentity(CvName name, String jobTitle, + Contact contact, List links) { + this(name, jobTitle, contact, links, Optional.empty()); } /** @@ -51,7 +73,7 @@ public record CvIdentity(CvName name, String jobTitle, * @param links ordered list of optional outbound links; never null */ public CvIdentity(CvName name, Contact contact, List links) { - this(name, "", contact, links); + this(name, "", contact, links, Optional.empty()); } /** @@ -91,6 +113,7 @@ public static final class Builder { private String jobTitle = ""; private Contact contact; private final List links = new ArrayList<>(); + private DocumentImageData portrait; private Builder() { } @@ -190,13 +213,29 @@ public Builder link(String label, String url) { return this; } + /** + * Sets the candidate's photograph. Only the presets with a place + * for one draw it; the rest ignore it, so a document carrying a + * portrait still renders through every preset in the family. + * + * @param value the image data; null leaves the identity without a + * photograph + * @return this builder for chaining + * @since 2.2.3 + */ + public Builder portrait(DocumentImageData value) { + this.portrait = value; + return this; + } + /** * Builds the immutable {@link CvIdentity}. * * @return the assembled identity block */ public CvIdentity build() { - return new CvIdentity(name, jobTitle, contact, links); + return new CvIdentity(name, jobTitle, contact, links, + Optional.ofNullable(portrait)); } } } diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java new file mode 100644 index 000000000..de980283a --- /dev/null +++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java @@ -0,0 +1,219 @@ +package com.demcha.compose.document.templates.cv.presets; + +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.api.PageBackgroundFill; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.components.SectionLookup; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvSection; +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.RowsSection; +import com.demcha.compose.document.templates.cv.data.SkillsSection; + +import java.util.List; +import java.util.Objects; + +import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.MAIN_WEIGHT; +import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.NAVY; +import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.PAGE; +import static com.demcha.compose.document.templates.cv.presets.NavySidebarStyles.SIDEBAR_WEIGHT; + +/** + * Navy Sidebar — a one-page A4 CV in two columns: a navy plate carrying a + * ringed portrait, the contact channels, the degrees, the skills and the + * languages, beside a white column carrying the name, the summary, the roles + * held on a timeline rail, the achievements and the certifications. + * + *

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.

+ * + *

Usage:

+ *
{@code
+ * DocumentTemplate template = NavySidebar.create();
+ * template.compose(session, cv);
+ * }
+ */ +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