Files
Reactive-Resume/packages/pdf/src/templates/shared/rich-text-html.ts
T
2026-07-30 12:39:15 +02:00

231 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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<typeof parse>) => {
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<typeof parse>) => {
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 <br> or one wrapped in an inline tag (e.g. `<strong><br></strong>` from
// the editor) as the segment separator.
const splitByBreaks = (html: string): string[] =>
html.split(/(?:<(?:strong|b|em|i|u|span)\b[^>]*>\s*<br\b[^>]*\/?>\s*<\/(?:strong|b|em|i|u|span)>)|<br\b[^>]*\/?>/gi);
const tryConvertPseudoBulletParagraph = (paragraphInnerHtml: string): string | null => {
const cleaned = stripEmptyInlineWrappers(paragraphInnerHtml);
if (!/<br\b/i.test(cleaned)) return null;
const segments: string[] = [];
for (const segment of splitByBreaks(cleaned)) {
const trimmed = segment.trim();
if (trimmed.length > 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 `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
};
export const convertPseudoBulletParagraphs = (html: string): string =>
html.replace(/<p\b([^>]*)>([\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(`<p>${inlineHtml}</p>`);
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();
};