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; 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 = { 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; }