diff --git a/apps/web/package.json b/apps/web/package.json index 4375b9f6e..50d2ef35b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -58,6 +58,8 @@ "@tanstack/react-router": "^1.170.32", "@tiptap/extension-color": "^3.31.2", "@tiptap/extension-highlight": "^3.31.2", + "@tiptap/extension-heading": "^3.31.2", + "@tiptap/extension-paragraph": "^3.31.2", "@tiptap/extension-table": "^3.31.2", "@tiptap/extension-text-align": "^3.31.2", "@tiptap/extension-text-style": "^3.31.2", diff --git a/apps/web/src/components/input/rich-input-whitespace.ts b/apps/web/src/components/input/rich-input-whitespace.ts new file mode 100644 index 000000000..27fd94be8 --- /dev/null +++ b/apps/web/src/components/input/rich-input-whitespace.ts @@ -0,0 +1,210 @@ +import type { Level } from "@tiptap/extension-heading"; +import type { Node as ProseMirrorNode, TagParseRule } from "@tiptap/pm/model"; +import { Heading } from "@tiptap/extension-heading"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { DOMSerializer, DOMParser as ProseMirrorDOMParser } from "@tiptap/pm/model"; +import { Plugin } from "@tiptap/pm/state"; +import { ReplaceAroundStep, ReplaceStep } from "@tiptap/pm/transform"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { combineTransactionSteps, Extension, getChangedRanges } from "@tiptap/react"; + +export const whitespaceAttribute = "data-resume-whitespace"; +export const whitespacePreserveValue = "preserve"; + +const preservedWhitespace = { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute(whitespaceAttribute) === whitespacePreserveValue ? whitespacePreserveValue : null, + renderHTML: (attributes: { resumeWhitespace?: string | null }) => + attributes.resumeWhitespace === whitespacePreserveValue ? { [whitespaceAttribute]: whitespacePreserveValue } : {}, +}; + +export const LiteralParagraph = Paragraph.extend({ + addAttributes() { + return { ...this.parent?.(), resumeWhitespace: preservedWhitespace }; + }, + parseHTML() { + return [ + { tag: `p[${whitespaceAttribute}="${whitespacePreserveValue}"]`, preserveWhitespace: "full" }, + { tag: "p" }, + ] as TagParseRule[]; + }, +}); + +export const LiteralHeading = Heading.extend({ + addAttributes() { + return { ...this.parent?.(), resumeWhitespace: preservedWhitespace }; + }, + parseHTML() { + return this.options.levels.flatMap((level: Level) => [ + { + tag: `h${level}[${whitespaceAttribute}="${whitespacePreserveValue}"]`, + attrs: { level }, + preserveWhitespace: "full", + }, + { tag: `h${level}`, attrs: { level } }, + ]) as TagParseRule[]; + }, +}); + +const isPreservableTextBlock = (node: ProseMirrorNode) => + node.type.name === "paragraph" || node.type.name === "heading"; + +// Mark real text blocks before ProseMirror collapses their whitespace. Containers +// that its parser turns into paragraphs need the same explicit block boundary. +const markPastedHtml = (html: string) => { + const root = document.createElement("div"); + root.innerHTML = html; + const blockTags = /^(P|H[1-6]|DIV|BLOCKQUOTE|UL|OL|LI|PRE|HR|TABLE)$/; + const normalize = (container: HTMLElement) => { + let paragraph: HTMLParagraphElement | undefined; + for (const child of Array.from(container.childNodes)) { + // Clipboard envelopes and ignored metadata do not create editor blocks. + if ( + child.nodeType === Node.COMMENT_NODE || + (child instanceof HTMLElement && /^(HEAD|META|LINK|NOSCRIPT|OBJECT|SCRIPT|STYLE|TITLE)$/.test(child.tagName)) + ) + continue; + if (child instanceof HTMLElement && blockTags.test(child.tagName)) { + paragraph = undefined; + if (/^(DIV|BLOCKQUOTE|LI)$/.test(child.tagName)) normalize(child); + else if (/^(UL|OL)$/.test(child.tagName)) { + for (const item of Array.from(child.children)) if (item instanceof HTMLElement) normalize(item); + } else if (child.tagName === "TABLE") { + for (const cell of child.querySelectorAll("td, th")) { + if (cell instanceof HTMLElement) normalize(cell); + } + } + continue; + } + // Formatting whitespace between blocks is not an authored paragraph. + if ( + !paragraph && + child.nodeType === Node.TEXT_NODE && + !/[^\t\n\f\r ]/.test(child.textContent ?? "") && + Array.from(container.children).some((element) => blockTags.test(element.tagName)) + ) + continue; + if (!paragraph) { + paragraph = document.createElement("p"); + container.insertBefore(paragraph, child); + } + paragraph.appendChild(child); + } + }; + normalize(root); + for (const block of root.querySelectorAll("p,h1,h2,h3,h4,h5,h6")) { + if (!block.hasAttribute(whitespaceAttribute)) block.setAttribute(whitespaceAttribute, whitespacePreserveValue); + } + return root.innerHTML; +}; + +type LiteralWhitespaceOptions = { + hasUnsupportedTableMarkup: (html: string) => boolean; +}; + +export const LiteralWhitespaceInput = Extension.create({ + name: "literalWhitespaceInput", + + addOptions() { + return { hasUnsupportedTableMarkup: (html) => /<\/?table(?=\s|\/?>|$)/i.test(html) }; + }, + + addProseMirrorPlugins() { + let rejectHtmlPaste = false; + return [ + new Plugin({ + props: { + decorations(state) { + const tabs: Decoration[] = []; + state.doc.descendants((block, blockPosition) => { + if (!isPreservableTextBlock(block) || block.attrs.resumeWhitespace !== whitespacePreserveValue) return; + block.descendants((node, offset) => { + if (!node.isText || !node.text) return; + for (const match of node.text.matchAll(/\t/g)) { + const from = blockPosition + 1 + offset + match.index; + // Each inline box starts its own tab stops at zero: exactly four + // ordinary spaces in the inherited font, regardless of preceding text. + // Decorations leave the single stored character and editor positions intact. + tabs.push( + Decoration.inline(from, from + 1, { + style: "display: inline-block; white-space: pre; tab-size: 4;", + }), + ); + } + }); + return false; + }); + return DecorationSet.create(state.doc, tabs); + }, + transformPastedHTML: (html) => { + // Inspect original bytes before either DOM parser can repair unsafe tables. + rejectHtmlPaste = this.options.hasUnsupportedTableMarkup(html); + return rejectHtmlPaste ? "" : markPastedHtml(html); + }, + handlePaste: () => { + const reject = rejectHtmlPaste; + rejectHtmlPaste = false; + return reject; + }, + clipboardTextParser(text, $context, _plain, view) { + rejectHtmlPaste = false; + const wrapper = document.createElement("div"); + const serializer = DOMSerializer.fromSchema(view.state.schema); + for (const block of text.split(/(?:\r\n?|\n)+/)) { + const paragraph = document.createElement("p"); + paragraph.setAttribute(whitespaceAttribute, whitespacePreserveValue); + if (block) + paragraph.appendChild(serializer.serializeNode(view.state.schema.text(block, $context.marks()))); + wrapper.appendChild(paragraph); + } + return ProseMirrorDOMParser.fromSchema(view.state.schema).parseSlice(wrapper, { + context: $context, + preserveWhitespace: "full", + }); + }, + }, + appendTransaction(transactions, oldState, newState) { + if (transactions.some((transaction) => transaction.getMeta("preventUpdate"))) return null; + const authored = transactions.some( + (transaction) => + transaction.docChanged && + !transaction.getMeta("appendedTransaction") && + !transaction.getMeta("history$") && + transaction.steps.some((step) => step instanceof ReplaceStep), + ); + const structural = transactions.some( + (transaction) => + !transaction.getMeta("appendedTransaction") && + !transaction.getMeta("history$") && + transaction.steps.some((step) => step instanceof ReplaceAroundStep), + ); + if (!authored && !structural) return null; + const transform = combineTransactionSteps(oldState.doc, [...transactions]); + const preservedPositions: number[] = []; + oldState.doc.descendants((node, position) => { + if (isPreservableTextBlock(node) && node.attrs.resumeWhitespace === whitespacePreserveValue) + preservedPositions.push(transform.mapping.map(position + 1)); + }); + + const changedRanges = getChangedRanges(transform).map(({ newRange }) => newRange); + if (changedRanges.length === 0) return null; + + const transaction = newState.tr; + newState.doc.descendants((node, position) => { + if (!isPreservableTextBlock(node) || node.attrs.resumeWhitespace === whitespacePreserveValue) return; + const nodeEnd = position + node.nodeSize; + if (!authored && !preservedPositions.some((mapped) => mapped > position && mapped < nodeEnd)) return; + if (!changedRanges.some(({ from, to }) => from < nodeEnd && to > position)) return; + transaction.setNodeMarkup(position, undefined, { + ...node.attrs, + resumeWhitespace: whitespacePreserveValue, + }); + }); + + return transaction.steps.length > 0 ? transaction : null; + }, + }), + ]; + }, +}); diff --git a/apps/web/src/components/input/rich-input.indent.test.tsx b/apps/web/src/components/input/rich-input.indent.test.tsx index a795a67ef..46268108e 100644 --- a/apps/web/src/components/input/rich-input.indent.test.tsx +++ b/apps/web/src/components/input/rich-input.indent.test.tsx @@ -221,17 +221,17 @@ describe("RichInput paragraph indentation (#3397)", () => { } }); - it("characterizes typed leading whitespace as lost on HTML re-import", async () => { + it("preserves typed leading whitespace on HTML re-import", async () => { const { editor, onChange } = await input("

