mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-22 06:12:17 +10:00
- Add getBaseRun() next to getHtmlStyle() and replace 8 inline baseRun spreads (~27 lines). - Hoist one mainConfig object in buildDocument; sidebar call spreads only the two differing color keys (~17 lines). - Remove BUILT_IN_SECTIONS Set; derive membership via `sectionId in data.sections` (~14 lines). - Delete unreachable ordered-list numbering machinery in html-to-docx.ts: numberingRef is always undefined through all call paths, so isOrdered && numberingRef is always false (~15 lines). Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
267 lines
7.2 KiB
TypeScript
267 lines
7.2 KiB
TypeScript
import type { IShadingAttributesProperties, ISpacingProperties } from "docx";
|
|
import { ExternalHyperlink, HeadingLevel, Paragraph, TextRun } from "docx";
|
|
import { isDarkColor, parseColorString } from "@reactive-resume/utils/color";
|
|
import { toSafeDocxLink } from "./link-utils";
|
|
|
|
export interface HtmlStyleConfig {
|
|
font?: string;
|
|
size?: number;
|
|
color?: string;
|
|
linkColor?: string;
|
|
}
|
|
|
|
interface InlineStyle {
|
|
bold?: boolean;
|
|
italics?: boolean;
|
|
underline?: Record<string, never>;
|
|
strike?: boolean;
|
|
font?: string;
|
|
size?: number;
|
|
color?: string;
|
|
shading?: IShadingAttributesProperties;
|
|
}
|
|
|
|
type InlineChild = TextRun | ExternalHyperlink;
|
|
|
|
/** Module-level link color, set per htmlToParagraphs invocation. */
|
|
let currentLinkColor = "0563C1";
|
|
|
|
const HEADING_MAP: Record<string, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
|
H1: HeadingLevel.HEADING_1,
|
|
H2: HeadingLevel.HEADING_2,
|
|
H3: HeadingLevel.HEADING_3,
|
|
H4: HeadingLevel.HEADING_4,
|
|
H5: HeadingLevel.HEADING_5,
|
|
H6: HeadingLevel.HEADING_6,
|
|
};
|
|
|
|
function toDocxColorValue(value: string) {
|
|
const rgba = parseColorString(value);
|
|
if (!rgba) return null;
|
|
|
|
return [rgba.r, rgba.g, rgba.b].map((channel) => channel.toString(16).padStart(2, "0").toUpperCase()).join("");
|
|
}
|
|
|
|
function mergeStyle(parent: InlineStyle, tag: string, element?: HTMLElement): InlineStyle {
|
|
const next = { ...parent };
|
|
|
|
switch (tag) {
|
|
case "STRONG":
|
|
case "B":
|
|
next.bold = true;
|
|
break;
|
|
case "EM":
|
|
case "I":
|
|
next.italics = true;
|
|
break;
|
|
case "U":
|
|
next.underline = {};
|
|
break;
|
|
case "S":
|
|
case "STRIKE":
|
|
next.strike = true;
|
|
break;
|
|
case "CODE":
|
|
next.font = "Courier New";
|
|
break;
|
|
case "MARK": {
|
|
const bgColor = (element as HTMLElement | undefined)?.style.backgroundColor;
|
|
const fill = bgColor ? toDocxColorValue(bgColor) : null;
|
|
next.shading = { fill: fill ?? "FFFF00" };
|
|
if (bgColor && isDarkColor(bgColor)) next.color = "FFFFFF";
|
|
break;
|
|
}
|
|
}
|
|
|
|
const colorValue = (element as HTMLElement | undefined)?.style.color;
|
|
if (colorValue) {
|
|
const color = toDocxColorValue(colorValue);
|
|
if (color) next.color = color;
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] {
|
|
const children: InlineChild[] = [];
|
|
|
|
for (const child of node.childNodes) {
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
const text = child.textContent ?? "";
|
|
if (text) {
|
|
children.push(new TextRun({ text, ...style }));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
|
|
|
const el = child as HTMLElement;
|
|
const tag = el.tagName;
|
|
|
|
if (tag === "BR") {
|
|
children.push(new TextRun({ break: 1 }));
|
|
continue;
|
|
}
|
|
|
|
if (tag === "A") {
|
|
const href = toSafeDocxLink(el.getAttribute("href") ?? "");
|
|
const linkChildren = collectInlineChildren(el, { ...style, color: currentLinkColor, underline: {} });
|
|
if (linkChildren.length > 0 && href) {
|
|
children.push(new ExternalHyperlink({ link: href, children: linkChildren as TextRun[] }));
|
|
} else {
|
|
children.push(...linkChildren);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const merged = mergeStyle(style, tag, el);
|
|
children.push(...collectInlineChildren(el, merged));
|
|
}
|
|
|
|
return children;
|
|
}
|
|
|
|
function processBlockElement(el: HTMLElement, style: InlineStyle, paragraphs: Paragraph[], listLevel?: number): void {
|
|
const tag = el.tagName;
|
|
const mergedStyle = mergeStyle(style, tag, el);
|
|
|
|
if (HEADING_MAP[tag]) {
|
|
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
|
if (inlineChildren.length > 0) {
|
|
paragraphs.push(new Paragraph({ heading: HEADING_MAP[tag], children: inlineChildren }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (tag === "P" || tag === "DIV") {
|
|
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
|
if (inlineChildren.length > 0) {
|
|
paragraphs.push(new Paragraph({ children: inlineChildren }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (tag === "UL" || tag === "OL") {
|
|
// ponytail: ordered-list numbering (numberingRef path) was never reachable; always uses bullet
|
|
const level = listLevel != null ? listLevel + 1 : 0;
|
|
|
|
for (const li of el.children) {
|
|
if (li.tagName !== "LI") continue;
|
|
|
|
const hasNestedBlocks = Array.from(li.children).some(
|
|
(c) => c.tagName === "UL" || c.tagName === "OL" || c.tagName === "P",
|
|
);
|
|
|
|
if (hasNestedBlocks) {
|
|
for (const liChild of li.childNodes) {
|
|
if (liChild.nodeType === Node.TEXT_NODE) {
|
|
const text = (liChild.textContent ?? "").trim();
|
|
if (text) {
|
|
paragraphs.push(new Paragraph({ children: [new TextRun({ text, ...mergedStyle })], bullet: { level } }));
|
|
}
|
|
} else if (liChild.nodeType === Node.ELEMENT_NODE) {
|
|
processBlockElement(liChild as HTMLElement, mergedStyle, paragraphs, level);
|
|
}
|
|
}
|
|
} else {
|
|
const inlineChildren = collectInlineChildren(li, mergedStyle);
|
|
if (inlineChildren.length > 0) {
|
|
paragraphs.push(new Paragraph({ children: inlineChildren, bullet: { level } }));
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (tag === "BLOCKQUOTE") {
|
|
const indent: ISpacingProperties = {};
|
|
const inlineChildren = collectInlineChildren(el, { ...mergedStyle, italics: true });
|
|
if (inlineChildren.length > 0) {
|
|
paragraphs.push(
|
|
new Paragraph({
|
|
children: inlineChildren,
|
|
indent: { left: 720 },
|
|
spacing: indent,
|
|
}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (tag === "PRE") {
|
|
const text = el.textContent ?? "";
|
|
if (text) {
|
|
paragraphs.push(
|
|
new Paragraph({
|
|
children: [new TextRun({ text, font: "Courier New", ...mergedStyle })],
|
|
}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (tag === "HR") {
|
|
paragraphs.push(new Paragraph({ children: [] }));
|
|
return;
|
|
}
|
|
|
|
if (tag === "LI") {
|
|
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
|
if (inlineChildren.length > 0) {
|
|
paragraphs.push(
|
|
new Paragraph({
|
|
children: inlineChildren,
|
|
bullet: { level: listLevel ?? 0 },
|
|
}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fallback: treat as inline container
|
|
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
|
if (inlineChildren.length > 0) {
|
|
paragraphs.push(new Paragraph({ children: inlineChildren }));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts an HTML string (from TipTap rich text editor) into an array of docx Paragraphs.
|
|
* Uses the browser's DOMParser to parse HTML, then walks the DOM tree to produce
|
|
* structured docx content with proper formatting (bold, italic, lists, links, etc.).
|
|
*
|
|
* @param html - The HTML string to convert
|
|
* @param styleConfig - Optional base typography (font, size, color) to apply to all text runs
|
|
*/
|
|
export function htmlToParagraphs(html: string, styleConfig?: HtmlStyleConfig): Paragraph[] {
|
|
if (!html?.trim()) return [];
|
|
|
|
currentLinkColor = styleConfig?.linkColor ?? "0563C1";
|
|
|
|
const baseStyle: InlineStyle = {};
|
|
if (styleConfig?.font) baseStyle.font = styleConfig.font;
|
|
if (styleConfig?.size) baseStyle.size = styleConfig.size;
|
|
if (styleConfig?.color) baseStyle.color = styleConfig.color;
|
|
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
const paragraphs: Paragraph[] = [];
|
|
|
|
for (const child of doc.body.childNodes) {
|
|
if (child.nodeType === Node.TEXT_NODE) {
|
|
const text = (child.textContent ?? "").trim();
|
|
if (text) {
|
|
paragraphs.push(new Paragraph({ children: [new TextRun({ text, ...baseStyle })] }));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
processBlockElement(child as HTMLElement, baseStyle, paragraphs);
|
|
}
|
|
}
|
|
|
|
return paragraphs;
|
|
}
|