Stage three: the viewer. Drawing on pages comes next. pdf.js stays out of the bundle everyone downloads — it is larger than the rest of the application put together, so it is behind a dynamic import and a session that never opens a PDF never fetches it. Main bundle grew by 8 kB for the viewer shell; the 162 kB library arrives only when needed. The worker is bundled rather than pulled from a CDN, because a viewer that stops working during someone else's outage is not one you can rely on offline. Two things that only showed up once it ran in a browser: - pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent browsers have. Anything older failed with "n.toHex is not a function", which says nothing about the cause. Now on the legacy build, which compiles those away — a little larger, and the difference between working everywhere and working on this year's browsers. - nginx has no MIME type for .mjs and served the worker as octet-stream, which a browser refuses to run as a module. Pages are laid out at their true proportions before anything is drawn, so the scrollbar is honest immediately and a document mixing portrait and landscape keeps both — the first version scaled every page to the widest one, which the suite caught by checking the two aspect ratios differ. Bytes come from the authenticated attachment route and are cached in IndexedDB. Content-addressed storage means a cached copy can never be stale, so a document opened once needs no connection again. Verified with the network switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.3 KiB
JavaScript
136 lines
5.3 KiB
JavaScript
/* 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"
|
||
);
|
||
|
||
/* ── 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);
|
||
}
|