mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 17:54:22 +10:00
Merge remote-tracking branch 'origin/main' into codex/issue-execution-ledger
This commit is contained in:
@@ -58,6 +58,7 @@
|
||||
"@tanstack/react-router": "^1.170.32",
|
||||
"@tiptap/extension-color": "^3.31.2",
|
||||
"@tiptap/extension-highlight": "^3.31.2",
|
||||
"@tiptap/extension-table": "^3.31.2",
|
||||
"@tiptap/extension-text-align": "^3.31.2",
|
||||
"@tiptap/extension-text-style": "^3.31.2",
|
||||
"@tiptap/pm": "^3.31.2",
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Editor, JSONContent } from "@tiptap/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { useState } from "react";
|
||||
import { PromptDialogProvider } from "@/hooks/use-prompt";
|
||||
import { RichInput } from "./rich-input";
|
||||
|
||||
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
|
||||
|
||||
const inlineTable = `<table style="width: 300pt; border-collapse: collapse"><tbody><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Alpha</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Beta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Gamma</td></tr><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Delta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Epsilon</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Zeta</td></tr></tbody></table>`;
|
||||
|
||||
const complexTable = `<table style="width: 300pt; border-collapse: collapse"><tbody><tr><th colspan="2" rowspan="2" style="width: 200pt; border: 2pt dashed #123456; padding: 3pt"><p><strong>Lead</strong> cell</p><p><em>Second</em> paragraph</p></th><th style="width: 100pt">Side</th></tr><tr><td style="border-left: 1pt solid rgb(1, 2, 3)"><p>Tail</p></td></tr></tbody></table>`;
|
||||
|
||||
const semanticTable = `<table style="width: 300pt; border-spacing: 0"><tbody><tr style="height: 24pt"><td colspan="2" rowspan="1" colwidth="100,200" align="right" style="padding: 4pt; text-align: right"><p data-indent="2" style="margin-inline-start: 48px; text-align: justify"><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow" class="link"><span style="color: #123456">Inside</span></a> <mark data-color="#ffff00" style="background-color: #ffff00; color: inherit">Mark</mark> <mark data-color="#000000" style="background-color: #000000; color: inherit; color: #ffffff">Dark</mark></p></td><td><ol start="2"><li style="text-align: center">Two</li></ol></td></tr></tbody></table>`;
|
||||
|
||||
const unsupportedTable = `<table border="1" style="width: 300pt"><tbody><tr><td>Original</td></tr></tbody></table>`;
|
||||
|
||||
const unsupportedTables = [
|
||||
["legacy table attributes", unsupportedTable],
|
||||
["truncated table marker", "<table"],
|
||||
["truncated table opening tag", '<table style="width: 100pt"'],
|
||||
["truncated table closing tag", "</table"],
|
||||
["unclosed table", "<table><tbody><tr><td>Open"],
|
||||
["truncated table after a complete table", "<table><tbody><tr><td>Complete</td></tr></tbody></table><table"],
|
||||
[
|
||||
"unrepresented descendant elements and attributes",
|
||||
`<table><tbody><tr><td><section aria-label="keep">Inside</section></td></tr></tbody></table>`,
|
||||
],
|
||||
[
|
||||
"multiple table bodies",
|
||||
"<table><tbody><tr><td>First</td></tr></tbody><tbody><tr><td>Second</td></tr></tbody></table>",
|
||||
],
|
||||
["empty table body", "<table><tbody></tbody></table>"],
|
||||
["non-row table body child", "<table><tbody><script></script><tr><td>Inside</td></tr></tbody></table>"],
|
||||
["non-cell table row child", "<table><tbody><tr><script></script><td>Inside</td></tr></tbody></table>"],
|
||||
["mismatched row widths", "<table><tbody><tr><td>One</td></tr><tr><td>Two</td><td>Three</td></tr></tbody></table>"],
|
||||
[
|
||||
"non-rectangular column span",
|
||||
'<table><tbody><tr><td colspan="2">Wide</td></tr><tr><td colspan="3">Wider</td></tr></tbody></table>',
|
||||
],
|
||||
["row span beyond table bounds", '<table><tbody><tr><td rowspan="2">Inside</td></tr></tbody></table>'],
|
||||
[
|
||||
"row span collision",
|
||||
'<table><tbody><tr><td rowspan="2">Tall</td><td>Side</td></tr><tr><td colspan="2">Overlap</td></tr></tbody></table>',
|
||||
],
|
||||
["browser-repaired malformed markup", "<table><tbody><tr><td>Broken</tr></tbody></table>"],
|
||||
[
|
||||
"unrepresented attributes on supported descendants",
|
||||
`<table><tbody><tr><td><p data-keep="yes">Inside</p></td></tr></tbody></table>`,
|
||||
],
|
||||
["invalid table styles", '<table style="width: banana"><tbody><tr><td>Inside</td></tr></tbody></table>'],
|
||||
["invalid row styles", '<table><tbody><tr style="height: banana"><td>Inside</td></tr></tbody></table>'],
|
||||
["invalid cell alignment", '<table><tbody><tr><td align="justify">Inside</td></tr></tbody></table>'],
|
||||
[
|
||||
"invalid cell style alignment",
|
||||
'<table><tbody><tr><td style="text-align: justify">Inside</td></tr></tbody></table>',
|
||||
],
|
||||
["invalid column widths", '<table><tbody><tr><td colwidth="100,bogus">Inside</td></tr></tbody></table>'],
|
||||
["mismatched column widths", '<table><tbody><tr><td colspan="2" colwidth="100">Inside</td></tr></tbody></table>'],
|
||||
["invalid column span", '<table><tbody><tr><td colspan="0">Inside</td></tr></tbody></table>'],
|
||||
["invalid row span", '<table><tbody><tr><td rowspan="-1">Inside</td></tr></tbody></table>'],
|
||||
[
|
||||
"invalid paragraph alignment",
|
||||
'<table><tbody><tr><td><p style="text-align: sideways">Inside</p></td></tr></tbody></table>',
|
||||
],
|
||||
["invalid indent level", '<table><tbody><tr><td><p data-indent="9">Inside</p></td></tr></tbody></table>'],
|
||||
[
|
||||
"mismatched indent margin",
|
||||
'<table><tbody><tr><td><p data-indent="2" style="margin-inline-start: 12px">Inside</p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"physical indent margin",
|
||||
'<table><tbody><tr><td><p data-indent="2" style="margin-left: 48px">Inside</p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"invalid ordered-list start",
|
||||
'<table><tbody><tr><td><ol start="first"><li>Inside</li></ol></td></tr></tbody></table>',
|
||||
],
|
||||
["empty blockquote", "<table><tbody><tr><td><blockquote></blockquote></td></tr></tbody></table>"],
|
||||
["whitespace-only blockquote", "<table><tbody><tr><td><blockquote> </blockquote></td></tr></tbody></table>"],
|
||||
["empty list", "<table><tbody><tr><td><ul></ul></td></tr></tbody></table>"],
|
||||
[
|
||||
"anchor without href",
|
||||
'<table><tbody><tr><td><p><a target="_blank" rel="noopener" class="link">Inside</a></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"unsafe link URI",
|
||||
'<table><tbody><tr><td><p><a href="javascript:alert(1)">Inside</a></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"unsafe link URI with ignored whitespace",
|
||||
'<table><tbody><tr><td><p><a href="java\u200Bscript:alert(1)">Inside</a></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"invalid text color",
|
||||
'<table><tbody><tr><td><p><span style="color: nope">Inside</span></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"invalid highlight color",
|
||||
'<table><tbody><tr><td><p><mark data-color="nope" style="background-color: nope; color: inherit">Inside</mark></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"mismatched highlight color",
|
||||
'<table><tbody><tr><td><p><mark data-color="#ffff00" style="background-color: #000000; color: inherit">Inside</mark></p></td></tr></tbody></table>',
|
||||
],
|
||||
] as const;
|
||||
|
||||
type InputOptions = {
|
||||
className?: string;
|
||||
editable?: boolean;
|
||||
};
|
||||
|
||||
async function input(value: string, options: InputOptions = {}) {
|
||||
let editor: Editor | undefined;
|
||||
const onChange = vi.fn();
|
||||
const renderInput = (nextValue: string, nextOptions: InputOptions = options) => (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PromptDialogProvider>
|
||||
<RichInput
|
||||
aria-label="Table editor"
|
||||
value={nextValue}
|
||||
onChange={onChange}
|
||||
className={nextOptions.className}
|
||||
editable={nextOptions.editable}
|
||||
onCreate={(event) => {
|
||||
editor = event.editor;
|
||||
}}
|
||||
/>
|
||||
</PromptDialogProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
const result = render(renderInput(value));
|
||||
await waitFor(() => expect(editor).toBeDefined());
|
||||
if (!editor) throw new Error("Editor did not initialize");
|
||||
return {
|
||||
editor,
|
||||
onChange,
|
||||
rerender: (nextValue: string, nextOptions?: InputOptions) => result.rerender(renderInput(nextValue, nextOptions)),
|
||||
};
|
||||
}
|
||||
|
||||
async function controlledInput(initialValue: string) {
|
||||
let editor: Editor | undefined;
|
||||
const onChange = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [editable, setEditable] = useState(true);
|
||||
const [className, setClassName] = useState("initial");
|
||||
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PromptDialogProvider>
|
||||
<button type="button" onClick={() => setEditable((current) => !current)}>
|
||||
Toggle lock
|
||||
</button>
|
||||
<button type="button" onClick={() => setClassName("updated")}>
|
||||
Update prop
|
||||
</button>
|
||||
<output data-testid="stored-value">{value}</output>
|
||||
<RichInput
|
||||
aria-label="Table editor"
|
||||
value={value}
|
||||
onChange={(nextValue) => {
|
||||
onChange(nextValue);
|
||||
setValue(nextValue);
|
||||
}}
|
||||
className={className}
|
||||
editable={editable}
|
||||
onCreate={(event) => {
|
||||
editor = event.editor;
|
||||
}}
|
||||
/>
|
||||
</PromptDialogProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(editor).toBeDefined());
|
||||
if (!editor) throw new Error("Editor did not initialize");
|
||||
return { editor, onChange };
|
||||
}
|
||||
|
||||
const jsonText = (node: JSONContent): string => {
|
||||
if ("text" in node) return node.text ?? "";
|
||||
return node.content?.map(jsonText).join("") ?? "";
|
||||
};
|
||||
|
||||
const tableMatrix = (editor: Editor): string[][] => {
|
||||
const table = editor.getJSON().content?.find((node) => node.type === "table");
|
||||
if (!table || !("content" in table)) return [];
|
||||
return table.content?.map((row) => ("content" in row ? (row.content?.map(jsonText) ?? []) : [])) ?? [];
|
||||
};
|
||||
|
||||
const textPosition = (editor: Editor, text: string): number => {
|
||||
let position: number | undefined;
|
||||
editor.state.doc.descendants((node, nodePosition) => {
|
||||
const offset = node.text?.indexOf(text) ?? -1;
|
||||
if (position === undefined && offset >= 0) position = nodePosition + offset;
|
||||
});
|
||||
if (position === undefined) throw new Error(`Missing text: ${text}`);
|
||||
return position;
|
||||
};
|
||||
|
||||
describe("RichInput imported tables (#3196)", () => {
|
||||
it("parses a supported 2x3 table as structured JSON without emitting on mount", async () => {
|
||||
const { editor, onChange } = await input(inlineTable);
|
||||
|
||||
expect(editor.getJSON().content?.[0]?.type).toBe("table");
|
||||
expect(tableMatrix(editor)).toEqual([
|
||||
["Alpha", "Beta", "Gamma"],
|
||||
["Delta", "Epsilon", "Zeta"],
|
||||
]);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edits one named cell and retains structure through undo, redo, and remount", async () => {
|
||||
const { editor, onChange } = await input(inlineTable);
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(textPosition(editor, "Beta") + "Beta".length);
|
||||
editor.commands.insertContent("!");
|
||||
});
|
||||
expect(tableMatrix(editor)).toEqual([
|
||||
["Alpha", "Beta!", "Gamma"],
|
||||
["Delta", "Epsilon", "Zeta"],
|
||||
]);
|
||||
const edited = editor.getHTML();
|
||||
expect(onChange).toHaveBeenLastCalledWith(edited);
|
||||
|
||||
act(() => {
|
||||
editor.commands.undo();
|
||||
});
|
||||
expect(tableMatrix(editor)[0]).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
act(() => {
|
||||
editor.commands.redo();
|
||||
editor.commands.setContent(edited, { emitUpdate: false });
|
||||
});
|
||||
expect(tableMatrix(editor)).toEqual([
|
||||
["Alpha", "Beta!", "Gamma"],
|
||||
["Delta", "Epsilon", "Zeta"],
|
||||
]);
|
||||
expect(editor.getHTML()).toBe(edited);
|
||||
});
|
||||
|
||||
it("retains spans, multiple paragraphs, inline marks, styles, and unrelated prop updates", async () => {
|
||||
const { editor, onChange, rerender } = await input(complexTable);
|
||||
const table = editor.getJSON().content?.[0];
|
||||
const firstRow = table && "content" in table ? table.content?.[0] : undefined;
|
||||
const lead = firstRow && "content" in firstRow ? firstRow.content?.[0] : undefined;
|
||||
expect(lead).toMatchObject({
|
||||
type: "tableHeader",
|
||||
attrs: {
|
||||
colspan: 2,
|
||||
rowspan: 2,
|
||||
style: "width: 200pt; border: 2pt dashed #123456; padding: 3pt",
|
||||
},
|
||||
});
|
||||
expect(lead && "content" in lead ? lead.content : undefined).toHaveLength(2);
|
||||
if (!lead || !("content" in lead)) throw new Error("Missing lead table cell content");
|
||||
const firstParagraph = lead.content?.[0];
|
||||
const secondParagraph = lead.content?.[1];
|
||||
expect(
|
||||
firstParagraph && "content" in firstParagraph ? firstParagraph.content?.[0]?.marks?.[0]?.type : undefined,
|
||||
).toBe("bold");
|
||||
expect(
|
||||
secondParagraph && "content" in secondParagraph ? secondParagraph.content?.[0]?.marks?.[0]?.type : undefined,
|
||||
).toBe("italic");
|
||||
|
||||
rerender(complexTable, { className: "unrelated", editable: true });
|
||||
await waitFor(() => expect(editor.getJSON().content?.[0]?.type).toBe("table"));
|
||||
expect(editor.getHTML()).toContain('colspan="2"');
|
||||
expect(editor.getHTML()).toContain('rowspan="2"');
|
||||
expect(editor.getHTML()).toContain("border: 2pt dashed #123456");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("parses pasted supported table HTML into editable table nodes", async () => {
|
||||
const { editor } = await input("<p>Before</p>");
|
||||
act(() => {
|
||||
editor.commands.selectAll();
|
||||
editor.view.pasteHTML(inlineTable);
|
||||
});
|
||||
expect(tableMatrix(editor)).toEqual([
|
||||
["Alpha", "Beta", "Gamma"],
|
||||
["Delta", "Epsilon", "Zeta"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps every supported table semantic editable", async () => {
|
||||
const { editor, onChange } = await input(semanticTable);
|
||||
expect(editor.isEditable).toBe(true);
|
||||
expect(screen.queryByText(/Original table formatting is preserved/)).not.toBeInTheDocument();
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(textPosition(editor, "Inside") + "Inside".length);
|
||||
editor.commands.insertContent("!");
|
||||
});
|
||||
expect(editor.getHTML()).toContain("Inside!");
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("edits a configured anchor while preserving its title", async () => {
|
||||
const titledLink =
|
||||
'<table><tbody><tr><td><p><a href="https://example.com" title="Profile">Inside</a></p></td></tr></tbody></table>';
|
||||
const { editor, onChange } = await input(titledLink);
|
||||
|
||||
expect(editor.isEditable).toBe(true);
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(textPosition(editor, "Inside") + "Inside".length);
|
||||
editor.commands.insertContent("!");
|
||||
});
|
||||
expect(editor.getHTML()).toContain('href="https://example.com"');
|
||||
expect(editor.getHTML()).toContain('title="Profile"');
|
||||
expect(onChange).toHaveBeenLastCalledWith(editor.getHTML());
|
||||
});
|
||||
|
||||
it.each(unsupportedTables)("preserves exact bytes for %s behind an accessible read-only notice", async (_, value) => {
|
||||
const user = userEvent.setup();
|
||||
const { editor, onChange } = await controlledInput(value);
|
||||
const notice = screen.getByText(/Original table formatting is preserved/).closest('[role="status"]');
|
||||
if (!notice) throw new Error("Missing unsupported-table status notice");
|
||||
expect(notice).toHaveTextContent("Original table formatting is preserved");
|
||||
expect(screen.queryByRole("button", { name: "Convert to editable text" })).not.toBeInTheDocument();
|
||||
expect(editor.isEditable).toBe(false);
|
||||
expect(screen.getByTestId("stored-value")).toHaveTextContent(value, { normalizeWhitespace: false });
|
||||
|
||||
await user.click(editor.view.dom);
|
||||
await user.keyboard("Changed");
|
||||
await user.click(screen.getByRole("button", { name: "Update prop" }));
|
||||
await user.click(screen.getByRole("button", { name: "Toggle lock" }));
|
||||
await user.click(screen.getByRole("button", { name: "Toggle lock" }));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("stored-value")).toHaveTextContent(value, { normalizeWhitespace: false });
|
||||
});
|
||||
|
||||
it("keeps unsupported content read-only without a conversion path when caller marks editor as locked", async () => {
|
||||
const { editor } = await input(unsupportedTable, { editable: false });
|
||||
expect(editor.isEditable).toBe(false);
|
||||
expect(screen.queryByRole("button", { name: "Convert to editable text" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
|
||||
@@ -62,6 +63,424 @@ import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-inpu
|
||||
|
||||
const defaultTextColor = "rgba(0, 0, 0, 1)";
|
||||
|
||||
const borderStyleProperties = [
|
||||
"border",
|
||||
"border-top",
|
||||
"border-right",
|
||||
"border-bottom",
|
||||
"border-left",
|
||||
"border-width",
|
||||
"border-style",
|
||||
"border-color",
|
||||
"border-top-width",
|
||||
"border-top-style",
|
||||
"border-top-color",
|
||||
"border-right-width",
|
||||
"border-right-style",
|
||||
"border-right-color",
|
||||
"border-bottom-width",
|
||||
"border-bottom-style",
|
||||
"border-bottom-color",
|
||||
"border-left-width",
|
||||
"border-left-style",
|
||||
"border-left-color",
|
||||
] as const;
|
||||
|
||||
const textBlockTags = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6"]);
|
||||
const inlineTags = new Set(["br", "strong", "b", "em", "i", "u", "s", "strike", "code", "a", "span", "mark"]);
|
||||
const cellBlockTags = new Set([...textBlockTags, "blockquote", "ul", "ol", "hr"]);
|
||||
|
||||
type ValueValidator = (value: string, element: Element) => boolean;
|
||||
|
||||
type ElementRule = {
|
||||
attributes?: ReadonlyMap<string, ValueValidator>;
|
||||
styles?: ReadonlyMap<string, ValueValidator>;
|
||||
validate?: (element: Element) => boolean;
|
||||
};
|
||||
|
||||
type StyleDeclaration = {
|
||||
property: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const cssWideKeywords = new Set(["inherit", "initial", "revert", "revert-layer", "unset"]);
|
||||
const textAlignments = new Set(["left", "center", "right", "justify"]);
|
||||
const cellAlignments = new Set(["left", "center", "right"]);
|
||||
const linkProtocols = new Set(["http", "https", "ftp", "ftps", "mailto", "tel", "callto", "sms", "cid", "xmpp"]);
|
||||
|
||||
const readStyleDeclarations = (element: Element): StyleDeclaration[] | null => {
|
||||
const style = element.getAttribute("style");
|
||||
if (style === null) return [];
|
||||
const declarations = style
|
||||
.split(";")
|
||||
.map((declaration) => declaration.trim())
|
||||
.filter(Boolean);
|
||||
if (declarations.length === 0) return null;
|
||||
const parsed = declarations.map((declaration) => {
|
||||
const separator = declaration.indexOf(":");
|
||||
if (separator <= 0) return null;
|
||||
const property = declaration.slice(0, separator).trim().toLowerCase();
|
||||
const value = declaration.slice(separator + 1).trim();
|
||||
return property && value ? { property, value } : null;
|
||||
});
|
||||
return parsed.every((declaration) => declaration !== null) ? parsed : null;
|
||||
};
|
||||
|
||||
const supportsCssValue =
|
||||
(property: string): ValueValidator =>
|
||||
(value, element) => {
|
||||
if (/!important\s*$/i.test(value)) return false;
|
||||
const probe = element.ownerDocument.createElement("span").style;
|
||||
probe.setProperty(property, value);
|
||||
return probe.getPropertyValue(property) !== "";
|
||||
};
|
||||
|
||||
const supportsColor: ValueValidator = (value, element) => supportsCssValue("color")(value, element);
|
||||
const supportsTextAlign =
|
||||
(allowed: ReadonlySet<string>): ValueValidator =>
|
||||
(value) =>
|
||||
allowed.has(value);
|
||||
const supportsCanonicalInteger =
|
||||
(minimum: number, maximum = Number.MAX_SAFE_INTEGER): ValueValidator =>
|
||||
(value) => {
|
||||
if (!/^-?\d+$/.test(value)) return false;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum && String(parsed) === value;
|
||||
};
|
||||
const supportsBorderSpacing: ValueValidator = (value, element) => {
|
||||
if (cssWideKeywords.has(value)) return true;
|
||||
const parts = value.split(/\s+/);
|
||||
return parts.length <= 2 && parts.every((part) => supportsCssValue("width")(part, element));
|
||||
};
|
||||
const supportsVerticalAlign: ValueValidator = (value, element) => {
|
||||
const keywords = new Set(["baseline", "sub", "super", "text-top", "text-bottom", "middle", "top", "bottom"]);
|
||||
return cssWideKeywords.has(value) || keywords.has(value) || supportsCssValue("width")(value, element);
|
||||
};
|
||||
const supportsLinkHref: ValueValidator = (value) => {
|
||||
if (
|
||||
!value ||
|
||||
value.trim() !== value ||
|
||||
/[\s\u200B-\u200D\u2060\uFEFF]/u.test(value) ||
|
||||
Array.from(value).some((character) => character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127)
|
||||
)
|
||||
return false;
|
||||
const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
|
||||
return !scheme || linkProtocols.has(scheme);
|
||||
};
|
||||
|
||||
const cssRules = (properties: readonly string[]) =>
|
||||
new Map<string, ValueValidator>(properties.map((property) => [property, supportsCssValue(property)]));
|
||||
|
||||
const styleValues = (element: Element, property: string) =>
|
||||
(readStyleDeclarations(element) ?? [])
|
||||
.filter((declaration) => declaration.property === property)
|
||||
.map(({ value }) => value);
|
||||
|
||||
const normalizedColor = (value: string, element: Element) => {
|
||||
const probe = element.ownerDocument.createElement("span").style;
|
||||
probe.color = value;
|
||||
return probe.color;
|
||||
};
|
||||
|
||||
const validateCell = (element: Element) => {
|
||||
const colspan = element.getAttribute("colspan");
|
||||
const colwidth = element.getAttribute("colwidth");
|
||||
if (colwidth) {
|
||||
const widths = colwidth.split(",");
|
||||
const span = colspan ? Number(colspan) : 1;
|
||||
if (widths.length !== span || !widths.every((width) => supportsCanonicalInteger(1)(width, element))) return false;
|
||||
}
|
||||
const align = element.getAttribute("align")?.trim().toLowerCase();
|
||||
const styleAligns = styleValues(element, "text-align");
|
||||
return styleAligns.length <= 1 && (!align || styleAligns.length === 0 || align === styleAligns[0]);
|
||||
};
|
||||
|
||||
const validateTextBlock = (element: Element) => {
|
||||
const indent = element.getAttribute("data-indent");
|
||||
const margins = styleValues(element, "margin-inline-start");
|
||||
const alignments = styleValues(element, "text-align");
|
||||
if (margins.length > 1 || alignments.length > 1) return false;
|
||||
if (!indent) return margins.length === 0;
|
||||
if (element.closest("li") || !supportsCanonicalInteger(1, 8)(indent, element)) return false;
|
||||
return margins.length === 0 || margins[0] === `${Number(indent) * 24}px`;
|
||||
};
|
||||
|
||||
const validateMark = (element: Element) => {
|
||||
const dataColor = element.getAttribute("data-color");
|
||||
const backgrounds = styleValues(element, "background-color");
|
||||
const colors = styleValues(element, "color");
|
||||
if (backgrounds.length > 1 || colors.length > 2) return false;
|
||||
const semanticColor = dataColor ?? backgrounds[0];
|
||||
if (!semanticColor) return colors.length === 0;
|
||||
if (dataColor && backgrounds[0] && normalizedColor(dataColor, element) !== normalizedColor(backgrounds[0], element))
|
||||
return false;
|
||||
if (colors.length === 0) return true;
|
||||
return isDarkColor(semanticColor)
|
||||
? colors.length === 2 && colors[0] === "inherit" && normalizedColor(colors[1] ?? "", element) === "#ffffff"
|
||||
: colors.length === 1 && colors[0] === "inherit";
|
||||
};
|
||||
|
||||
const tableStyles = new Map([
|
||||
...cssRules(["width", "min-width", "max-width", "border-collapse", ...borderStyleProperties]),
|
||||
["border-spacing", supportsBorderSpacing],
|
||||
]);
|
||||
const rowStyles = cssRules(["height", ...borderStyleProperties]);
|
||||
const cellStyles = new Map([
|
||||
...cssRules([
|
||||
"width",
|
||||
"min-width",
|
||||
"max-width",
|
||||
"height",
|
||||
"padding",
|
||||
"padding-top",
|
||||
"padding-right",
|
||||
"padding-bottom",
|
||||
"padding-left",
|
||||
...borderStyleProperties,
|
||||
]),
|
||||
["vertical-align", supportsVerticalAlign],
|
||||
["text-align", supportsTextAlign(cellAlignments)],
|
||||
["background-color", supportsColor],
|
||||
]);
|
||||
const textBlockStyles = new Map([
|
||||
["text-align", supportsTextAlign(textAlignments)],
|
||||
["margin-inline-start", supportsCssValue("margin-inline-start")],
|
||||
]);
|
||||
const listItemStyles = new Map([["text-align", supportsTextAlign(textAlignments)]]);
|
||||
const textStyles = new Map([["color", supportsColor]]);
|
||||
const highlightStyles = new Map([
|
||||
["background-color", supportsColor],
|
||||
["color", supportsColor],
|
||||
]);
|
||||
const cellAttributes = new Map<string, ValueValidator>([
|
||||
["colspan", supportsCanonicalInteger(1, 1000)],
|
||||
["rowspan", supportsCanonicalInteger(1, 65_534)],
|
||||
["colwidth", (value, element) => value.split(",").every((width) => supportsCanonicalInteger(1)(width, element))],
|
||||
["align", (value) => cellAlignments.has(value.trim().toLowerCase())],
|
||||
]);
|
||||
const noValues: ElementRule = {};
|
||||
const textBlockRule: ElementRule = {
|
||||
attributes: new Map([["data-indent", supportsCanonicalInteger(1, 8)]]),
|
||||
styles: textBlockStyles,
|
||||
validate: validateTextBlock,
|
||||
};
|
||||
const elementRules = new Map<string, ElementRule>([
|
||||
["table", { styles: tableStyles }],
|
||||
["tbody", noValues],
|
||||
["tr", { styles: rowStyles }],
|
||||
["td", { attributes: cellAttributes, styles: cellStyles, validate: validateCell }],
|
||||
["th", { attributes: cellAttributes, styles: cellStyles, validate: validateCell }],
|
||||
...[...textBlockTags].map((tag): [string, ElementRule] => [tag, textBlockRule]),
|
||||
["blockquote", noValues],
|
||||
["ul", noValues],
|
||||
["ol", { attributes: new Map([["start", supportsCanonicalInteger(Number.MIN_SAFE_INTEGER)]]) }],
|
||||
["li", { styles: listItemStyles, validate: (element) => styleValues(element, "text-align").length <= 1 }],
|
||||
["hr", noValues],
|
||||
["br", noValues],
|
||||
["strong", noValues],
|
||||
["b", noValues],
|
||||
["em", noValues],
|
||||
["i", noValues],
|
||||
["u", noValues],
|
||||
["s", noValues],
|
||||
["strike", noValues],
|
||||
["code", noValues],
|
||||
[
|
||||
"a",
|
||||
{
|
||||
attributes: new Map([
|
||||
["href", supportsLinkHref],
|
||||
["target", () => true],
|
||||
["rel", () => true],
|
||||
["class", () => true],
|
||||
["title", () => true],
|
||||
]),
|
||||
validate: (element) => element.hasAttribute("href"),
|
||||
},
|
||||
],
|
||||
["span", { styles: textStyles, validate: (element) => styleValues(element, "color").length === 1 }],
|
||||
["mark", { attributes: new Map([["data-color", supportsColor]]), styles: highlightStyles, validate: validateMark }],
|
||||
]);
|
||||
|
||||
const hasOnlySupportedValues = (element: Element) => {
|
||||
const rule = elementRules.get(element.tagName.toLowerCase());
|
||||
if (!rule) return false;
|
||||
for (const attribute of element.attributes) {
|
||||
const name = attribute.name.toLowerCase();
|
||||
if (name === "style" && rule.styles) continue;
|
||||
const validate = rule.attributes?.get(name);
|
||||
if (!validate?.(attribute.value, element)) return false;
|
||||
}
|
||||
const declarations = readStyleDeclarations(element);
|
||||
if (!declarations) return false;
|
||||
if (declarations.length > 0 && !rule.styles) return false;
|
||||
for (const { property, value } of declarations) {
|
||||
if (!rule.styles?.get(property)?.(value, element)) return false;
|
||||
}
|
||||
return rule.validate?.(element) ?? true;
|
||||
};
|
||||
|
||||
const hasUnsupportedChildNode = (node: Node) =>
|
||||
node.nodeType === Node.TEXT_NODE ? Boolean(node.textContent?.trim()) : node.nodeType !== Node.ELEMENT_NODE;
|
||||
|
||||
const hasOnlyInlineContent = (element: Element): boolean =>
|
||||
Array.from(element.childNodes).every((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) return true;
|
||||
if (!(child instanceof Element) || !inlineTags.has(child.tagName.toLowerCase())) return false;
|
||||
return hasOnlyInlineContent(child);
|
||||
});
|
||||
|
||||
const hasOnlyCellBlockContent = (element: Element): boolean =>
|
||||
Array.from(element.childNodes).every((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) return true;
|
||||
if (!(child instanceof Element)) return false;
|
||||
const tagName = child.tagName.toLowerCase();
|
||||
if (inlineTags.has(tagName)) return hasOnlyInlineContent(child);
|
||||
if (!cellBlockTags.has(tagName)) return false;
|
||||
if (textBlockTags.has(tagName)) return hasOnlyInlineContent(child);
|
||||
if (tagName === "hr") return child.childNodes.length === 0;
|
||||
if (tagName === "blockquote") {
|
||||
const hasContent = Array.from(child.childNodes).some(
|
||||
(blockChild) => blockChild.nodeType === Node.ELEMENT_NODE || Boolean(blockChild.textContent?.trim()),
|
||||
);
|
||||
return hasContent && hasOnlyCellBlockContent(child);
|
||||
}
|
||||
const listItems = Array.from(child.children);
|
||||
if (listItems.length === 0) return false;
|
||||
return Array.from(child.childNodes).every((listChild) => {
|
||||
if (listChild.nodeType === Node.TEXT_NODE) return !listChild.textContent?.trim();
|
||||
if (!(listChild instanceof Element) || listChild.tagName.toLowerCase() !== "li") return false;
|
||||
return hasOnlyCellBlockContent(listChild);
|
||||
});
|
||||
});
|
||||
|
||||
const hasRectangularTableGrid = (rows: readonly Element[]) => {
|
||||
let expectedWidth: number | undefined;
|
||||
const occupiedUntil: number[] = [];
|
||||
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
const coverage = occupiedUntil.map((endRow) => endRow > rowIndex);
|
||||
let column = 0;
|
||||
|
||||
for (const cell of Array.from(row.children)) {
|
||||
while (coverage[column]) column++;
|
||||
const colspan = Number(cell.getAttribute("colspan") ?? 1);
|
||||
const rowspan = Number(cell.getAttribute("rowspan") ?? 1);
|
||||
if (rowIndex + rowspan > rows.length) return false;
|
||||
for (let offset = 0; offset < colspan; offset++) {
|
||||
if (coverage[column + offset]) return false;
|
||||
coverage[column + offset] = true;
|
||||
occupiedUntil[column + offset] = rowIndex + rowspan;
|
||||
}
|
||||
column += colspan;
|
||||
}
|
||||
|
||||
const rowWidth = coverage.lastIndexOf(true) + 1;
|
||||
if (rowWidth === 0 || coverage.slice(0, rowWidth).some((covered) => !covered)) return false;
|
||||
expectedWidth ??= rowWidth;
|
||||
if (rowWidth !== expectedWidth) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const hasUnsupportedCellDescendant = (element: Element) => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
return !inlineTags.has(tagName) && !cellBlockTags.has(tagName) && tagName !== "li"
|
||||
? true
|
||||
: !hasOnlySupportedValues(element);
|
||||
};
|
||||
|
||||
const sourceTablesFrom = (html: string) =>
|
||||
Array.from(html.matchAll(/<table\b[^>]*>[\s\S]*?<\/table\s*>/gi), (match) => match[0]);
|
||||
|
||||
const tableMarkersFrom = (html: string) => Array.from(html.matchAll(/<\/?table(?=\s|\/?>|$)/gi));
|
||||
|
||||
const openingTableMarkersFrom = (html: string) => Array.from(html.matchAll(/<table(?=\s|\/?>|$)/gi));
|
||||
|
||||
const parsedTablesMatchSource = (sourceTables: readonly string[], tables: readonly HTMLTableElement[]) => {
|
||||
return (
|
||||
sourceTables.length === tables.length && sourceTables.every((source, index) => source === tables[index]?.outerHTML)
|
||||
);
|
||||
};
|
||||
|
||||
const hasUnsupportedTableMarkup = (html: string) => {
|
||||
if (typeof DOMParser === "undefined") return false;
|
||||
const tableMarkers = tableMarkersFrom(html);
|
||||
if (tableMarkers.length === 0) return false;
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const tables = Array.from(document.querySelectorAll("table"));
|
||||
const sourceTables = sourceTablesFrom(html);
|
||||
const openingTableMarkers = openingTableMarkersFrom(html);
|
||||
if (
|
||||
tables.length === 0 ||
|
||||
sourceTables.length === 0 ||
|
||||
openingTableMarkers.length !== sourceTables.length ||
|
||||
tableMarkers.length !== sourceTables.length * 2 ||
|
||||
!parsedTablesMatchSource(sourceTables, tables)
|
||||
)
|
||||
return true;
|
||||
|
||||
for (const table of tables) {
|
||||
if (!hasOnlySupportedValues(table)) return true;
|
||||
const bodies = Array.from(table.children);
|
||||
if (bodies.length !== 1 || bodies[0]?.tagName.toLowerCase() !== "tbody") return true;
|
||||
if (Array.from(table.childNodes).some(hasUnsupportedChildNode)) return true;
|
||||
|
||||
const body = bodies[0];
|
||||
if (!body || !hasOnlySupportedValues(body)) return true;
|
||||
if (Array.from(body.childNodes).some(hasUnsupportedChildNode)) return true;
|
||||
const rows = Array.from(body.children);
|
||||
if (rows.length === 0 || rows.some((row) => row.tagName.toLowerCase() !== "tr")) return true;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!hasOnlySupportedValues(row)) return true;
|
||||
if (Array.from(row.childNodes).some(hasUnsupportedChildNode)) return true;
|
||||
if (Array.from(row.children).some((cell) => !["td", "th"].includes(cell.tagName.toLowerCase()))) return true;
|
||||
}
|
||||
for (const cell of table.querySelectorAll("td, th")) {
|
||||
if (!hasOnlySupportedValues(cell)) return true;
|
||||
if (!hasOnlyCellBlockContent(cell)) return true;
|
||||
if (Array.from(cell.querySelectorAll("*"), hasUnsupportedCellDescendant).some(Boolean)) return true;
|
||||
}
|
||||
if (!hasRectangularTableGrid(rows)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const preservedStyle = {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute("style"),
|
||||
renderHTML: (attributes: { style?: string | null }) => (attributes.style ? { style: attributes.style } : {}),
|
||||
};
|
||||
|
||||
const StyledTable = Table.extend({
|
||||
addAttributes() {
|
||||
return { ...this.parent?.(), style: preservedStyle };
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["table", HTMLAttributes, ["tbody", 0]];
|
||||
},
|
||||
});
|
||||
|
||||
const StyledTableRow = TableRow.extend({
|
||||
addAttributes() {
|
||||
return { ...this.parent?.(), style: preservedStyle };
|
||||
},
|
||||
});
|
||||
|
||||
const StyledTableHeader = TableHeader.extend({
|
||||
addAttributes() {
|
||||
return { ...this.parent?.(), style: preservedStyle };
|
||||
},
|
||||
});
|
||||
|
||||
const StyledTableCell = TableCell.extend({
|
||||
addAttributes() {
|
||||
return { ...this.parent?.(), style: preservedStyle };
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
@@ -88,6 +507,10 @@ const extensions = [
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
|
||||
ParagraphIndent,
|
||||
StyledTable,
|
||||
StyledTableRow,
|
||||
StyledTableHeader,
|
||||
StyledTableCell,
|
||||
];
|
||||
|
||||
type Props = UseEditorOptions & {
|
||||
@@ -111,12 +534,15 @@ export function RichInput({
|
||||
const { i18n } = useLingui();
|
||||
const textDirection = isRTL(i18n.locale) ? "rtl" : undefined;
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const hasUnsupportedTable = useMemo(() => hasUnsupportedTableMarkup(value), [value]);
|
||||
const requestedEditable = options.editable ?? true;
|
||||
|
||||
const editor = useEditor({
|
||||
...options,
|
||||
extensions,
|
||||
textDirection,
|
||||
content: value,
|
||||
editable: requestedEditable && !hasUnsupportedTable,
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
@@ -136,6 +562,7 @@ export function RichInput({
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
if (hasUnsupportedTable) return;
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
});
|
||||
@@ -147,11 +574,26 @@ export function RichInput({
|
||||
editor.commands.setContent(value, { emitUpdate: false });
|
||||
}, [editor, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.setEditable(requestedEditable && !hasUnsupportedTable, false);
|
||||
}, [editor, hasUnsupportedTable, requestedEditable]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const editorElement = (
|
||||
<div className="relative">
|
||||
<EditorToolbar editor={editor} isFullscreen={isFullscreen} />
|
||||
{hasUnsupportedTable ? (
|
||||
<div role="status" className="rounded-md rounded-b-none border border-b-0 bg-muted px-3 py-2 text-sm">
|
||||
<span>
|
||||
<Trans>
|
||||
Original table formatting is preserved. This content is read-only because it cannot be edited safely.
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<EditorToolbar editor={editor} isFullscreen={isFullscreen} />
|
||||
)}
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
|
||||
@@ -2,19 +2,20 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { getDocument, OPS } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { act } from "react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { ResumeDocument } from "../document";
|
||||
import { rasterizePdf } from "./test/rasterize-pdf";
|
||||
|
||||
const table = (paragraphs = false) =>
|
||||
`<table style="width: 240pt; border-collapse: collapse"><tbody>${[
|
||||
["Alpha", "Beta"],
|
||||
["Gamma", "Delta"],
|
||||
`<table style="width: 300pt; border-collapse: collapse"><tbody>${[
|
||||
["Alpha", "Beta", "Gamma"],
|
||||
["Delta", "Epsilon", "Zeta"],
|
||||
]
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr>${row.map((text) => `<td style="width: 120pt; border: 1pt solid black; padding: 4pt">${paragraphs ? `<p>${text}</p>` : text}</td>`).join("")}</tr>`,
|
||||
`<tr>${row.map((text) => `<td style="width: 100pt; border: 1pt solid black; padding: 4pt">${paragraphs ? `<p>${text}</p>` : text}</td>`).join("")}</tr>`,
|
||||
)
|
||||
.join("")}</tbody></table>`;
|
||||
|
||||
@@ -32,24 +33,71 @@ const fixture = (html: string, mode: "legacy" | "semantic", css = ""): ResumeDat
|
||||
|
||||
const readPdf = async (data: ResumeData, template: Template) => {
|
||||
const bytes = new Uint8Array(await act(() => renderToBuffer(<ResumeDocument data={data} template={template} />)));
|
||||
const loading = getDocument({ data: bytes });
|
||||
const loading = getDocument({ data: bytes.slice() });
|
||||
try {
|
||||
const document = await loading.promise;
|
||||
const page = await document.getPage(1);
|
||||
const content = await page.getTextContent();
|
||||
return content.items.flatMap((item) =>
|
||||
"str" in item ? [{ text: item.str, x: item.transform[4], y: item.transform[5] }] : [],
|
||||
);
|
||||
const operators = await page.getOperatorList();
|
||||
return {
|
||||
bytes,
|
||||
operators,
|
||||
items: content.items.flatMap((item) =>
|
||||
"str" in item ? [{ text: item.str, x: item.transform[4], y: item.transform[5] }] : [],
|
||||
),
|
||||
};
|
||||
} finally {
|
||||
await loading.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const inspectTableBorders = async ({ bytes, operators }: Awaited<ReturnType<typeof readPdf>>) => {
|
||||
let stroke = "";
|
||||
let horizontal = 0;
|
||||
let vertical = 0;
|
||||
let anyHorizontal = 0;
|
||||
let anyVertical = 0;
|
||||
for (const [index, fn] of operators.fnArray.entries()) {
|
||||
if (fn === OPS.setStrokeRGBColor) stroke = operators.argsArray[index][0];
|
||||
if (fn !== OPS.constructPath) continue;
|
||||
const bounds = operators.argsArray[index][2] as ArrayLike<number>;
|
||||
const width = Math.abs((bounds[2] ?? 0) - (bounds[0] ?? 0));
|
||||
const height = Math.abs((bounds[3] ?? 0) - (bounds[1] ?? 0));
|
||||
if (height > 0 && height <= 1.01 && width > height) {
|
||||
anyHorizontal++;
|
||||
if (stroke === "#cc00cc") horizontal++;
|
||||
}
|
||||
if (width > 0 && width <= 1.01 && height > width) {
|
||||
anyVertical++;
|
||||
if (stroke === "#cc00cc") vertical++;
|
||||
}
|
||||
}
|
||||
|
||||
const [page] = await rasterizePdf(bytes);
|
||||
if (!page) throw new Error("Missing rendered page");
|
||||
let pixels = 0;
|
||||
for (let index = 0; index < page.data.length; index += 4) {
|
||||
if ((page.data[index] ?? 0) > 180 && (page.data[index + 1] ?? 255) < 80 && (page.data[index + 2] ?? 0) > 180)
|
||||
pixels++;
|
||||
}
|
||||
return {
|
||||
colored: { horizontal, vertical, pixels },
|
||||
geometry: { horizontal: anyHorizontal, vertical: anyVertical },
|
||||
};
|
||||
};
|
||||
|
||||
const tableCoordinates = (items: Awaited<ReturnType<typeof readPdf>>["items"]) =>
|
||||
Object.fromEntries(
|
||||
items
|
||||
.filter(({ text }) => ["Alpha", "Beta", "Beta!", "Gamma", "Delta", "Epsilon", "Zeta"].includes(text))
|
||||
.map(({ text, x, y }) => [text, [Number(x.toFixed(3)), Number(y.toFixed(3))]]),
|
||||
);
|
||||
|
||||
describe("imported rich-text tables", () => {
|
||||
for (const template of ["ditgar", "onyx"] as const) {
|
||||
for (const mode of ["legacy", "semantic"] as const) {
|
||||
it(`${template} ${mode} preserves bare cell text and row/column positions`, async () => {
|
||||
const items = await readPdf(fixture(table(), mode), template);
|
||||
const { items } = await readPdf(fixture(table(), mode), template);
|
||||
const cell = (text: string) => {
|
||||
const cell = items.find((item) => item.text === text);
|
||||
if (!cell) throw new Error(`Missing table cell ${text}`);
|
||||
@@ -59,29 +107,72 @@ describe("imported rich-text tables", () => {
|
||||
const beta = cell("Beta");
|
||||
const gamma = cell("Gamma");
|
||||
const delta = cell("Delta");
|
||||
const epsilon = cell("Epsilon");
|
||||
const zeta = cell("Zeta");
|
||||
expect(alpha.y).toBe(beta.y);
|
||||
expect(gamma.y).toBe(delta.y);
|
||||
expect(alpha.y).toBeGreaterThan(gamma.y);
|
||||
expect(alpha.x).toBe(gamma.x);
|
||||
expect(beta.x).toBe(delta.x);
|
||||
expect(alpha.y).toBe(gamma.y);
|
||||
expect(delta.y).toBe(epsilon.y);
|
||||
expect(delta.y).toBe(zeta.y);
|
||||
expect(alpha.y).toBeGreaterThan(delta.y);
|
||||
expect(alpha.x).toBe(delta.x);
|
||||
expect(beta.x).toBe(epsilon.x);
|
||||
expect(gamma.x).toBe(zeta.x);
|
||||
expect(beta.x).toBeGreaterThan(alpha.x);
|
||||
expect(gamma.x).toBeGreaterThan(beta.x);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("preserves table cells containing recognized paragraphs", async () => {
|
||||
const items = await readPdf(fixture(table(true), "semantic"), "ditgar");
|
||||
const { items } = await readPdf(fixture(table(true), "semantic"), "ditgar");
|
||||
expect(items.map((item) => item.text)).toEqual(expect.arrayContaining(["Alpha", "Beta", "Gamma", "Delta"]));
|
||||
});
|
||||
|
||||
it("preserves raw text inside an unrecognized block wrapper", async () => {
|
||||
const items = await readPdf(fixture("<div>Wrapper content</div>", "semantic"), "ditgar");
|
||||
const { items } = await readPdf(fixture("<div>Wrapper content</div>", "semantic"), "ditgar");
|
||||
expect(items.map((item) => item.text)).toContain("Wrapper content");
|
||||
});
|
||||
|
||||
it("still honors explicit semantic rich-text hiding", async () => {
|
||||
const items = await readPdf(fixture(table(), "semantic", "rich-text { display: none; }"), "ditgar");
|
||||
const { items } = await readPdf(fixture(table(), "semantic", "rich-text { display: none; }"), "ditgar");
|
||||
expect(items.map((item) => item.text)).toContain("Table probe");
|
||||
expect(items.map((item) => item.text)).not.toContain("Alpha");
|
||||
});
|
||||
|
||||
it.each(["legacy", "semantic"] as const)(
|
||||
"keeps six cell coordinates, border operators, and fixed-DPI pixels through %s persistence",
|
||||
async (mode) => {
|
||||
const bordered = table().replaceAll("black", "#cc00cc");
|
||||
const unrelated = fixture(bordered, mode);
|
||||
unrelated.basics.name = "Border Probe unrelated edit";
|
||||
const stages = [
|
||||
{ name: "original", data: fixture(bordered, mode), expectedBeta: "Beta" },
|
||||
{ name: "unrelated edit", data: unrelated, expectedBeta: "Beta" },
|
||||
{ name: "table edit", data: fixture(bordered.replace("Beta", "Beta!"), mode), expectedBeta: "Beta!" },
|
||||
];
|
||||
for (const stage of stages) {
|
||||
const pdf = await readPdf(stage.data, "ditgar");
|
||||
expect(tableCoordinates(pdf.items), stage.name).toEqual({
|
||||
Alpha: [227.348, 808.69],
|
||||
[stage.expectedBeta]: [327.348, 808.69],
|
||||
Gamma: [427.348, 808.69],
|
||||
Delta: [227.348, 778.89],
|
||||
Epsilon: [327.348, 778.89],
|
||||
Zeta: [427.348, 778.89],
|
||||
});
|
||||
expect((await inspectTableBorders(pdf)).colored, stage.name).toEqual({
|
||||
horizontal: 17,
|
||||
vertical: 12,
|
||||
pixels: 1851,
|
||||
});
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it.each(["legacy", "semantic"] as const)("keeps borderless tables borderless in %s mode", async (mode) => {
|
||||
const borderless = table().replaceAll("border: 1pt solid black; ", "");
|
||||
const pdf = await readPdf(fixture(borderless, mode), "ditgar");
|
||||
expect((await inspectTableBorders(pdf)).geometry).toEqual({ horizontal: 0, vertical: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+14
@@ -454,6 +454,9 @@ importers:
|
||||
'@tiptap/extension-highlight':
|
||||
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)
|
||||
'@tiptap/extension-text-align':
|
||||
specifier: ^3.31.2
|
||||
version: 3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))
|
||||
@@ -4546,6 +4549,12 @@ packages:
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.2
|
||||
|
||||
'@tiptap/extension-table@3.31.2':
|
||||
resolution: {integrity: sha512-0LfcQG2YK2Q3q5BjvJqs8Zks1PuSdiNGYf2lnyC7ixtItuKXTL2xB5yzj53gMIdRfteqcUfbxIJjmU57W3F+Yg==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.31.2
|
||||
'@tiptap/pm': 3.31.2
|
||||
|
||||
'@tiptap/extension-text-align@3.31.2':
|
||||
resolution: {integrity: sha512-ZHBpokt4iz0fA67K1TofmWhVxVYpw1TK03XmdZQucE5eWGqI6nBmv/eWrkk3j3gAaY17jBgo+vKUt9N2L0H3zA==}
|
||||
peerDependencies:
|
||||
@@ -11982,6 +11991,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.2(@tiptap/pm@3.31.2)
|
||||
|
||||
'@tiptap/extension-table@3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))(@tiptap/pm@3.31.2)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.2(@tiptap/pm@3.31.2)
|
||||
'@tiptap/pm': 3.31.2
|
||||
|
||||
'@tiptap/extension-text-align@3.31.2(@tiptap/core@3.31.2(@tiptap/pm@3.31.2))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.31.2(@tiptap/pm@3.31.2)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { Pool } from "pg";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { ACTIVE_PREVIEW_PAGE_SELECTOR } from "../fixtures/preview";
|
||||
import { openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
const requireWeb = createRequire(`${process.cwd()}/apps/web/package.json`);
|
||||
const tableHtml = `<table style="width: 300pt; border-collapse: collapse"><tbody><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Alpha</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Beta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Gamma</td></tr><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Delta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Epsilon</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Zeta</td></tr></tbody></table>`;
|
||||
|
||||
const fixture = () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.basics.name = "Imported Table Probe";
|
||||
data.picture.hidden = true;
|
||||
data.summary.content = tableHtml;
|
||||
data.metadata.template = "ditgar";
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
|
||||
data.metadata.typography.body.fontFamily = "Helvetica";
|
||||
data.metadata.typography.heading.fontFamily = "Helvetica";
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 1, text: "@version 1;" } };
|
||||
return data;
|
||||
};
|
||||
|
||||
async function readImportedResume(id: string) {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for imported table E2E.");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
try {
|
||||
const result = await pool.query<{
|
||||
data: { basics: { name: string }; summary: { content: string } };
|
||||
slug: string;
|
||||
updatedAt: Date;
|
||||
}>('select data, slug, updated_at as "updatedAt" from "resume" where id = $1', [id]);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error(`Resume ${id} was not found.`);
|
||||
return row;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function publishImportedResume(id: string) {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for imported table E2E.");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
try {
|
||||
await pool.query('update "resume" set is_public = true where id = $1', [id]);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectPdf(bytes: Uint8Array) {
|
||||
const { getDocument, OPS } = await import(requireWeb.resolve("pdfjs-dist/legacy/build/pdf.mjs"));
|
||||
const loading = getDocument({ data: bytes.slice(), useSystemFonts: true });
|
||||
try {
|
||||
const document = await loading.promise;
|
||||
const page = await document.getPage(1);
|
||||
const text = (await page.getTextContent()).items.flatMap((item: { str?: string }) => item.str ?? []);
|
||||
const operators = await page.getOperatorList();
|
||||
let stroke = "";
|
||||
let horizontal = 0;
|
||||
let vertical = 0;
|
||||
for (const [index, fn] of operators.fnArray.entries()) {
|
||||
if (fn === OPS.setStrokeRGBColor) stroke = operators.argsArray[index][0];
|
||||
if (fn !== OPS.constructPath || stroke !== "#cc00cc") continue;
|
||||
const bounds = operators.argsArray[index][2] as ArrayLike<number>;
|
||||
const width = Math.abs((bounds[2] ?? 0) - (bounds[0] ?? 0));
|
||||
const height = Math.abs((bounds[3] ?? 0) - (bounds[1] ?? 0));
|
||||
if (height > 0 && height <= 1.01 && width > height) horizontal++;
|
||||
if (width > 0 && width <= 1.01 && height > width) vertical++;
|
||||
}
|
||||
return { text, horizontal, vertical };
|
||||
} finally {
|
||||
await loading.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBuilderPdf(page: Page, testInfo: TestInfo, stage: string) {
|
||||
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 PDF", exact: true }).click();
|
||||
const download = await pending;
|
||||
const path = testInfo.outputPath(`${stage}-browser.pdf`);
|
||||
await download.saveAs(path);
|
||||
return new Uint8Array(await readFile(path));
|
||||
}
|
||||
|
||||
async function previewBorderPixels(page: Page, testInfo: TestInfo, stage: string) {
|
||||
const canvas = page.locator(ACTIVE_PREVIEW_PAGE_SELECTOR);
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||
await canvas.screenshot({ path: testInfo.outputPath(`${stage}-preview.png`), animations: "disabled" });
|
||||
return canvas.evaluate((element) => {
|
||||
const context = (element as HTMLCanvasElement).getContext("2d");
|
||||
if (!context) throw new Error("Missing preview canvas context.");
|
||||
const pixels = context.getImageData(0, 0, context.canvas.width, context.canvas.height).data;
|
||||
let magenta = 0;
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if ((pixels[index] ?? 0) > 180 && (pixels[index + 1] ?? 255) < 80 && (pixels[index + 2] ?? 0) > 180) magenta++;
|
||||
}
|
||||
return magenta;
|
||||
});
|
||||
}
|
||||
|
||||
test("edits and exports a synthetic imported table without losing its grid", async ({
|
||||
authPage: page,
|
||||
account,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const importPath = testInfo.outputPath("imported-table.json");
|
||||
await writeFile(importPath, JSON.stringify(fixture()));
|
||||
|
||||
await page.goto("/dashboard/resumes");
|
||||
await page.getByText("Import an existing resume", { exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Import an existing resume" });
|
||||
await dialog.locator('input[type="file"]').setInputFiles(importPath);
|
||||
await dialog.getByRole("button", { name: "Import", exact: true }).click();
|
||||
await page.waitForURL(/\/builder\/.+/);
|
||||
const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
|
||||
if (!id) throw new Error("Missing imported resume id.");
|
||||
await publishImportedResume(id);
|
||||
let stored = await readImportedResume(id);
|
||||
const slug = stored.slug;
|
||||
|
||||
const captureStage = async (stage: string, expectedCell: string) => {
|
||||
const summaryHtml = (await readImportedResume(id)).data.summary.content;
|
||||
await writeFile(testInfo.outputPath(`${stage}-summary.html`), summaryHtml);
|
||||
expect(summaryHtml).toContain("<table");
|
||||
expect(summaryHtml).toContain(expectedCell);
|
||||
expect(await previewBorderPixels(page, testInfo, stage)).toBeGreaterThan(1_000);
|
||||
|
||||
for (const [surface, bytes] of [
|
||||
["browser", await downloadBuilderPdf(page, testInfo, stage)],
|
||||
[
|
||||
"server",
|
||||
new Uint8Array(
|
||||
await (
|
||||
await page.request.get(
|
||||
`/api/resumes/${encodeURIComponent(account.username)}/${encodeURIComponent(slug)}/pdf`,
|
||||
)
|
||||
).body(),
|
||||
),
|
||||
],
|
||||
] as const) {
|
||||
await writeFile(testInfo.outputPath(`${stage}-${surface}.pdf`), bytes);
|
||||
const pdf = await inspectPdf(bytes);
|
||||
expect(pdf.text, `${stage} ${surface}`).toEqual(
|
||||
expect.arrayContaining(["Alpha", expectedCell, "Gamma", "Delta", "Epsilon", "Zeta"]),
|
||||
);
|
||||
expect({ horizontal: pdf.horizontal, vertical: pdf.vertical }, `${stage} ${surface}`).toEqual({
|
||||
horizontal: 17,
|
||||
vertical: 12,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await openSidebarSection(page, "Summary");
|
||||
const summary = page.locator("#sidebar-summary");
|
||||
await expect(summary.locator("table tr")).toHaveCount(2);
|
||||
await expect(summary.locator("table td")).toHaveCount(6);
|
||||
expect(stored.data.summary.content).toBe(tableHtml);
|
||||
await captureStage("initial", "Beta");
|
||||
|
||||
await openSidebarSection(page, "Basics");
|
||||
const unrelatedName = "Imported Table Probe unrelated edit";
|
||||
const beforeUnrelatedEdit = stored.updatedAt;
|
||||
await page.getByLabel("Name").fill(unrelatedName);
|
||||
await expect.poll(async () => (await readImportedResume(id)).data.basics.name).toBe(unrelatedName);
|
||||
stored = await readImportedResume(id);
|
||||
expect(stored.updatedAt.getTime()).toBeGreaterThan(beforeUnrelatedEdit.getTime());
|
||||
await page.reload();
|
||||
stored = await readImportedResume(id);
|
||||
expect(stored.data.basics.name).toBe(unrelatedName);
|
||||
expect(stored.data.summary.content).toBe(tableHtml);
|
||||
await openSidebarSection(page, "Basics");
|
||||
await expect(page.getByLabel("Name")).toHaveValue(unrelatedName);
|
||||
await openSidebarSection(page, "Summary");
|
||||
await expect(summary.locator("table td")).toHaveCount(6);
|
||||
await captureStage("unrelated-edit", "Beta");
|
||||
|
||||
const beta = summary.locator("td").filter({ hasText: "Beta" });
|
||||
await beta.click();
|
||||
await page.keyboard.press("End");
|
||||
await page.keyboard.type("!");
|
||||
await expect(beta).toContainText("Beta!");
|
||||
await page.keyboard.press("Control+z");
|
||||
await expect(beta).toContainText("Beta");
|
||||
await page.keyboard.press("Control+Shift+z");
|
||||
await expect(beta).toContainText("Beta!");
|
||||
await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible();
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Summary");
|
||||
await expect(summary.locator("table tr")).toHaveCount(2);
|
||||
await expect(summary.locator("table td")).toHaveCount(6);
|
||||
await expect(summary.locator("td").filter({ hasText: "Beta!" })).toBeVisible();
|
||||
stored = await readImportedResume(id);
|
||||
expect(stored.data.summary.content).toContain("<table");
|
||||
expect(stored.data.summary.content).toContain("Beta!");
|
||||
await captureStage("table-edit", "Beta!");
|
||||
});
|
||||
@@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { compareResumeRecovery } from "./compare-resume";
|
||||
|
||||
const SYNTHETIC_SOURCE_HASH = "68cbff28a704f3859c7f5385e9e82a3517521374d99ec2f65ca15887f81310cc";
|
||||
const RECOVERED_COPY_HASH = "56d3e7d3ecd336b6d910224e2ecb64c7a3c010ff980782f3bea985682018e3a6";
|
||||
const CURRENT_COPY_HASH = "40cb0aba1e7b3d0950314c3ad20a74b30785545658b296fea97885d6364c9b2f";
|
||||
const DEFAULT_RESUME_HASH = "15c8a97e15f248c630a6e1c16e5e257a5b02959ef749acbc18c41cd150e853a4";
|
||||
const SYNTHETIC_SOURCE_HASH = "a544b2abf225396f8c16cd2ea1c182d195a8d9179d3a95be4b1b1e7d43722e11";
|
||||
const RECOVERED_COPY_HASH = "704cd69adcd227d6975aca64afea9a63fe0eeb8e17c275b4c79627d85bdd5791";
|
||||
const CURRENT_COPY_HASH = "33d337241cd4ad8cf0faa2cdc0107fa4d607b3070f27dab7166c206b05afd882";
|
||||
const DEFAULT_RESUME_HASH = "a6930afc82a24637de715bdcac5300318261d3b69969527f43917904478e885b";
|
||||
|
||||
const FORMAT_CHARACTERS = [
|
||||
["zero-width space (U+200B)", "\u200B"],
|
||||
|
||||
Reference in New Issue
Block a user