From fb7fc0edf16bd66670b034c32d67f6a9b61cc223 Mon Sep 17 00:00:00 2001
From: DemchaAV All sections live in {@code Slot.MAIN}; the Mint preset routes each
+ * to its sidebar/main region by title keyword. Section titles are chosen
+ * to match the preset's lookup constants: "Interests" → Interests,
+ * "Education" → Education, "Technical Skills" → both Expertise (group
+ * categories) and Skills (leveled entries), "Profile" → Profile,
+ * "Experience" → Experience, "Awards" → Awards, "References" →
+ * References. Expertise and Skills are both derived from the single
+ * {@code Technical Skills} section: the preset reads the group
+ * categories as the Expertise label list and the flattened
+ * leveled entries as the proficiency bars. Each Expertise
+ * category therefore carries one leveled skill so both lists render as in
+ * the reference. The 14 letter renders match the 14 CV renders in
+ * The 15 letter renders match the 15 CV renders in
* {@link com.demcha.examples.templates.cv.CvTemplateGalleryFileExample}
* — a writer can render both galleries and pick a CV / cover-letter
* pair sharing the same visual signature. Output:
+ * {@code examples/target/generated-pdfs/templates/coverletter/cover-letter-mint-editorial-v2.pdf}. The showcase dataset is example-local on purpose: the shared
+ * {@link ExampleDataFactory#sampleCvDocumentV2()} stays untouched so no
+ * other preset's visual baseline moves. Output:
+ * {@code examples/target/generated-pdfs/templates/cv/cv-mint-editorial-v2.pdf}
+ * (falls back to {@code …-rev2.pdf} when the primary file is locked by an
+ * open viewer). Reproduces the CV's signature masthead 1:1 — a centred spaced-caps
+ * Poppins name, a centred soft-mint accent tagline, and a full-width 6pt
+ * mint accent rule — then a single-column letter body via the shared
+ * {@link LetterBody}. Palette / typography / spacing come from the
+ * same {@link CvTheme#mintEditorial()} the CV uses, and the
+ * mint accent is read from {@code theme.palette().banner()} exactly as in the
+ * CV, so the CV and the letter read as one matched set. The CV's two-column
+ * sidebar grids are a CV-body concern and are intentionally not part of the
+ * letter. The level is what data-driven skill visuals — proficiency bars,
+ * dots, meters — read. Skills created without a level (the common case,
+ * and every skill built through the string-based
+ * {@link SkillGroup#of(String, String...)} /
+ * {@link SkillGroup#ofNames(String, java.util.List)} APIs) carry an
+ * empty level, so name-only renderers are completely unaffected by the
+ * level channel. Examples: {@code Languages -> [Java 21, Kotlin, SQL]} or
* {@code Testing -> [JUnit 5, AssertJ, visual regression]}. Presets
* can render the same group as a table cell, a sidebar list, chips,
- * or a compact inline row without reparsing free text.
Each entry is a {@link CvSkill} that optionally carries a + * proficiency level in {@code [0, 1]}. The level is read only by + * data-driven visuals (skill bars/meters); name-only renderers use + * {@link #skills()} and are unaffected by whether levels are present. + * Groups built through the string APIs ({@link #of(String, String...)}, + * {@link #ofNames(String, List)}) carry no levels.
* * @param category category label, non-blank - * @param skills ordered skill labels; blank values are ignored + * @param entries ordered skill entries; blank-named entries are ignored */ -public record SkillGroup(String category, ListThis is the shape every name-only renderer consumes; it is kept + * stable so adding levels never changes name-based output.
+ * + * @return ordered skill labels + */ + public ListVisual signature: a centred spaced-caps name with a soft mint accent + * tagline, a full-width 6pt mint accent rule, then two pages that each lay + * out a narrow left sidebar (weight {@value #SIDEBAR_WEIGHT}) beside a wide + * main column. Page 1 sidebar carries Contact (icon rows), Interests and + * Education; page 1 main carries Profile and the first slice of Experience. + * Page 2 sidebar carries Expertise (badge + label list), level-driven Skill + * bars and Social rows; page 2 main carries the rest of Experience plus + * Awards and References two-column grids. Poppins throughout; the mint + * accent lives in {@code theme.palette().banner()}.
+ * + *Each page's sidebar+main grid is a single {@code addRow}, which the + * canonical paginator treats as atomic — the whole row must + * fit on one page or it raises {@code AtomicNodeTooLargeException} (rows do + * not page-break, and a row cannot host a table directly, so the Awards / + * References tables live inside the main section column, not the row). + * To keep each page's row inside the page bound the preset slices Experience + * across the two pages ({@value #EXPERIENCE_PAGE_ONE} entries on page 1, the + * remainder on page 2), mirroring the blueprint's + * {@code experiencePage1 / experiencePage2} split.
+ * + *The two rows reach two pages by natural atomic overflow, + * not an explicit page break: the page-1 row fills page 1, so the page-2 row — + * being atomic — moves whole onto page 2. An explicit {@code addPageBreak} is + * deliberately avoided because a break landing on an already-full page-1 + * emits a blank intermediate page.
+ * + *Sections are resolved from {@link Slot#MAIN} by title keyword via + * {@link SectionLookup}; the canonical sample places everything in MAIN. + * Mappings:
+ * + *Absent sidebar/main sections are skipped rather than crashing, so the + * preset renders cleanly against documents that omit Interests, Awards, + * References, or Social.
+ * + *Unlike the sidebar presets, Mint draws no {@code pageBackgrounds}: the + * page is white and the two-column structure comes purely from the per-page + * weighted {@code addRow}. The reusable drawing — icon rows, skill bars — is + * delegated to {@link IconTextRow} and {@link SkillBar}; the preset only + * orchestrates page composition, section mapping, and the Awards / References + * grids (which are page-composition concerns local to this layout).
+ */ +public final class MintEditorial { + + /** Stable template identifier. */ + public static final String ID = "mint-editorial"; + + /** Human-readable display name. */ + public static final String DISPLAY_NAME = "Mint Editorial"; + + /** Recommended symmetric page margin (in points). */ + public static final double RECOMMENDED_MARGIN = 48.0; + + /** Sidebar column weight; main column gets {@code 1 - SIDEBAR_WEIGHT}. */ + private static final double SIDEBAR_WEIGHT = 0.31; + + /** Main column weight. */ + private static final double MAIN_WEIGHT = 1.0 - SIDEBAR_WEIGHT; + + /** Horizontal gap (points) between the sidebar and main columns. */ + private static final double COLUMN_GAP = 40.0; + + /** + * Vertical gap (points) between consecutive blocks within a column. + * Each block is wrapped in its own sub-section so this gap applies + * between blocks only — never between the leaf paragraphs / + * lines inside a block (whose rhythm is margin-driven). Flattening all + * leaves into one section would multiply this gap across every child + * and overflow the atomic page row. + */ + private static final double BLOCK_GAP = 22.0; + + /** Visual gap (points) between the two grid columns in Awards / References. */ + private static final double GRID_COLUMN_GAP = 24.0; + + /** Experience entries rendered on page 1; the rest go to page 2. */ + private static final int EXPERIENCE_PAGE_ONE = 2; + + /** Expertise category labels shown beneath the badge. */ + private static final int EXPERTISE_LIMIT = 6; + + /** Skill bars rendered in the page-2 sidebar. */ + private static final int SKILL_LIMIT = 6; + + /** Inline contact / social icon edge length (points). */ + private static final double CONTACT_ICON_SIZE = 9.0; + + /** Social icon edge length (points) — the filled badges read larger. */ + private static final double SOCIAL_ICON_SIZE = 12.0; + + /** Expertise badge edge length (points). */ + private static final double BADGE_SIZE = 36.0; + + private static final String ICON_ROOT = "/templates/cv/mint-editorial/icons/"; + private static final MapOne left-aligned paragraph holding two inline runs: a centred inline + * image of {@code iconSize}×{@code iconSize}, then three spaces and the + * label text. When a {@link DocumentLinkOptions} is supplied it is applied + * to both the image run and the text run, so the whole row + * (icon + label) is one clickable rectangle in the PDF — mirroring the flat + * Mint Editorial blueprint's {@code iconLine()} contact / social rows.
+ * + *Reach for {@code IconTextRow} when a sidebar stacks contact details or + * social links as PNG-glyph rows (phone / email / location / website / + * LinkedIn …) where each entire row should be clickable. It differs from the + * shared {@code ContactLine} variants — those assume pipe-separated text or a + * stacked link list with no per-row glyph — so this is the icon-driven row + * primitive those widgets do not cover.
+ * + *Lives in {@code cv/v2/widgets} as a CV-sidebar primitive. Mint Editorial + * is the first consumer; any future CV preset that wants icon-led contact or + * social rows with a full-row click target can reuse it instead of inlining + * the inline-image + link paragraph again.
+ */ +public final class IconTextRow { + + /** Spacer between the icon glyph and the label text. */ + private static final String GAP = " "; + + private IconTextRow() { + } + + /** + * Renders an icon + text row. + * + * @param host host section the row paragraph is appended to + * @param icon glyph image payload (already decoded / cached by the + * caller); when {@code null} the row renders text only + * @param iconSize icon edge length in points (width == height) + * @param text label text rendered after the icon; a blank label + * still renders the icon, so callers should skip empty + * rows upstream if that is unwanted + * @param style text style for the label + * @param link optional link wrapping the whole row (icon + label); + * {@code null} renders a non-clickable row + * @param margin paragraph margin (vertical rhythm between rows) + */ + public static void render(SectionBuilder host, DocumentImageData icon, + double iconSize, String text, + DocumentTextStyle style, DocumentLinkOptions link, + DocumentInsets margin) { + Objects.requireNonNull(host, "host"); + Objects.requireNonNull(style, "style"); + String label = text == null ? "" : text; + DocumentInsets rowMargin = margin == null ? DocumentInsets.zero() : margin; + + host.addParagraph(paragraph -> { + paragraph.textStyle(style) + .align(TextAlign.LEFT) + .link(link) + .margin(rowMargin) + .rich(rich -> { + if (icon != null) { + rich.image(icon, iconSize, iconSize, + InlineImageAlignment.CENTER, 0.0, link); + } + if (link != null) { + rich.link(GAP + label, link); + } else { + rich.style(GAP + label, style); + } + }); + }); + } +} diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SkillBar.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SkillBar.java new file mode 100644 index 00000000..e60d7629 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SkillBar.java @@ -0,0 +1,134 @@ +package com.demcha.compose.document.templates.cv.v2.widgets; + +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.node.TextAlign; +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.cv.v2.components.CvTextStyles; +import com.demcha.compose.document.templates.cv.v2.components.TextOrnaments; +import com.demcha.compose.document.templates.cv.v2.data.CvSkill; +import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; + +import java.util.Objects; + +/** + * Data-driven proficiency-bar widget — a spaced-caps skill label above a + * thin full-width track with a short vertical marker positioned by the + * skill's proficiency level. + * + *Two stacked elements:
+ * + *The marker is drawn as a second {@code addLine} immediately after the + * track, pulled back up over the track with a negative top margin and + * shifted right with a left margin of {@code level * trackWidth}. This is + * the same overlay trick the flat Mint Editorial blueprint used for its + * {@code skillBar()} (track 0.65pt rule, marker 1.2pt ink, ~8pt tall).
+ * + *Reach for {@code SkillBar} when a preset renders skills as visual + * meters rather than plain labels or comma-separated chips — the editorial + * sidebar look. It reads the proficiency from {@link CvSkill#level()}: when + * the level is empty the label renders with no bar (so a + * mixed list of levelled and name-only skills degrades gracefully instead of + * drawing a meaningless empty track).
+ * + *Lives in {@code cv/v2/widgets} because a proficiency meter keyed off + * {@link CvSkill} is CV-specific (no other document family models skill + * levels). Mint Editorial is the first consumer; any future CV preset that + * wants level-driven skill bars can reuse it without re-deriving the marker + * geometry.
+ */ +public final class SkillBar { + + /** Track stroke width in points — a hairline rule. */ + private static final double TRACK_THICKNESS = 0.65; + + /** Proficiency marker stroke width in points — heavier than the track. */ + private static final double MARKER_THICKNESS = 1.2; + + /** Proficiency marker height in points. */ + private static final double MARKER_HEIGHT = 8.0; + + /** Gap (points) between the skill label and the track below it. */ + private static final double LABEL_TO_TRACK_GAP = 8.0; + + /** + * Vertical pull-up (points) applied to the marker so it overlays the + * track instead of stacking below it. Half the marker height plus a + * touch so the tick straddles the track line. + */ + private static final double MARKER_OVERLAP = MARKER_HEIGHT / 2.0 + 0.35; + + /** Gap (points) below the marker before the next skill bar. */ + private static final double BAR_BOTTOM_GAP = 12.0; + + private SkillBar() { + } + + /** + * Renders a skill label and, when the skill carries a proficiency + * level, its proficiency bar. + * + * @param host host section the label + bar are appended to + * @param skill skill to render; its {@link CvSkill#level()} drives + * the marker position (empty level → label only) + * @param trackWidth full track width in points (typically the sidebar + * inner width) + * @param theme active theme — supplies the body font, label/ink + * colour, and the rule colour for the track + */ + public static void render(SectionBuilder host, CvSkill skill, + double trackWidth, CvTheme theme) { + Objects.requireNonNull(host, "host"); + Objects.requireNonNull(skill, "skill"); + Objects.requireNonNull(theme, "theme"); + + boolean levelled = skill.level().isPresent(); + DocumentTextStyle labelStyle = CvTextStyles.of( + theme.typography().bodyFont(), + theme.typography().sizeEntryTitle(), + DocumentTextDecoration.BOLD, + theme.palette().ink()); + // When there is no bar, the label is the whole entry, so give it a + // little breathing room below to match the rhythm of a barred entry. + double labelBottom = levelled ? LABEL_TO_TRACK_GAP : BAR_BOTTOM_GAP; + host.addParagraph(paragraph -> paragraph + .text(TextOrnaments.spacedUpper(skill.name())) + .textStyle(labelStyle) + .align(TextAlign.LEFT) + .margin(DocumentInsets.bottom(labelBottom))); + + if (!levelled) { + return; + } + + double level = skill.level().getAsDouble(); + double markerLeft = Math.max(0.0, Math.min(1.0, level)) * trackWidth; + host.addLine(line -> line + .horizontal(trackWidth) + .color(theme.palette().rule()) + .thickness(TRACK_THICKNESS) + .margin(DocumentInsets.zero())); + host.addLine(line -> line + .vertical(MARKER_HEIGHT) + .color(theme.palette().ink()) + .thickness(MARKER_THICKNESS) + .margin(new DocumentInsets(-MARKER_OVERLAP, 0, + BAR_BOTTOM_GAP, markerLeft))); + } +} diff --git a/src/main/resources/templates/cv/mint-editorial/icons/email.png b/src/main/resources/templates/cv/mint-editorial/icons/email.png new file mode 100644 index 0000000000000000000000000000000000000000..c3369c191a0a4bd1ff12cdfbf8e60184b60fd93d GIT binary patch literal 941 zcmeAS@N?(olHy`uVBq!ia0vp^4j{|{BpCXc^q7E@Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZK3>dAqwXFs$lTHUjcE3p^r=fizGR5P!&aIuB&9 zmw5WRvcF;#5@*s0sIlAv6jCj5jVMV;EJ?LWE=mPb3`Pb~W-}1Om%K@;n<4vPr}v3W5`^KYHl+n9C{0u7H%+Fq^RpZ?1xk8|SQ4HOjRf`yyE`6U=ku z=l#*Ga5WJaz(RScwwN o)1EkdZkG@MmhS;ruKZ^TnCcGhHgPlyKq_^44;jh406?fs_%F@O!oWQ2yCo9Gr zf)csvw%R&_7uwzlti8bHiAX|lR949UL&1_MlSbg1>FVE%Xf5QW+If$Hz7Z}mu71WU z9>w+*k5Xado?gERvTKmopxCo6PxJK}M;{4Y7q0}h-C;DBl3>SQ3A~-}k|xhi9uYN{ ziYtm}5U!u9JHn(DLw3f^kTm-^p>>Ozg<@h>r7{6Zyqiu^$<#aVklk niBOX~ zIQlkdnpjlV9kRqzxV<7Ts#1IlLaORO-_rRYw #QbiI(~IEDi_g$wcZ=i( ze9)a)*kpDVKa2g-z*Gj=%?kb$CiWaH#=B#GJ 1x#Wu&&8i}?^uWzH zZjO2D4Mw4TdH#=eE{>IMj^&JK;%v0zzJ1zvc*UE3m0*9=HJH!qiS+Fu@<_8`xEHYI z%JJFpx%}!^2mo0(V@|#o<}W;`g wWpzou njtlK3rFYI{u7QOAJco>bb^A6KpRh=Oh5%!IbG>q1mze(mS?`@n literal 0 HcmV?d00001 diff --git a/src/main/resources/templates/cv/mint-editorial/icons/twitter.png b/src/main/resources/templates/cv/mint-editorial/icons/twitter.png new file mode 100644 index 0000000000000000000000000000000000000000..c43d9de37d99457a0fc3804491d0c262ec054149 GIT binary patch literal 2263 zcmZ`)dpOe#8~)8^lM*?HG(sb6A+*e4Ejf-@Bj>}M$uOijOrmmnlT$gaNGL@rUa4;q zBBMmTPMbm)uT$2Xz5D+8uIu~by{_ke?&ox0&-34N%N~DTL|9H3000q73!DR=z5WX! zz5}{`A*T2Q_qA}a1%Rt+06@DA0Gs?0Z59BcQ2@a40s!<~0FWV @_{uCd}A=rL?0Havio`Z1bpGvj#Kw3^Gm9fbIuf!H^mR_8|lX< zKnJCxuZ23Lb<&Waql-rAp!H6jK%vlli)iioKS5YHDab$W-v!p15Gr4Q_}>OfP?%p7 z#XF4r?;3^Es+IgZt1NM5gg{nDQAjAEOR8&6m-_AX?-7s86jk|cMwOxR{ZG(9iOdvN zbq6`JqglRe V!}K~scyPhO zwX8Pj&i(UT !sr|&&J3wh%9zTP0YFA4TW`QDbF?c zOQ&Z0$fGX?sum2Ve{+P8DD72gyu^L+5bFX&-hrM@cYx_Y-H^_W6 zdp7{5jd@pZGHhR5Yz!yw+TZBP>+w=z5*xjPh*tp)=2Ik6bw%m@i~Ou8uIoJ5++-vv z>!FTN`#Jj|ByrdQYFJdXV>DeO>40_Sc-B~n!21?BmX7g4E9-qNxH<_?GLLrvk2N;+ z>3sFFpc3eR3uaNC9Ic?nOctzz IVp)?(`j@_cjeTx4sjpq z{4nO!hS!hTV#U+&dftb@DX>lJ>u*T#P2Rz3$2?IKm$8Kv2predkK4mrD1@LZM9Hnj zd7us8=-BipaHc+&gfnJPi_D{eqxyEiyBx{RViOyP+xSxd?ReS-&oc`KIf&LF!GVeF zlw?EG^ZP&E6##s-W_& Fb=mkP32~5uT>J) zLfsmZP1fGFngx_I(ps>
Z2jOVP{-~I4;wB^76f_RM2VM9gBb~LUf06jsaGCwMZ} !*)JDSdH!FA0Ic zPdrKLFZailX-Y};+uK{Ca-1v##}4{Otf%>5#Qgd_<96_xIw#KD=hjOy7B}gPnVccV zHu+ QKU*%hJm9iDl|T{np8{GX`_sw|-}h?ej1(yjDwSzmH6=Y@lrwwWi8J zZfF>IPgD-ZOH3CRuaeEGQJJbXN3^;@g=1+$3h5 cJ9P#O*+fbyj}B~ zu;AA@^Rt-d&{C9`QuessZM#$=`EG{DRO19S!F)8$`Y0o?OtQGGt*1Fzc>A%5TyJX5 zn-r+Fnnx!ixmGhy9;Lv>Xg(f1*d*6i@x+P-mWnxc9L4?gGp0OoJZLfuIeY>TM5rIp z<3J_$7PeWuqX|APSmp*>M830^fR>n`C6ukCciSx5{f<7K-qPG2q>%f?cLRX^CEi$K ziYp5COjs|Bi;()^>H179C*0#5wqfqw;ihO?bqJ7Kw@@?lwpgq=>qvM@F3Ha9-2OuX zdC2C6y0SMhE=;Y|>8Ke=wLyoax4CagROtqKyn qm+Stw~I9uK`J7NuV_2~@L4 z%)7#*al(;Z%*hm|p^O8EEC!#Y8`%J%t}_82EPQsq-Xd8H$`M{RB91{WywKH<5`XhS zi(QZly7qxZl;O&XOoYhitG`apvkj>TAoc2$zKlCkuAIEy!aV71zYq(L?HZ{$bo1Hp zA^CZKbjS0{HzLvnm$x@cZxpE%ITSq(94(p((l#v_=MGhhNB@e|wo| r0(Y!ROK%UiZzp~F+ Kp~ywO`;`{Ot@2oQ3o&MFEUJqZy7fW54z8HEza%gzgpimpc74Eg)Yife3g zGj!4XX_=&SA@O2m3|B?fw3?msQI}{fR~X;`vw6NPdX=)V(W>~di2vmQ%X4_#Q>@pu Fe*p3N3p4-# literal 0 HcmV?d00001 diff --git a/src/main/resources/templates/cv/mint-editorial/icons/website.png b/src/main/resources/templates/cv/mint-editorial/icons/website.png new file mode 100644 index 0000000000000000000000000000000000000000..3e29e34ab20cef6ca363eacf00d8a0b89288e5cb GIT binary patch literal 1906 zcmZ`)Sv=c`7XI7!OVk#$Ays=!L{J7z6h%o@sl5|TXo!#sT8c!frFvDVR%Q_S(NdHc zI~laqqP2Ev#4?r{t)19vUv7FI?tPkvbH1~D-#KsRJaEHYkdaiC1OPzB$ |X*L>NE-w?ch@q!%y zl%=0W@G}XrqYs6Td=}qUiMIXxKrG71)n05tL|$3Y&@JAgmR~v(W$zt@4~z=3#6<@2 z0YJbJW-z!Z%=oMa!o(7eutXva;BZSgJWsUY`@sJTND=su;Mji_Ec?lRU#k1R4N)Pa zAWBpqiTux+W{i;>0EnWU?9d+G?aMhNvd34&PU>va2>!|AS4sHoe;G$)5z?bVZ$EGm zo35=tf2iTMdfxAO#Yy6va=cz&ZC%le&oOf@PGHL-x=Fn+I9gQg-A#w!$E6G xnoj*PYbmfk` zO6#JERh=%`7webFxGX2>#r1f`APUayR<@lym5LeuLYzq{3y<)TW5(zqdd=djIg<;g z6hT==X=O@7KlmlqP)vd4u}_K4t}D}n&R^5&q9g55uM);23dqhgZ1>6L<{e2|YcD$k zEZ_-3J$xWBUph*^17UZ^bb+=9GOp#5g}*(wVzGW7)t6Ld+$~9cGTgK>C2cU!P%vjo zFQbel;7HT-hA**+ _`{LtT{%4)vty7Vr@D_qUwQz 43nFRMYOUSW z579a@TYYAbsZU 9w zIXh3uOjQdx%VN&PGE2l%q2c%y1E;Y&Qtg#$;d}7e>9}rZh51Q&(+-tiW_9g;gm a3V^tkr z0LHdk67 zDT*5J@RfB5TP%VQCeq~X?;okB>^wYAzkhXRglc_|(kQ@=DPj*&5;J$HIMR(ljjf_K zjK}0=Vgp*VjVu*my(}@og;*Y!qz9<1>YLZ?*djPRWBYamAP?8+gcJPJ!#%K)J!nVY zRm0D=RRo6qoO7i##wPZi_{->ESsmf0-@-i6LT8jozYlD7|5$Amd2xKAms8_BZtHOP zB7Aj1#^S{a^8HrLc!6J-d2MC0`>!&i92|Exd*6W9mKF(FZN9~GGE*04vC8Z>o>dSU z%kB#mhs2h(4OUnEUFLV{H8)D+;%QG4WB`Jq&xst%DVc_7$^Vgy3Kz;fRjDO JuF@7u{||dJVB-J) literal 0 HcmV?d00001 diff --git a/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2SmokeTest.java b/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2SmokeTest.java index 85452b24..5cbf664f 100644 --- a/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2SmokeTest.java +++ b/src/test/java/com/demcha/compose/document/templates/coverletter/v2/presets/CoverLetterV2SmokeTest.java @@ -75,7 +75,9 @@ private static Stream presets() { Arguments.of((Supplier >) MonogramSidebarLetter::create, "monogram-sidebar-letter", "Monogram Sidebar Letter"), Arguments.of((Supplier >) TimelineMinimalLetter::create, - "timeline-minimal-letter", "Timeline Minimal Letter")); + "timeline-minimal-letter", "Timeline Minimal Letter"), + Arguments.of((Supplier >) MintEditorialLetter::create, + "mint-editorial-letter", "Mint Editorial Letter")); } private static CoverLetterDocument sampleDocument() { diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSectionTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSectionTest.java index f01ddab1..5cd01e92 100644 --- a/src/test/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSectionTest.java +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/data/SkillsSectionTest.java @@ -11,7 +11,7 @@ class SkillsSectionTest { @Test void skillGroup_trims_category_and_skips_blank_skills() { - SkillGroup group = new SkillGroup(" Languages ", + SkillGroup group = SkillGroup.ofNames(" Languages ", List.of(" Java 21 ", "", "Kotlin")); assertThat(group.category()).isEqualTo("Languages"); @@ -19,11 +19,34 @@ void skillGroup_trims_category_and_skips_blank_skills() { assertThat(group.skillsInline()).isEqualTo("Java 21, Kotlin"); } + @Test + void skillGroup_carries_optional_levels_without_affecting_names() { + SkillGroup group = new SkillGroup("Design", List.of( + CvSkill.of("Illustration", 0.9), + CvSkill.of("Typography"))); + + // Name-only view is unchanged whether or not levels are present. + assertThat(group.skills()).containsExactly("Illustration", "Typography"); + // Levels are readable by data-driven renderers. + assertThat(group.entries()).hasSize(2); + assertThat(group.entries().get(0).level().getAsDouble()).isEqualTo(0.9); + assertThat(group.entries().get(1).level()).isEmpty(); + } + + @Test + void cvSkill_clamps_level_via_factory_and_rejects_blank_name() { + assertThat(CvSkill.of("X", 1.5).level().getAsDouble()).isEqualTo(1.0); + assertThat(CvSkill.of("X", -0.2).level().getAsDouble()).isEqualTo(0.0); + assertThatThrownBy(() -> CvSkill.of(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("name"); + } + @Test void skillsSection_keeps_non_empty_groups_only() { SkillsSection section = SkillsSection.builder("Technical Skills") .group("Languages", "Java 21", "Kotlin") - .group(new SkillGroup("Empty", List.of())) + .group(SkillGroup.ofNames("Empty", List.of())) .group("Testing", "JUnit 5") .build(); diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java index a36090a1..addd9e78 100644 --- a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/CvV2VisualParityTest.java @@ -6,6 +6,7 @@ import com.demcha.compose.document.templates.api.DocumentTemplate; import com.demcha.compose.document.templates.cv.v2.data.CvDocument; import com.demcha.compose.document.templates.cv.v2.data.CvIdentity; +import com.demcha.compose.document.templates.cv.v2.data.CvSkill; import com.demcha.compose.document.templates.cv.v2.data.EntriesSection; import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection; import com.demcha.compose.document.templates.cv.v2.data.RowStyle; @@ -17,6 +18,7 @@ import org.junit.jupiter.params.provider.MethodSource; import java.nio.file.Path; +import java.util.List; import java.util.function.Supplier; import java.util.stream.Stream; @@ -158,26 +160,50 @@ private static CvDocument canonicalDocument() { + "high-throughput PDF rendering, semantic authoring " + "DSLs, and turning brittle production-ops scripts " + "into typed, snapshot-tested libraries that scale.")) + // Skill names match the name-only form exactly; only + // optional proficiency levels are added so data-driven + // presets (Mint Editorial) can render meters. Name-only + // presets read SkillGroup.skills() and render identically, + // so the existing baselines are unaffected. .section(SkillsSection.builder("Technical Skills") - .group("Languages", "Java 21", "Kotlin", "Groovy", - "Python", "SQL") - .group("Document & Print", "PDFBox", - "Apache POI (DOCX/XLSX)", "iText", - "PostScript", "ICC colour profiles", - "font metrics") - .group("Layout engines", "Custom DSL design", - "semantic layout trees", "pagination", - "snapshot testing", "visual regression") - .group("Build & infrastructure", "Maven", "Gradle", - "GitHub Actions", "JitPack", "Docker", - "JMH benchmarking") - .group("Testing", "JUnit 5", "AssertJ", - "PDFBox-based PNG diff", - "layout-graph snapshots", - "mutation testing (Pitest)") - .group("Distribution", "Maven Central", - "Sonatype OSSRH", "GPG signing", "JitPack", - "semantic versioning discipline") + .leveledGroup("Languages", List.of( + CvSkill.of("Java 21", 0.95), + CvSkill.of("Kotlin", 0.85), + CvSkill.of("Groovy", 0.7), + CvSkill.of("Python", 0.75), + CvSkill.of("SQL", 0.8))) + .leveledGroup("Document & Print", List.of( + CvSkill.of("PDFBox", 0.9), + CvSkill.of("Apache POI (DOCX/XLSX)", 0.7), + CvSkill.of("iText", 0.65), + CvSkill.of("PostScript", 0.6), + CvSkill.of("ICC colour profiles", 0.55), + CvSkill.of("font metrics", 0.7))) + .leveledGroup("Layout engines", List.of( + CvSkill.of("Custom DSL design", 0.9), + CvSkill.of("semantic layout trees", 0.85), + CvSkill.of("pagination", 0.85), + CvSkill.of("snapshot testing", 0.8), + CvSkill.of("visual regression", 0.8))) + .leveledGroup("Build & infrastructure", List.of( + CvSkill.of("Maven", 0.9), + CvSkill.of("Gradle", 0.75), + CvSkill.of("GitHub Actions", 0.85), + CvSkill.of("JitPack", 0.8), + CvSkill.of("Docker", 0.7), + CvSkill.of("JMH benchmarking", 0.65))) + .leveledGroup("Testing", List.of( + CvSkill.of("JUnit 5", 0.9), + CvSkill.of("AssertJ", 0.85), + CvSkill.of("PDFBox-based PNG diff", 0.8), + CvSkill.of("layout-graph snapshots", 0.8), + CvSkill.of("mutation testing (Pitest)", 0.6))) + .leveledGroup("Distribution", List.of( + CvSkill.of("Maven Central", 0.8), + CvSkill.of("Sonatype OSSRH", 0.75), + CvSkill.of("GPG signing", 0.7), + CvSkill.of("JitPack", 0.8), + CvSkill.of("semantic versioning discipline", 0.85))) .build()) .section(EntriesSection.builder("Education & Certifications") .entry("MSc Computer Science", diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java new file mode 100644 index 00000000..ffa43d51 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorialSmokeTest.java @@ -0,0 +1,168 @@ +package com.demcha.compose.document.templates.cv.v2.presets; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.v2.data.CvDocument; +import com.demcha.compose.document.templates.cv.v2.data.CvIdentity; +import com.demcha.compose.document.templates.cv.v2.data.CvSkill; +import com.demcha.compose.document.templates.cv.v2.data.EntriesSection; +import com.demcha.compose.document.templates.cv.v2.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.v2.data.RowStyle; +import com.demcha.compose.document.templates.cv.v2.data.RowsSection; +import com.demcha.compose.document.templates.cv.v2.data.SkillsSection; +import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Smoke test for the v2 Mint Editorial preset. Covers stable identity, + * the two-page atomic-row composition against the full canonical sample + * (including level-driven skill bars and the Awards / References grids), + * and graceful degradation when the optional sidebar/main sections are + * absent. + */ +class MintEditorialSmokeTest { + + @Test + void exposes_stable_identity() { + DocumentTemplate template = MintEditorial.create(); + assertThat(template.id()).isEqualTo("mint-editorial"); + assertThat(template.displayName()).isEqualTo("Mint Editorial"); + } + + @Test + void default_factory_renders_two_pages() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(48, 48, 48, 48) + .create()) { + MintEditorial.create().compose(session, fullDocument()); + assertThat(session.roots()).isNotEmpty(); + LayoutGraph layout = session.layoutGraph(); + // The preset emits two atomic page rows. The dense fullDocument + // fills page 1, so the second (atomic) row flows whole onto + // page 2 — a clean two-page document with neither row overflowing. + assertThat(layout.totalPages()).isEqualTo(2); + } + } + + @Test + void custom_theme_factory_renders() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(48, 48, 48, 48) + .create()) { + MintEditorial.create(CvTheme.mintEditorial()) + .compose(session, fullDocument()); + assertThat(session.roots()).isNotEmpty(); + } + } + + @Test + void renders_with_awards_and_references_grids() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(48, 48, 48, 48) + .create()) { + MintEditorial.create().compose(session, documentWithAwardsAndReferences()); + assertThat(session.roots()).isNotEmpty(); + } + } + + @Test + void degrades_when_optional_sections_absent() throws Exception { + // Only identity + profile + a single experience entry — no skills, + // education, interests, awards, references or social. + CvDocument minimal = CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jane", "Doe") + .jobTitle("Designer") + .contact("+44 0", "j@d.com", "London") + .build()) + .sections( + new ParagraphSection("Profile", "Builds **clean** layouts."), + EntriesSection.builder("Professional Experience") + .entry("Designer", "Acme", "2021-2024", "Did design.") + .build()) + .build(); + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(48, 48, 48, 48) + .create()) { + MintEditorial.create().compose(session, minimal); + assertThat(session.roots()).isNotEmpty(); + } + } + + private static CvDocument fullDocument() { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jordan", "Rivera") + .jobTitle("Platform Engineer") + .contact("+44 20 5555 1000", "jordan.rivera@example.com", + "London, UK") + .link("LinkedIn", "https://linkedin.com/in/jordan-rivera-demo") + .link("GitHub", "https://github.com/jrivera-demo") + .build()) + .sections( + new ParagraphSection("Professional Summary", + "Platform engineer building **resilient** document " + + "pipelines and developer-facing template systems."), + SkillsSection.builder("Technical Skills") + .leveledGroup("Languages", List.of( + CvSkill.of("Java 21", 0.95), + CvSkill.of("Kotlin", 0.85), + CvSkill.of("SQL", 0.8))) + .leveledGroup("Testing", List.of( + CvSkill.of("JUnit 5", 0.9), + CvSkill.of("AssertJ", 0.85))) + .build(), + EntriesSection.builder("Education & Certifications") + .entry("MSc Computer Science", + "University of Manchester", "2019-2021", + "Distinction.") + .entry("BSc Software Engineering", + "Imperial College London", "2015-2019", + "First-class honours.") + .build(), + EntriesSection.builder("Professional Experience") + .entry("Senior Platform Engineer", "Northwind Systems", + "2024-Present", "Led the document platform.") + .entry("Software Engineer", "BrightLeaf Labs", + "2021-2024", "Built rendering pipelines.") + .entry("Backend Engineer", "Helix Print Co", + "2019-2021", "Maintained invoice printing.") + .build()) + .build(); + } + + private static CvDocument documentWithAwardsAndReferences() { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jordan", "Rivera") + .jobTitle("Designer") + .contact("+44 0", "j@d.com", "London") + .link("LinkedIn", "https://linkedin.com/in/jordan") + .build()) + .sections( + new ParagraphSection("Profile", "Designs editorial layouts."), + EntriesSection.builder("Professional Experience") + .entry("Designer", "Acme", "2021-2024", "Did design.") + .build(), + RowsSection.builder("Awards", RowStyle.PLAIN) + .row("Best Layout", "Design Guild | 2023") + .row("Editorial Prize", "Type Society | 2022") + .build(), + RowsSection.builder("References", RowStyle.PLAIN) + .row("Alex Stone", "Acme | alex@acme.com") + .row("Sam Reed", "BrightLeaf | sam@bl.com") + .build()) + .build(); + } +} diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java index 984ef065..d423d21e 100644 --- a/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/widgets/WidgetSmokeTest.java @@ -2,6 +2,7 @@ import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.image.DocumentImageData; import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; @@ -9,6 +10,7 @@ import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.templates.cv.v2.data.CvIdentity; import com.demcha.compose.document.templates.cv.v2.data.CvName; +import com.demcha.compose.document.templates.cv.v2.data.CvSkill; import com.demcha.compose.document.templates.cv.v2.theme.CvTheme; import com.demcha.compose.font.FontName; import org.junit.jupiter.api.Test; @@ -77,6 +79,31 @@ void subheadline_variants_render_without_throwing() throws Exception { theme.bodyStyle())); } + @Test + void skillBar_renders_with_and_without_level() throws Exception { + CvTheme theme = CvTheme.mintEditorial(); + // Levelled skill → label + proficiency bar. + renderWithSection(section -> + SkillBar.render(section, CvSkill.of("Java 21", 0.9), 120, theme)); + // Name-only skill → label, no bar (graceful degrade). + renderWithSection(section -> + SkillBar.render(section, CvSkill.of("Kotlin"), 120, theme)); + } + + @Test + void iconTextRow_renders_with_and_without_link() throws Exception { + CvTheme theme = CvTheme.mintEditorial(); + DocumentImageData icon = DocumentImageData.fromBytes(readMintIcon("phone.png")); + // Linked row (whole row clickable) and plain row. + renderWithSection(section -> IconTextRow.render(section, icon, 9.0, + "hello@example.com", theme.bodyStyle(), + new com.demcha.compose.document.node.DocumentLinkOptions( + "mailto:hello@example.com"), + DocumentInsets.bottom(12))); + renderWithSection(section -> IconTextRow.render(section, icon, 9.0, + "London, UK", theme.bodyStyle(), null, DocumentInsets.bottom(12))); + } + @Test void sectionHeader_variants_render_without_throwing() throws Exception { CvTheme theme = CvTheme.boxedClassic(); @@ -187,6 +214,14 @@ private static void renderWithFlow(FlowAction action) throws Exception { } } + private static byte[] readMintIcon(String fileName) throws Exception { + try (var input = WidgetSmokeTest.class.getResourceAsStream( + "/templates/cv/mint-editorial/icons/" + fileName)) { + assertThat(input).as("mint editorial icon %s", fileName).isNotNull(); + return input.readAllBytes(); + } + } + private static CvName name() { return CvName.of("Jane", "Doe"); } From 58778f320ce306a41da2ad4359e8bddf8f0a2228 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 30 May 2026 01:53:14 +0100 Subject: [PATCH 2/4] chore(showcase): guard + auto-bump docs/index.html version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/index.html hardcodes the version in five spots (JSON-LD softwareVersion, JitPack downloadUrl, hero badge, Maven + Gradle install snippets) that do not inherit from the pom — they had silently drifted to v1.6.1 while the library shipped v1.6.4 because nothing bumped or guarded them. - VersionConsistencyGuardTest.showcaseSiteVersionMatchesTheProjectVersion: fails the verify gate if any of the five spots lags behind the root pom, matching the existing guard on the README install snippets. - cut-release.ps1 Update-IndexHtmlVersion: bumps all five spots in lockstep with README + poms during the release commit, and stages docs/index.html alongside the rest of the release artefacts. - SHOWCASE.md: rewritten to match reality — previews and the gallery manifest are auto-generated by ShowcaseSync under docs/showcase/ (pdf + screenshots), not hand-edited under docs/assets/ as the old doc claimed. --- docs/SHOWCASE.md | 110 ++++++++++++------ scripts/cut-release.ps1 | 51 +++++++- .../VersionConsistencyGuardTest.java | 32 ++++- 3 files changed, 152 insertions(+), 41 deletions(-) diff --git a/docs/SHOWCASE.md b/docs/SHOWCASE.md index c0a12903..5e2a30ef 100644 --- a/docs/SHOWCASE.md +++ b/docs/SHOWCASE.md @@ -1,51 +1,85 @@ # GraphCompose GitHub Pages Showcase -This folder contains the lightweight static showcase site for GraphCompose. It is intentionally plain HTML, CSS, JavaScript, and JSON so GitHub Pages can publish it directly from the main repository without a build step. +This folder is the static showcase site for GraphCompose. It is plain HTML, +CSS, JavaScript, and a generated JSON manifest, so GitHub Pages publishes it +directly from the main repository with no build step. ## Files -- `index.html` is the single-page showcase. -- `styles.css` contains the visual system and responsive layout. -- `examples.json` is the gallery data source. -- `assets/logo/` contains the showcase logo asset. -- `assets/screenshots/` contains PNG previews for generated PDFs. -- `assets/pdf/` contains the generated PDF examples linked from the gallery. - -## Add A New Showcase Item - -1. Generate the PDF from the examples module. -2. Copy the PDF into `docs/assets/pdf/`. -3. Add a PNG screenshot into `docs/assets/screenshots/`. -4. Add a new object to `docs/examples.json`. -5. Commit and push. - -Each `examples.json` object uses this shape: - -```json -{ - "title": "Cinematic Invoice", - "description": "A short description of the generated document.", - "tags": ["Template", "Tables", "Theme"], - "image": "assets/screenshots/invoice-v2.png", - "pdf": "assets/pdf/invoice-v2.pdf", - "code": "https://github.com/DemchaAV/GraphCompose/blob/main/examples/src/main/java/com/demcha/examples/InvoiceCinematicFileExample.java" -} -``` +- `index.html` — the single-page showcase. Holds the hero, install snippets, + feature/architecture sections, and the searchable gallery shell. +- `styles.css` — the visual system and responsive layout. +- `examples.js` — client script that fetches the manifest and renders the + gallery (search, category filters, highlights strip, lightbox). +- `examples.json` — **generated** gallery manifest. Do **not** hand-edit it; + it is rewritten by `ShowcaseSync` (see below). +- `assets/logo/` — the showcase logo asset. +- `showcase/pdf/ / /…` — generated PDF examples linked from the + gallery. +- `showcase/screenshots/ / /…` — PNG previews auto-rendered from + those PDFs. -## Local Preview +> Older revisions of this doc referenced `docs/assets/screenshots` and +> `docs/assets/pdf` plus a manual "copy the PDF, add a PNG, edit examples.json" +> flow. That is no longer accurate — previews and the manifest are generated +> under `docs/showcase/**` by `ShowcaseSync`. -Open `docs/index.html` directly in a browser for a quick visual check. If your browser blocks `fetch("examples.json")` for local `file://` pages, run any tiny static server from the repository root and open the `/docs/` path. No build step is required. +## Add a new showcase item -## GitHub Pages +The gallery is driven by code, not by hand-editing JSON. Source of truth: +`examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java`. + +1. Add the example `.java` under the right category sub-package in + `examples/src/main/java/com/demcha/examples/`. +2. Make it write its PDF via `ExampleOutputPaths.prepare(category, fileName)` + so the output lands under the matching `examples/target/generated-pdfs/` + subfolder. +3. Wire it into `GenerateAllExamples.main`. +4. Register a metadata entry in `ShowcaseMetadata.java` (title, one-line + description, search tags, source link) keyed by the generated PDF basename. +5. Regenerate, then sync: + + ```bash + ./mvnw -f examples/pom.xml exec:java -Dexec.mainClass=com.demcha.examples.GenerateAllExamples + ./mvnw -f examples/pom.xml exec:java -Dexec.mainClass=com.demcha.examples.support.ShowcaseSync + ``` + + `ShowcaseSync` copies each PDF into `docs/showcase/pdf/…`, rasterises a PNG + into `docs/showcase/screenshots/…`, and rewrites `docs/examples.json`. +6. Commit the regenerated `docs/showcase/**` assets and `docs/examples.json`. + +## Version and source links -To publish this site with GitHub Pages: +- The displayed version lives **only** in `index.html` (JSON-LD + `softwareVersion`, the JitPack download URL, the hero badge, and the Maven + + Gradle install snippets). It does not inherit from the pom. +- `scripts/cut-release.ps1` flips all of those — plus the README and poms — to + the release tag in the release commit, and `VersionConsistencyGuardTest` + fails the `verify` gate if any of them drift out of sync with the library + `pom.xml`. Do not hand-bump the site version ahead of a release. +- "View source" links resolve through `ShowcaseMetadata.GH_BASE`, which + `cut-release.ps1` flips between `/blob/develop` (while developing) and + `/blob/v ` (at release) so the deployed site points at the exact source + that produced each artefact. -1. Open repository Settings. -2. Go to Pages. -3. Set Source to "Deploy from a branch". -4. Select the main branch and `/docs` folder. -5. Save. +## Local preview + +The gallery uses `fetch("examples.json")`, which browsers block for local +`file://` pages. Run any tiny static server from the repository root and open +the `/docs/` path: + +```bash +python -m http.server 8000 +# then open http://localhost:8000/docs/ +``` + +Opening `docs/index.html` directly still renders everything except the +JS-driven gallery (the `