diff --git a/packages/pdf/src/templates/shared/rich-text-html.test.ts b/packages/pdf/src/templates/shared/rich-text-html.test.ts index 669777520..adf2640d8 100644 --- a/packages/pdf/src/templates/shared/rich-text-html.test.ts +++ b/packages/pdf/src/templates/shared/rich-text-html.test.ts @@ -17,6 +17,51 @@ describe("normalizeRichTextHtml", () => { expect(normalizeRichTextHtml("bold text")).toBe("

bold text

"); }); + it("moves trailing whitespace outside bold tags", () => { + expect(normalizeRichTextHtml("

Built and deployed

")).toBe( + "

Built and deployed

", + ); + }); + + it("moves leading whitespace outside bold tags", () => { + expect(normalizeRichTextHtml("

Built and deployed

")).toBe( + "

Built and deployed

", + ); + }); + + it("preserves whitespace moved outside top-level bold tags", () => { + expect(normalizeRichTextHtml("Built ")).toBe("

Built

"); + expect(normalizeRichTextHtml(" Built")).toBe("

Built

"); + }); + + it.each([" ", " ", " "])( + "moves encoded non-breaking spaces outside bold boundaries: %s", + (whitespace) => { + expect(normalizeRichTextHtml(`

Built${whitespace}and deployed

`)).toBe( + `

Built${whitespace}and deployed

`, + ); + expect(normalizeRichTextHtml(`

Built${whitespace}and deployed

`)).toBe( + `

Built${whitespace}and deployed

`, + ); + }, + ); + + it("preserves > characters inside quoted bold-tag attributes", () => { + expect(normalizeRichTextHtml('

Built and deployed

')).toBe( + '

Built and deployed

', + ); + }); + + it("preserves closing bold tags inside quoted attributes", () => { + expect(normalizeRichTextHtml('

Built next

')).toBe( + '

Built next

', + ); + }); + + it("preserves whitespace inside bold text", () => { + expect(normalizeRichTextHtml("

two words

")).toBe("

two words

"); + }); + it("preserves block-level

as-is", () => { expect(normalizeRichTextHtml("

Already wrapped

")).toBe("

Already wrapped

"); }); diff --git a/packages/pdf/src/templates/shared/rich-text-html.ts b/packages/pdf/src/templates/shared/rich-text-html.ts index db01e9c30..c587ea806 100644 --- a/packages/pdf/src/templates/shared/rich-text-html.ts +++ b/packages/pdf/src/templates/shared/rich-text-html.ts @@ -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]| | | )+/i; +const TRAILING_BOLD_BOUNDARY_WHITESPACE = /(?:[\u0020\u00a0]| | | )+$/i; + +const normalizeBoldBoundaryWhitespace = (root: ReturnType) => { + 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) => { 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(`

${inlineHtml}

`); + if (inlineHtml.trim()) normalized.push(`

${inlineHtml}

`); inlineNodes = []; };