import type { HTMLElement, Node } from "node-html-parser"; import { NodeType, parse } from "node-html-parser"; import { isDarkColor } from "@reactive-resume/utils/color"; import { getRichTextSemanticKind, getRichTextSemanticNodeKey } from "../../semantic/rich-text-keys"; export const richTextMarkClassName = "rr-pdf-mark"; export const richTextSemanticNodeKeyAttribute = "data-resume-semantic-node-key"; const inlineTags = new Set([ "a", "abbr", "b", "br", "button", "cite", "code", "dfn", "em", "i", "label", "q", "s", "span", "strong", "sub", "sup", "u", ]); const getTagName = (node: Node) => node.rawTagName.toLowerCase(); const hasBlockDescendant = (node: Node): boolean => node.childNodes.some((child) => child.nodeType === NodeType.ELEMENT_NODE && !isInlineNode(child)); const mergeClassNames = (...classNames: (string | undefined)[]): string => { const uniqueClassNames = new Set(); for (const className of classNames) { if (!className) continue; for (const part of className.split(/\s+/)) { if (part) uniqueClassNames.add(part); } } return [...uniqueClassNames].join(" "); }; const normalizeMarkElements = (root: ReturnType) => { for (const mark of root.querySelectorAll("mark")) { const dataColor = mark.getAttribute("data-color"); mark.tagName = "span"; mark.setAttribute("class", mergeClassNames(mark.getAttribute("class"), richTextMarkClassName)); // Preserve custom highlight color as inline background-color for react-pdf-html. // Legacy marks without data-color fall back to the .rr-pdf-mark stylesheet (yellow). if (dataColor) { const existingStyle = mark.getAttribute("style") ?? ""; let inlineStyle = `background-color: ${dataColor}`; if (isDarkColor(dataColor)) inlineStyle += "; color: #ffffff"; mark.setAttribute("style", existingStyle ? `${existingStyle}; ${inlineStyle}` : inlineStyle); } } }; const isMeaningfulNode = (node: Node): boolean => node.nodeType !== NodeType.TEXT_NODE || node.toString().trim().length > 0; const isElement = (node: Node): node is HTMLElement => node.nodeType === NodeType.ELEMENT_NODE; const unwrapSingleParagraphListItems = (root: ReturnType) => { for (const listItem of root.querySelectorAll("li")) { const meaningfulChildren = listItem.childNodes.filter(isMeaningfulNode); if (meaningfulChildren.length !== 1) continue; const child = meaningfulChildren[0]; if (!child || !isElement(child) || getTagName(child) !== "p") continue; listItem.innerHTML = child.innerHTML; } }; const isInlineNode = (node: Node): boolean => { if (node.nodeType === NodeType.TEXT_NODE || node.nodeType === NodeType.COMMENT_NODE) return true; if (node.nodeType !== NodeType.ELEMENT_NODE) return false; return inlineTags.has(getTagName(node)) && !hasBlockDescendant(node); }; // Allow optional leading whitespace + LRM/RLM marks before the bullet character. const PSEUDO_BULLET_LEAD = /^[\sā€Žā€]*[-•*]\s+/; const stripEmptyInlineWrappers = (html: string): string => html.replace(/<(strong|b|em|i|u|span)\b[^>]*>\s*<\/\1>/gi, ""); // Treat a bare
or one wrapped in an inline tag (e.g. `
` from // the editor) as the segment separator. const splitByBreaks = (html: string): string[] => html.split(/(?:<(?:strong|b|em|i|u|span)\b[^>]*>\s*]*\/?>\s*<\/(?:strong|b|em|i|u|span)>)|]*\/?>/gi); const tryConvertPseudoBulletParagraph = (paragraphInnerHtml: string): string | null => { const cleaned = stripEmptyInlineWrappers(paragraphInnerHtml); if (!/ 0) segments.push(trimmed); } if (segments.length < 2) return null; if (!segments.every((segment) => PSEUDO_BULLET_LEAD.test(segment))) return null; const items = segments.map((segment) => segment.replace(PSEUDO_BULLET_LEAD, "")); return `
    ${items.map((item) => `
  • ${item}
  • `).join("")}
`; }; export const convertPseudoBulletParagraphs = (html: string): string => html.replace(/]*)>([\s\S]*?)<\/p>/gi, (full, _attrs, inner) => { const converted = tryConvertPseudoBulletParagraph(inner); return converted ?? full; }); type NormalizeRichTextHtmlOptions = { direction?: "ltr" | "rtl"; }; export const normalizeRichTextHtml = ( html: string, { direction = "ltr" }: NormalizeRichTextHtmlOptions = {}, ): string => { const root = parse(html.trim(), { comment: false }); const normalized: string[] = []; let inlineNodes: string[] = []; normalizeMarkElements(root); unwrapSingleParagraphListItems(root); const flushInlineNodes = () => { const inlineHtml = inlineNodes.join("").trim(); if (inlineHtml) normalized.push(`

${inlineHtml}

`); inlineNodes = []; }; for (const node of root.childNodes) { const nodeHtml = node.toString(); if (isInlineNode(node)) { inlineNodes.push(nodeHtml); continue; } flushInlineNodes(); normalized.push(nodeHtml); } flushInlineNodes(); const normalizedHtml = normalized.join(""); if (direction !== "rtl") return normalizedHtml; // RTL pseudo-bullets must become real list items before both the semantic // descriptor and renderer traverse the HTML. RLM anchors each independent // react-pdf-html text frame without changing element ancestry or indices. return convertPseudoBulletParagraphs(normalizedHtml).replace( /<(p|li)\b([^>]*)>/gi, (_match, tag, rest) => `<${tag}${rest}>ā€`, ); }; export const parseNormalizedRichTextHtml = (html: string, options?: NormalizeRichTextHtmlOptions) => parse(normalizeRichTextHtml(html, options), { comment: false }); export const projectNormalizedRichTextHtml = ( html: string, rootNodeKey: string, renderedChildKeysFor: (nodeKey: string) => readonly string[] | undefined, ): string => { const root = parse(html, { comment: false }); const elements = root.querySelectorAll("*"); for (const element of elements) { if (!getRichTextSemanticKind(element, richTextMarkClassName)) continue; element.setAttribute( richTextSemanticNodeKeyAttribute, getRichTextSemanticNodeKey(rootNodeKey, element, richTextMarkClassName), ); } const visit = (parent: HTMLElement, parentNodeKey: string) => { for (const child of parent.childNodes) { if (!isElement(child)) continue; const childNodeKey = child.getAttribute(richTextSemanticNodeKeyAttribute); const childContentNodeKey = childNodeKey && getTagName(child) === "li" ? `${childNodeKey}/list-item-content-0` : (childNodeKey ?? parentNodeKey); visit(child, childContentNodeKey); } const keyedChildren = parent.childNodes.flatMap((child) => { if (!isElement(child)) return []; const nodeKey = child.getAttribute(richTextSemanticNodeKeyAttribute); return nodeKey ? [{ nodeKey, child }] : []; }); if (keyedChildren.length === 0) return; const renderedChildKeys = renderedChildKeysFor(parentNodeKey); if (!renderedChildKeys) return; const childByNodeKey = new Map(keyedChildren.map(({ nodeKey, child }) => [nodeKey, child])); const projected = renderedChildKeys.flatMap((nodeKey) => { const child = childByNodeKey.get(nodeKey); return child ? [child] : []; }); let projectedIndex = 0; const nextChildren = parent.childNodes.flatMap((child) => { if (!isElement(child) || !child.getAttribute(richTextSemanticNodeKeyAttribute)) return [child]; const projectedChild = projected[projectedIndex++]; return projectedChild ? [projectedChild] : []; }); parent.set_content(nextChildren); }; visit(root, rootNodeKey); return root.toString(); };