/* Importing and reading a PDF. * * The interesting parts are not that a canvas appears: it is that the library * stays out of the main bundle until a PDF is opened, that the pages come out at * the right proportions, and that a document read once survives without the * network — the offline promise is the whole reason the bytes are cached. */ import { writeFileSync } from "node:fs"; import { check, createNotebook, launch, report, withFreshUser } from "./lib.mjs"; const PDF_PATH = "/tmp/e2e-test.pdf"; /** A two-page PDF built here, so the suite depends on no fixture file. */ function writeTestPdf() { const objs = [ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 7 0 R >> >> >>", null, // stream, filled below "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>", null, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", ]; const s1 = "BT /F1 24 Tf 72 760 Td (Seite eins Zebrastreifen) Tj ET"; const s2 = "BT /F1 24 Tf 72 520 Td (Seite zwei Dichtung) Tj ET"; objs[3] = `<< /Length ${s1.length} >>\nstream\n${s1}\nendstream`; objs[5] = `<< /Length ${s2.length} >>\nstream\n${s2}\nendstream`; let out = "%PDF-1.4\n"; const offsets = []; objs.forEach((o, i) => { offsets.push(out.length); out += `${i + 1} 0 obj\n${o}\nendobj\n`; }); const xref = out.length; out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`; for (const off of offsets) out += `${String(off).padStart(10, "0")} 00000 n \n`; out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; writeFileSync(PDF_PATH, out, "latin1"); } const browser = await launch(); let session; try { writeTestPdf(); session = await withFreshUser(browser, "pdf"); const p = session.page; /* The library must not be in the bundle everyone downloads. */ const scriptsBefore = await p.evaluate(() => performance.getEntriesByType("resource").map((r) => r.name) ); check( !scriptsBefore.some((u) => /pdf[.-]/i.test(u)), "pdf.js is not loaded before a PDF is opened", scriptsBefore.filter((u) => /pdf/i.test(u)).join(", ") || "none" ); await createNotebook(p, "PDF Buch"); /* ── import ── */ const chooser = p.waitForEvent("filechooser"); await p.getByTitle("Import PDF").click(); await (await chooser).setFiles(PDF_PATH); await p.waitForTimeout(4000); check((await p.locator("[data-pdf-page]").count()) > 0, "the document is shown after importing"); check( (await p.locator("[data-pdf-position]").innerText()).includes("/ 2"), "both pages are counted", await p.locator("[data-pdf-position]").innerText() ); await p.screenshot({ path: "/out/pdf-imported.png" }); /* ── proportions ── */ const boxes = await p.$$eval("[data-pdf-page]", (els) => els.map((e) => ({ w: e.offsetWidth, h: e.offsetHeight })) ); check(boxes.length === 2, "both pages are laid out", JSON.stringify(boxes)); // Page one is A4 portrait (595×842), page two the same turned on its side. const ratio1 = boxes[0].h / boxes[0].w; const ratio2 = boxes[1].h / boxes[1].w; check(Math.abs(ratio1 - 842 / 595) < 0.05, "the portrait page keeps its proportions", ratio1.toFixed(2)); check(Math.abs(ratio2 - 595 / 842) < 0.05, "the landscape page is not stretched to match", ratio2.toFixed(2)); /* ── something was actually painted ── */ const inkedPixels = await p.evaluate(() => { const c = document.querySelector("[data-pdf-page] canvas"); if (!c) return -1; const ctx = c.getContext("2d"); const d = ctx.getImageData(0, 0, c.width, Math.min(c.height, 400)).data; let dark = 0; for (let i = 0; i < d.length; i += 4) if (d[i] < 200) dark++; return dark; }); check(inkedPixels > 50, "the page has actual content drawn on it", `${inkedPixels} dark pixels`); const loadedAfter = await p.evaluate(() => performance.getEntriesByType("resource").map((r) => r.name)); check( loadedAfter.some((u) => /pdf.*\.js|pdf\.worker/i.test(u)), "pdf.js was fetched only once it was needed" ); /* ── drawing on a page ── */ const inkOnPage = (n) => p.evaluate((page) => { const c = document.querySelector(`[data-pdf-ink="${page}"]`); if (!c) return -1; const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data; let painted = 0; for (let i = 3; i < d.length; i += 4) if (d[i] > 40) painted++; return painted; }, n); /** Draws a line across the page in fractions of its box, then waits for the save. */ const drawOn = async (pageNo, x1, y1, x2, y2) => { const box = await p.locator(`[data-pdf-page="${pageNo}"]`).boundingBox(); await p.mouse.move(box.x + box.width * x1, box.y + box.height * y1); await p.mouse.down(); await p.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 14 }); await p.mouse.up(); await p.waitForTimeout(1600); }; check((await inkOnPage(1)) === 0, "no ink before anything is drawn"); await p.locator("[data-pdf-mode]").click(); check((await p.locator('[data-pdf-mode="draw"]').count()) === 1, "drawing mode can be switched on"); await drawOn(1, 0.2, 0.25, 0.7, 0.3); const inkedFirst = await inkOnPage(1); check(inkedFirst > 100, "the stroke appears on the page", `${inkedFirst} painted pixels`); check((await inkOnPage(2)) === 0, "and only on the page it was drawn on"); await p.screenshot({ path: "/out/pdf-drawn.png" }); /* Where the stroke sits relative to the page, so zoom can be compared against it. */ const inkBounds = (n) => p.evaluate((page) => { const c = document.querySelector(`[data-pdf-ink="${page}"]`); const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data; let minX = 1e9, maxX = -1, minY = 1e9, maxY = -1; for (let i = 3, px = 0; i < d.length; i += 4, px++) { if (d[i] <= 40) continue; const x = px % c.width, y = (px / c.width) | 0; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } // As fractions of the canvas, so they can be compared across zoom levels. return { x: minX / c.width, y: minY / c.height, w: (maxX - minX) / c.width, h: (maxY - minY) / c.height }; }, n); const before = await inkBounds(1); await p.getByTitle("Größer").click(); await p.waitForTimeout(2500); const after = await inkBounds(1); const drift = Math.max(Math.abs(before.x - after.x), Math.abs(before.y - after.y), Math.abs(before.w - after.w)); check(drift < 0.02, "the stroke stays on the same spot when zoomed", `drift ${drift.toFixed(3)}`); await p.getByTitle("Kleiner").click(); await p.waitForTimeout(1500); /* ── hiding it ── */ await p.locator("[data-pdf-hide-ink]").click(); await p.waitForTimeout(600); check((await inkOnPage(1)) >= 0, "hiding the drawing leaves the document readable"); const hiddenOpacity = await p.locator('[data-pdf-ink="1"]').evaluate((c) => getComputedStyle(c).opacity); check(hiddenOpacity === "0", "the ink layer is hidden, not erased", hiddenOpacity); await p.locator("[data-pdf-hide-ink]").click(); await p.waitForTimeout(600); /* ── it survives ── */ await p.reload({ waitUntil: "networkidle" }); await p.waitForTimeout(2500); await p.locator("button").filter({ hasText: "PDF Buch" }).first().click(); await p.waitForTimeout(1200); await p.locator("[data-note-row]").first().click(); await p.waitForTimeout(4500); const afterReload = await inkOnPage(1); check(afterReload > 100, "the stroke survived a reload", `${afterReload} painted pixels`); /* ── it must not be undone by the background poller ── */ await p.waitForTimeout(9000); check((await inkOnPage(1)) > 100, "and is still there after the sync poller has run"); /* ── it stays readable offline ── */ // A reload lands on the dashboard, which has no note list; the notebook has // to be opened first before a row exists to click. const reopen = async () => { await p.locator("button").filter({ hasText: "PDF Buch" }).first().click(); await p.waitForTimeout(1200); await p.locator("[data-note-row]").first().click(); await p.waitForTimeout(4000); }; await p.reload({ waitUntil: "networkidle" }); await p.waitForTimeout(2500); await reopen(); check((await p.locator("[data-pdf-page]").count()) > 0, "the document reopens after a reload"); await p.context().setOffline(true); await p.reload({ waitUntil: "domcontentloaded" }); await p.waitForTimeout(3000); await reopen(); check( (await p.locator("[data-pdf-page]").count()) > 0, "and still opens with the network switched off" ); await p.screenshot({ path: "/out/pdf-offline.png" }); await p.context().setOffline(false); } catch (e) { check(false, "suite ran to the end", e.message); } finally { await session?.cleanup(); const code = report(); await browser.close(); process.exit(code); }