From 5a377a21988b7f92d2cf0f49461a6f5168f1d129 Mon Sep 17 00:00:00 2001 From: mithral Date: Fri, 9 Oct 2026 21:43:43 -0400 Subject: [PATCH] PDF reading via pdf.js - PdfReader renders PDFs to canvas with page navigation and page-number progress, reusing the same integer progress model as comics. - Reader routes by file container, not media kind, so a PDF in a comics library and a PDF ebook both open in the PDF reader. - pdf.js is dynamically imported: the main bundle stays 443 kB and the 474 kB pdf chunk plus 1.26 MB worker load only when a PDF is actually opened. - comics cover extractor now only handles zip archives. A PDF in a comics library is a valid item but not a zip, so it returns no cover instead of logging an error on every request. - fixed the page scale to leave slack for the vertical scrollbar, which was otherwise forcing a horizontal scrollbar on tall pages. Verified live: a 3-page PDF renders, navigates, and reports 33% progress. --- internal/modules/comics/cover.go | 10 ++ web/package.json | 3 +- web/pnpm-lock.yaml | 134 ++++++++++++++++ web/src/lib/components/PdfReader.svelte | 194 ++++++++++++++++++++++++ web/src/lib/components/Reader.svelte | 5 + 5 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/components/PdfReader.svelte diff --git a/internal/modules/comics/cover.go b/internal/modules/comics/cover.go index c37ee41..885c18a 100644 --- a/internal/modules/comics/cover.go +++ b/internal/modules/comics/cover.go @@ -18,7 +18,17 @@ func init() { // ExtractCover writes the first page of the archive into the cache and returns // its path. A comic's first page is its cover by convention. +// +// Only zip-based archives are handled. A PDF in a comics library is a valid +// item but not a zip, so it returns no cover rather than logging an error on +// every request — the grid falls back to a placeholder. func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) { + switch strings.ToLower(filepath.Ext(item.Path)) { + case ".cbz", ".zip": + default: + return "", nil + } + pages, err := ListPages(absPath) if err != nil { return "", err diff --git a/web/package.json b/web/package.json index e037e7c..e67e4bb 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,8 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "epubjs": "^0.3.93" + "epubjs": "^0.3.93", + "pdfjs-dist": "^6.4.299" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index fa881d1..4aba9e1 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: epubjs: specifier: ^0.3.93 version: 0.3.93 + pdfjs-dist: + specifier: ^6.4.299 + version: 6.4.299 devDependencies: '@sveltejs/vite-plugin-svelte': specifier: ^5.0.3 @@ -202,6 +205,81 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/canvas-android-arm64@1.0.10': + resolution: {integrity: sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.10': + resolution: {integrity: sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.10': + resolution: {integrity: sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.10': + resolution: {integrity: sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.10': + resolution: {integrity: sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@1.0.10': + resolution: {integrity: sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.10': + resolution: {integrity: sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@1.0.10': + resolution: {integrity: sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@1.0.10': + resolution: {integrity: sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.10': + resolution: {integrity: sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.10': + resolution: {integrity: sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.10': + resolution: {integrity: sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==} + engines: {node: '>= 10'} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -545,6 +623,10 @@ packages: path-webpack@0.0.3: resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==} + pdfjs-dist@6.4.299: + resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -765,6 +847,54 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@napi-rs/canvas-android-arm64@1.0.10': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.10': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.10': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.10': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.10': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.10': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.10': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.10': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.10': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.10': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.10': + optional: true + + '@napi-rs/canvas@1.0.10': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.10 + '@napi-rs/canvas-darwin-arm64': 1.0.10 + '@napi-rs/canvas-darwin-x64': 1.0.10 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.10 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.10 + '@napi-rs/canvas-linux-arm64-musl': 1.0.10 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.10 + '@napi-rs/canvas-linux-x64-gnu': 1.0.10 + '@napi-rs/canvas-linux-x64-musl': 1.0.10 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.10 + '@napi-rs/canvas-win32-x64-msvc': 1.0.10 + optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -1049,6 +1179,10 @@ snapshots: path-webpack@0.0.3: {} + pdfjs-dist@6.4.299: + optionalDependencies: + '@napi-rs/canvas': 1.0.10 + picocolors@1.1.1: {} picomatch@4.0.7: {} diff --git a/web/src/lib/components/PdfReader.svelte b/web/src/lib/components/PdfReader.svelte new file mode 100644 index 0000000..b0939cc --- /dev/null +++ b/web/src/lib/components/PdfReader.svelte @@ -0,0 +1,194 @@ + + +{#if error} +
{error}
+{:else} +
+ + {#if loading || rendering} +
+ {/if} +
+ + +{/if} + + diff --git a/web/src/lib/components/Reader.svelte b/web/src/lib/components/Reader.svelte index 5f03626..a9b5f3c 100644 --- a/web/src/lib/components/Reader.svelte +++ b/web/src/lib/components/Reader.svelte @@ -5,6 +5,7 @@ import ComicReader from './ComicReader.svelte' import AudiobookPlayer from './AudiobookPlayer.svelte' import PhotoViewer from './PhotoViewer.svelte' + import PdfReader from './PdfReader.svelte' let { itemId }: { itemId: number } = $props() @@ -14,6 +15,7 @@ let error = $state(null) let loading = $state(true) let percent = $state(0) + let fileContainer = $state('') let book: any = null let rendition: any = null @@ -23,6 +25,7 @@ try { const detail = await api.item(itemId) item = detail.item + fileContainer = detail.files?.[0]?.container ?? '' if (item.kind !== 'ebooks') return meta = (await api.ebook(itemId)).metadata @@ -92,6 +95,8 @@ {#if error}
{error}
+{:else if item && fileContainer === 'pdf'} + {:else if item && item.kind === 'comics'} {:else if item && (item.kind === 'audiobooks' || item.kind === 'music')}