mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 01:34:26 +10:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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中 文 \t </p>`
|
||||
: `<p dir="rtl" ${preserve}>\u3000中 文 \t </p>`,
|
||||
);
|
||||
expect(editor.getText()).toBe("\u3000中\u00a0文 \t ");
|
||||
} finally {
|
||||
act(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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="­">Soft­ware ­ ­ &shy; <a href="https://example.com/­">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 } : {}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+17
-11
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user