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.
This commit is contained in:
mithral 2026-10-09 21:43:43 -04:00
parent dc95a9f78a
commit 5a377a2198
5 changed files with 345 additions and 1 deletions

View file

@ -18,7 +18,17 @@ func init() {
// ExtractCover writes the first page of the archive into the cache and returns // 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. // 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) { 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) pages, err := ListPages(absPath)
if err != nil { if err != nil {
return "", err return "", err

View file

@ -10,7 +10,8 @@
"check": "svelte-check --tsconfig ./tsconfig.json" "check": "svelte-check --tsconfig ./tsconfig.json"
}, },
"dependencies": { "dependencies": {
"epubjs": "^0.3.93" "epubjs": "^0.3.93",
"pdfjs-dist": "^6.4.299"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",

134
web/pnpm-lock.yaml generated
View file

@ -11,6 +11,9 @@ importers:
epubjs: epubjs:
specifier: ^0.3.93 specifier: ^0.3.93
version: 0.3.93 version: 0.3.93
pdfjs-dist:
specifier: ^6.4.299
version: 6.4.299
devDependencies: devDependencies:
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: ^5.0.3 specifier: ^5.0.3
@ -202,6 +205,81 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25} engines: {node: ^22.20 || ^24.12 || >=25}
@ -545,6 +623,10 @@ packages:
path-webpack@0.0.3: path-webpack@0.0.3:
resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==} 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: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -765,6 +847,54 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0 '@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': '@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true optional: true
@ -1049,6 +1179,10 @@ snapshots:
path-webpack@0.0.3: {} path-webpack@0.0.3: {}
pdfjs-dist@6.4.299:
optionalDependencies:
'@napi-rs/canvas': 1.0.10
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.7: {} picomatch@4.0.7: {}

View file

@ -0,0 +1,194 @@
<script lang="ts">
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import { onMount, onDestroy } from 'svelte'
import { api, ApiError } from '../api'
let { itemId }: { itemId: number } = $props()
let canvasEl = $state<HTMLCanvasElement>()
let doc: any = null
let numPages = $state(0)
let current = $state(1)
let loading = $state(true)
let rendering = $state(false)
let error = $state<string | null>(null)
let pageInput = $state('1')
let saveTimer: number | undefined
onMount(async () => {
try {
const prog = await api.progress(itemId)
// pdf.js is ~1 MB and most people never open a PDF. Load it on demand so
// it lands in its own chunk instead of the main bundle.
const pdfjs: any = await import('pdfjs-dist')
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
doc = await pdfjs.getDocument({
url: api.fileUrl(itemId),
withCredentials: true,
}).promise
numPages = doc.numPages
// Progress is stored as a page number, same as comics.
if (prog.progress && prog.progress.position > 0 && prog.progress.position <= numPages) {
current = prog.progress.position
}
pageInput = String(current)
await render()
window.addEventListener('keydown', onKey)
window.addEventListener('resize', onResize)
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this document'
} finally {
loading = false
}
})
onDestroy(() => {
window.removeEventListener('keydown', onKey)
window.removeEventListener('resize', onResize)
if (saveTimer) clearTimeout(saveTimer)
flush()
void doc?.destroy?.()
})
async function render() {
if (!doc || !canvasEl) return
rendering = true
try {
const page = await doc.getPage(current)
const container = canvasEl.parentElement
const base = page.getViewport({ scale: 1 })
// Leave slack for the vertical scrollbar that appears on tall pages;
// measuring clientWidth before it exists makes the page too wide and
// triggers a horizontal scrollbar in turn.
const available = container ? container.clientWidth - 28 : base.width
const scale = Math.max(available / base.width, 0.1)
const viewport = page.getViewport({ scale })
const dpr = window.devicePixelRatio || 1
canvasEl.width = Math.floor(viewport.width * dpr)
canvasEl.height = Math.floor(viewport.height * dpr)
canvasEl.style.width = `${Math.floor(viewport.width)}px`
canvasEl.style.height = `${Math.floor(viewport.height)}px`
const ctx = canvasEl.getContext('2d')
if (!ctx) return
await page.render({
canvasContext: ctx,
viewport,
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
}).promise
} finally {
rendering = false
}
}
let resizeTimer: number | undefined
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = window.setTimeout(() => void render(), 200)
}
function goTo(n: number) {
const next = Math.min(Math.max(n, 1), numPages)
if (next === current) return
current = next
pageInput = String(next)
void render()
scheduleSave()
}
function onKey(event: KeyboardEvent) {
if (event.key === 'ArrowRight') goTo(current + 1)
if (event.key === 'ArrowLeft') goTo(current - 1)
if (event.key === 'Home') goTo(1)
if (event.key === 'End') goTo(numPages)
}
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = window.setTimeout(flush, 900)
}
function flush() {
if (numPages === 0) return
void api.putProgress(itemId, { position: current, percent: current / numPages }).catch(() => {})
}
function submitJump(event: SubmitEvent) {
event.preventDefault()
const n = parseInt(pageInput, 10)
if (!Number.isNaN(n)) goTo(n)
}
</script>
{#if error}
<div class="alert">{error}</div>
{:else}
<div class="stage">
<canvas bind:this={canvasEl}></canvas>
{#if loading || rendering}
<div class="overlay"><div class="spinner"></div></div>
{/if}
</div>
<footer class="bar">
<button class="btn btn-ghost" onclick={() => goTo(current - 1)} disabled={current <= 1}>←</button>
<form class="jump" onsubmit={submitJump}>
<input class="input page-input" bind:value={pageInput} aria-label="Page number" disabled={loading} />
<span class="muted mono">/ {numPages || '–'}</span>
</form>
<span class="pct mono">{numPages ? Math.round((current / numPages) * 100) : 0}%</span>
<button class="btn btn-ghost" onclick={() => goTo(current + 1)} disabled={current >= numPages}>→</button>
</footer>
{/if}
<style>
.stage {
position: relative;
height: calc(100vh - 60px - 96px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: #17171b;
overflow: auto;
display: grid;
place-items: start center;
padding: var(--space-4) 0;
}
canvas {
display: block;
background: #fff;
box-shadow: var(--shadow-cover);
border-radius: 2px;
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--bg) 60%, transparent);
}
.bar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
height: 56px;
}
.jump {
display: flex;
align-items: center;
gap: var(--space-2);
}
.page-input {
width: 64px;
text-align: center;
}
.pct {
color: var(--faint);
min-width: 40px;
text-align: right;
}
</style>

