mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
fix(editor): preserve literal rich-text whitespace (#3472)
* fix(web): preserve imported rich-text tables * fix(web): harden imported table preservation * fix(web): fail closed on lossy table markup * chore: remove plan 16 evidence reports * fix(web): close imported table preservation gaps * fix(editor): preserve literal rich-text whitespace * chore: remove plan 19 evidence report * fix: preserve literal whitespace through layout and editor transforms * fix: preserve whitespace in bare table cells * chore: keep orchestration evidence untracked
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { promisify } from "node:util";
|
||||
import { Pool } from "pg";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
const requireWeb = createRequire(`${process.cwd()}/apps/web/package.json`);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const preservedHtml =
|
||||
'<p data-resume-whitespace="preserve"> LIT A\tB END </p><p data-resume-whitespace="preserve"> REF A B END </p><p data-resume-whitespace="preserve">CTL A B END</p><p data-resume-whitespace="preserve">\tTAB A B END</p>';
|
||||
|
||||
async function readSummary(id: string) {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for literal-whitespace E2E.");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
try {
|
||||
const result = await pool.query<{ content: string }>(
|
||||
`select data->'summary'->>'content' as content from "resume" where id = $1`,
|
||||
[id],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error(`Resume ${id} was not found.`);
|
||||
return row.content;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function download(page: Page, testInfo: TestInfo, format: "PDF" | "DOCX" | "JSON") {
|
||||
await openSidebarSection(page, "Export");
|
||||
await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click();
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: `Download ${format}`, exact: true }).click();
|
||||
const result = await pending;
|
||||
const path = testInfo.outputPath(`literal-whitespace.${format.toLowerCase()}`);
|
||||
await result.saveAs(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function pdfLineMetrics(path: string) {
|
||||
const { getDocument } = await import(requireWeb.resolve("pdfjs-dist/legacy/build/pdf.mjs"));
|
||||
const loading = getDocument({ data: new Uint8Array(await readFile(path)), useSystemFonts: true });
|
||||
try {
|
||||
const document = await loading.promise;
|
||||
const lines: Array<{ marker: string; start: number; gap: number; tailWidth: number }> = [];
|
||||
for (let pageIndex = 0; pageIndex < document.numPages; pageIndex++) {
|
||||
const page = await document.getPage(pageIndex + 1);
|
||||
const items = (await page.getTextContent()).items.flatMap((item) =>
|
||||
"str" in item ? [{ text: item.str, x: item.transform[4], y: item.transform[5], width: item.width }] : [],
|
||||
);
|
||||
for (const marker of ["LIT", "REF", "CTL", "TAB"]) {
|
||||
const anchor = items.find((item) => item.text.includes(marker));
|
||||
if (!anchor) continue;
|
||||
const tail = items.find(
|
||||
(item) => item.x > anchor.x && Math.abs(item.y - anchor.y) < 0.01 && item.text.includes("B END"),
|
||||
);
|
||||
if (!tail) continue;
|
||||
const anchorEnd = anchor.x + anchor.width;
|
||||
lines.push({ marker, start: anchor.x, gap: tail.x - anchorEnd, tailWidth: tail.x + tail.width - anchorEnd });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
} finally {
|
||||
await loading.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
test("persists typed and pasted literal whitespace through JSON, PDF, and DOCX", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
|
||||
if (!id) throw new Error("Missing resume id.");
|
||||
|
||||
await openSidebarSection(page, "Summary");
|
||||
const editor = page.locator("#sidebar-summary [data-editor=true]");
|
||||
await editor.click();
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.insertText(" LIT A\tB END ");
|
||||
await page.keyboard.press("Enter");
|
||||
await editor.evaluate((element) => {
|
||||
const clipboard = new DataTransfer();
|
||||
clipboard.setData("text/plain", " REF A B END \nCTL A B END\n\tTAB A B END");
|
||||
element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard }));
|
||||
});
|
||||
|
||||
await expect.poll(() => readSummary(id)).toBe(preservedHtml);
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Summary");
|
||||
const reloadedEditor = page.locator("#sidebar-summary [data-editor=true]");
|
||||
await expect(reloadedEditor).toBeVisible();
|
||||
expect(await reloadedEditor.evaluate((element) => element.textContent)).toBe(
|
||||
" LIT A\tB END REF A B END CTL A B END\tTAB A B END",
|
||||
);
|
||||
expect(
|
||||
await reloadedEditor
|
||||
.locator("p")
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).whiteSpace),
|
||||
).toBe("pre-wrap");
|
||||
expect(await readSummary(id)).toBe(preservedHtml);
|
||||
|
||||
const jsonPath = await download(page, testInfo, "JSON");
|
||||
const exported = JSON.parse(await readFile(jsonPath, "utf8")) as { summary: { content: string } };
|
||||
expect(exported.summary.content).toBe(preservedHtml);
|
||||
|
||||
const pdfPath = await download(page, testInfo, "PDF");
|
||||
const lines = await pdfLineMetrics(pdfPath);
|
||||
const literal = lines.find((line) => line.marker === "LIT");
|
||||
const reference = lines.find((line) => line.marker === "REF");
|
||||
expect(literal).toBeDefined();
|
||||
expect(reference).toBeDefined();
|
||||
if (!literal || !reference) throw new Error("Expected literal whitespace PDF lines.");
|
||||
expect(literal.start).toBeCloseTo(reference.start, 2);
|
||||
expect(literal.gap).toBeCloseTo(reference.gap, 2);
|
||||
expect(literal.tailWidth).toBeCloseTo(reference.tailWidth, 2);
|
||||
const control = lines.find((line) => line.marker === "CTL");
|
||||
const leadingTab = lines.find((line) => line.marker === "TAB");
|
||||
if (!control || !leadingTab) throw new Error("Expected unpadded and leading-tab PDF controls.");
|
||||
expect(literal.gap).toBeGreaterThan(0);
|
||||
expect(literal.start - control.start).toBeCloseTo(literal.gap / 2, 2);
|
||||
expect(leadingTab.start - control.start).toBeCloseTo(literal.gap, 2);
|
||||
await testInfo.attach("pdf-whitespace-geometry", {
|
||||
body: JSON.stringify(lines, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
const docxPath = await download(page, testInfo, "DOCX");
|
||||
const { stdout: documentXml } = await execFileAsync("unzip", ["-p", docxPath, "word/document.xml"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(documentXml).toContain('xml:space="preserve"> LIT A B END </w:t>');
|
||||
expect(documentXml).toContain('xml:space="preserve"> REF A B END </w:t>');
|
||||
expect(documentXml).toContain('xml:space="preserve">CTL A B END</w:t>');
|
||||
expect(documentXml).toContain('xml:space="preserve"> TAB A B END</w:t>');
|
||||
});
|
||||
|
||||
test("displays each stored tab as four spaces with usable caret and selection", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
await openSidebarSection(page, "Summary");
|
||||
const editor = page.locator("#sidebar-summary [data-editor=true]");
|
||||
await editor.click();
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page.keyboard.press("Backspace");
|
||||
await editor.evaluate((element) => {
|
||||
const clipboard = new DataTransfer();
|
||||
clipboard.setData("text/plain", "A\tB\nAB\nABC\tD\nABCD\nA B\n\tZ\nZ\nA\t\tB");
|
||||
element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard }));
|
||||
});
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
const geometry = await editor.evaluate((element, direction) => {
|
||||
element.dir = direction;
|
||||
element.style.fontFamily = "Arial";
|
||||
// Measure ordinary-space advances without Arial's A/space pair kerning.
|
||||
element.style.fontKerning = "none";
|
||||
const blocks = Array.from(element.querySelectorAll("p"));
|
||||
const glyph = (index: number, offset: number) => {
|
||||
const walker = document.createTreeWalker(blocks[index], NodeFilter.SHOW_TEXT);
|
||||
let remaining = offset;
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (remaining < (node.textContent?.length ?? 0)) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, remaining);
|
||||
range.setEnd(node, remaining + 1);
|
||||
return range.getBoundingClientRect();
|
||||
}
|
||||
remaining -= node.textContent?.length ?? 0;
|
||||
}
|
||||
throw new Error("Missing glyph");
|
||||
};
|
||||
return {
|
||||
one: glyph(0, 2).x - glyph(0, 0).right,
|
||||
three: glyph(2, 4).x - glyph(2, 2).right,
|
||||
spaces: glyph(4, 5).x - glyph(4, 0).right,
|
||||
leading: (direction === "rtl" ? -1 : 1) * (glyph(5, 1).x - glyph(6, 0).x),
|
||||
two: glyph(7, 3).x - glyph(7, 0).right,
|
||||
text: blocks.map((block) => block.textContent),
|
||||
};
|
||||
}, direction);
|
||||
await testInfo.attach(`tab-geometry-${direction}`, {
|
||||
body: JSON.stringify(geometry, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(geometry.spaces).toBeGreaterThan(0);
|
||||
for (const width of [geometry.one, geometry.three, geometry.leading]) expect(width).toBeCloseTo(geometry.spaces, 1);
|
||||
expect(geometry.two).toBeCloseTo(geometry.spaces * 2, 1);
|
||||
expect(geometry.text).toEqual(["A\tB", "AB", "ABC\tD", "ABCD", "A B", "\tZ", "Z", "A\t\tB"]);
|
||||
}
|
||||
const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
|
||||
if (!id) throw new Error("Missing resume id.");
|
||||
await expect.poll(() => readSummary(id)).toContain('data-resume-whitespace="preserve">A\tB</p>');
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Summary");
|
||||
await editor.click();
|
||||
await editor.evaluate((element) => {
|
||||
const text = element.querySelector("p")?.firstChild;
|
||||
if (!text) throw new Error("Missing caret target");
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 1);
|
||||
range.collapse(true);
|
||||
getSelection()?.removeAllRanges();
|
||||
getSelection()?.addRange(range);
|
||||
});
|
||||
await page.keyboard.press("Shift+ArrowRight");
|
||||
expect(await page.evaluate(() => getSelection()?.toString())).toBe("\t");
|
||||
await page.keyboard.insertText("Q");
|
||||
await expect(editor.locator("p").first()).toHaveText("AQB");
|
||||
await page.keyboard.press("ControlOrMeta+z");
|
||||
expect(await editor.locator("p").first().textContent()).toBe("A\tB");
|
||||
});
|
||||
Reference in New Issue
Block a user