fix(editor): preserve literal rich-text whitespace (#3472)

* fix(web): preserve imported rich-text tables

* fix(web): harden imported table preservation

* fix(web): fail closed on lossy table markup

* chore: remove plan 16 evidence reports

* fix(web): close imported table preservation gaps

* fix(editor): preserve literal rich-text whitespace

* chore: remove plan 19 evidence report

* fix: preserve literal whitespace through layout and editor transforms

* fix: preserve whitespace in bare table cells

* chore: keep orchestration evidence untracked
This commit is contained in:
Amruth Pillai
2026-09-05 19:57:50 -07:00
committed by GitHub
parent a6057abd79
commit ea97de5ec4
16 changed files with 1141 additions and 36 deletions
+2
View File
@@ -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",
@@ -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<LiteralWhitespaceOptions>({
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;
},
}),
];
},
});
@@ -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("<p>First</p>");
act(() => {
editor.view.dispatch(editor.state.tr.insertText(" \t", 1));
});
const saved = editor.getHTML();
expect(saved).toBe("<p> \tFirst</p>");
expect(saved).toBe('<p data-resume-whitespace="preserve"> \tFirst</p>');
expect(onChange).toHaveBeenLastCalledWith(saved);
act(() => {
editor.commands.setContent(saved, { emitUpdate: false });
});
expect(editor.getHTML()).toBe("<p>First</p>");
expect(editor.getHTML()).toBe(saved);
});
});
+17 -5
View File
@@ -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<string, ValueValidator>([
]);
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]",
@@ -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 (
<I18nProvider i18n={i18n}>
<PromptDialogProvider>
<RichInput value={value} onChange={onChange} onCreate={({ editor }) => onCreate(editor)} />
</PromptDialogProvider>
</I18nProvider>
);
}
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 </${tag}>`;
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}<p></p>`);
expect(onChange).not.toHaveBeenCalled();
});
it("keeps marked whitespace through marks, line breaks, undo, redo, and remount", async () => {
const original = `<p ${preserve}><strong> Bold</strong> tail<br> Second\t </p>`;
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("<p>First</p>");
act(() => editor.view.dispatch(editor.state.tr.insertText(" \t", 1)));
const saved = `<p ${preserve}> \tFirst</p>`;
expect(editor.getHTML()).toBe(saved);
expect(onChange).toHaveBeenLastCalledWith(saved);
act(() => editor.commands.undo());
expect(editor.getHTML()).toBe("<p>First</p>");
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("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML("<p> Pasted\ttext<br> next </p>");
});
expect(editor.getHTML()).toBe(`<p ${preserve}> Pasted\ttext<br> next </p>`);
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("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteText(" Plain\ttext \n\tSecond ");
});
expect(editor.getHTML()).toBe(`<p ${preserve}> Plain\ttext </p><p ${preserve}>\tSecond </p>`);
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("<p>First</p>");
act(() => {
editor.commands.setTextSelection(3);
editor.commands.enter();
});
expect(editor.getHTML()).toBe(`<p ${preserve}>Fi</p><p ${preserve}>rst</p>`);
act(() => {
editor.commands.setTextSelection(3);
editor.commands.setHardBreak();
});
expect(editor.getHTML()).toBe(`<p ${preserve}>Fi<br></p><p ${preserve}>rst</p>`);
});
it("preserves marked whitespace through paragraph, heading, and list transitions", async () => {
const html = `<p ${preserve}> First\t </p>`;
const { editor } = await input(html);
act(() => editor.commands.toggleHeading({ level: 2 }));
expect(editor.getHTML()).toBe(`<h2 ${preserve}> First\t </h2><p></p>`);
act(() => editor.commands.setParagraph());
expect(editor.getHTML()).toBe(`${html}<p></p>`);
act(() => editor.commands.toggleBulletList());
expect(editor.getHTML()).toBe(`<ul><li><p ${preserve}> First\t </p></li></ul><p></p>`);
act(() => editor.commands.toggleBulletList());
expect(editor.getHTML()).toBe(`${html}<p></p>`);
});
it.each(["bullet", "ordered"])("keeps heading whitespace through %s list conversion and reload", async (list) => {
const { editor } = await input(`<h2 ${preserve}> Literal\t </h2>`);
const toggle = () => (list === "bullet" ? editor.commands.toggleBulletList() : editor.commands.toggleOrderedList());
act(toggle);
const saved = editor.getHTML();
expect(saved).toContain(`<p ${preserve}> Literal\t </p>`);
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(`<h2 ${preserve}> Literal\t </h2>`);
});
it("keeps structural markers local to previously marked blocks, including undo and redo", async () => {
const html = `<p ${preserve}> First\t </p><p>Legacy</p>`;
const { editor } = await input(html);
act(() => {
editor.commands.selectAll();
editor.commands.toggleHeading({ level: 2 });
});
const saved = `<h2 ${preserve}> First\t </h2><h2>Legacy</h2><p></p>`;
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(`<p ${preserve}> First\t </p><p ${preserve}> Second\t </p>`);
act(() => {
editor.commands.selectAll();
editor.commands.toggleHeading({ level: 2 });
});
const headings = `<h2 ${preserve}> First\t </h2><h2 ${preserve}> Second\t </h2><p></p>`;
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(`<p ${preserve}> First\t </p><p ${preserve}> Second\t </p>`);
act(() => editor.commands.setContent(saved, { emitUpdate: false }));
expect(editor.getHTML()).toBe(saved);
});
it.each([
['<p title="a > b"> Text\tX </p>', `<p ${preserve}> Text\tX </p>`],
["<p title='a < b > c'> Text\tX </p>", `<p ${preserve}> Text\tX </p>`],
["<div> Text\tX </div>", `<p ${preserve}> Text\tX </p>`],
['<meta charset="utf-8"><div> Text\tX </div>', `<p ${preserve}> Text\tX </p>`],
["<!--StartFragment--><div> Text\tX </div><!--EndFragment-->", `<p ${preserve}> Text\tX </p>`],
[
"<div><div> First\t </div><div> Second\t </div></div>",
`<p ${preserve}> First\t </p><p ${preserve}> Second\t </p>`,
],
["<ul><li> Text\tX </li></ul>", `<ul><li><p ${preserve}> Text\tX </p></li></ul><p></p>`],
["<ol><li> Text\tX </li></ol>", `<ol><li><p ${preserve}> Text\tX </p></li></ol><p></p>`],
["<blockquote> Text\tX </blockquote>", `<blockquote><p ${preserve}> Text\tX </p></blockquote><p></p>`],
["<div> First<br> Second\t </div>", `<p ${preserve}> First<br> Second\t </p>`],
])("preserves supported clipboard text and baseline blocks: %s", async (html, expected) => {
const { editor } = await input("<p>Start</p>");
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("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML("<table><tbody><tr><td><p> Cell\ttext </p></td></tr></tbody></table>");
});
expect(editor.getHTML()).toContain(`<p ${preserve}> Cell\ttext </p>`);
});
it.each(["td", "th"])("preserves whitespace in pasted bare %s table cells", async (cellTag) => {
const { editor } = await input("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML(`<table><tbody><tr><${cellTag}> Cell\ttext </${cellTag}></tr></tbody></table>`);
});
expect(editor.getHTML()).toContain(`<p ${preserve}> Cell\ttext </p>`);
expect(editor.getText()).toContain(" Cell\ttext ");
});
it.each([
"<table><tr><td><p> Cell\t </p></td></tr></table>",
'<table border="1"><tbody><tr><td><p> Cell\t </p></td></tr></tbody></table>',
])("rejects unsafe table paste before DOM normalization: %s", async (html) => {
const { editor, onChange } = await input("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML(html);
});
expect(editor.getHTML()).toBe("<p>Start</p>");
expect(onChange).not.toHaveBeenCalled();
});
it("keeps marked paragraphs editable inside supported table cells", async () => {
const html = `<table><tbody><tr><td colspan="1" rowspan="1"><p ${preserve}> Cell\ttext </p></td></tr></tbody></table><p></p>`;
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("<p> Legacy\ttext</p>", onChange, (created) => (editor = created)));
await waitFor(() => expect(editor).toBeDefined());
if (!editor) throw new Error("Editor did not initialize");
expect(editor.getHTML()).toBe("<p>Legacy text</p>");
expect(onChange).not.toHaveBeenCalled();
rendered.rerender(richInput("\n <p> Updated\tlegacy</p>\n", onChange, () => {}));
await waitFor(() => expect(editor?.getHTML()).toBe("<p>Updated legacy</p>"));
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 = `<p ${preserve}>\u3000中\u00a0文 \t </p>`;
const { editor } = await input(html);
expect(editor.getHTML()).toBe(
locale === "en"
? `<p ${preserve}>\u3000中&nbsp;文 \t </p>`
: `<p dir="rtl" ${preserve}>\u3000中&nbsp;文 \t </p>`,
);
expect(editor.getText()).toBe("\u3000中\u00a0文 \t ");
} finally {
act(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
}
});
});
+11 -1
View File
@@ -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 }));
}
@@ -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 </${tag}>`);
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('<p data-resume-whitespace="preserve">A\tB\t\tC</p>');
expect(marked).toContain("A B C");
expect(marked).not.toContain("\\t");
expect(marked).toContain('"xml:space":"preserve"');
const legacy = paragraphXml("<p>A\tB</p>");
expect(legacy).toContain("A\\tB");
});
it("preserves marked tabs through marks, line breaks, lists, quotes, and table cells", () => {
const html = [
'<p data-resume-whitespace="preserve"><strong> Bold</strong><br>\tNext</p>',
'<blockquote><p data-resume-whitespace="preserve">\tQuoted</p></blockquote>',
'<ul><li><p data-resume-whitespace="preserve">\tListed</p></li></ul>',
'<table><tbody><tr><td><p data-resume-whitespace="preserve">\tCell</p></td></tr></tbody></table>',
].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"');
});
});
@@ -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<TextItem[]> {
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(
`<p ${marked ? preserve : ""}>${prefix}<strong>LIT</strong> AB END</p>`,
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(`<p ${preserve}>\tLIT AB END</p><p> LIT AB END</p><p>LIT AB END</p>`);
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(`<p ${preserve}>LIT AB END</p>`);
const oneSpace = await line(`<p ${preserve}>LIT A B END</p>`);
const fourSpaces = await line(`<p ${preserve}>LIT A B END</p>`);
const tab = await line(`<p ${preserve}>LIT A\tB END</p>`);
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(`<p ${preserve}>LIT A\t\tB END</p>`);
const eightSpaces = await line(`<p ${preserve}>LIT A B END</p>`);
const oneLetter = await line(`<p ${preserve}>LIT A\tB END</p>`);
const oneLetterCompact = await line(`<p ${preserve}>LIT AB END</p>`);
const threeLetters = await line(`<p ${preserve}>LIT ABC\tD END</p>`);
const threeLettersCompact = await line(`<p ${preserve}>LIT ABCD END</p>`);
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(`<p ${preserve}>LIT A\tB END</p>`, locale);
const spaces = await line(`<p ${preserve}>LIT A B END</p>`, locale);
expect(tab.width).toBeCloseTo(spaces.width, 2);
});
it("keeps unmarked collapse node-local and marked narrow content complete", async () => {
const unmarked = await line("<p>LIT A B END</p>");
const collapsed = await line("<p>LIT A B END</p>");
expect(unmarked).toEqual(collapsed);
const sample = "LIT START\tSome breakable words continue through narrow content without disappearing END";
const items = await renderItems(`<p ${preserve}>${sample}</p>`, "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",
'<ul><li><p data-resume-whitespace="preserve">LIT A\tB END</p></li></ul>',
'<ul><li><p data-resume-whitespace="preserve">LIT A B END</p></li></ul>',
],
[
"quote",
'<blockquote><p data-resume-whitespace="preserve">LIT A\tB END</p></blockquote>',
'<blockquote><p data-resume-whitespace="preserve">LIT A B END</p></blockquote>',
],
[
"table cell",
'<table><tbody><tr><td><p data-resume-whitespace="preserve">LIT A\tB END</p></td></tr></tbody></table>',
'<table><tbody><tr><td><p data-resume-whitespace="preserve">LIT A B END</p></td></tr></tbody></table>',
],
] 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);
});
});
@@ -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(
'<p data-resume-whitespace="preserve">A\tB</p><h2 data-resume-whitespace="preserve">\tC</h2><p>D\tE</p>',
),
).toBe(
'<p data-resume-whitespace="preserve">A B</p><h2 data-resume-whitespace="preserve"> C</h2><p>D\tE</p>',
);
});
it("retains marked paragraphs inside lists so preservation stays node-local", () => {
expect(normalizeRichTextHtml('<ul><li><p data-resume-whitespace="preserve"> Listed\ttext </p></li></ul>')).toBe(
'<ul><li><p data-resume-whitespace="preserve"> Listed text </p></li></ul>',
);
});
it("does not reinterpret marked RTL line breaks as pseudo-bullet lists", () => {
const html = '<p data-resume-whitespace="preserve"> - First<br> - Second</p>';
expect(normalizeRichTextHtml(html, { direction: "rtl" })).toBe(
'<p data-resume-whitespace="preserve">‏ - First<br> - Second</p>',
);
});
it("decodes opted-in soft hyphens in text without changing links or escaped literals", () => {
const html =
'<p title="&shy;">Soft&shy;ware &#173; &#xAD; &amp;shy; <a href="https://example.com/&shy;">link</a></p>';
@@ -104,6 +104,7 @@ const unwrapSingleParagraphListItems = (root: ReturnType<typeof parse>) => {
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<typeof parse>, direction
}
};
const expandPreservedTabs = (root: ReturnType<typeof parse>) => {
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(/<p\b([^>]*)>([\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 = () => {
@@ -137,7 +137,11 @@ export const RichText = ({ children, semanticField }: RichTextProps) => {
const visible = isNodeVisible(nodeKey);
if (!visible) return null;
const text = (
<PdfText {...resolvedPdfTextProps(resolved)} style={composeStyles(style, resolved.style, safeTextStyle)}>
<PdfText
{...resolvedPdfTextProps(resolved)}
data-resume-whitespace={element.getAttribute("data-resume-whitespace")}
style={composeStyles(style, resolved.style, safeTextStyle)}
>
{textChildren}
</PdfText>
);
@@ -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 } : {}),
+38 -3
View File
@@ -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);
+28 -6
View File
@@ -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,
+19 -4
View File
@@ -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)
+17 -11
View File
@@ -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
+216
View File
@@ -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 =
'<p data-resume-whitespace="preserve"> LIT A\tB END </p><p data-resume-whitespace="preserve"> REF A B END </p><p data-resume-whitespace="preserve">CTL A B END</p><p data-resume-whitespace="preserve">\tTAB A B END</p>';
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 </w:t>');
expect(documentXml).toContain('xml:space="preserve"> REF A B END </w:t>');
expect(documentXml).toContain('xml:space="preserve">CTL A B END</w:t>');
expect(documentXml).toContain('xml:space="preserve"> TAB A B END</w:t>');
});
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</p>');
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");
});