diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs
new file mode 100644
index 0000000..6da9f06
--- /dev/null
+++ b/e2e/pdf.mjs
@@ -0,0 +1,135 @@
+/* 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);
+}
diff --git a/e2e/run.sh b/e2e/run.sh
index 7a7f48f..6db33ce 100755
--- a/e2e/run.sh
+++ b/e2e/run.sh
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
URL="${INKNOTE_URL:-http://localhost:3000}"
SUITES=("$@")
-[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict)
+[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf)
mkdir -p shots
if [ ! -d node_modules ]; then
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 6f12c7f..43e198a 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -26,6 +26,15 @@ server {
# Vite hashed assets — immutable cache
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
+ # nginx has no mapping for .mjs and falls back to octet-stream, which a
+ # browser refuses to run as a module. pdf.js ships its worker as .mjs.
+ types {
+ application/javascript js mjs;
+ text/css css;
+ application/json json map;
+ image/svg+xml svg;
+ font/woff2 woff2;
+ }
try_files $uri =404;
}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b164984..cc89a62 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -23,6 +23,7 @@
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"@tiptap/suggestion": "^2.27.2",
+ "pdfjs-dist": "^6.2.108",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -897,6 +898,271 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz",
+ "integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==",
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "1.0.3",
+ "@napi-rs/canvas-darwin-arm64": "1.0.3",
+ "@napi-rs/canvas-darwin-x64": "1.0.3",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3",
+ "@napi-rs/canvas-linux-arm64-gnu": "1.0.3",
+ "@napi-rs/canvas-linux-arm64-musl": "1.0.3",
+ "@napi-rs/canvas-linux-riscv64-gnu": "1.0.3",
+ "@napi-rs/canvas-linux-x64-gnu": "1.0.3",
+ "@napi-rs/canvas-linux-x64-musl": "1.0.3",
+ "@napi-rs/canvas-win32-arm64-msvc": "1.0.3",
+ "@napi-rs/canvas-win32-x64-msvc": "1.0.3"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz",
+ "integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz",
+ "integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz",
+ "integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz",
+ "integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz",
+ "integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz",
+ "integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz",
+ "integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz",
+ "integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz",
+ "integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz",
+ "integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz",
+ "integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -3048,6 +3314,18 @@
"node": ">= 14.16"
}
},
+ "node_modules/pdfjs-dist": {
+ "version": "6.2.108",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
+ "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=22.13.0 || >=24"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^1.0.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 81d0f0a..7c63865 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -28,6 +28,7 @@
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"@tiptap/suggestion": "^2.27.2",
+ "pdfjs-dist": "^6.2.108",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 6b6bfa7..c44ed0c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -234,6 +234,39 @@ export default function App() {
);
/* ── Actions ── */
+ /**
+ * Picks a file and imports it as a PDF note.
+ *
+ * The input is created and thrown away per use rather than living in the
+ * markup: a hidden input that persists keeps its previous selection, and
+ * choosing the same file twice in a row would then fire no change event.
+ */
+ const importPdf = () => {
+ const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
+ if (!folderId) return;
+
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "application/pdf,.pdf";
+ input.onchange = async () => {
+ const file = input.files?.[0];
+ if (!file) return;
+ try {
+ const note = await data.importPdf(folderId, file);
+ if (activeFolder === "dashboard") setActiveFolder(folderId);
+ openNote(note.id);
+ } catch (e) {
+ setConfirmDialog({
+ title: "Import fehlgeschlagen",
+ message: e?.message || "Die Datei konnte nicht importiert werden.",
+ confirmLabel: "OK",
+ onConfirm: () => setConfirmDialog(null),
+ });
+ }
+ };
+ input.click();
+ };
+
const createNote = async () => {
const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
if (!folderId) return;
@@ -489,6 +522,7 @@ export default function App() {
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
onCreateNote={createNote}
+ onImportPdf={importPdf}
onEmptyTrash={emptyTrash}
/>
)}
diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx
index 2c2ef4e..7ef1521 100644
--- a/frontend/src/features/notes/EditorPane.jsx
+++ b/frontend/src/features/notes/EditorPane.jsx
@@ -2,6 +2,7 @@ import { I } from "../../ui/icons.jsx";
import { BackButton } from "../../ui/BackButton.jsx";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
import { NoteEditor } from "./NoteEditor.jsx";
+import { PdfViewer } from "../pdf/PdfViewer.jsx";
const toolbarButton = (T, color) => ({
display: "flex",
@@ -209,17 +210,21 @@ export function EditorPane({
{I.trash}
-
-
+
+ {note.type === "pdf" ? (
+
+ ) : (
+
+ )}
);
diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx
index 49dffa9..4fbabf5 100644
--- a/frontend/src/features/notes/NoteList.jsx
+++ b/frontend/src/features/notes/NoteList.jsx
@@ -16,6 +16,7 @@ export function NoteList({
onSelectNote,
onNoteContextMenu,
onCreateNote,
+ onImportPdf,
onEmptyTrash,
}) {
return (
@@ -63,6 +64,26 @@ export function NoteList({
+ {!isTrashView && (
+
+ )}
{!isTrashView && (