Files
Reactive-Resume/packages/docx/src/builder.ts
T
Amruth Pillai 62f8270b3e Squashed commit of the following:
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:13:38 2026 +0200

    chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies

commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:08:04 2026 +0200

    fix: remove timestamp conflict guard

commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 12:11:51 2026 +0200

    chore(release): v5.1.5

commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:35 2026 +0200

    revert: compose.yml

commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:08 2026 +0200

    refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086

commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:10:41 2026 +0200

    refactor(pdf): simplify sidebar section filtering and update summary feature logic

commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:53:37 2026 +0200

    chore: update pnpm-lock.yaml and turbo.json

commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:38:30 2026 +0200

    fix(polyfill): add tested polyfill for Map Upsert methods

commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:23:29 2026 +0200

    Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration

    # Conflicts:
    #	packages/api/src/services/agent-url.ts
    #	packages/runtime-externals/package.json

commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:22:12 2026 +0200

    chore: preserve branch changes before main sync

commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Mon May 18 07:50:28 2026 +0200

    chore: lot of fixes for monorepo migration

commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 13:57:17 2026 +0200

    chore: update knip version and refine web app routing with new SEO endpoints

commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 12:10:06 2026 +0200

    refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint

commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:54:29 2026 +0200

    chore: update dependencies and enhance PWA metadata in web app

commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:12:35 2026 +0200

    docs: revise manifest-only pwa testing scope

commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:11:33 2026 +0200

    docs: add manifest-only pwa design

commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:05:04 2026 +0200

    chore(dev): simplify server proxy config

commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:50 2026 +0200

    docs: add unsafe oauth redirect plan

commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:34 2026 +0200

    feat(auth): add unsafe oauth redirect flag

commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:55:02 2026 +0200

    docs: design unsafe oauth redirect flag

commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:22:04 2026 +0200

    chore: update translation source paths

commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:09:25 2026 +0200

    refactor(arch): react spa + hono migration

commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 01:10:47 2026 +0200

    docs: add docker nightly tagging design

commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Thu May 14 20:05:44 2026 +0200

    feat: migrate to hono spa server
2026-05-19 13:14:21 +02:00

527 lines
15 KiB
TypeScript

