fix: improper rendering of text blocks in PDFs

This commit is contained in:
Amruth Pillai
2026-05-10 13:22:21 +02:00
parent 62f4532157
commit 42e83cc676
20 changed files with 974 additions and 844 deletions
+2 -1
View File
@@ -21,6 +21,7 @@
"@reactive-resume/fonts": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"node-html-parser": "^7.1.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.6",
"react-pdf-html": "^2.1.5",
@@ -30,7 +31,7 @@
"@react-pdf/types": "^2.11.1",
"@reactive-resume/config": "workspace:*",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260508.1",
"@typescript/native-preview": "7.0.0-dev.20260510.1",
"typescript": "^6.0.3"
}
}
@@ -0,0 +1,65 @@
import type { Node } from "node-html-parser";
import { NodeType, parse } from "node-html-parser";
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 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);
};
export const normalizeRichTextHtml = (html: string): string => {
const root = parse(html.trim(), { comment: false });
const normalized: string[] = [];
let inlineNodes: string[] = [];
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();
return normalized.join("");
};
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { normalizeRichTextHtml } from "./rich-text-html";
describe("normalizeRichTextHtml", () => {
it("wraps top-level inline rich text in a paragraph", () => {
const html =
"Passionate game developer with 5+ years of professional experience</strong> creating engaging gameplay. <a href='https://www.google.com'>Specialized</a> in Unity.";
expect(normalizeRichTextHtml(html)).toBe(
"<p>Passionate game developer with 5+ years of professional experience creating engaging gameplay. <a href='https://www.google.com'>Specialized</a> in Unity.</p>",
);
});
it("preserves existing block rich text", () => {
expect(normalizeRichTextHtml("<p>Existing paragraph.</p><ul><li><p>Existing item.</p></li></ul>")).toBe(
"<p>Existing paragraph.</p><ul><li><p>Existing item.</p></li></ul>",
);
});
it("wraps inline runs around top-level blocks", () => {
expect(normalizeRichTextHtml("Intro <strong>text</strong><ul><li><p>Item</p></li></ul>Outro")).toBe(
"<p>Intro <strong>text</strong></p><ul><li><p>Item</p></li></ul><p>Outro</p>",
);
});
});
@@ -3,6 +3,7 @@ import { Text as PdfText, View } from "@react-pdf/renderer";
import { Html } from "react-pdf-html";
import { useTemplateStyle } from "./context";
import { safeTextStyle } from "./primitives";
import { normalizeRichTextHtml } from "./rich-text-html";
import { composeStyles, mergeLinkStyles, mergeStyles } from "./styles";
const richListItemContentStackStyle = {
@@ -17,7 +18,9 @@ export const RichText = ({ children }: { children: string }) => {
const richListItemMarkerStyle = useTemplateStyle("richListItemMarker");
const richListItemContentStyle = useTemplateStyle("richListItemContent");
if (!children.trim()) return null;
const html = normalizeRichTextHtml(children);
if (!html) return null;
return (
<Html
@@ -54,7 +57,7 @@ export const RichText = ({ children }: { children: string }) => {
a: mergeLinkStyles(linkStyle, safeTextStyle),
}}
>
{children}
{html}
</Html>
);
};