fix(pdf): missing spaces around bold rich text in PDFs (#3273)

* fix(pdf): missing spaces around bold rich text in PDFs

* fix(pdf): make bold tag matching quote-aware

* fix(pdf): preserve quoted bold tag attributes

* fix(pdf): handle encoded non-breaking spaces in bold boundaries

* fix(pdf): preserve top-level bold boundary spaces

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
Shehraan Hafiz
2026-08-13 22:54:05 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent 7eb6d3bdbf
commit 69961210bd
2 changed files with 73 additions and 2 deletions
@@ -17,6 +17,51 @@ describe("normalizeRichTextHtml", () => {
expect(normalizeRichTextHtml("<strong>bold</strong> text")).toBe("<p><strong>bold</strong> text</p>");
});
it("moves trailing whitespace outside bold tags", () => {
expect(normalizeRichTextHtml("<p><strong>Built </strong>and deployed</p>")).toBe(
"<p><strong>Built</strong> and deployed</p>",
);
});
it("moves leading whitespace outside bold tags", () => {
expect(normalizeRichTextHtml("<p>Built<strong> and deployed</strong></p>")).toBe(
"<p>Built <strong>and deployed</strong></p>",
);
});
it("preserves whitespace moved outside top-level bold tags", () => {
expect(normalizeRichTextHtml("<strong>Built </strong>")).toBe("<p><strong>Built</strong> </p>");
expect(normalizeRichTextHtml("<strong> Built</strong>")).toBe("<p> <strong>Built</strong></p>");
});
it.each(["&nbsp;", "&#160;", "&#xA0;"])(
"moves encoded non-breaking spaces outside bold boundaries: %s",
(whitespace) => {
expect(normalizeRichTextHtml(`<p>Built<strong>${whitespace}and deployed</strong></p>`)).toBe(
`<p>Built${whitespace}<strong>and deployed</strong></p>`,
);
expect(normalizeRichTextHtml(`<p><strong>Built${whitespace}</strong>and deployed</p>`)).toBe(
`<p><strong>Built</strong>${whitespace}and deployed</p>`,
);
},
);
it("preserves > characters inside quoted bold-tag attributes", () => {
expect(normalizeRichTextHtml('<p>Built<strong title="1 > 0"> and deployed</strong></p>')).toBe(
'<p>Built <strong title="1 > 0">and deployed</strong></p>',
);
});
it("preserves closing bold tags inside quoted attributes", () => {
expect(normalizeRichTextHtml('<p><strong title="Use </strong> here">Built </strong>next</p>')).toBe(
'<p><strong title="Use </strong> here">Built</strong> next</p>',
);
});
it("preserves whitespace inside bold text", () => {
expect(normalizeRichTextHtml("<p><strong>two words</strong></p>")).toBe("<p><strong>two words</strong></p>");
});
it("preserves block-level <p> as-is", () => {
expect(normalizeRichTextHtml("<p>Already wrapped</p>")).toBe("<p>Already wrapped</p>");
});
@@ -69,6 +69,31 @@ const isMeaningfulNode = (node: Node): boolean =>
const isElement = (node: Node): node is HTMLElement => node.nodeType === NodeType.ELEMENT_NODE;
const LEADING_BOLD_BOUNDARY_WHITESPACE = /^(?:[\u0020\u00a0]|&nbsp;|&#160;|&#xA0;)+/i;
const TRAILING_BOLD_BOUNDARY_WHITESPACE = /(?:[\u0020\u00a0]|&nbsp;|&#160;|&#xA0;)+$/i;
const normalizeBoldBoundaryWhitespace = (root: ReturnType<typeof parse>) => {
for (const bold of root.querySelectorAll("strong,b").reverse()) {
const firstChild = bold.childNodes[0];
if (firstChild?.nodeType === NodeType.TEXT_NODE) {
const whitespace = firstChild.rawText.match(LEADING_BOLD_BOUNDARY_WHITESPACE)?.[0];
if (whitespace) {
firstChild.rawText = firstChild.rawText.slice(whitespace.length);
bold.insertAdjacentHTML("beforebegin", whitespace);
}
}
const lastChild = bold.childNodes[bold.childNodes.length - 1];
if (lastChild?.nodeType === NodeType.TEXT_NODE) {
const whitespace = lastChild.rawText.match(TRAILING_BOLD_BOUNDARY_WHITESPACE)?.[0];
if (whitespace) {
lastChild.rawText = lastChild.rawText.slice(0, -whitespace.length);
bold.insertAdjacentHTML("afterend", whitespace);
}
}
}
};
const unwrapSingleParagraphListItems = (root: ReturnType<typeof parse>) => {
for (const listItem of root.querySelectorAll("li")) {
const meaningfulChildren = listItem.childNodes.filter(isMeaningfulNode);
@@ -135,13 +160,14 @@ export const normalizeRichTextHtml = (
const normalized: string[] = [];
let inlineNodes: string[] = [];
normalizeBoldBoundaryWhitespace(root);
normalizeMarkElements(root);
unwrapSingleParagraphListItems(root);
const flushInlineNodes = () => {
const inlineHtml = inlineNodes.join("").trim();
const inlineHtml = inlineNodes.join("");
if (inlineHtml) normalized.push(`<p>${inlineHtml}</p>`);
if (inlineHtml.trim()) normalized.push(`<p>${inlineHtml}</p>`);
inlineNodes = [];
};