View file

@ -5,6 +5,7 @@
import ComicReader from './ComicReader.svelte' import ComicReader from './ComicReader.svelte'
import AudiobookPlayer from './AudiobookPlayer.svelte' import AudiobookPlayer from './AudiobookPlayer.svelte'
import PhotoViewer from './PhotoViewer.svelte' import PhotoViewer from './PhotoViewer.svelte'
import PdfReader from './PdfReader.svelte'
let { itemId }: { itemId: number } = $props() let { itemId }: { itemId: number } = $props()
@ -14,6 +15,7 @@
let error = $state<string | null>(null) let error = $state<string | null>(null)
let loading = $state(true) let loading = $state(true)
let percent = $state(0) let percent = $state(0)
let fileContainer = $state('')
let book: any = null let book: any = null
let rendition: any = null let rendition: any = null
@ -23,6 +25,7 @@
try { try {
const detail = await api.item(itemId) const detail = await api.item(itemId)
item = detail.item item = detail.item
fileContainer = detail.files?.[0]?.container ?? ''
if (item.kind !== 'ebooks') return if (item.kind !== 'ebooks') return
meta = (await api.ebook(itemId)).metadata meta = (await api.ebook(itemId)).metadata
@ -92,6 +95,8 @@
{#if error} {#if error}
<div class="alert">{error}</div> <div class="alert">{error}</div>
{:else if item && fileContainer === 'pdf'}
<PdfReader {itemId} />
{:else if item && item.kind === 'comics'} {:else if item && item.kind === 'comics'}
<ComicReader {itemId} /> <ComicReader {itemId} />
{:else if item && (item.kind === 'audiobooks' || item.kind === 'music')} {:else if item && (item.kind === 'audiobooks' || item.kind === 'music')}