import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import {
BorderStyle,
convertMillimetersToTwip,
Document,
ExternalHyperlink,
HeadingLevel,
Paragraph,
ShadingType,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from "docx";
import { parseColorString } from "@reactive-resume/utils/color";
import { toSafeDocxLink } from "./link-utils";
import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers";
// --- Color helpers ---
function rgbToHex(r: number, g: number, b: number): string {
return [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("");
}
function getColorHex(rgba: string, fallback: string): string {
const parsed = parseColorString(rgba);
if (!parsed) return fallback;
return rgbToHex(parsed.r, parsed.g, parsed.b);
}
// --- Unit conversion helpers ---
/** Convert pt font size to docx half-points (docx uses half-pt units). */
function ptToHalfPt(pt: number): number {
return Math.round(pt * 2);
}
/** Convert pt spacing to twips (1pt = 20 twips). */
function ptToTwips(pt: number): number {
return Math.round(pt * 20);
}
// --- Page size constants (in mm) ---
interface PageSize {
width: number;
height: number;
}
const DEFAULT_PAGE_SIZE: PageSize = { width: 210, height: 297 };
const PAGE_SIZES = {
a4: DEFAULT_PAGE_SIZE,
letter: { width: 215.9, height: 279.4 },
} satisfies Record<string, PageSize>;
type DocxPageFormat = keyof typeof PAGE_SIZES;
const resolveDocxPageFormat = (format: ResumeData["metadata"]["page"]["format"]): DocxPageFormat => {
if (format === "letter") return "letter";
return "a4";
};
// --- Invisible border preset for table cells ---
const NO_BORDERS = {
top: { style: BorderStyle.NONE, size: 0 },
bottom: { style: BorderStyle.NONE, size: 0 },
left: { style: BorderStyle.NONE, size: 0 },
right: { style: BorderStyle.NONE, size: 0 },
} as const;
// --- Template layout config ---
interface TemplateConfig {
/** Which side the sidebar appears on */
sidebarSide: "left" | "right" | "none";
/** Sidebar background: "solid" = full primary color, "tint" = 20% opacity, "none" = no background */
sidebarBackground: "solid" | "tint" | "none";
/** Where the header is rendered */
headerPosition: "full-width" | "main-only" | "sidebar-only";
}
const TEMPLATE_CONFIGS: Record<Template, TemplateConfig> = {
azurill: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
bronzor: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
chikorita: { sidebarSide: "right", sidebarBackground: "solid", headerPosition: "main-only" },
ditgar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
ditto: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
gengar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
glalie: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
kakuna: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
lapras: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
leafish: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
meowth: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
onyx: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
pikachu: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "main-only" },
rhyhorn: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
scizor: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
};
const DEFAULT_TEMPLATE_CONFIG: TemplateConfig = {
sidebarSide: "left",
sidebarBackground: "none",
headerPosition: "full-width",
};
/**
* Blends a hex color toward white at the given opacity (0-1).
* Used to approximate CSS `background-color: rgba(r,g,b, 0.2)` on a white background.
*/
function blendWithWhite(hex: string, opacity: number): string {
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.slice(2, 4), 16);
const b = Number.parseInt(hex.slice(4, 6), 16);
const blend = (c: number) => Math.round(c * opacity + 255 * (1 - opacity));
return [blend(r), blend(g), blend(b)].map((c) => c.toString(16).padStart(2, "0")).join("");
}
// --- Section rendering dispatch ---
const BUILT_IN_SECTIONS = new Set<string>([
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
]);
function renderSection(sectionId: string, data: ResumeData, colorHex: string): Paragraph[] {
if (sectionId === "summary") {
return renderSummary(data.summary, colorHex);
}
if (BUILT_IN_SECTIONS.has(sectionId)) {
const section = data.sections[sectionId as SectionType];
if (section) {
return renderBuiltInSection(sectionId as SectionType, section, colorHex);
}
return [];
}
const customSection = data.customSections.find((cs) => cs.id === sectionId);
if (customSection) {
return renderCustomSection(customSection, colorHex);
}
return [];
}
// --- Header ---
function buildHeader(data: ResumeData, colorHex: string, textColorHex: string): Paragraph[] {
const paragraphs: Paragraph[] = [];
const { basics } = data;
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
// h2 = calc(var(--page-heading-font-size) * 1.25pt)
const nameSize = ptToHalfPt(data.metadata.typography.heading.fontSize * 1.25);
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
if (basics.name) {
paragraphs.push(
new Paragraph({
heading: HeadingLevel.TITLE,
spacing: { after: 60 },
children: [
new TextRun({
text: basics.name,
bold: true,
size: nameSize,
font: headingFont,
color: colorHex,
}),
],
}),
);
}
if (basics.headline) {
paragraphs.push(
new Paragraph({
spacing: { after: 60 },
children: [
new TextRun({
text: basics.headline,
italics: true,
size: bodySize,
font: bodyFont,
color: textColorHex,
}),
],
}),
);
}
const contactParts: (TextRun | ExternalHyperlink)[] = [];
const addSeparator = () => {
if (contactParts.length > 0) {
contactParts.push(new TextRun({ text: " | ", color: "999999", size: bodySize, font: bodyFont }));
}
};
if (basics.email) {
const mailtoLink = toSafeDocxLink(`mailto:${basics.email}`);
if (mailtoLink) {
addSeparator();
contactParts.push(
new ExternalHyperlink({
link: mailtoLink,
children: [
new TextRun({ text: basics.email, color: colorHex, underline: {}, size: bodySize, font: bodyFont }),
],
}),
);
}
}
if (basics.phone) {
addSeparator();
contactParts.push(new TextRun({ text: basics.phone, size: bodySize, font: bodyFont, color: textColorHex }));
}
if (basics.location) {
addSeparator();
contactParts.push(new TextRun({ text: basics.location, size: bodySize, font: bodyFont, color: textColorHex }));
}
if (basics.website.url) {
const websiteLink = toSafeDocxLink(basics.website.url);
if (websiteLink) {
addSeparator();
contactParts.push(
new ExternalHyperlink({
link: websiteLink,
children: [
new TextRun({
text: basics.website.label || websiteLink,
color: colorHex,
underline: {},
size: bodySize,
font: bodyFont,
}),
],
}),
);
}
}
for (const field of basics.customFields) {
if (!field.text) continue;
addSeparator();
if (field.link) {
const customLink = toSafeDocxLink(field.link);
if (customLink) {
contactParts.push(
new ExternalHyperlink({
link: customLink,
children: [
new TextRun({
text: field.text,
color: colorHex,
underline: {},
size: bodySize,
font: bodyFont,
}),
],
}),
);
} else {
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
}
} else {
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
}
}
if (contactParts.length > 0) {
paragraphs.push(
new Paragraph({
spacing: { after: 200 },
children: contactParts,
}),
);
}
return paragraphs;
}
// --- Two-column table layout ---
function buildTwoColumnTable(
mainParagraphs: Paragraph[],
sidebarParagraphs: Paragraph[],
sidebarWidthPct: number,
gapXTwips: number,
sidebarSide: "left" | "right" | "none",
sidebarShadingHex?: string,
): Table {
const mainWidthPct = 100 - sidebarWidthPct;
// DOCX table cells require at least one child
const mainChildren = mainParagraphs.length > 0 ? mainParagraphs : [new Paragraph({})];
const sidebarChildren = sidebarParagraphs.length > 0 ? sidebarParagraphs : [new Paragraph({})];
const sidebarShading = sidebarShadingHex
? { fill: sidebarShadingHex, type: ShadingType.CLEAR, color: "auto" }
: undefined;
const margins: { right?: number; left?: number } = {};
if (sidebarSide === "left") {
margins.right = gapXTwips;
} else if (sidebarSide === "right") {
margins.left = gapXTwips;
}
const sidebarCell = new TableCell({
width: { size: sidebarWidthPct, type: WidthType.PERCENTAGE },
borders: NO_BORDERS,
margins,
children: sidebarChildren,
...(sidebarShading ? { shading: sidebarShading } : {}),
});
const mainCell = new TableCell({
width: { size: mainWidthPct, type: WidthType.PERCENTAGE },
borders: NO_BORDERS,
margins,
children: mainChildren,
});
const cells = sidebarSide === "left" ? [sidebarCell, mainCell] : [mainCell, sidebarCell];
return new Table({
rows: [new TableRow({ children: cells })],
width: { size: 100, type: WidthType.PERCENTAGE },
borders: {
top: { style: BorderStyle.NONE, size: 0 },
bottom: { style: BorderStyle.NONE, size: 0 },
left: { style: BorderStyle.NONE, size: 0 },
right: { style: BorderStyle.NONE, size: 0 },
insideHorizontal: { style: BorderStyle.NONE, size: 0 },
insideVertical: { style: BorderStyle.NONE, size: 0 },
},
});
}
/**
* Builds a complete docx Document from resume data.
*
* Mirrors the resume builder's visual layout:
* - Header with name, headline, and contact info (centered, full-width)
* - Two-column table layout (main + sidebar) matching `metadata.layout`
* - Typography (font family, size, line height) from `metadata.typography`
* - Page margins and fixed DOCX page format from `metadata.page`; free-form exports as A4
* - Primary, text, and background colors from `metadata.design.colors`
*/
export function buildDocument(data: ResumeData): Document {
const colorHex = getColorHex(data.metadata.design.colors.primary, "DC2626");
const textColorHex = getColorHex(data.metadata.design.colors.text, "000000");
const bgColorHex = getColorHex(data.metadata.design.colors.background, "FFFFFF");
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
const lineSpacing = Math.round(data.metadata.typography.body.lineHeight * 240);
const { page } = data.metadata;
const pageSize = PAGE_SIZES[resolveDocxPageFormat(page.format)];
// Margins and gaps are defined in points (pt), not mm
const marginXTwips = ptToTwips(page.marginX);
const marginYTwips = ptToTwips(page.marginY);
const gapXTwips = ptToTwips(page.gapX);
const sidebarWidth = data.metadata.layout.sidebarWidth;
// Template-aware layout config
const templateConfig = TEMPLATE_CONFIGS[data.metadata.template] ?? DEFAULT_TEMPLATE_CONFIG;
// Compute sidebar background shading hex
let sidebarShadingHex: string | undefined;
if (templateConfig.sidebarBackground === "solid") {
sidebarShadingHex = colorHex;
} else if (templateConfig.sidebarBackground === "tint") {
sidebarShadingHex = blendWithWhite(colorHex, 0.2);
}
// Determine sidebar text colors — inverted when sidebar has a solid background
const sidebarTextColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : textColorHex;
const sidebarHeadingColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : colorHex;
// Configure heading typography for section renderers
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
const headingSize = ptToHalfPt(data.metadata.typography.heading.fontSize);
const documentChildren: (Paragraph | Table)[] = [];
// Header placement depends on template
if (templateConfig.headerPosition === "full-width") {
// Configure colors for main content
setRenderConfig({
headingFont,
headingSizeHalfPt: headingSize,
bodyFont,
bodySizeHalfPt: bodySize,
textColorHex,
primaryColorHex: colorHex,
});
documentChildren.push(...buildHeader(data, colorHex, textColorHex));
}
// Process each page in the layout
for (const layoutPage of data.metadata.layout.pages) {
const isFullWidth = layoutPage.fullWidth || layoutPage.sidebar.length === 0;
if (isFullWidth) {
setRenderConfig({
headingFont,
headingSizeHalfPt: headingSize,
bodyFont,
bodySizeHalfPt: bodySize,
textColorHex,
primaryColorHex: colorHex,
});
for (const sectionId of [...layoutPage.main, ...layoutPage.sidebar]) {
documentChildren.push(...renderSection(sectionId, data, colorHex));
}
} else {
// Render main sections with normal colors
setRenderConfig({
headingFont,
headingSizeHalfPt: headingSize,
bodyFont,
bodySizeHalfPt: bodySize,
textColorHex,
primaryColorHex: colorHex,
});
const mainParagraphs: Paragraph[] = [];
if (templateConfig.headerPosition === "main-only") {
mainParagraphs.push(...buildHeader(data, colorHex, textColorHex));
}
for (const sectionId of layoutPage.main) {
mainParagraphs.push(...renderSection(sectionId, data, colorHex));
}
// Render sidebar sections with potentially inverted colors
setRenderConfig({
headingFont,
headingSizeHalfPt: headingSize,
bodyFont,
bodySizeHalfPt: bodySize,
textColorHex: sidebarTextColorHex,
primaryColorHex: sidebarHeadingColorHex,
});
const sidebarParagraphs: Paragraph[] = [];
if (templateConfig.headerPosition === "sidebar-only") {
sidebarParagraphs.push(...buildHeader(data, sidebarHeadingColorHex, sidebarTextColorHex));
}
for (const sectionId of layoutPage.sidebar) {
sidebarParagraphs.push(...renderSection(sectionId, data, sidebarHeadingColorHex));
}
if (mainParagraphs.length > 0 || sidebarParagraphs.length > 0) {
documentChildren.push(
buildTwoColumnTable(
mainParagraphs,
sidebarParagraphs,
sidebarWidth,
gapXTwips,
templateConfig.sidebarSide,
sidebarShadingHex,
),
);
}
}
}
return new Document({
styles: {
default: {
document: {
run: {
font: bodyFont,
size: bodySize,
color: textColorHex,
},
paragraph: {
spacing: { line: lineSpacing },
},
},
},
},
...(bgColorHex !== "FFFFFF" ? { background: { color: bgColorHex } } : {}),
sections: [
{
properties: {
page: {
size: {
width: convertMillimetersToTwip(pageSize.width),
height: convertMillimetersToTwip(pageSize.height),
},
margin: {
top: marginYTwips,
bottom: marginYTwips,
left: marginXTwips,
right: marginXTwips,
},
},
},
children: documentChildren,
},
],
});
}