From 999cd618cb2e54826aa860c298c7114045f8ebdd Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 18:41:38 -0700 Subject: [PATCH 1/2] feat(web): preserve editable imported tables (#3464) * 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 --- apps/web/package.json | 1 + .../input/rich-input.table.test.tsx | 346 ++++++++++++++ apps/web/src/components/input/rich-input.tsx | 444 +++++++++++++++++- .../rich-text-table.integration.test.tsx | 125 ++++- pnpm-lock.yaml | 14 + tests/e2e/specs/imported-table.spec.ts | 201 ++++++++ 6 files changed, 1113 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/input/rich-input.table.test.tsx create mode 100644 tests/e2e/specs/imported-table.spec.ts diff --git a/apps/web/package.json b/apps/web/package.json index 4164b815f..4375b9f6e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/input/rich-input.table.test.tsx b/apps/web/src/components/input/rich-input.table.test.tsx new file mode 100644 index 000000000..15c13c336 --- /dev/null +++ b/apps/web/src/components/input/rich-input.table.test.tsx @@ -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 = `
AlphaBetaGamma
DeltaEpsilonZeta
`; + +const complexTable = `

Lead cell

Second paragraph

Side

Tail

`; + +const semanticTable = `

Inside Mark Dark

  1. Two
`; + +const unsupportedTable = `
Original
`; + +const unsupportedTables = [ + ["legacy table attributes", unsupportedTable], + ["truncated table marker", "Open"], + ["truncated table after a complete table", "
Complete
Inside
`, + ], + [ + "multiple table bodies", + "
First
Second
", + ], + ["empty table body", "
"], + ["non-row table body child", "
Inside
"], + ["non-cell table row child", "
Inside
"], + ["mismatched row widths", "
One
TwoThree
"], + [ + "non-rectangular column span", + '
Wide
Wider
', + ], + ["row span beyond table bounds", '
Inside
'], + [ + "row span collision", + '
TallSide
Overlap
', + ], + ["browser-repaired malformed markup", "
Broken
"], + [ + "unrepresented attributes on supported descendants", + `

Inside

`, + ], + ["invalid table styles", '
Inside
'], + ["invalid row styles", '
Inside
'], + ["invalid cell alignment", '
Inside
'], + [ + "invalid cell style alignment", + '
Inside
', + ], + ["invalid column widths", '
Inside
'], + ["mismatched column widths", '
Inside
'], + ["invalid column span", '
Inside
'], + ["invalid row span", '
Inside
'], + [ + "invalid paragraph alignment", + '

Inside

', + ], + ["invalid indent level", '

Inside

'], + [ + "mismatched indent margin", + '

Inside

', + ], + [ + "physical indent margin", + '

Inside

', + ], + [ + "invalid ordered-list start", + '
  1. Inside
', + ], + ["empty blockquote", "
"], + ["whitespace-only blockquote", "
"], + ["empty list", "
    "], + [ + "anchor without href", + '

    Inside

    ', + ], + [ + "unsafe link URI", + '

    Inside

    ', + ], + [ + "unsafe link URI with ignored whitespace", + '

    Inside

    ', + ], + [ + "invalid text color", + '

    Inside

    ', + ], + [ + "invalid highlight color", + '

    Inside

    ', + ], + [ + "mismatched highlight color", + '

    Inside

    ', + ], +] 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) => ( + + + { + editor = event.editor; + }} + /> + + + ); + 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 ( + + + + + {value} + { + onChange(nextValue); + setValue(nextValue); + }} + className={className} + editable={editable} + onCreate={(event) => { + editor = event.editor; + }} + /> + + + ); + } + + render(); + 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("

    Before

    "); + 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 = + '

    Inside

    '; + 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(); + }); +}); diff --git a/apps/web/src/components/input/rich-input.tsx b/apps/web/src/components/input/rich-input.tsx index 13f43285e..38108b251 100644 --- a/apps/web/src/components/input/rich-input.tsx +++ b/apps/web/src/components/input/rich-input.tsx @@ -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; + styles?: ReadonlyMap; + 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): 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(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([ + ["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([ + ["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(/]*>[\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(/|$)/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 = (
    - + {hasUnsupportedTable ? ( +
    + + + Original table formatting is preserved. This content is read-only because it cannot be edited safely. + + +
    + ) : ( + + )} diff --git a/packages/pdf/src/semantic/rich-text-table.integration.test.tsx b/packages/pdf/src/semantic/rich-text-table.integration.test.tsx index 3b8083b6a..0515993a3 100644 --- a/packages/pdf/src/semantic/rich-text-table.integration.test.tsx +++ b/packages/pdf/src/semantic/rich-text-table.integration.test.tsx @@ -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) => - `${[ - ["Alpha", "Beta"], - ["Gamma", "Delta"], + `
    ${[ + ["Alpha", "Beta", "Gamma"], + ["Delta", "Epsilon", "Zeta"], ] .map( (row) => - `${row.map((text) => ``).join("")}`, + `${row.map((text) => ``).join("")}`, ) .join("")}
    ${paragraphs ? `

    ${text}

    ` : text}
    ${paragraphs ? `

    ${text}

    ` : text}
    `; @@ -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())); - 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>) => { + 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; + 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>["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("
    Wrapper content
    ", "semantic"), "ditgar"); + const { items } = await readPdf(fixture("
    Wrapper content
    ", "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 }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f82dcbf15..4197605bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/tests/e2e/specs/imported-table.spec.ts b/tests/e2e/specs/imported-table.spec.ts new file mode 100644 index 000000000..65aa55c22 --- /dev/null +++ b/tests/e2e/specs/imported-table.spec.ts @@ -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 = `
    AlphaBetaGamma
    DeltaEpsilonZeta
    `; + +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; + 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(" (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(" Date: Sat, 5 Sep 2026 18:50:31 -0700 Subject: [PATCH 2/2] test(recovery): refresh hashes for picture fit default (#3466) --- tooling/recovery/compare-resume.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tooling/recovery/compare-resume.test.ts b/tooling/recovery/compare-resume.test.ts index eee8c76a5..62639897e 100644 --- a/tooling/recovery/compare-resume.test.ts +++ b/tooling/recovery/compare-resume.test.ts @@ -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"],