diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5e1da015..6adf06ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -407,6 +407,19 @@ Entries land here as they merge.
### Internal
+- **CV / cover-letter template icons moved from PNG to recolorable SVG.**
+ The bundled contact / social glyphs (phone, email, location, website,
+ LinkedIn, GitHub, …) and the sidebar-portrait avatar now ship as SVG
+ instead of raster PNG. A new internal `SvgGlyph` helper flattens an icon's
+ filled layers into one outline that the presets fill with each template's
+ own accent colour via `rich.shape(...)` — so one bundled glyph recolours
+ per template with no per-template copies, and the icons stay crisp at any
+ zoom. The sidebar-portrait avatar is a swappable SVG placeholder. This
+ shrinks the bundled `templates/cv` assets from ~717 KB to ~133 KB (the
+ 431 KB `portrait.png` alone becomes a ~4 KB SVG), trimming the published
+ jar. No public API change; the CV / cover-letter presets render the same
+ layout (visual baselines refreshed for the new glyphs; the sidebar-portrait
+ layout snapshot updated for the vector avatar).
- **Benchmark suite cleanup (not shipped).** Removed three redundant
benchmark mains: `FullCvBenchmark` (superseded by the JMH
`TemplateCvJmhBenchmark`), `GraphComposeBenchmark` (early-engine relic
diff --git a/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/TimelineMinimalLetter.java b/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/TimelineMinimalLetter.java
index 1ecb3fba..98a28495 100644
--- a/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/TimelineMinimalLetter.java
+++ b/src/main/java/com/demcha/compose/document/templates/coverletter/v2/presets/TimelineMinimalLetter.java
@@ -3,10 +3,10 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.PageFlowBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
-import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
@@ -19,19 +19,16 @@
import com.demcha.compose.document.templates.cv.v2.data.CvIdentity;
import com.demcha.compose.document.templates.cv.v2.data.CvLink;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import com.demcha.compose.document.templates.cv.v2.widgets.SvgGlyph;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.UncheckedIOException;
import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
/**
* v2 cover-letter pair for the {@code TimelineMinimal} CV preset.
*
*
Reproduces the CV's masthead: a left spaced-caps Barlow-Condensed
* name + UPPERCASE role line, balanced by a right-aligned contact stack
- * where each line ends with its PNG glyph icon (LinkedIn / GitHub /
+ * where each line ends with its recolorable SVG glyph icon (LinkedIn / GitHub /
* location / phone / email), all under a thin full-width rule — the same
* header as
* {@link com.demcha.compose.document.templates.cv.v2.presets.TimelineMinimal}.
@@ -72,8 +69,7 @@ public final class TimelineMinimalLetter {
private static final double CONTACT_ICON_BASELINE_OFFSET = -1.35;
private static final String CONTACT_ICON_ROOT =
"/templates/cv/timeline-minimal/icons/";
- private static final Map CONTACT_ICON_CACHE =
- new ConcurrentHashMap<>();
+ private static final DocumentColor ICON_COLOR = DocumentColor.rgb(58, 58, 58);
private TimelineMinimalLetter() {
}
@@ -170,9 +166,8 @@ private void addContact(SectionBuilder section, CvIdentity identity) {
rich.style(item.text(), textStyle);
rich.plain(" ");
if (item.iconFile() != null) {
- rich.image(contactIcon(item.iconFile()),
- CONTACT_ICON_SIZE,
- CONTACT_ICON_SIZE,
+ rich.shape(glyph(item.iconFile()).outline(CONTACT_ICON_SIZE),
+ ICON_COLOR, null,
InlineImageAlignment.CENTER,
CONTACT_ICON_BASELINE_OFFSET,
item.linkOptions());
@@ -188,13 +183,13 @@ private List contactItems(CvIdentity identity) {
return List.of();
}
List items = new ArrayList<>();
- addContactItem(items, "LOC", "location.png",
+ addContactItem(items, "LOC", "location.svg",
identity.contact().address(), null);
- addContactItem(items, "TEL", "phone.png",
+ addContactItem(items, "TEL", "phone.svg",
identity.contact().phone(), null);
String email = identity.contact().email();
if (!email.isBlank()) {
- addContactItem(items, "@", "email.png", email,
+ addContactItem(items, "@", "email.svg", email,
new DocumentLinkOptions("mailto:" + email));
}
for (CvLink link : identity.links()) {
@@ -210,10 +205,8 @@ private List contactItems(CvIdentity identity) {
return List.copyOf(items);
}
- private DocumentImageData contactIcon(String iconFile) {
- return DocumentImageData.fromBytes(
- CONTACT_ICON_CACHE.computeIfAbsent(iconFile,
- TimelineMinimalLetter::readIconBytes));
+ private SvgGlyph glyph(String iconFile) {
+ return SvgGlyph.fromResource(CONTACT_ICON_ROOT + iconFile);
}
private DocumentTextStyle nameStyle() {
@@ -250,16 +243,16 @@ private static void addContactItem(List items,
private static String pickIconFile(String label) {
String normalized = SectionLookup.normalize(label);
if (normalized.contains("linkedin")) {
- return "linkedin.png";
+ return "linkedin.svg";
}
if (normalized.contains("github")) {
- return "github.png";
+ return "github.svg";
}
if (normalized.contains("dribbble")) {
- return "dribbble.png";
+ return "dribbble.svg";
}
if (normalized.contains("google")) {
- return "google.png";
+ return "google.svg";
}
return null;
}
@@ -275,20 +268,6 @@ private static String pickFallbackIcon(String label) {
return "@";
}
- private static byte[] readIconBytes(String iconFile) {
- try (InputStream input = TimelineMinimalLetter.class.getResourceAsStream(
- CONTACT_ICON_ROOT + iconFile)) {
- if (input == null) {
- throw new IllegalStateException(
- "Missing timeline minimal contact icon: " + iconFile);
- }
- return input.readAllBytes();
- } catch (IOException e) {
- throw new UncheckedIOException(
- "Failed to read timeline minimal contact icon: " + iconFile, e);
- }
- }
-
private record ContactItem(String fallbackIcon, String iconFile,
String text, DocumentLinkOptions linkOptions) {
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java b/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java
index e946da75..15cebbe5 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/presets/MonogramSidebar.java
@@ -6,7 +6,6 @@
import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.dsl.RichText;
import com.demcha.compose.document.dsl.SectionBuilder;
-import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.InlineRun;
@@ -21,6 +20,7 @@
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.widgets.SvgGlyph;
import com.demcha.compose.document.templates.blocks.Block;
import com.demcha.compose.document.templates.blocks.BulletListBlock;
import com.demcha.compose.document.templates.blocks.IndentedBlock;
@@ -35,15 +35,10 @@
import com.demcha.compose.document.theme.BusinessTheme;
import com.demcha.compose.font.FontName;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
-import java.util.Map;
import java.util.Objects;
-import java.util.concurrent.ConcurrentHashMap;
/**
* Templates v2 "Monogram Sidebar" CV preset.
@@ -81,6 +76,8 @@ public final class MonogramSidebar {
private static final DocumentColor MAIN_RULE = DocumentColor.rgb(72, 79, 84);
private static final DocumentColor ACCENT = DocumentColor.rgb(158, 146, 104);
private static final DocumentColor MONOGRAM_RING = DocumentColor.rgb(54, 62, 74);
+ /** Contact glyph fill — the muted gold accent, readable on the pale sidebar. */
+ private static final DocumentColor ICON_COLOR = DocumentColor.rgb(158, 146, 104);
private static final FontName HEADLINE_FONT = FontName.CRIMSON_TEXT;
private static final FontName MONOGRAM_FONT = FontName.PT_SERIF;
@@ -91,7 +88,6 @@ public final class MonogramSidebar {
private static final double CONTACT_ICON_SIZE = 18;
private static final String CONTACT_ICON_ROOT = "/templates/cv/monogram-sidebar/icons/";
- private static final Map CONTACT_ICON_CACHE = new ConcurrentHashMap<>();
private static final List EDUCATION_KEYS = List.of("education", "certifications");
private static final List SKILL_KEYS = List.of("skills", "technical skills", "expertise");
@@ -258,10 +254,10 @@ private void addContactBlock(SectionBuilder section, CvHeader header) {
.textStyle(textStyle)
.align(TextAlign.CENTER)
.margin(DocumentInsets.top(4))
- .rich(rich -> rich.image(
- contactIcon(contact.iconFile()),
- CONTACT_ICON_SIZE,
- CONTACT_ICON_SIZE,
+ .rich(rich -> rich.shape(
+ glyph(contact.iconFile()).outline(CONTACT_ICON_SIZE),
+ ICON_COLOR,
+ null,
InlineImageAlignment.CENTER,
0.0,
contact.linkOptions())));
@@ -660,13 +656,13 @@ private static List contactLines(CvHeader header) {
return List.of();
}
List lines = new ArrayList<>();
- addContactLine(lines, "phone.png", safe(header.phone()), null);
+ addContactLine(lines, "phone.svg", safe(header.phone()), null);
String email = safe(header.email());
if (!email.isBlank()) {
- addContactLine(lines, "email.png", email,
+ addContactLine(lines, "email.svg", email,
new DocumentLinkOptions("mailto:" + email));
}
- addContactLine(lines, "location.png", safe(header.address()), null);
+ addContactLine(lines, "location.svg", safe(header.address()), null);
for (CvHeader.Link link : header.links()) {
String label = safe(link.label());
if (label.isBlank()) {
@@ -691,29 +687,14 @@ private static void addContactLine(List lines, String iconFile,
private static String pickIconFile(String label) {
String n = normalize(label);
if (n.contains("github")) {
- return "github.png";
+ return "github.svg";
}
- if (n.contains("linkedin")) {
- return "linkedin.png";
- }
- return "linkedin.png";
- }
-
- private static DocumentImageData contactIcon(String iconFile) {
- return DocumentImageData.fromBytes(
- CONTACT_ICON_CACHE.computeIfAbsent(CONTACT_ICON_ROOT + iconFile,
- MonogramSidebar::readIconBytes));
+ // LinkedIn and any other link → the LinkedIn glyph (V1 fallback).
+ return "linkedin.svg";
}
- private static byte[] readIconBytes(String resourcePath) {
- try (InputStream input = MonogramSidebar.class.getResourceAsStream(resourcePath)) {
- if (input == null) {
- throw new IllegalStateException("Missing monogram sidebar icon: " + resourcePath);
- }
- return input.readAllBytes();
- } catch (IOException e) {
- throw new UncheckedIOException("Failed to read monogram sidebar icon: " + resourcePath, e);
- }
+ private static SvgGlyph glyph(String iconFile) {
+ return SvgGlyph.fromResource(CONTACT_ICON_ROOT + iconFile);
}
private static String stripBasicMarkdown(String value) {
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java b/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java
index 44509a90..0ba2c034 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java
@@ -3,7 +3,6 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.RichText;
import com.demcha.compose.document.dsl.SectionBuilder;
-import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.InlineRun;
@@ -13,7 +12,9 @@
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.svg.SvgIcon;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.widgets.SvgGlyph;
import com.demcha.compose.document.templates.blocks.Block;
import com.demcha.compose.document.templates.blocks.BulletListBlock;
import com.demcha.compose.document.templates.blocks.IndentedBlock;
@@ -31,6 +32,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -75,6 +77,8 @@ public final class SidebarPortrait {
private static final DocumentColor SIDEBAR_SOFT = SOFT;
private static final DocumentColor ACCENT = DocumentColor.rgb(106, 106, 106);
private static final DocumentColor RULE = DocumentColor.rgb(178, 178, 178);
+ /** Contact glyph fill — dark slate, readable on the pale-beige sidebar. */
+ private static final DocumentColor ICON_COLOR = DocumentColor.rgb(58, 58, 58);
private static final FontName DISPLAY_FONT = FontName.CRIMSON_TEXT;
private static final FontName BODY_FONT = FontName.LATO;
@@ -91,9 +95,9 @@ public final class SidebarPortrait {
private static final String TEMPLATE_ASSET_ROOT = "/templates/cv/sidebar-portrait/";
private static final String CONTACT_ICON_ROOT = TEMPLATE_ASSET_ROOT + "icons/";
- private static final String PORTRAIT_FILE = "portrait.png";
+ private static final String PORTRAIT_FILE = "portrait.svg";
- private static final Map ASSET_CACHE = new ConcurrentHashMap<>();
+ private static final Map PORTRAIT_CACHE = new ConcurrentHashMap<>();
private static final List EDUCATION_KEYS = List.of("education", "certifications");
private static final List SKILL_KEYS = List.of("skills", "technical skills");
@@ -208,11 +212,15 @@ private void addSidebar(SectionBuilder section, CvSpec spec, double pageHeight)
private void addPhotoBlock(SectionBuilder section) {
double sideInset = Math.max(0.0, (SIDEBAR_INNER_WIDTH - PHOTO_DIAMETER) / 2.0);
- section.addImage(image -> image
+ // Default avatar is the bundled portrait.svg, whose outermost
+ // layer is a full-frame filled circle, so it is already round at
+ // PHOTO_DIAMETER. Wrapped in a layer stack to keep the centred
+ // side insets + 17pt bottom margin (addSvgIcon has no margin
+ // overload). A user-supplied override is a follow-up.
+ section.addLayerStack(photo -> photo
.name("SidebarPortraitPhoto")
- .source(portraitImage())
- .size(PHOTO_DIAMETER, PHOTO_DIAMETER)
- .margin(new DocumentInsets(0, sideInset, 17, sideInset)));
+ .margin(new DocumentInsets(0, sideInset, 17, sideInset))
+ .layer(portraitIcon().node(PHOTO_DIAMETER)));
}
private void addContactBlock(SectionBuilder section, CvHeader header) {
@@ -231,7 +239,8 @@ private void addContactBlock(SectionBuilder section, CvHeader header) {
.link(contact.linkOptions())
.rich(rich -> {
if (contact.iconFile() != null) {
- rich.image(contactIcon(contact.iconFile()), 10.0, 10.0,
+ rich.shape(glyph(contact.iconFile()).outline(10.0),
+ ICON_COLOR, null,
InlineImageAlignment.CENTER, 0.0, contact.linkOptions());
rich.style(" ", textStyle);
}
@@ -689,13 +698,13 @@ private static List contactLines(CvHeader header) {
return List.of();
}
List lines = new ArrayList<>();
- addContactLine(lines, "phone.png", safe(header.phone()), null);
+ addContactLine(lines, "phone.svg", safe(header.phone()), null);
String email = safe(header.email());
if (!email.isBlank()) {
- addContactLine(lines, "email.png", email,
+ addContactLine(lines, "email.svg", email,
new DocumentLinkOptions("mailto:" + email));
}
- addContactLine(lines, "location.png", safe(header.address()), null);
+ addContactLine(lines, "location.svg", safe(header.address()), null);
for (CvHeader.Link link : header.links()) {
String label = safe(link.label());
if (label.isBlank()) {
@@ -719,39 +728,34 @@ private static void addContactLine(List lines, String iconFile,
private static String pickIconFile(String label) {
String n = normalize(label);
- if (n.contains("linkedin")) {
- return "linkedin.png";
- }
if (n.contains("github")) {
- return "github.png";
+ return "github.svg";
}
if (n.contains("dribbble")) {
- return "dribbble.png";
+ return "dribbble.svg";
}
if (n.contains("google")) {
- return "google.png";
+ return "google.svg";
}
- return "linkedin.png";
+ // LinkedIn and any other link → the LinkedIn glyph (V1 fallback).
+ return "linkedin.svg";
}
- private static DocumentImageData contactIcon(String iconFile) {
- return DocumentImageData.fromBytes(
- ASSET_CACHE.computeIfAbsent(CONTACT_ICON_ROOT + iconFile,
- SidebarPortrait::readAssetBytes));
+ private static SvgGlyph glyph(String iconFile) {
+ return SvgGlyph.fromResource(CONTACT_ICON_ROOT + iconFile);
}
- private static DocumentImageData portraitImage() {
- return DocumentImageData.fromBytes(
- ASSET_CACHE.computeIfAbsent(TEMPLATE_ASSET_ROOT + PORTRAIT_FILE,
- SidebarPortrait::readAssetBytes));
+ private static SvgIcon portraitIcon() {
+ return PORTRAIT_CACHE.computeIfAbsent(TEMPLATE_ASSET_ROOT + PORTRAIT_FILE,
+ SidebarPortrait::readSvgIcon);
}
- private static byte[] readAssetBytes(String resourcePath) {
+ private static SvgIcon readSvgIcon(String resourcePath) {
try (InputStream input = SidebarPortrait.class.getResourceAsStream(resourcePath)) {
if (input == null) {
throw new IllegalStateException("Missing sidebar portrait asset: " + resourcePath);
}
- return input.readAllBytes();
+ return SvgIcon.parse(new String(input.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException("Failed to read sidebar portrait asset: " + resourcePath, e);
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java b/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java
index 6c6183b8..955cad36 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/presets/TimelineMinimal.java
@@ -4,7 +4,6 @@
import com.demcha.compose.document.dsl.LineBuilder;
import com.demcha.compose.document.dsl.RowBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
-import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.TextAlign;
@@ -14,6 +13,7 @@
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.v2.widgets.SvgGlyph;
import com.demcha.compose.document.templates.blocks.Block;
import com.demcha.compose.document.templates.blocks.BulletListBlock;
import com.demcha.compose.document.templates.blocks.IndentedBlock;
@@ -27,15 +27,10 @@
import com.demcha.compose.document.theme.BusinessTheme;
import com.demcha.compose.font.FontName;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
-import java.util.Map;
import java.util.Objects;
-import java.util.concurrent.ConcurrentHashMap;
/**
* Templates v2 "Timeline Minimal" CV preset.
@@ -70,6 +65,8 @@ public final class TimelineMinimal {
private static final DocumentColor SOFT = DocumentColor.rgb(122, 122, 122);
private static final DocumentColor RULE = DocumentColor.rgb(195, 195, 195);
private static final DocumentColor DOT = DocumentColor.rgb(170, 170, 170);
+ /** Contact glyph fill — dark slate, readable on the white page. */
+ private static final DocumentColor ICON_COLOR = DocumentColor.rgb(58, 58, 58);
private static final double TIMELINE_DOT = 7.0;
private static final double TIMELINE_LINE_BOX = 1.0;
@@ -78,7 +75,6 @@ public final class TimelineMinimal {
private static final double CONTACT_ICON_SIZE = 10.5;
private static final double CONTACT_ICON_BASELINE_OFFSET = -1.35;
private static final String CONTACT_ICON_ROOT = "/templates/cv/timeline-minimal/icons/";
- private static final Map CONTACT_ICON_CACHE = new ConcurrentHashMap<>();
private TimelineMinimal() {
// utility class — not instantiable
@@ -206,10 +202,10 @@ private void addContact(SectionBuilder section, CvHeader header) {
rich.style(line.text(), textStyle);
rich.plain(" ");
if (line.iconFile() != null) {
- rich.image(
- contactIcon(line.iconFile()),
- CONTACT_ICON_SIZE,
- CONTACT_ICON_SIZE,
+ rich.shape(
+ glyph(line.iconFile()).outline(CONTACT_ICON_SIZE),
+ ICON_COLOR,
+ null,
InlineImageAlignment.CENTER,
CONTACT_ICON_BASELINE_OFFSET,
line.linkOptions());
@@ -225,13 +221,13 @@ private List contactLines(CvHeader header) {
return List.of();
}
List lines = new ArrayList<>();
- addContactLine(lines, "LOC", "location.png",
+ addContactLine(lines, "LOC", "location.svg",
safe(header.address()), null);
- addContactLine(lines, "TEL", "phone.png",
+ addContactLine(lines, "TEL", "phone.svg",
safe(header.phone()), null);
String email = safe(header.email());
if (!email.isBlank()) {
- addContactLine(lines, "@", "email.png", email,
+ addContactLine(lines, "@", "email.svg", email,
new DocumentLinkOptions("mailto:" + email));
}
for (CvHeader.Link link : header.links()) {
@@ -258,9 +254,8 @@ private void addContactLine(List lines,
}
}
- private DocumentImageData contactIcon(String iconFile) {
- return DocumentImageData.fromBytes(
- CONTACT_ICON_CACHE.computeIfAbsent(iconFile, TimelineMinimal::readIconBytes));
+ private SvgGlyph glyph(String iconFile) {
+ return SvgGlyph.fromResource(CONTACT_ICON_ROOT + iconFile);
}
private void addSidebarModule(SectionBuilder sidebar, String title,
@@ -360,31 +355,19 @@ private void addMainModule(SectionBuilder main, String title,
// -- helpers ---------------------------------------------------------
- private static byte[] readIconBytes(String iconFile) {
- try (InputStream input = TimelineMinimal.class.getResourceAsStream(
- CONTACT_ICON_ROOT + iconFile)) {
- if (input == null) {
- throw new IllegalStateException("Missing timeline minimal contact icon: " + iconFile);
- }
- return input.readAllBytes();
- } catch (IOException e) {
- throw new UncheckedIOException("Failed to read timeline minimal contact icon: " + iconFile, e);
- }
- }
-
private static String pickIconFile(String label) {
String n = normalize(label);
if (n.contains("linkedin")) {
- return "linkedin.png";
+ return "linkedin.svg";
}
if (n.contains("github")) {
- return "github.png";
+ return "github.svg";
}
if (n.contains("dribbble")) {
- return "dribbble.png";
+ return "dribbble.svg";
}
if (n.contains("google")) {
- return "google.png";
+ return "google.svg";
}
return null;
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java
index b04108b3..0c741f22 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MintEditorial.java
@@ -5,7 +5,6 @@
import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.dsl.ShapeBuilder;
-import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.ParagraphNode;
@@ -25,10 +24,13 @@
import com.demcha.compose.document.templates.cv.v2.widgets.IconTextRow;
import com.demcha.compose.document.templates.cv.v2.widgets.SkillBar;
import com.demcha.compose.document.templates.cv.v2.widgets.Subheadline;
+import com.demcha.compose.document.templates.cv.v2.widgets.SvgGlyph;
+import com.demcha.compose.document.svg.SvgIcon;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -172,17 +174,17 @@ public final class MintEditorial {
/**
* Inline contact / social icon edge length (points).
*/
- private static final double CONTACT_ICON_SIZE = 9.0;
+ private static final double CONTACT_ICON_SIZE = 11.0;
/**
* Social icon edge length (points) — the filled badges read larger.
*/
- private static final double SOCIAL_ICON_SIZE = 12.0;
+ private static final double SOCIAL_ICON_SIZE = 14.0;
/**
* Expertise badge edge length (points).
*/
- private static final double BADGE_SIZE = 36.0;
+ private static final double BADGE_SIZE = 72.0;
// Banded-masthead canvas geometry. These values reproduce the DEFAULT
// (bandless) masthead flow positions exactly — name baseline, tagline
@@ -222,7 +224,20 @@ public final class MintEditorial {
static final double MASTHEAD_RULE_Y = 123.76;
private static final String ICON_ROOT = "/templates/cv/mint-editorial/icons/";
- private static final Map ICON_CACHE = new ConcurrentHashMap<>();
+
+ /**
+ * Contact / social glyph fill — a deep teal-ink that reads clearly on the
+ * white editorial page and harmonises with the mint accent. Recolours the
+ * shared {@link SvgGlyph} silhouettes via {@code rich.shape(...)}.
+ */
+ private static final DocumentColor ICON_COLOR = DocumentColor.rgb(47, 122, 106);
+
+ /**
+ * Cached SVG source for the expertise badge — parsed once into an
+ * {@link SvgIcon} so the multi-stroke badge keeps its authored look.
+ */
+ private static final String BADGE_SVG = ICON_ROOT + "expertise-badge.svg";
+ private static final Map SVG_CACHE = new ConcurrentHashMap<>();
private static final List INTERESTS_KEYS =
List.of("interests", "interest");
@@ -661,19 +676,22 @@ private void addContact(SectionBuilder section, CvIdentity identity) {
DocumentTextStyle style = contactStyle();
String phone = identity.contact().phone();
if (!phone.isBlank()) {
- IconTextRow.render(block, icon("phone.png"), CONTACT_ICON_SIZE,
- phone, style, null, DocumentInsets.bottom(13));
+ IconTextRow.render(block, glyph("phone.svg"), ICON_COLOR,
+ CONTACT_ICON_SIZE, phone, style, null,
+ DocumentInsets.bottom(13));
}
String email = identity.contact().email();
if (!email.isBlank()) {
- IconTextRow.render(block, icon("email.png"), CONTACT_ICON_SIZE,
- email, style, new DocumentLinkOptions("mailto:" + email),
+ IconTextRow.render(block, glyph("email.svg"), ICON_COLOR,
+ CONTACT_ICON_SIZE, email, style,
+ new DocumentLinkOptions("mailto:" + email),
DocumentInsets.bottom(13));
}
String address = identity.contact().address();
if (!address.isBlank()) {
- IconTextRow.render(block, icon("location.png"), CONTACT_ICON_SIZE,
- address, style, null, DocumentInsets.bottom(13));
+ IconTextRow.render(block, glyph("location.svg"), ICON_COLOR,
+ CONTACT_ICON_SIZE, address, style, null,
+ DocumentInsets.bottom(13));
}
for (CvLink link : identity.links()) {
if (link.label().isBlank()) {
@@ -682,9 +700,9 @@ email, style, new DocumentLinkOptions("mailto:" + email),
DocumentLinkOptions options = link.url().isBlank()
? null
: new DocumentLinkOptions(link.url().trim());
- IconTextRow.render(block, icon(contactIconFile(link.label())),
- CONTACT_ICON_SIZE, link.label(), style, options,
- DocumentInsets.bottom(13));
+ IconTextRow.render(block, glyph(contactIconFile(link.label())),
+ ICON_COLOR, CONTACT_ICON_SIZE, link.label(), style,
+ options, DocumentInsets.bottom(13));
}
});
}
@@ -759,11 +777,15 @@ private void addExpertise(SectionBuilder section, CvSection skills) {
section.addSection("CvV2MintEditorialExpertise", block -> {
block.spacing(0).padding(DocumentInsets.zero());
addBlockHeading(block, "Expertise");
- block.addImage(image -> image
+ // Badge is a stroked multi-path SVG (a checkmark in a ring),
+ // so it renders through SvgIcon.node — preserving its authored
+ // stroke — rather than being flattened to a recoloured glyph.
+ // Wrapped in a layer stack only to carry the 18pt bottom margin
+ // (addSvgIcon has no margin overload).
+ block.addLayerStack(badge -> badge
.name("CvV2MintEditorialExpertiseBadge")
- .source(icon("expertise-badge.png"))
- .size(BADGE_SIZE, BADGE_SIZE)
- .margin(DocumentInsets.bottom(18)));
+ .margin(DocumentInsets.bottom(18))
+ .layer(badgeIcon().node(BADGE_SIZE)));
for (String category : categories.stream().limit(EXPERTISE_LIMIT).toList()) {
addLabel(block, category);
}
@@ -810,9 +832,9 @@ private void addSocial(SectionBuilder section, CvIdentity identity) {
DocumentLinkOptions options = link.url().isBlank()
? null
: new DocumentLinkOptions(link.url().trim());
- IconTextRow.render(block, icon(socialIconFile(link.label())),
- SOCIAL_ICON_SIZE, link.label(), style, options,
- DocumentInsets.bottom(11));
+ IconTextRow.render(block, glyph(socialIconFile(link.label())),
+ ICON_COLOR, SOCIAL_ICON_SIZE, link.label(), style,
+ options, DocumentInsets.bottom(11));
}
});
}
@@ -1158,12 +1180,6 @@ private DocumentTableStyle cellStyle(DocumentTextStyle textStyle,
.fillColor(DocumentColor.WHITE)
.build();
}
-
- private DocumentImageData icon(String fileName) {
- return DocumentImageData.fromBytes(
- ICON_CACHE.computeIfAbsent(ICON_ROOT + fileName,
- MintEditorial::readIconBytes));
- }
}
// -- Static helpers ----------------------------------------------------
@@ -1250,19 +1266,19 @@ private static String extractEmail(String line) {
private static String contactIconFile(String label) {
String normalized = SectionLookup.normalize(label);
if (normalized.contains("linkedin")) {
- return "linkedin.png";
+ return "linkedin.svg";
}
if (normalized.contains("twitter")) {
- return "twitter.png";
+ return "twitter.svg";
}
if (normalized.contains("facebook")) {
- return "facebook.png";
+ return "facebook.svg";
}
if (normalized.contains("pinterest")) {
- return "pinterest.png";
+ return "pinterest.svg";
}
// GitHub, portfolio, personal site, etc. → the globe glyph.
- return "website.png";
+ return "website.svg";
}
/**
@@ -1271,29 +1287,44 @@ private static String contactIconFile(String label) {
private static String socialIconFile(String label) {
String normalized = SectionLookup.normalize(label);
if (normalized.contains("twitter")) {
- return "twitter.png";
+ return "twitter.svg";
}
if (normalized.contains("facebook")) {
- return "facebook.png";
+ return "facebook.svg";
}
if (normalized.contains("pinterest")) {
- return "pinterest.png";
+ return "pinterest.svg";
}
if (normalized.contains("linkedin")) {
- return "linkedin.png";
+ return "linkedin.svg";
}
// GitHub, portfolio, personal site, etc. → the globe glyph.
- return "website.png";
+ return "website.svg";
+ }
+
+ /**
+ * Loads (and caches) a recolorable contact / social glyph by file name.
+ */
+ private static SvgGlyph glyph(String fileName) {
+ return SvgGlyph.fromResource(ICON_ROOT + fileName);
+ }
+
+ /**
+ * Loads (and caches) the expertise badge as a stroked {@link SvgIcon}.
+ */
+ private static SvgIcon badgeIcon() {
+ return SVG_CACHE.computeIfAbsent(BADGE_SVG, MintEditorial::readSvgIcon);
}
- private static byte[] readIconBytes(String resourcePath) {
+ private static SvgIcon readSvgIcon(String resourcePath) {
try (InputStream input = MintEditorial.class
.getResourceAsStream(resourcePath)) {
if (input == null) {
throw new IllegalStateException(
"Missing mint editorial icon: " + resourcePath);
}
- return input.readAllBytes();
+ return SvgIcon.parse(
+ new String(input.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(
"Failed to read mint editorial icon: " + resourcePath, e);
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebar.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebar.java
index bf3c6316..4bf3fcfc 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebar.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/MonogramSidebar.java
@@ -6,20 +6,16 @@
import com.demcha.compose.document.dsl.LayerStackBuilder;
import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
-import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.*;
import com.demcha.compose.document.style.*;
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.cv.v2.components.*;
import com.demcha.compose.document.templates.cv.v2.data.*;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import com.demcha.compose.document.templates.cv.v2.widgets.SvgGlyph;
import com.demcha.compose.font.FontName;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.UncheckedIOException;
import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
/**
* v2 port of the legacy "Monogram Sidebar" CV preset.
@@ -64,6 +60,15 @@ public final class MonogramSidebar {
private static final DocumentColor DEFAULT_ACCENT =
DocumentColor.rgb(158, 146, 104);
+ /**
+ * Contact glyph fill — the muted gold accent, which reads clearly on the
+ * pale teal-grey sidebar fill and ties the centred icon stack to the
+ * subtitle / date accent. Recolours the shared {@link SvgGlyph}
+ * silhouettes via {@code rich.shape(...)}.
+ */
+ private static final DocumentColor ICON_COLOR =
+ DocumentColor.rgb(158, 146, 104);
+
/**
* V1 default dark monogram ring + initials colour.
*/
@@ -100,8 +105,6 @@ public final class MonogramSidebar {
private static final String CONTACT_ICON_ROOT =
"/templates/cv/monogram-sidebar/icons/";
- private static final Map CONTACT_ICON_CACHE =
- new ConcurrentHashMap<>();
private static final List EDUCATION_KEYS =
List.of("education", "certifications");
@@ -443,10 +446,10 @@ private void addContactBlock(SectionBuilder section, CvIdentity identity) {
.textStyle(textStyle)
.align(TextAlign.CENTER)
.margin(DocumentInsets.top(4))
- .rich(rich -> rich.image(
- contactIcon(item.iconFile()),
- CONTACT_ICON_SIZE,
- CONTACT_ICON_SIZE,
+ .rich(rich -> rich.shape(
+ glyph(item.iconFile()).outline(CONTACT_ICON_SIZE),
+ ICON_COLOR,
+ null,
InlineImageAlignment.CENTER,
0.0,
item.linkOptions())));
@@ -837,13 +840,13 @@ private static List contactItems(CvIdentity identity) {
return List.of();
}
List items = new ArrayList<>();
- addContactItem(items, "phone.png", identity.contact().phone(), null);
+ addContactItem(items, "phone.svg", identity.contact().phone(), null);
String email = identity.contact().email();
if (!email.isBlank()) {
- addContactItem(items, "email.png", email,
+ addContactItem(items, "email.svg", email,
new DocumentLinkOptions("mailto:" + email));
}
- addContactItem(items, "location.png", identity.contact().address(),
+ addContactItem(items, "location.svg", identity.contact().address(),
null);
for (CvLink link : identity.links()) {
String label = link.label();
@@ -870,32 +873,14 @@ private static void addContactItem(List items,
private static String pickIconFile(String label) {
String normalized = SectionLookup.normalize(label);
if (normalized.contains("github")) {
- return "github.png";
- }
- if (normalized.contains("linkedin")) {
- return "linkedin.png";
+ return "github.svg";
}
- return "linkedin.png";
+ // LinkedIn and any other link → the LinkedIn glyph (V1 fallback).
+ return "linkedin.svg";
}
- private static DocumentImageData contactIcon(String iconFile) {
- return DocumentImageData.fromBytes(
- CONTACT_ICON_CACHE.computeIfAbsent(CONTACT_ICON_ROOT + iconFile,
- MonogramSidebar::readIconBytes));
- }
-
- private static byte[] readIconBytes(String resourcePath) {
- try (InputStream input = MonogramSidebar.class
- .getResourceAsStream(resourcePath)) {
- if (input == null) {
- throw new IllegalStateException(
- "Missing monogram sidebar icon: " + resourcePath);
- }
- return input.readAllBytes();
- } catch (IOException e) {
- throw new UncheckedIOException(
- "Failed to read monogram sidebar icon: " + resourcePath, e);
- }
+ private static SvgGlyph glyph(String iconFile) {
+ return SvgGlyph.fromResource(CONTACT_ICON_ROOT + iconFile);
}
private static List skillTokens(SkillsSection skills) {
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/SidebarPortrait.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/SidebarPortrait.java
index be4e6f5b..a92e9696 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/SidebarPortrait.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/SidebarPortrait.java
@@ -3,7 +3,6 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.api.PageBackgroundFill;
import com.demcha.compose.document.dsl.SectionBuilder;
-import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.TextAlign;
@@ -11,6 +10,7 @@
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.svg.SvgIcon;
import com.demcha.compose.document.templates.api.DocumentTemplate;
import com.demcha.compose.document.templates.cv.v2.components.CvTextStyles;
import com.demcha.compose.document.templates.cv.v2.components.MarkdownInline;
@@ -18,10 +18,12 @@
import com.demcha.compose.document.templates.cv.v2.components.SectionLookup;
import com.demcha.compose.document.templates.cv.v2.data.*;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import com.demcha.compose.document.templates.cv.v2.widgets.SvgGlyph;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@@ -72,6 +74,13 @@ public final class SidebarPortrait {
private static final DocumentColor DEFAULT_ACCENT =
DocumentColor.rgb(106, 106, 106);
+ /**
+ * Contact glyph fill — a dark slate that reads clearly on the pale-beige
+ * portrait sidebar fill. Recolours the shared {@link SvgGlyph} silhouettes
+ * via {@code rich.shape(...)}.
+ */
+ private static final DocumentColor ICON_COLOR = DocumentColor.rgb(58, 58, 58);
+
/**
* Inner content width of the sidebar column. Derived from the V1
* SidebarPortrait token set (sidebar outer width minus 13pt left +
@@ -133,8 +142,8 @@ public final class SidebarPortrait {
"/templates/cv/sidebar-portrait/";
private static final String CONTACT_ICON_ROOT =
TEMPLATE_ASSET_ROOT + "icons/";
- private static final String PORTRAIT_FILE = "portrait.png";
- private static final Map ASSET_CACHE =
+ private static final String PORTRAIT_FILE = "portrait.svg";
+ private static final Map PORTRAIT_CACHE =
new ConcurrentHashMap<>();
private static final List EDUCATION_KEYS =
@@ -405,11 +414,16 @@ private void addSidebar(SectionBuilder section, CvDocument doc,
private void addPhotoBlock(SectionBuilder section) {
double sideInset = Math.max(0.0,
(SIDEBAR_INNER_WIDTH - PHOTO_DIAMETER) / 2.0);
- section.addImage(image -> image
+ // Default avatar is the bundled portrait.svg, whose outermost
+ // layer is a full-frame filled circle, so the illustration is
+ // already round at PHOTO_DIAMETER — no extra circular clip is
+ // needed. Wrapped in a layer stack so the photo keeps its
+ // centred side insets + 17pt bottom margin (addSvgIcon has no
+ // margin overload). A user-supplied override is a follow-up.
+ section.addLayerStack(photo -> photo
.name("CvV2SidebarPortraitPhoto")
- .source(portraitImage())
- .size(PHOTO_DIAMETER, PHOTO_DIAMETER)
- .margin(new DocumentInsets(0, sideInset, 17, sideInset)));
+ .margin(new DocumentInsets(0, sideInset, 17, sideInset))
+ .layer(portraitIcon().node(PHOTO_DIAMETER)));
}
/**
@@ -439,8 +453,8 @@ private void addContactBlock(SectionBuilder section, CvIdentity identity) {
.link(item.linkOptions())
.rich(rich -> {
if (item.iconFile() != null) {
- rich.image(contactIcon(item.iconFile()),
- 10.0, 10.0,
+ rich.shape(glyph(item.iconFile()).outline(10.0),
+ ICON_COLOR, null,
InlineImageAlignment.CENTER,
0.0, item.linkOptions());
rich.style(" ", textStyle);
@@ -874,13 +888,13 @@ private static List contactItems(CvIdentity identity) {
return List.of();
}
List items = new ArrayList<>();
- addContactItem(items, "phone.png", identity.contact().phone(), null);
+ addContactItem(items, "phone.svg", identity.contact().phone(), null);
String email = identity.contact().email();
if (!email.isBlank()) {
- addContactItem(items, "email.png", email,
+ addContactItem(items, "email.svg", email,
new DocumentLinkOptions("mailto:" + email));
}
- addContactItem(items, "location.png", identity.contact().address(),
+ addContactItem(items, "location.svg", identity.contact().address(),
null);
for (CvLink link : identity.links()) {
String label = link.label();
@@ -906,41 +920,37 @@ private static void addContactItem(List items,
private static String pickIconFile(String label) {
String normalized = SectionLookup.normalize(label);
- if (normalized.contains("linkedin")) {
- return "linkedin.png";
- }
if (normalized.contains("github")) {
- return "github.png";
+ return "github.svg";
}
if (normalized.contains("dribbble")) {
- return "dribbble.png";
+ return "dribbble.svg";
}
if (normalized.contains("google")) {
- return "google.png";
+ return "google.svg";
}
- return "linkedin.png";
+ // LinkedIn and any other link → the LinkedIn glyph (V1 fallback).
+ return "linkedin.svg";
}
- private static DocumentImageData contactIcon(String iconFile) {
- return DocumentImageData.fromBytes(
- ASSET_CACHE.computeIfAbsent(CONTACT_ICON_ROOT + iconFile,
- SidebarPortrait::readAssetBytes));
+ private static SvgGlyph glyph(String iconFile) {
+ return SvgGlyph.fromResource(CONTACT_ICON_ROOT + iconFile);
}
- private static DocumentImageData portraitImage() {
- return DocumentImageData.fromBytes(
- ASSET_CACHE.computeIfAbsent(TEMPLATE_ASSET_ROOT + PORTRAIT_FILE,
- SidebarPortrait::readAssetBytes));
+ private static SvgIcon portraitIcon() {
+ return PORTRAIT_CACHE.computeIfAbsent(TEMPLATE_ASSET_ROOT + PORTRAIT_FILE,
+ SidebarPortrait::readSvgIcon);
}
- private static byte[] readAssetBytes(String resourcePath) {
+ private static SvgIcon readSvgIcon(String resourcePath) {
try (InputStream input = SidebarPortrait.class
.getResourceAsStream(resourcePath)) {
if (input == null) {
throw new IllegalStateException(
"Missing sidebar portrait asset: " + resourcePath);
}
- return input.readAllBytes();
+ return SvgIcon.parse(
+ new String(input.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(
"Failed to read sidebar portrait asset: " + resourcePath,
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java
index 64e40e7b..40a9e315 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/presets/TimelineMinimal.java
@@ -3,10 +3,10 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.RowBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
-import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextDecoration;
@@ -17,13 +17,10 @@
import com.demcha.compose.document.templates.cv.v2.components.SectionLookup;
import com.demcha.compose.document.templates.cv.v2.data.*;
import com.demcha.compose.document.templates.cv.v2.theme.CvTheme;
+import com.demcha.compose.document.templates.cv.v2.widgets.SvgGlyph;
import com.demcha.compose.document.templates.widgets.TimelineAxisWidget;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.UncheckedIOException;
import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
/**
* v2 port of the legacy "Timeline Minimal" CV preset.
@@ -94,8 +91,13 @@ public final class TimelineMinimal {
private static final double CONTACT_ICON_BASELINE_OFFSET = -1.35;
private static final String CONTACT_ICON_ROOT =
"/templates/cv/timeline-minimal/icons/";
- private static final Map CONTACT_ICON_CACHE =
- new ConcurrentHashMap<>();
+
+ /**
+ * Contact glyph fill — a dark slate that reads clearly on the white page.
+ * Recolours the shared {@link SvgGlyph} silhouettes via
+ * {@code rich.shape(...)}.
+ */
+ private static final DocumentColor ICON_COLOR = DocumentColor.rgb(58, 58, 58);
private static final List SUMMARY_KEYS =
List.of("summary", "professional summary", "profile");
@@ -256,9 +258,9 @@ private void addContact(SectionBuilder section, CvIdentity identity) {
rich.style(item.text(), textStyle);
rich.plain(" ");
if (item.iconFile() != null) {
- rich.image(contactIcon(item.iconFile()),
- CONTACT_ICON_SIZE,
- CONTACT_ICON_SIZE,
+ rich.shape(glyph(item.iconFile())
+ .outline(CONTACT_ICON_SIZE),
+ ICON_COLOR, null,
InlineImageAlignment.CENTER,
CONTACT_ICON_BASELINE_OFFSET,
item.linkOptions());
@@ -275,13 +277,13 @@ private List contactItems(CvIdentity identity) {
return List.of();
}
List items = new ArrayList<>();
- addContactItem(items, "LOC", "location.png",
+ addContactItem(items, "LOC", "location.svg",
identity.contact().address(), null);
- addContactItem(items, "TEL", "phone.png",
+ addContactItem(items, "TEL", "phone.svg",
identity.contact().phone(), null);
String email = identity.contact().email();
if (!email.isBlank()) {
- addContactItem(items, "@", "email.png", email,
+ addContactItem(items, "@", "email.svg", email,
new DocumentLinkOptions("mailto:" + email));
}
for (CvLink link : identity.links()) {
@@ -310,10 +312,8 @@ private static void addContactItem(List items,
}
}
- private DocumentImageData contactIcon(String iconFile) {
- return DocumentImageData.fromBytes(
- CONTACT_ICON_CACHE.computeIfAbsent(iconFile,
- TimelineMinimal::readIconBytes));
+ private SvgGlyph glyph(String iconFile) {
+ return SvgGlyph.fromResource(CONTACT_ICON_ROOT + iconFile);
}
private void addSidebarModule(SectionBuilder sidebar, String title,
@@ -557,34 +557,19 @@ private static void addLines(List lines, String value) {
}
}
- private static byte[] readIconBytes(String iconFile) {
- try (InputStream input = TimelineMinimal.class.getResourceAsStream(
- CONTACT_ICON_ROOT + iconFile)) {
- if (input == null) {
- throw new IllegalStateException(
- "Missing timeline minimal contact icon: " + iconFile);
- }
- return input.readAllBytes();
- } catch (IOException e) {
- throw new UncheckedIOException(
- "Failed to read timeline minimal contact icon: " + iconFile,
- e);
- }
- }
-
private static String pickIconFile(String label) {
String normalized = SectionLookup.normalize(label);
if (normalized.contains("linkedin")) {
- return "linkedin.png";
+ return "linkedin.svg";
}
if (normalized.contains("github")) {
- return "github.png";
+ return "github.svg";
}
if (normalized.contains("dribbble")) {
- return "dribbble.png";
+ return "dribbble.svg";
}
if (normalized.contains("google")) {
- return "google.png";
+ return "google.svg";
}
return null;
}
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/IconTextRow.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/IconTextRow.java
index a7995432..b5f115e0 100644
--- a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/IconTextRow.java
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/IconTextRow.java
@@ -5,6 +5,7 @@
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextStyle;
@@ -17,16 +18,20 @@
*
What it renders
*
*
One left-aligned paragraph holding two inline runs: a centred inline
- * image of {@code iconSize}×{@code iconSize}, then three spaces and the
+ * glyph 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
+ * to both the glyph 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.
*
+ *
The glyph is a recolorable {@link SvgGlyph} silhouette filled with the
+ * caller's {@code glyphColor} via {@code rich.shape(...)}, so the same bundled
+ * SVG renders in each template's own accent without per-template icon copies.
+ *
*
When to use
*
*
Reach for {@code IconTextRow} when a sidebar stacks contact details or
- * social links as PNG-glyph rows (phone / email / location / website /
+ * social links as 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
@@ -50,15 +55,63 @@ private IconTextRow() {
}
/**
- * Renders an icon + text row.
+ * Renders a recolorable-glyph + text row.
+ *
+ * @param host host section the row paragraph is appended to
+ * @param glyph recolorable vector glyph drawn before the label; when
+ * {@code null} the row renders text only
+ * @param glyphColor fill colour for the glyph silhouette; when
+ * {@code null} the glyph run is skipped
+ * @param iconSize icon edge width in points (height follows the glyph's
+ * aspect ratio)
+ * @param text label text rendered after the glyph; a blank label
+ * still renders the glyph, 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, SvgGlyph glyph,
+ DocumentColor glyphColor, 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 (glyph != null && glyphColor != null) {
+ rich.shape(glyph.outline(iconSize), glyphColor, null,
+ InlineImageAlignment.CENTER, 0.0, link);
+ }
+ if (link != null) {
+ rich.link(GAP + label, link);
+ } else {
+ rich.style(GAP + label, style);
+ }
+ });
+ });
+ }
+
+ /**
+ * Renders an icon + text row from a pre-decoded raster glyph.
+ *
+ *
Public API retained for callers that supply their own raster
+ * {@link DocumentImageData} icon. The bundled CV presets use the
+ * recolorable {@link SvgGlyph} overload above; prefer it for new code.
*
* @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 text label text rendered after the icon
* @param style text style for the label
* @param link optional link wrapping the whole row (icon + label);
* {@code null} renders a non-clickable row
diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SvgGlyph.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SvgGlyph.java
new file mode 100644
index 00000000..8ee51189
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/widgets/SvgGlyph.java
@@ -0,0 +1,140 @@
+package com.demcha.compose.document.templates.cv.v2.widgets;
+
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentPathSegment;
+import com.demcha.compose.document.style.ShapeOutline;
+import com.demcha.compose.document.svg.SvgIcon;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * A bundled, recolorable single-colour vector glyph for CV contact / social
+ * rows.
+ *
+ *
What it is
+ *
+ *
CV templates draw small monochrome glyphs (phone / email / location /
+ * LinkedIn …). Since v1.8.0 these ship as classpath SVGs instead of raster
+ * PNGs, which shrinks the published artifact dramatically. The engine renders
+ * the colours baked into an SVG file as-is (there is no render-time tint), so
+ * this helper flattens an icon's filled layers into one
+ * {@link ShapeOutline} silhouette whose colour is then chosen by the caller via
+ * {@code rich.shape(outline, colour)}. That is what lets the same glyph render
+ * in each template's own accent colour without per-template copies.
+ *
+ *
How it works
+ *
+ *
{@link #fromResource(String)} parses the SVG with {@link SvgIcon} (which
+ * normalizes every path into one shared unit frame), concatenates the segments
+ * of the layers that carry a fill, and caches the result. Stroke-only
+ * decorative sub-paths ({@code fill="none"}) are dropped; if an icon has no
+ * filled layer at all its full geometry is used as a fallback so it still
+ * renders. {@link #outline(double)} scales that silhouette to a target width,
+ * preserving the icon's aspect ratio. The outline fills under the non-zero
+ * winding rule, so authored holes (handset cut-outs, letter counters) stay
+ * open.
+ *
+ *
Reuse
+ *
+ *
Lives in {@code cv/v2/widgets} as the shared glyph primitive behind
+ * {@link IconTextRow} and the per-preset contact rows. Reuse it instead of
+ * loading icon bytes by hand.