First

"); act(() => { editor.view.dispatch(editor.state.tr.insertText(" \t", 1)); }); const saved = editor.getHTML(); - expect(saved).toBe("

\tFirst

"); + expect(saved).toBe('

\tFirst

'); expect(onChange).toHaveBeenLastCalledWith(saved); act(() => { editor.commands.setContent(saved, { emitUpdate: false }); }); - expect(editor.getHTML()).toBe("

First

"); + expect(editor.getHTML()).toBe(saved); }); }); diff --git a/apps/web/src/components/input/rich-input.tsx b/apps/web/src/components/input/rich-input.tsx index 38108b251..494d36681 100644 --- a/apps/web/src/components/input/rich-input.tsx +++ b/apps/web/src/components/input/rich-input.tsx @@ -60,6 +60,13 @@ import { isRTL } from "@/libs/locale"; import { ColorPicker } from "./color-picker"; import { ParagraphIndent } from "./paragraph-indent"; import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils"; +import { + LiteralHeading, + LiteralParagraph, + LiteralWhitespaceInput, + whitespaceAttribute, + whitespacePreserveValue, +} from "./rich-input-whitespace"; const defaultTextColor = "rgba(0, 0, 0, 1)"; @@ -260,7 +267,10 @@ const cellAttributes = new Map([ ]); const noValues: ElementRule = {}; const textBlockRule: ElementRule = { - attributes: new Map([["data-indent", supportsCanonicalInteger(1, 8)]]), + attributes: new Map([ + ["data-indent", supportsCanonicalInteger(1, 8)], + [whitespaceAttribute, (value) => value === whitespacePreserveValue], + ]), styles: textBlockStyles, validate: validateTextBlock, }; @@ -483,9 +493,8 @@ const StyledTableCell = TableCell.extend({ const extensions = [ StarterKit.configure({ - heading: { - levels: [1, 2, 3, 4, 5, 6], - }, + heading: false, + paragraph: false, codeBlock: false, link: { openOnClick: false, @@ -494,6 +503,9 @@ const extensions = [ protocols: ["http", "https"], }, }), + LiteralParagraph, + LiteralHeading.configure({ levels: [1, 2, 3, 4, 5, 6] }), + LiteralWhitespaceInput.configure({ hasUnsupportedTableMarkup }), TextStyle, Color, Highlight.configure({ multicolor: true }).extend({ @@ -552,7 +564,7 @@ export function RichInput({ "data-editor": "true", "data-fullscreen": isFullscreen ? "true" : "false", class: cn( - "wysiwyg group/editor overflow-y-auto p-3 pb-4", + "wysiwyg group/editor overflow-y-auto p-3 pb-4 [&_[data-resume-whitespace=preserve]]:whitespace-pre-wrap", "rounded-md rounded-t-none border outline-none focus-visible:border-ring", "[td:has(.selectedCell)]:bg-primary", "data-[fullscreen=false]:max-h-[400px] data-[fullscreen=false]:min-h-[100px]", diff --git a/apps/web/src/components/input/rich-input.whitespace.test.tsx b/apps/web/src/components/input/rich-input.whitespace.test.tsx new file mode 100644 index 000000000..4b434f9bd --- /dev/null +++ b/apps/web/src/components/input/rich-input.whitespace.test.tsx @@ -0,0 +1,295 @@ +// @vitest-environment happy-dom + +import type { Editor } from "@tiptap/react"; +import { act, render, waitFor } from "@testing-library/react"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { PromptDialogProvider } from "@/hooks/use-prompt"; +import { RichInput } from "./rich-input"; + +const preserve = 'data-resume-whitespace="preserve"'; + +beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} })); + +function richInput(value: string, onChange: (value: string) => void, onCreate: (editor: Editor) => void) { + return ( + + + onCreate(editor)} /> + + + ); +} + +async function input(value: string) { + let editor: Editor | undefined; + const onChange = vi.fn(); + const rendered = render(richInput(value, onChange, (created) => (editor = created))); + await waitFor(() => expect(editor).toBeDefined()); + if (!editor) throw new Error("Editor did not initialize"); + return { editor, onChange, rendered }; +} + +describe("RichInput literal whitespace (#3397)", () => { + it.each(["p", "h2"])("round-trips exact marked %s whitespace", async (tag) => { + const html = `<${tag} ${preserve}> Lead middle\tend `; + const { editor, onChange } = await input(html); + + expect(editor.getHTML()).toBe(html); + expect(editor.getText()).toBe(" Lead middle\tend "); + expect(onChange).not.toHaveBeenCalled(); + + act(() => editor.commands.setContent(html, { emitUpdate: false })); + expect(editor.getHTML()).toBe(tag === "p" ? html : `${html}

`); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("keeps marked whitespace through marks, line breaks, undo, redo, and remount", async () => { + const original = `

Bold tail
Second\t

`; + const { editor, onChange, rendered } = await input(original); + expect(editor.getHTML()).toBe(original); + + act(() => editor.view.dispatch(editor.state.tr.insertText("X", 3))); + const edited = editor.getHTML(); + expect(edited).toContain(preserve); + expect(editor.getText({ blockSeparator: "\n" })).toBe(" XBold tail\n Second\t "); + expect(editor.getAttributes("textStyle")).toBeDefined(); + + act(() => editor.commands.undo()); + expect(editor.getHTML()).toBe(original); + act(() => editor.commands.redo()); + expect(editor.getHTML()).toBe(edited); + expect(onChange).toHaveBeenLastCalledWith(edited); + + rendered.unmount(); + const remounted = await input(edited); + expect(remounted.editor.getHTML()).toBe(edited); + expect(remounted.onChange).not.toHaveBeenCalled(); + }); + + it("marks unmarked blocks only after text input and preserves the marker in history", async () => { + const { editor, onChange } = await input("

First

"); + + act(() => editor.view.dispatch(editor.state.tr.insertText(" \t", 1))); + const saved = `

\tFirst

`; + expect(editor.getHTML()).toBe(saved); + expect(onChange).toHaveBeenLastCalledWith(saved); + + act(() => editor.commands.undo()); + expect(editor.getHTML()).toBe("

First

"); + act(() => editor.commands.redo()); + expect(editor.getHTML()).toBe(saved); + }); + + it("marks pasted text without changing paste block or line-break semantics", async () => { + const { editor } = await input("

Start

"); + act(() => { + editor.commands.selectAll(); + editor.view.pasteHTML("

Pasted\ttext
next

"); + }); + + expect(editor.getHTML()).toBe(`

Pasted\ttext
next

`); + expect(editor.getText({ blockSeparator: "\n" })).toBe(" Pasted\ttext\n next "); + }); + + it("preserves exact plain-text paste codepoints across authored blocks", async () => { + const { editor } = await input("

Start

"); + act(() => { + editor.commands.selectAll(); + editor.view.pasteText(" Plain\ttext \n\tSecond "); + }); + + expect(editor.getHTML()).toBe(`

Plain\ttext

\tSecond

`); + expect(editor.getText({ blockSeparator: "\n" })).toBe(" Plain\ttext \n\tSecond "); + }); + + it("marks blocks authored by Enter and keeps Shift+Enter as a line break", async () => { + const { editor } = await input("

First

"); + act(() => { + editor.commands.setTextSelection(3); + editor.commands.enter(); + }); + expect(editor.getHTML()).toBe(`

Fi

rst

`); + + act(() => { + editor.commands.setTextSelection(3); + editor.commands.setHardBreak(); + }); + expect(editor.getHTML()).toBe(`

Fi

rst

`); + }); + + it("preserves marked whitespace through paragraph, heading, and list transitions", async () => { + const html = `

First\t

`; + const { editor } = await input(html); + + act(() => editor.commands.toggleHeading({ level: 2 })); + expect(editor.getHTML()).toBe(`

First\t

`); + act(() => editor.commands.setParagraph()); + expect(editor.getHTML()).toBe(`${html}

`); + act(() => editor.commands.toggleBulletList()); + expect(editor.getHTML()).toBe(`

`); + act(() => editor.commands.toggleBulletList()); + expect(editor.getHTML()).toBe(`${html}

`); + }); + + it.each(["bullet", "ordered"])("keeps heading whitespace through %s list conversion and reload", async (list) => { + const { editor } = await input(`

Literal\t

`); + const toggle = () => (list === "bullet" ? editor.commands.toggleBulletList() : editor.commands.toggleOrderedList()); + act(toggle); + const saved = editor.getHTML(); + expect(saved).toContain(`

Literal\t

`); + act(() => editor.commands.setContent(saved, { emitUpdate: false })); + expect(editor.getHTML()).toBe(saved); + act(() => { + editor.commands.setTextSelection(5); + toggle(); + editor.commands.toggleHeading({ level: 2 }); + }); + expect(editor.getHTML()).toContain(`

Literal\t

`); + }); + + it("keeps structural markers local to previously marked blocks, including undo and redo", async () => { + const html = `

First\t

Legacy

`; + const { editor } = await input(html); + act(() => { + editor.commands.selectAll(); + editor.commands.toggleHeading({ level: 2 }); + }); + const saved = `

First\t

Legacy

`; + expect(editor.getHTML()).toBe(saved); + act(() => editor.commands.undo()); + expect(editor.getHTML()).toBe(html); + act(() => editor.commands.redo()); + expect(editor.getHTML()).toBe(saved); + act(() => editor.commands.setContent(saved, { emitUpdate: false })); + expect(editor.getHTML()).toBe(saved); + }); + + it("preserves both blocks through multi-block paragraph and heading conversions", async () => { + const { editor } = await input(`

First\t

Second\t

`); + act(() => { + editor.commands.selectAll(); + editor.commands.toggleHeading({ level: 2 }); + }); + const headings = `

First\t

Second\t

`; + expect(editor.getHTML()).toBe(headings); + act(() => editor.commands.setContent(headings, { emitUpdate: false })); + expect(editor.getHTML()).toBe(headings); + act(() => { + editor.commands.selectAll(); + editor.commands.setParagraph(); + }); + const saved = editor.getHTML(); + expect(saved).toContain(`

First\t

Second\t

`); + act(() => editor.commands.setContent(saved, { emitUpdate: false })); + expect(editor.getHTML()).toBe(saved); + }); + + it.each([ + ['

Text\tX

', `

Text\tX

`], + ["

Text\tX

", `

Text\tX

`], + ["
Text\tX
", `

Text\tX

`], + ['
Text\tX
', `

Text\tX

`], + ["
Text\tX
", `

Text\tX

`], + [ + "
First\t
Second\t
", + `

First\t

Second\t

`, + ], + ["", `

`], + ["
  1. Text\tX
", `
  1. Text\tX

`], + ["
Text\tX
", `

Text\tX

`], + ["
First
Second\t
", `

First
Second\t

`], + ])("preserves supported clipboard text and baseline blocks: %s", async (html, expected) => { + const { editor } = await input("

Start

"); + act(() => { + editor.commands.selectAll(); + editor.view.pasteHTML(html); + }); + expect(editor.getHTML()).toBe(expected); + act(() => editor.commands.setContent(expected, { emitUpdate: false })); + expect(editor.getHTML()).toBe(expected); + }); + + it("preserves whitespace in pasted supported table paragraphs", async () => { + const { editor } = await input("

Start

"); + act(() => { + editor.commands.selectAll(); + editor.view.pasteHTML("

Cell\ttext

"); + }); + expect(editor.getHTML()).toContain(`

Cell\ttext

`); + }); + + it.each(["td", "th"])("preserves whitespace in pasted bare %s table cells", async (cellTag) => { + const { editor } = await input("

Start

"); + act(() => { + editor.commands.selectAll(); + editor.view.pasteHTML(`<${cellTag}> Cell\ttext
`); + }); + + expect(editor.getHTML()).toContain(`

Cell\ttext

`); + expect(editor.getText()).toContain(" Cell\ttext "); + }); + + it.each([ + "

Cell\t

", + '

Cell\t

', + ])("rejects unsafe table paste before DOM normalization: %s", async (html) => { + const { editor, onChange } = await input("

Start

"); + act(() => { + editor.commands.selectAll(); + editor.view.pasteHTML(html); + }); + expect(editor.getHTML()).toBe("

Start

"); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("keeps marked paragraphs editable inside supported table cells", async () => { + const html = `

Cell\ttext

`; + const { editor, onChange } = await input(html); + + expect(editor.isEditable).toBe(true); + expect(editor.getHTML()).toBe(html); + let cellTextPosition: number | undefined; + editor.state.doc.descendants((node, position) => { + if (cellTextPosition === undefined && node.isText && node.text?.includes("Cell")) cellTextPosition = position; + }); + const insertionPosition = cellTextPosition; + if (insertionPosition === undefined) throw new Error("Expected table-cell text position"); + act(() => editor.view.dispatch(editor.state.tr.insertText("X", insertionPosition + 2))); + expect(editor.view.dom.querySelector("td")?.textContent).toBe(" XCell\ttext "); + expect(editor.getHTML()).toContain(preserve); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("does not mark or save mount, unmarked legacy import, or controlled prop updates", async () => { + let editor: Editor | undefined; + const onChange = vi.fn(); + const rendered = render(richInput("

Legacy\ttext

", onChange, (created) => (editor = created))); + await waitFor(() => expect(editor).toBeDefined()); + if (!editor) throw new Error("Editor did not initialize"); + expect(editor.getHTML()).toBe("

Legacy text

"); + expect(onChange).not.toHaveBeenCalled(); + + rendered.rerender(richInput("\n

Updated\tlegacy

\n", onChange, () => {})); + await waitFor(() => expect(editor?.getHTML()).toBe("

Updated legacy

")); + expect(editor.getHTML()).not.toContain("data-resume-whitespace"); + expect(onChange).not.toHaveBeenCalled(); + }); + + it.each(["en", "he", "ar"])("preserves Unicode and marked codepoints in %s editor direction", async (locale) => { + act(() => i18n.loadAndActivate({ locale, messages: {} })); + try { + const html = `

\u3000中\u00a0文 \t

`; + const { editor } = await input(html); + expect(editor.getHTML()).toBe( + locale === "en" + ? `

\u3000中 文 \t

` + : `

\u3000中 文 \t

`, + ); + expect(editor.getText()).toBe("\u3000中\u00a0文 \t "); + } finally { + act(() => i18n.loadAndActivate({ locale: "en", messages: {} })); + } + }); +}); diff --git a/packages/docx/src/html-to-docx.ts b/packages/docx/src/html-to-docx.ts index d25399d7b..f6c02a3b4 100644 --- a/packages/docx/src/html-to-docx.ts +++ b/packages/docx/src/html-to-docx.ts @@ -23,6 +23,16 @@ interface InlineStyle { type InlineChild = TextRun | ExternalHyperlink; +const preservesWhitespace = (node: Node) => { + let ancestor = node.parentElement; + while (ancestor) { + if (/^(P|H[1-6])$/.test(ancestor.tagName) && ancestor.getAttribute("data-resume-whitespace") === "preserve") + return true; + ancestor = ancestor.parentElement; + } + return false; +}; + /** Module-level link color, set per htmlToParagraphs invocation. */ let currentLinkColor = "0563C1"; @@ -87,7 +97,7 @@ function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] { for (const child of node.childNodes) { if (child.nodeType === Node.TEXT_NODE) { - const text = child.textContent ?? ""; + const text = (child.textContent ?? "").replace(/\t/g, preservesWhitespace(child) ? " " : "\t"); if (text) { children.push(new TextRun({ text, ...style })); } diff --git a/packages/docx/src/literal-whitespace.test.ts b/packages/docx/src/literal-whitespace.test.ts new file mode 100644 index 000000000..df085b9e4 --- /dev/null +++ b/packages/docx/src/literal-whitespace.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { Document } from "docx"; +import { htmlToParagraphs } from "./html-to-docx"; + +function paragraphXml(html: string) { + const paragraphs = htmlToParagraphs(html); + const file = new Document({ sections: [{ children: paragraphs }] }); + return JSON.stringify( + paragraphs.map((paragraph) => paragraph.prepForXml({ file, viewWrapper: file.Document, stack: [] })), + ); +} + +describe("DOCX literal whitespace (#3397)", () => { + it.each(["p", "h2"])("emits exact marked %s spaces with XML preservation", (tag) => { + const xml = paragraphXml(`<${tag} data-resume-whitespace="preserve"> Lead middle end `); + expect(xml).toContain(" Lead middle end "); + expect(xml).toContain('"xml:space":"preserve"'); + }); + + it("expands marked tabs to four ordinary spaces while leaving unmarked legacy tabs unchanged", () => { + const marked = paragraphXml('

A\tB\t\tC

'); + expect(marked).toContain("A B C"); + expect(marked).not.toContain("\\t"); + expect(marked).toContain('"xml:space":"preserve"'); + + const legacy = paragraphXml("

A\tB

"); + expect(legacy).toContain("A\\tB"); + }); + + it("preserves marked tabs through marks, line breaks, lists, quotes, and table cells", () => { + const html = [ + '

Bold
\tNext

', + '

\tQuoted

', + '', + '

\tCell

', + ].join(""); + const xml = paragraphXml(html); + expect(xml).toContain(" Bold"); + expect(xml).toContain(" Next"); + expect(xml).toContain(" Quoted"); + expect(xml).toContain(" Listed"); + expect(xml).toContain(" Cell"); + expect(xml).not.toContain("\\t"); + expect(xml).toContain('"w:b"'); + expect(xml).toContain('"w:br"'); + }); +}); diff --git a/packages/pdf/src/literal-whitespace.integration.test.tsx b/packages/pdf/src/literal-whitespace.integration.test.tsx new file mode 100644 index 000000000..9cee8d80c --- /dev/null +++ b/packages/pdf/src/literal-whitespace.integration.test.tsx @@ -0,0 +1,186 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; +import { act } from "react"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { createResumePdfFile } from "./server"; + +const require = createRequire(import.meta.url); +const standardFontDataUrl = `${join(dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts")}/`; +const preserve = 'data-resume-whitespace="preserve"'; + +type TextItem = { + text: string; + x: number; + y: number; + width: number; +}; + +async function renderItems(content: string, locale = "en-US", narrow = false): Promise { + const data = structuredClone(defaultResumeData); + data.picture.hidden = true; + data.basics.name = ""; + data.metadata.template = narrow ? "chikorita" : "onyx"; + data.metadata.page.locale = locale; + data.metadata.typography.body.fontFamily = "Helvetica"; + data.metadata.typography.heading.fontFamily = "Helvetica"; + data.metadata.layout.pages = [ + narrow ? { fullWidth: false, main: [], sidebar: ["summary"] } : { fullWidth: true, main: ["summary"], sidebar: [] }, + ]; + if (narrow) data.metadata.layout.sidebarWidth = 25; + data.summary.content = content; + + let file: File | undefined; + await act(async () => { + file = await createResumePdfFile({ data, filename: "literal-whitespace.pdf" }); + }); + if (!file) throw new Error("PDF generation failed"); + const task = getDocument({ data: new Uint8Array(await file.arrayBuffer()), standardFontDataUrl }); + try { + const document = await task.promise; + const items: TextItem[] = []; + for (let pageIndex = 0; pageIndex < document.numPages; pageIndex++) { + const page = await document.getPage(pageIndex + 1); + const text = await page.getTextContent(); + for (const item of text.items) { + if ("str" in item) + items.push({ text: item.str, x: item.transform[4], y: item.transform[5], width: item.width }); + } + } + return items; + } finally { + await task.destroy(); + } +} + +function lineMetrics(items: TextItem[]) { + const anchor = items.find((item) => item.text.includes("LIT")); + if (!anchor) throw new Error(`Expected LIT anchor in: ${items.map((item) => item.text).join("|")}`); + const line = items.filter((item) => Math.abs(item.y - anchor.y) < 0.01); + const start = Math.min(...line.map((item) => item.x)); + const end = Math.max(...line.map((item) => item.x + item.width)); + return { start, width: end - start, text: line.map((item) => item.text).join("") }; +} + +async function line(content: string, locale = "en-US") { + return lineMetrics(await renderItems(content, locale)); +} + +describe("actual PDF literal whitespace (#3397)", () => { + it.each(["en-US", "he-IL", "ar-SA"])( + "advances first content for marked leading spaces and tabs in %s", + async (locale) => { + const rtl = locale !== "en-US"; + const firstContent = async (prefix: string, marked = true) => { + const items = await renderItems( + `

${prefix}LIT AB END

`, + locale, + ); + const anchor = items.find((item) => item.text.includes("LIT")); + if (!anchor) throw new Error("Missing first-content anchor"); + expect( + items + .filter((item) => Math.abs(item.y - anchor.y) < 0.01) + .map((item) => item.text) + .join("") + .replace(/\s/g, ""), + ).toBe("LITABEND"); + return rtl ? anchor.x + anchor.width : anchor.x; + }; + const compact = await firstContent(""); + const spaces = await firstContent(" "); + const tab = await firstContent("\t"); + const sign = rtl ? -1 : 1; + // Helvetica body is 10pt, with an ordinary-space advance of 2.78pt. + expect(sign * (spaces - compact)).toBeCloseTo(5.56, 2); + expect(sign * (tab - compact)).toBeCloseTo(11.12, 2); + expect(await firstContent(" ", false)).toBeCloseTo(await firstContent("", false), 2); + }, + ); + + it("keeps literal layout local to marked siblings in the same PDF", async () => { + const items = await renderItems(`

\tLIT AB END

LIT AB END

LIT AB END

`); + const anchors = items.filter((item) => item.text.includes("LIT")); + expect(anchors).toHaveLength(3); + const [marked, legacy, compact] = anchors; + if (!marked || !legacy || !compact) throw new Error("Missing mixed-block anchors"); + expect(marked.x - legacy.x).toBeCloseTo(11.12, 2); + expect(legacy.x).toBeCloseTo(compact.x, 2); + for (const anchor of anchors) { + expect( + items + .filter((item) => Math.abs(item.y - anchor.y) < 0.01) + .map((item) => item.text) + .join("") + .replace(/\s/g, ""), + ).toBe("LITABEND"); + } + }); + + it("renders one tab as exactly four ordinary-space advances", async () => { + const compact = await line(`

LIT AB END

`); + const oneSpace = await line(`

LIT A B END

`); + const fourSpaces = await line(`

LIT A B END

`); + const tab = await line(`

LIT A\tB END

`); + const spaceAdvance = oneSpace.width - compact.width; + + expect(spaceAdvance).toBeGreaterThan(0); + expect(fourSpaces.width - compact.width).toBeCloseTo(spaceAdvance * 4, 2); + expect(tab.width).toBeCloseTo(fourSpaces.width, 2); + }); + + it("renders two tabs as eight spaces independent of current x", async () => { + const twoTabs = await line(`

LIT A\t\tB END

`); + const eightSpaces = await line(`

LIT A B END

`); + const oneLetter = await line(`

LIT A\tB END

`); + const oneLetterCompact = await line(`

LIT AB END

`); + const threeLetters = await line(`

LIT ABC\tD END

`); + const threeLettersCompact = await line(`

LIT ABCD END

`); + + expect(twoTabs.width).toBeCloseTo(eightSpaces.width, 2); + expect(oneLetter.width - oneLetterCompact.width).toBeCloseTo(threeLetters.width - threeLettersCompact.width, 2); + }); + + it.each(["en-US", "he-IL", "ar-SA"])("keeps marked tab geometry in %s", async (locale) => { + const tab = await line(`

LIT A\tB END

`, locale); + const spaces = await line(`

LIT A B END

`, locale); + expect(tab.width).toBeCloseTo(spaces.width, 2); + }); + + it("keeps unmarked collapse node-local and marked narrow content complete", async () => { + const unmarked = await line("

LIT A B END

"); + const collapsed = await line("

LIT A B END

"); + expect(unmarked).toEqual(collapsed); + + const sample = "LIT START\tSome breakable words continue through narrow content without disappearing END"; + const items = await renderItems(`

${sample}

`, "en-US", true); + const text = items + .map((item) => item.text) + .join("") + .replace(/\s/g, ""); + expect(text.slice(text.indexOf("LIT"))).toBe(sample.replace(/\s/g, "")); + }); + + it.each([ + [ + "list", + '
  • LIT A\tB END

', + '
  • LIT A B END

', + ], + [ + "quote", + '

LIT A\tB END

', + '

LIT A B END

', + ], + [ + "table cell", + '

LIT A\tB END

', + '

LIT A B END

', + ], + ] as const)("preserves marked tab geometry in a %s", async (_name, tabHtml, spacesHtml) => { + const tab = await line(tabHtml); + const spaces = await line(spacesHtml); + expect(tab.width).toBeCloseTo(spaces.width, 2); + }); +}); 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 c9459251c..f5f041b8f 100644 --- a/packages/pdf/src/templates/shared/rich-text-html.test.ts +++ b/packages/pdf/src/templates/shared/rich-text-html.test.ts @@ -9,6 +9,29 @@ type PdfElement = ReactElement<{ children?: unknown; element?: { tag: string } } const getPdfElementProps = (element: unknown) => (element as PdfElement).props; describe("normalizeRichTextHtml", () => { + it("expands tabs only inside marked paragraphs and headings", () => { + expect( + normalizeRichTextHtml( + '

A\tB

\tC

D\tE

', + ), + ).toBe( + '

A B

C

D\tE

', + ); + }); + + it("retains marked paragraphs inside lists so preservation stays node-local", () => { + expect(normalizeRichTextHtml('
  • Listed\ttext

')).toBe( + '
  • Listed text

', + ); + }); + + it("does not reinterpret marked RTL line breaks as pseudo-bullet lists", () => { + const html = '

- First
- Second

'; + expect(normalizeRichTextHtml(html, { direction: "rtl" })).toBe( + '

‏ - First
- Second

', + ); + }); + it("decodes opted-in soft hyphens in text without changing links or escaped literals", () => { const html = '

Soft­ware ­ ­ &shy; link

'; diff --git a/packages/pdf/src/templates/shared/rich-text-html.ts b/packages/pdf/src/templates/shared/rich-text-html.ts index 8c415bf3c..bf1a97443 100644 --- a/packages/pdf/src/templates/shared/rich-text-html.ts +++ b/packages/pdf/src/templates/shared/rich-text-html.ts @@ -104,6 +104,7 @@ const unwrapSingleParagraphListItems = (root: ReturnType) => { const child = meaningfulChildren[0]; if (!child || !isElement(child) || getTagName(child) !== "p") continue; + if (child.getAttribute("data-resume-whitespace") === "preserve") continue; listItem.innerHTML = child.innerHTML; } @@ -127,6 +128,18 @@ const normalizeParagraphIndentation = (root: ReturnType, direction } }; +const expandPreservedTabs = (root: ReturnType) => { + for (const element of root.querySelectorAll( + 'p[data-resume-whitespace="preserve"],h1[data-resume-whitespace="preserve"],h2[data-resume-whitespace="preserve"],h3[data-resume-whitespace="preserve"],h4[data-resume-whitespace="preserve"],h5[data-resume-whitespace="preserve"],h6[data-resume-whitespace="preserve"]', + )) { + const visit = (node: Node): void => { + if (node.nodeType === NodeType.TEXT_NODE) node.rawText = node.rawText.replace(/\t/g, " "); + for (const child of node.childNodes) visit(child); + }; + visit(element); + } +}; + 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; @@ -165,9 +178,11 @@ const tryConvertPseudoBulletParagraph = (paragraphInnerHtml: string): string | n export const convertPseudoBulletParagraphs = (html: string, direction: "ltr" | "rtl" = "ltr"): string => html.replace(/]*)>([\s\S]*?)<\/p>/gi, (full, _attrs, inner) => { + const paragraph = parse(full).querySelector("p"); + if (paragraph?.getAttribute("data-resume-whitespace") === "preserve") return full; const converted = tryConvertPseudoBulletParagraph(inner); if (!converted) return full; - const level = Number(parse(full).querySelector("p")?.getAttribute("data-indent")); + const level = Number(paragraph?.getAttribute("data-indent")); if (!Number.isInteger(level) || level <= 0 || level > 8) return converted; // Keep the original paragraph's offset around the entire generated list. return converted.replace( @@ -200,6 +215,7 @@ export const normalizeRichTextHtml = ( normalizeBoldBoundaryWhitespace(root); normalizeMarkElements(root); normalizeParagraphIndentation(root, direction); + expandPreservedTabs(root); unwrapSingleParagraphListItems(root); const flushInlineNodes = () => { diff --git a/packages/pdf/src/templates/shared/rich-text.tsx b/packages/pdf/src/templates/shared/rich-text.tsx index cd7f7242f..9f4e4f8a6 100644 --- a/packages/pdf/src/templates/shared/rich-text.tsx +++ b/packages/pdf/src/templates/shared/rich-text.tsx @@ -137,7 +137,11 @@ export const RichText = ({ children, semanticField }: RichTextProps) => { const visible = isNodeVisible(nodeKey); if (!visible) return null; const text = ( - + {textChildren} ); @@ -230,7 +234,11 @@ export const RichText = ({ children, semanticField }: RichTextProps) => { style: props.style, indent: Number(props.element.getAttribute("data-indent")), semanticStyle: resolved.style, - textProps: { ...resolvedPdfTextProps(resolved), hyphenationCallback }, + textProps: { + ...resolvedPdfTextProps(resolved), + hyphenationCallback, + "data-resume-whitespace": props.element.getAttribute("data-resume-whitespace"), + }, rtl, ...(rtlTextWrapStyle ? { rtlTextWrapStyle } : {}), ...(rtl ? { applyRtlDirection: applyRtlDirectionRecursively } : {}), diff --git a/patches/@react-pdf__layout@5.2.0.patch b/patches/@react-pdf__layout@5.2.0.patch index 2b9aa2324..b752e32a9 100644 --- a/patches/@react-pdf__layout@5.2.0.patch +++ b/patches/@react-pdf__layout@5.2.0.patch @@ -1,8 +1,43 @@ diff --git a/lib/index.js b/lib/index.js -index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..79e1898aadcbbfc78a206e235d868cb178683a07 100644 +index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..8e6117947e87e40dd39f1bf33a1435194cc3f334 100644 --- a/lib/index.js +++ b/lib/index.js -@@ -3163,7 +3163,10 @@ const splitNodes = (height, contentArea, nodes) => { +@@ -1887,7 +1887,7 @@ const isTextInstance$2 = (node) => node.type === P.TextInstance; + * @param level - Fragment level + * @returns Text fragments + */ +-const getFragments = (fontStore, instance, parentLink = null, level = 0) => { ++const getFragments = (fontStore, instance, parentLink = null, level = 0, parentPreserveWhitespace = false) => { + if (!instance) + return [{ string: '' }]; + let fragments = []; +@@ -1902,6 +1902,8 @@ const getFragments = (fontStore, instance, parentLink = null, level = 0) => { + }); + // Don't pass main background color to textkit. Will be rendered by the render package instead + const backgroundColor = level === 0 ? null : instance.style.backgroundColor; ++ // The editor's literal-whitespace contract is local to this Text subtree. ++ const preserveWhitespace = parentPreserveWhitespace || instance.props?.['data-resume-whitespace'] === 'preserve'; + const attributes = { + font, + color, +@@ -1911,6 +1913,7 @@ const getFragments = (fontStore, instance, parentLink = null, level = 0) => { + direction, + verticalAlign, + backgroundColor, ++ preserveWhitespace, + indent: textIndent, + characterSpacing: letterSpacing, + strikeStyle: textDecorationStyle, +@@ -1950,7 +1953,7 @@ const getFragments = (fontStore, instance, parentLink = null, level = 0) => { + }); + } + else if (child && !isImage$1(child)) { +- fragments.push(...getFragments(fontStore, child, attributes.link, level + 1)); ++ fragments.push(...getFragments(fontStore, child, attributes.link, level + 1, preserveWhitespace)); + } + } + for (let i = 0; i < PREPROCESSORS.length; i += 1) { +@@ -3163,7 +3166,10 @@ const splitNodes = (height, contentArea, nodes) => { if (shouldSplit) { const [currentChild, nextChild] = split(child, height, contentArea); // All children are moved to the next page, it doesn't make sense to show the parent on the current page @@ -14,7 +49,7 @@ index 18c2618f4630b3c1296c8c3cac317b8d7666bbe8..79e1898aadcbbfc78a206e235d868cb1 // But if the current page is empty then we can just include the parent on the current page if (currentChildren.length === 0) { currentChildren.push(child, ...futureFixedNodes); -@@ -3196,7 +3199,52 @@ const splitChildren = (height, contentArea, node) => { +@@ -3196,7 +3202,52 @@ const splitChildren = (height, contentArea, node) => { }; const splitView = (node, height, contentArea) => { const [currentNode, nextNode] = splitNode(node, height); diff --git a/patches/@react-pdf__textkit.patch b/patches/@react-pdf__textkit.patch index c11c73526..2f55677ce 100644 --- a/patches/@react-pdf__textkit.patch +++ b/patches/@react-pdf__textkit.patch @@ -1,8 +1,8 @@ diff --git a/lib/textkit.js b/lib/textkit.js -index 2c0ec95..3683493 100644 +index 7bfe0d9a119a8bd1ec98ff0f4827bd6cb3a967a4..92821b761893c6a5132a722f72bc86c1d1e9ec89 100644 --- a/lib/textkit.js +++ b/lib/textkit.js -@@ -709,6 +709,28 @@ const omit = (value, run) => { +@@ -715,6 +715,28 @@ const omit = (value, run) => { return Object.assign({}, run, { attributes }); }; @@ -31,7 +31,7 @@ index 2c0ec95..3683493 100644 /** * Get run ascent * -@@ -718,7 +743,7 @@ const omit = (value, run) => { +@@ -724,7 +746,7 @@ const omit = (value, run) => { const ascent$1 = (run) => { const { font, attachment } = run.attributes; const attachmentHeight = attachment?.height || 0; @@ -40,7 +40,7 @@ index 2c0ec95..3683493 100644 return Math.max(attachmentHeight, fontAscent * scale(run)); }; -@@ -730,7 +755,7 @@ const ascent$1 = (run) => { +@@ -736,7 +758,7 @@ const ascent$1 = (run) => { */ const descent = (run) => { const font = run.attributes?.font; @@ -49,7 +49,7 @@ index 2c0ec95..3683493 100644 return scale(run) * fontDescent; }; -@@ -742,8 +767,8 @@ const descent = (run) => { +@@ -748,8 +770,8 @@ const descent = (run) => { */ const lineGap = (run) => { const font = run.attributes?.font; @@ -60,7 +60,7 @@ index 2c0ec95..3683493 100644 }; /** -@@ -754,7 +779,8 @@ const lineGap = (run) => { +@@ -760,7 +782,8 @@ const lineGap = (run) => { */ const height$1 = (run) => { const lineHeight = run.attributes?.lineHeight; @@ -70,3 +70,25 @@ index 2c0ec95..3683493 100644 }; /** +@@ -1680,8 +1703,11 @@ const getOverflowRight = (line) => { + * @returns Line + */ + const adjustOverflow = (line) => { +- const overflowLeft = getOverflowLeft(line); +- const overflowRight = getOverflowRight(line); ++ // Preserve literal edge advances after bidi reordering. Other Text nodes ++ // retain textkit's HTML-style hanging whitespace alignment. ++ const preserveWhitespace = line.runs.some((run) => run.attributes.preserveWhitespace); ++ const overflowLeft = preserveWhitespace ? 0 : getOverflowLeft(line); ++ const overflowRight = preserveWhitespace ? 0 : getOverflowRight(line); + const x = line.box.x - overflowLeft; + const width = line.box.width + overflowLeft + overflowRight; + const box = Object.assign({}, line.box, { x, width }); +@@ -1842,6 +1868,7 @@ const applyAttributes = (a) => { + opacity: a.opacity, + paddingTop: a.paddingTop || a.padding || 0, + paragraphSpacing: a.paragraphSpacing || 0, ++ preserveWhitespace: a.preserveWhitespace === true, + script: a.script || null, + shrinkFactor: a.shrinkFactor || 0, + strike: a.strike || false, diff --git a/patches/react-pdf-html@2.1.5.patch b/patches/react-pdf-html@2.1.5.patch index c57644995..1474b164b 100644 --- a/patches/react-pdf-html@2.1.5.patch +++ b/patches/react-pdf-html@2.1.5.patch @@ -1,5 +1,5 @@ diff --git a/dist/cjs/render.js b/dist/cjs/render.js -index cea3de37aceb8e98b7698087639a6e98205d40e3..a1e34ea0bf60f3bca2d7e79e0354eda5245b927d 100644 +index cea3de37aceb8e98b7698087639a6e98205d40e3..57626f540ed7e818471cc3f1492101436f9cda09 100644 --- a/dist/cjs/render.js +++ b/dist/cjs/render.js @@ -72,8 +72,9 @@ const hasBlockContent = (element) => { @@ -14,7 +14,7 @@ index cea3de37aceb8e98b7698087639a6e98205d40e3..a1e34ea0bf60f3bca2d7e79e0354eda5 const isCustomElement = (element) => { if (!element || typeof element === 'string') return false; -@@ -160,7 +161,7 @@ const renderElement = (element, stylesheets, renderers, children, index) => { +@@ -160,13 +161,14 @@ const renderElement = (element, stylesheets, renderers, children, index) => { return (React.createElement(Element, { key: index, style: element.style, children: children, element: element, stylesheets: stylesheets })); }; exports.renderElement = renderElement; @@ -23,8 +23,16 @@ index cea3de37aceb8e98b7698087639a6e98205d40e3..a1e34ea0bf60f3bca2d7e79e0354eda5 exports.collapseWhitespace = collapseWhitespace; const renderBucketElement = (element, options, index) => { if (typeof element === 'string') { + return (0, exports.renderElement)(options.collapse ? (0, exports.collapseWhitespace)(element) : element, options.stylesheets, options.renderers, undefined, index); + } +- return (0, exports.renderElement)(element, options.stylesheets, options.renderers, (0, exports.renderElements)(element.content, element.tag === 'pre' ? Object.assign(Object.assign({}, options), { collapse: false }) : options, element), index); ++ const preservesWhitespace = element.attributes?.['data-resume-whitespace'] === 'preserve'; ++ return (0, exports.renderElement)(element, options.stylesheets, options.renderers, (0, exports.renderElements)(element.content, element.tag === 'pre' || preservesWhitespace ? Object.assign(Object.assign({}, options), { collapse: false }) : options, element), index); + }; + exports.renderBucketElement = renderBucketElement; + const isAnchor = (content) => { diff --git a/dist/esm/render.js b/dist/esm/render.js -index 30365527b67332d0a29f684152cad1d8417ae90d..18b24f488e81404df50d772bf5868bfcb61b810d 100644 +index 30365527b67332d0a29f684152cad1d8417ae90d..6488c6891397d3f2af2c5a85acd9918445451666 100644 --- a/dist/esm/render.js +++ b/dist/esm/render.js @@ -40,8 +40,9 @@ export const hasBlockContent = (element) => { @@ -39,7 +47,7 @@ index 30365527b67332d0a29f684152cad1d8417ae90d..18b24f488e81404df50d772bf5868bfc const isCustomElement = (element) => { if (!element || typeof element === 'string') return false; -@@ -126,7 +127,7 @@ export const renderElement = (element, stylesheets, renderers, children, index) +@@ -126,12 +127,13 @@ export const renderElement = (element, stylesheets, renderers, children, index) } return (React.createElement(Element, { key: index, style: element.style, children: children, element: element, stylesheets: stylesheets })); }; @@ -48,3 +56,10 @@ index 30365527b67332d0a29f684152cad1d8417ae90d..18b24f488e81404df50d772bf5868bfc export const renderBucketElement = (element, options, index) => { if (typeof element === 'string') { return renderElement(options.collapse ? collapseWhitespace(element) : element, options.stylesheets, options.renderers, undefined, index); + } +- return renderElement(element, options.stylesheets, options.renderers, renderElements(element.content, element.tag === 'pre' ? { ...options, collapse: false } : options, element), index); ++ const preservesWhitespace = element.attributes?.['data-resume-whitespace'] === 'preserve'; ++ return renderElement(element, options.stylesheets, options.renderers, renderElements(element.content, element.tag === 'pre' || preservesWhitespace ? { ...options, collapse: false } : options, element), index); + }; + const isAnchor = (content) => { + return Array.isArray(content) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4197605bc..f1d350c16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,10 +12,10 @@ overrides: uuid@<11.1.1: ^11.1.1 patchedDependencies: - '@react-pdf/layout@5.2.0': 75dac7cb8260f9c96cd9ab5522d095a3aa047881a9e9d1d2a597409b45944494 - '@react-pdf/textkit': 092a6fe8baf3c472a81cdf2ab52a00ec98ef3364e1b8e744005dbcf219a8a213 + '@react-pdf/layout@5.2.0': 0b6c66a14ff45a9b594fcd6c207fcb72dc9608384451803359d36bc8ec5c1245 + '@react-pdf/textkit': e1e963cedc6a2f322b4309c782bba317d2b49bad73da787321151a89491e5d62 fontkit@2.0.4: 90f4c51c676a88b91dcc397d60a677c77b5e6dc8e15ffb3538310965ef5c05a4 - react-pdf-html@2.1.5: f0618f9c9f654758495189f6c15e1ff2d45fbd1a4645c4a18885573aeadbbd10 + react-pdf-html@2.1.5: e8456fc885241f35e3b642ad6122015b88f855d717663793f5b3293c19afac9b importers: @@ -272,7 +272,7 @@ importers: version: 6.9.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0) react-pdf-html: specifier: ^2.1.5 - version: 2.1.5(patch_hash=f0618f9c9f654758495189f6c15e1ff2d45fbd1a4645c4a18885573aeadbbd10)(@react-pdf/renderer@4.9.0(react@19.2.8))(react@19.2.8) + version: 2.1.5(patch_hash=e8456fc885241f35e3b642ad6122015b88f855d717663793f5b3293c19afac9b)(@react-pdf/renderer@4.9.0(react@19.2.8))(react@19.2.8) resumable-stream: specifier: ^2.2.12 version: 2.2.12 @@ -451,9 +451,15 @@ importers: '@tiptap/extension-color': specifier: ^3.31.2 version: 3.31.2(@tiptap/extension-text-style@3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))) + '@tiptap/extension-heading': + specifier: ^3.31.2 + version: 3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2)) '@tiptap/extension-highlight': specifier: ^3.31.2 version: 3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2)) + '@tiptap/extension-paragraph': + specifier: ^3.31.2 + version: 3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2)) '@tiptap/extension-table': specifier: ^3.31.2 version: 3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))(@tiptap/pm@3.31.2) @@ -1144,7 +1150,7 @@ importers: version: 19.2.8 react-pdf-html: specifier: ^2.1.5 - version: 2.1.5(patch_hash=f0618f9c9f654758495189f6c15e1ff2d45fbd1a4645c4a18885573aeadbbd10)(@react-pdf/renderer@4.9.0(react@19.2.8))(react@19.2.8) + version: 2.1.5(patch_hash=e8456fc885241f35e3b642ad6122015b88f855d717663793f5b3293c19afac9b)(@react-pdf/renderer@4.9.0(react@19.2.8))(react@19.2.8) devDependencies: '@napi-rs/canvas': specifier: 1.0.8 @@ -11268,14 +11274,14 @@ snapshots: jay-peg: 1.1.1 png-js: 2.0.0 - '@react-pdf/layout@5.2.0(patch_hash=75dac7cb8260f9c96cd9ab5522d095a3aa047881a9e9d1d2a597409b45944494)': + '@react-pdf/layout@5.2.0(patch_hash=0b6c66a14ff45a9b594fcd6c207fcb72dc9608384451803359d36bc8ec5c1245)': dependencies: '@react-pdf/fns': 3.1.3 '@react-pdf/image': 3.1.2 '@react-pdf/paginate': 1.0.1 '@react-pdf/primitives': 4.4.0 '@react-pdf/stylesheet': 6.3.2 - '@react-pdf/textkit': 7.0.1(patch_hash=092a6fe8baf3c472a81cdf2ab52a00ec98ef3364e1b8e744005dbcf219a8a213) + '@react-pdf/textkit': 7.0.1(patch_hash=e1e963cedc6a2f322b4309c782bba317d2b49bad73da787321151a89491e5d62) '@react-pdf/types': 2.14.0 emoji-regex-xs: 1.0.0 queue: 6.0.2 @@ -11296,7 +11302,7 @@ snapshots: '@babel/runtime': 7.29.7 '@react-pdf/fns': 3.1.3 '@react-pdf/primitives': 4.4.0 - '@react-pdf/textkit': 7.0.1(patch_hash=092a6fe8baf3c472a81cdf2ab52a00ec98ef3364e1b8e744005dbcf219a8a213) + '@react-pdf/textkit': 7.0.1(patch_hash=e1e963cedc6a2f322b4309c782bba317d2b49bad73da787321151a89491e5d62) '@react-pdf/types': 2.14.0 abs-svg-path: 0.1.1 color-string: 2.1.4 @@ -11309,7 +11315,7 @@ snapshots: '@babel/runtime': 7.29.7 '@react-pdf/fns': 3.1.3 '@react-pdf/font': 4.1.2 - '@react-pdf/layout': 5.2.0(patch_hash=75dac7cb8260f9c96cd9ab5522d095a3aa047881a9e9d1d2a597409b45944494) + '@react-pdf/layout': 5.2.0(patch_hash=0b6c66a14ff45a9b594fcd6c207fcb72dc9608384451803359d36bc8ec5c1245) '@react-pdf/primitives': 4.4.0 '@react-pdf/reconciler': 2.0.0(react@19.2.8) '@react-pdf/render': 4.7.0 @@ -11334,7 +11340,7 @@ snapshots: dependencies: '@react-pdf/primitives': 4.4.0 - '@react-pdf/textkit@7.0.1(patch_hash=092a6fe8baf3c472a81cdf2ab52a00ec98ef3364e1b8e744005dbcf219a8a213)': + '@react-pdf/textkit@7.0.1(patch_hash=e1e963cedc6a2f322b4309c782bba317d2b49bad73da787321151a89491e5d62)': dependencies: '@react-pdf/fns': 3.1.3 '@react-pdf/hyphenate': 0.1.0 @@ -15224,7 +15230,7 @@ snapshots: transitivePeerDependencies: - supports-color - react-pdf-html@2.1.5(patch_hash=f0618f9c9f654758495189f6c15e1ff2d45fbd1a4645c4a18885573aeadbbd10)(@react-pdf/renderer@4.9.0(react@19.2.8))(react@19.2.8): + react-pdf-html@2.1.5(patch_hash=e8456fc885241f35e3b642ad6122015b88f855d717663793f5b3293c19afac9b)(@react-pdf/renderer@4.9.0(react@19.2.8))(react@19.2.8): dependencies: '@react-pdf/renderer': 4.9.0(react@19.2.8) css-tree: 1.1.3 diff --git a/tests/e2e/specs/literal-whitespace.spec.ts b/tests/e2e/specs/literal-whitespace.spec.ts new file mode 100644 index 000000000..d70233e45 --- /dev/null +++ b/tests/e2e/specs/literal-whitespace.spec.ts @@ -0,0 +1,216 @@ +import type { Page, TestInfo } from "@playwright/test"; +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { promisify } from "node:util"; +import { Pool } from "pg"; +import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +const requireWeb = createRequire(`${process.cwd()}/apps/web/package.json`); +const execFileAsync = promisify(execFile); +const preservedHtml = + '

LIT A\tB END

REF A B END

CTL A B END

\tTAB A B END

'; + +async function readSummary(id: string) { + if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for literal-whitespace E2E."); + const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + try { + const result = await pool.query<{ content: string }>( + `select data->'summary'->>'content' as content from "resume" where id = $1`, + [id], + ); + const row = result.rows[0]; + if (!row) throw new Error(`Resume ${id} was not found.`); + return row.content; + } finally { + await pool.end(); + } +} + +async function download(page: Page, testInfo: TestInfo, format: "PDF" | "DOCX" | "JSON") { + await openSidebarSection(page, "Export"); + await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click(); + const pending = page.waitForEvent("download"); + await page.getByRole("button", { name: `Download ${format}`, exact: true }).click(); + const result = await pending; + const path = testInfo.outputPath(`literal-whitespace.${format.toLowerCase()}`); + await result.saveAs(path); + return path; +} + +async function pdfLineMetrics(path: string) { + const { getDocument } = await import(requireWeb.resolve("pdfjs-dist/legacy/build/pdf.mjs")); + const loading = getDocument({ data: new Uint8Array(await readFile(path)), useSystemFonts: true }); + try { + const document = await loading.promise; + const lines: Array<{ marker: string; start: number; gap: number; tailWidth: number }> = []; + for (let pageIndex = 0; pageIndex < document.numPages; pageIndex++) { + const page = await document.getPage(pageIndex + 1); + const items = (await page.getTextContent()).items.flatMap((item) => + "str" in item ? [{ text: item.str, x: item.transform[4], y: item.transform[5], width: item.width }] : [], + ); + for (const marker of ["LIT", "REF", "CTL", "TAB"]) { + const anchor = items.find((item) => item.text.includes(marker)); + if (!anchor) continue; + const tail = items.find( + (item) => item.x > anchor.x && Math.abs(item.y - anchor.y) < 0.01 && item.text.includes("B END"), + ); + if (!tail) continue; + const anchorEnd = anchor.x + anchor.width; + lines.push({ marker, start: anchor.x, gap: tail.x - anchorEnd, tailWidth: tail.x + tail.width - anchorEnd }); + } + } + return lines; + } finally { + await loading.destroy(); + } +} + +test("persists typed and pasted literal whitespace through JSON, PDF, and DOCX", async ({ + authPage: page, +}, testInfo) => { + test.setTimeout(180_000); + await createSampleResumeFromDashboard(page, testInfo); + const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1]; + if (!id) throw new Error("Missing resume id."); + + await openSidebarSection(page, "Summary"); + const editor = page.locator("#sidebar-summary [data-editor=true]"); + await editor.click(); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("Backspace"); + await page.keyboard.insertText(" LIT A\tB END "); + await page.keyboard.press("Enter"); + await editor.evaluate((element) => { + const clipboard = new DataTransfer(); + clipboard.setData("text/plain", " REF A B END \nCTL A B END\n\tTAB A B END"); + element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard })); + }); + + await expect.poll(() => readSummary(id)).toBe(preservedHtml); + await page.reload(); + await openSidebarSection(page, "Summary"); + const reloadedEditor = page.locator("#sidebar-summary [data-editor=true]"); + await expect(reloadedEditor).toBeVisible(); + expect(await reloadedEditor.evaluate((element) => element.textContent)).toBe( + " LIT A\tB END REF A B END CTL A B END\tTAB A B END", + ); + expect( + await reloadedEditor + .locator("p") + .first() + .evaluate((element) => getComputedStyle(element).whiteSpace), + ).toBe("pre-wrap"); + expect(await readSummary(id)).toBe(preservedHtml); + + const jsonPath = await download(page, testInfo, "JSON"); + const exported = JSON.parse(await readFile(jsonPath, "utf8")) as { summary: { content: string } }; + expect(exported.summary.content).toBe(preservedHtml); + + const pdfPath = await download(page, testInfo, "PDF"); + const lines = await pdfLineMetrics(pdfPath); + const literal = lines.find((line) => line.marker === "LIT"); + const reference = lines.find((line) => line.marker === "REF"); + expect(literal).toBeDefined(); + expect(reference).toBeDefined(); + if (!literal || !reference) throw new Error("Expected literal whitespace PDF lines."); + expect(literal.start).toBeCloseTo(reference.start, 2); + expect(literal.gap).toBeCloseTo(reference.gap, 2); + expect(literal.tailWidth).toBeCloseTo(reference.tailWidth, 2); + const control = lines.find((line) => line.marker === "CTL"); + const leadingTab = lines.find((line) => line.marker === "TAB"); + if (!control || !leadingTab) throw new Error("Expected unpadded and leading-tab PDF controls."); + expect(literal.gap).toBeGreaterThan(0); + expect(literal.start - control.start).toBeCloseTo(literal.gap / 2, 2); + expect(leadingTab.start - control.start).toBeCloseTo(literal.gap, 2); + await testInfo.attach("pdf-whitespace-geometry", { + body: JSON.stringify(lines, null, 2), + contentType: "application/json", + }); + + const docxPath = await download(page, testInfo, "DOCX"); + const { stdout: documentXml } = await execFileAsync("unzip", ["-p", docxPath, "word/document.xml"], { + encoding: "utf8", + }); + expect(documentXml).toContain('xml:space="preserve"> LIT A B END '); + expect(documentXml).toContain('xml:space="preserve"> REF A B END '); + expect(documentXml).toContain('xml:space="preserve">CTL A B END'); + expect(documentXml).toContain('xml:space="preserve"> TAB A B END'); +}); + +test("displays each stored tab as four spaces with usable caret and selection", async ({ + authPage: page, +}, testInfo) => { + await createSampleResumeFromDashboard(page, testInfo); + await openSidebarSection(page, "Summary"); + const editor = page.locator("#sidebar-summary [data-editor=true]"); + await editor.click(); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("Backspace"); + await editor.evaluate((element) => { + const clipboard = new DataTransfer(); + clipboard.setData("text/plain", "A\tB\nAB\nABC\tD\nABCD\nA B\n\tZ\nZ\nA\t\tB"); + element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard })); + }); + for (const direction of ["ltr", "rtl"] as const) { + const geometry = await editor.evaluate((element, direction) => { + element.dir = direction; + element.style.fontFamily = "Arial"; + // Measure ordinary-space advances without Arial's A/space pair kerning. + element.style.fontKerning = "none"; + const blocks = Array.from(element.querySelectorAll("p")); + const glyph = (index: number, offset: number) => { + const walker = document.createTreeWalker(blocks[index], NodeFilter.SHOW_TEXT); + let remaining = offset; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (remaining < (node.textContent?.length ?? 0)) { + const range = document.createRange(); + range.setStart(node, remaining); + range.setEnd(node, remaining + 1); + return range.getBoundingClientRect(); + } + remaining -= node.textContent?.length ?? 0; + } + throw new Error("Missing glyph"); + }; + return { + one: glyph(0, 2).x - glyph(0, 0).right, + three: glyph(2, 4).x - glyph(2, 2).right, + spaces: glyph(4, 5).x - glyph(4, 0).right, + leading: (direction === "rtl" ? -1 : 1) * (glyph(5, 1).x - glyph(6, 0).x), + two: glyph(7, 3).x - glyph(7, 0).right, + text: blocks.map((block) => block.textContent), + }; + }, direction); + await testInfo.attach(`tab-geometry-${direction}`, { + body: JSON.stringify(geometry, null, 2), + contentType: "application/json", + }); + expect(geometry.spaces).toBeGreaterThan(0); + for (const width of [geometry.one, geometry.three, geometry.leading]) expect(width).toBeCloseTo(geometry.spaces, 1); + expect(geometry.two).toBeCloseTo(geometry.spaces * 2, 1); + expect(geometry.text).toEqual(["A\tB", "AB", "ABC\tD", "ABCD", "A B", "\tZ", "Z", "A\t\tB"]); + } + const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1]; + if (!id) throw new Error("Missing resume id."); + await expect.poll(() => readSummary(id)).toContain('data-resume-whitespace="preserve">A\tB

'); + await page.reload(); + await openSidebarSection(page, "Summary"); + await editor.click(); + await editor.evaluate((element) => { + const text = element.querySelector("p")?.firstChild; + if (!text) throw new Error("Missing caret target"); + const range = document.createRange(); + range.setStart(text, 1); + range.collapse(true); + getSelection()?.removeAllRanges(); + getSelection()?.addRange(range); + }); + await page.keyboard.press("Shift+ArrowRight"); + expect(await page.evaluate(() => getSelection()?.toString())).toBe("\t"); + await page.keyboard.insertText("Q"); + await expect(editor.locator("p").first()).toHaveText("AQB"); + await page.keyboard.press("ControlOrMeta+z"); + expect(await editor.locator("p").first().textContent()).toBe("A\tB"); +});