| Open"],
+ ["truncated table after a complete table", "`,
+ ],
+ [
+ "multiple table bodies",
+ "",
+ ],
+ ["empty table body", ""],
+ ["non-row table body child", ""],
+ ["non-cell table row child", ""],
+ ["mismatched row widths", ""],
+ [
+ "non-rectangular column span",
+ '',
+ ],
+ ["row span beyond table bounds", ''],
+ [
+ "row span collision",
+ '',
+ ],
+ ["browser-repaired malformed markup", ""],
+ [
+ "unrepresented attributes on supported descendants",
+ ``,
+ ],
+ ["invalid table styles", ''],
+ ["invalid row styles", ''],
+ ["invalid cell alignment", ''],
+ [
+ "invalid cell style alignment",
+ '',
+ ],
+ ["invalid column widths", ''],
+ ["mismatched column widths", ''],
+ ["invalid column span", ''],
+ ["invalid row span", ''],
+ [
+ "invalid paragraph alignment",
+ '',
+ ],
+ ["invalid indent level", ''],
+ [
+ "mismatched indent margin",
+ '',
+ ],
+ [
+ "physical indent margin",
+ '',
+ ],
+ [
+ "invalid ordered-list start",
+ '',
+ ],
+ ["empty blockquote", ""],
+ ["whitespace-only blockquote", ""],
+ ["empty list", ""],
+ [
+ "anchor without href",
+ '',
+ ],
+ [
+ "unsafe link URI",
+ '',
+ ],
+ [
+ "unsafe link URI with ignored whitespace",
+ '',
+ ],
+ [
+ "invalid text color",
+ '',
+ ],
+ [
+ "invalid highlight color",
+ '',
+ ],
+ [
+ "mismatched highlight color",
+ '',
+ ],
+] 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 (
+
+
+
+
+
+ {
+ 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 =
+ '';
+ 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) => `| ${paragraphs ? ` ${text} ` : text} | `).join("")} `,
+ `${row.map((text) => `| ${paragraphs ? ` ${text} ` : text} | `).join("")} `,
)
.join("")} `;
@@ -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 = `| Alpha | Beta | Gamma | | Delta | Epsilon | Zeta | `;
+
+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(" |