refactor(reader): add HTTP range requests and improve PDF metadata

This commit is contained in:
neon
2026-01-01 20:42:35 -06:00
parent d99ebb7d64
commit 57b58a9404
14 changed files with 619 additions and 476 deletions
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, BookOpen, ChevronLeft, ChevronRight, Minus, Plus, RotateCw, Search, X } from 'lucide-vue-next'
import { ArrowLeft, ChevronLeft, ChevronRight, LayoutGrid, Maximize, Minimize, Minus, Plus, RotateCw, Search, X } from 'lucide-vue-next'
import { usePdf } from '../composables/usePdf'
import { usePdfZoom, ZOOM_PRESETS } from '../composables/usePdfZoom'
import { usePdfLayout, PAGE_GAP } from '../composables/usePdfLayout'
import { usePdfRenderer } from '../composables/usePdfRenderer'
import { useReaderProgress } from '../composables/useReaderProgress'
import { useVisibility } from '../composables/useVisibility'
import type { PageDim } from '../composables/usePdf'
@@ -14,254 +17,124 @@ const { pdfDoc, totalPages, loading, error, load, getAllPageDims, renderPage, ge
const progress = useReaderProgress(props.bookId, props.fileId)
const { headerVisible, showHeader } = useVisibility()
// ── State ─────────────────────────────────────────────────────────────────────
// ── Container & scroll ────────────────────────────────────────────────────────
const scrollRef = ref<HTMLElement | null>(null)
const currentPage = ref(1)
const pageInput = ref(1)
const rotation = ref<0 | 90 | 180 | 270>(0)
const spread = ref<'none' | 'odd' | 'even'>('none')
const showFind = ref(false)
const findQuery = ref('')
const findMatches = ref(0)
const PAGE_GAP = 16
const containerW = ref(0)
const containerH = ref(0)
// ── Page dimensions ───────────────────────────────────────────────────────────
function measure() {
if (!scrollRef.value) return
containerW.value = scrollRef.value.clientWidth
containerH.value = scrollRef.value.clientHeight
}
// ── Page dims + rotation ──────────────────────────────────────────────────────
const pageDims = ref<PageDim[]>([])
const rotation = ref<0 | 90 | 180 | 270>(0)
// After rotation, swap width/height for 90°/270°.
// Swap width/height for 90°/270° rotations.
const effectiveDims = computed(() =>
pageDims.value.map((d) => (rotation.value === 90 || rotation.value === 270 ? { width: d.height, height: d.width } : d)),
)
// ── Container size ────────────────────────────────────────────────────────────
const containerW = ref(0)
const containerH = ref(0)
function rotate() {
rotation.value = ((rotation.value + 90) % 360) as 0 | 90 | 180 | 270
}
// ── Zoom ──────────────────────────────────────────────────────────────────────
type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
const zoomMode = ref<ZoomMode>('fit-width')
const customScale = ref(1.0)
// ── Spread (owned here; passed to both zoom and layout) ───────────────────────
const spread = ref<'none' | 'odd' | 'even'>('none')
const scale = computed(() => {
const first = effectiveDims.value[0]
if (!first || !containerW.value) return 1
if (spread.value !== 'none') {
// Two-page spread: fit two pages side by side
const sw = (containerW.value - 48) / (first.width * 2 + PAGE_GAP)
if (zoomMode.value === 'fit-page') return Math.min(sw, (containerH.value - 48) / first.height)
if (zoomMode.value === 'fit-width') return sw
}
if (zoomMode.value === 'fit-width') return (containerW.value - 32) / first.width
if (zoomMode.value === 'fit-page') return Math.min((containerW.value - 32) / first.width, (containerH.value - 48) / first.height)
return customScale.value
// ── Composables ───────────────────────────────────────────────────────────────
const zoom = usePdfZoom(containerW, containerH, effectiveDims, spread)
const { scale, zoomMode, customScale, zoomLabel, adjustZoom, applyZoomPreset } = zoom
const layout = usePdfLayout(scrollRef, totalPages, effectiveDims, scale, containerH, spread)
const { scrollMode, currentPage, pageInput, pageRows, rowHeights, goToPage, onScroll } = layout
const renderer = usePdfRenderer(renderPage, getTextContent, scale, rotation, totalPages)
const { canvasMap, textLayerMap, invalidate, setupIO, reset, destroy } = renderer
// ── Invalidation ──────────────────────────────────────────────────────────────
watch([scale, rotation], invalidate)
// Spread/scrollMode change alters DOM structure — rebuild IO observers after re-render.
watch([spread, scrollMode], async () => {
invalidate()
await nextTick()
if (scrollRef.value) setupIO(scrollRef.value)
})
const zoomPct = computed(() => Math.round(scale.value * 100))
const showZoomMenu = ref(false)
const ZOOM_PRESETS = [
{ label: 'Fit Page', value: 'fit-page' },
{ label: 'Fit Width', value: 'fit-width' },
{ label: 'Actual Size', value: '1' },
{ label: '50%', value: '0.5' },
{ label: '75%', value: '0.75' },
{ label: '100%', value: '1' },
{ label: '125%', value: '1.25' },
{ label: '150%', value: '1.5' },
{ label: '200%', value: '2' },
] as const
function applyZoomPreset(value: string) {
showZoomMenu.value = false
if (value === 'fit-width' || value === 'fit-page') {
zoomMode.value = value
} else {
customScale.value = parseFloat(value)
zoomMode.value = 'custom'
}
}
function adjustZoom(delta: number) {
customScale.value = Math.round(Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20)
zoomMode.value = 'custom'
}
const zoomLabel = computed(() => {
if (zoomMode.value === 'fit-width') return 'Fit Width'
if (zoomMode.value === 'fit-page') return 'Fit Page'
return `${zoomPct.value}%`
})
// ── Spread pages into rows ────────────────────────────────────────────────────
// Returns array of rows, each row = [pageNum] or [pageNum, pageNum+1]
const pageRows = computed<number[][]>(() => {
const rows: number[][] = []
const total = totalPages.value
if (spread.value === 'none') {
for (let i = 1; i <= total; i++) rows.push([i])
return rows
}
// 'odd' spread: page 1 alone, then pairs [2,3], [4,5]...
// 'even' spread: pairs [1,2], [3,4]...
let i = spread.value === 'odd' ? 1 : 0
if (spread.value === 'odd' && total >= 1) {
rows.push([1])
i = 2
}
while (i < total) {
const a = i + 1
const b = i + 2
rows.push(b <= total ? [a, b] : [a])
i += 2
}
return rows
})
// Height of each row in px
const rowHeights = computed(() =>
pageRows.value.map((row) => {
const maxH = Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value)))
return maxH
}),
)
// ── Canvas / text-layer tracking ──────────────────────────────────────────────
const canvasMap = ref(new Map<number, HTMLCanvasElement>())
const textLayerMap = ref(new Map<number, HTMLElement>())
const rendered = new Set<number>()
let renderQueue: number[] = []
let isRendering = false
async function flushQueue() {
if (isRendering) return
isRendering = true
while (renderQueue.length > 0) {
const pageNum = renderQueue.shift()!
if (!pdfDoc.value) break
const canvas = canvasMap.value.get(pageNum)
if (!canvas) continue
await renderPage(pageNum, canvas, scale.value)
rendered.add(pageNum)
// Text layer
await renderTextLayer(pageNum)
}
isRendering = false
}
async function renderTextLayer(pageNum: number) {
const container = textLayerMap.value.get(pageNum)
if (!container) return
const result = await getTextContent(pageNum)
if (!result) return
const { content, viewport } = result
const scaledVp = viewport.clone({ scale: scale.value, rotation: rotation.value })
container.innerHTML = ''
container.style.width = `${scaledVp.width}px`
container.style.height = `${scaledVp.height}px`
for (const item of content.items) {
if (!('str' in item) || !item.str.trim()) continue
const [a, b, c, d, e, f] = item.transform
const span = document.createElement('span')
span.textContent = item.str
const tx = pdfjsLib_scaleTransform([a, b, c, d, e, f], scale.value, scaledVp.height)
span.style.cssText = `position:absolute;left:${tx[4]}px;top:${scaledVp.height - tx[5] - (item.height ?? 0) * scale.value}px;font-size:${Math.abs(tx[0]) || Math.abs(tx[1])}px;transform-origin:0% 0%;white-space:pre;color:transparent;cursor:text;`
container.appendChild(span)
}
}
// Scale a 2D transform matrix
function pdfjsLib_scaleTransform([a, b, c, d, e, f]: number[], s: number, _h: number) {
return [a * s, b * s, c * s, d * s, e * s, f * s]
}
function enqueue(pageNum: number) {
if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return
renderQueue.push(pageNum)
flushQueue()
}
// Invalidate and re-render on zoom/rotation change
watch([scale, rotation], () => {
rendered.clear()
renderQueue = []
isRendering = false
nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
})
// ── IntersectionObserver ──────────────────────────────────────────────────────
let ioEntries: IntersectionObserverEntry[] = []
let io: IntersectionObserver | null = null
function ioCallback(entries: IntersectionObserverEntry[]) {
for (const e of entries) {
if (!e.isIntersecting) continue
const nums = (e.target as HTMLElement).dataset.pages?.split(',').map(Number) ?? []
for (const n of nums) {
enqueue(n)
if (n > 1) enqueue(n - 1)
if (n < totalPages.value) enqueue(n + 1)
}
}
ioEntries = entries
}
function setupIO() {
io?.disconnect()
io = new IntersectionObserver(ioCallback, { root: scrollRef.value, rootMargin: '300px', threshold: 0 })
nextTick(() => scrollRef.value?.querySelectorAll('[data-pages]').forEach((el) => io!.observe(el)))
}
// ── Scroll → page tracking ────────────────────────────────────────────────────
// ── Progress: debounced save on page change ───────────────────────────────────
let saveTimer: ReturnType<typeof setTimeout> | null = null
function onScroll() {
const el = scrollRef.value
if (!el || !rowHeights.value.length) return
let offset = el.scrollTop
let rowIdx = 0
for (let i = 0; i < rowHeights.value.length; i++) {
const h = (rowHeights.value[i] ?? 0) + PAGE_GAP
if (offset < h) {
rowIdx = i
break
}
offset -= h
rowIdx = i + 1
}
const row = pageRows.value[rowIdx]
currentPage.value = row?.[0] ?? totalPages.value
watch(currentPage, (page) => {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
progress.pageNumber.value = currentPage.value
progress.percentage.value = (currentPage.value / totalPages.value) * 100
progress.pageNumber.value = page
progress.percentage.value = (page / totalPages.value) * 100
progress.save()
}, 2000)
}
watch(currentPage, (v) => {
pageInput.value = v
})
// ── Navigation ────────────────────────────────────────────────────────────────
function goToPage(n: number) {
const el = scrollRef.value
if (!el) return
const clamped = Math.max(1, Math.min(n, totalPages.value))
// Find which row contains this page
const rowIdx = pageRows.value.findIndex((row) => row.includes(clamped))
if (rowIdx < 0) return
let top = 0
for (let i = 0; i < rowIdx; i++) top += (rowHeights.value[i] ?? 0) + PAGE_GAP
el.scrollTo({ top, behavior: 'smooth' })
// ── Fullscreen ────────────────────────────────────────────────────────────────
const isFullscreen = ref(false)
function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen?.()
} else {
document.documentElement.requestFullscreen?.()
}
}
function onPageCommit() {
const n = parseInt(String(pageInput.value))
if (!isNaN(n)) goToPage(n)
// ── Find ──────────────────────────────────────────────────────────────────────
const showFind = ref(false)
const findQuery = ref('')
const findMatches = ref(0)
watch(findQuery, (q) => {
if (!q.trim()) { findMatches.value = 0; return }
let count = 0
scrollRef.value?.querySelectorAll('[data-text-layer] span').forEach((el) => {
if (el.textContent?.toLowerCase().includes(q.toLowerCase())) count++
})
findMatches.value = count
})
function closeFind() {
showFind.value = false
findQuery.value = ''
}
// ── Menus (zoom + view dropdowns) ────────────────────────────────────────────
const showZoomMenu = ref(false)
const showViewMenu = ref(false)
const zoomBtnRef = ref<HTMLElement | null>(null)
const viewBtnRef = ref<HTMLElement | null>(null)
const zoomMenuStyle = ref({ top: '0px', left: '0px' })
const viewMenuStyle = ref({ top: '0px', right: '0px' })
function openZoomMenu() {
if (zoomBtnRef.value) {
const r = zoomBtnRef.value.getBoundingClientRect()
zoomMenuStyle.value = { top: `${r.bottom + 4}px`, left: `${r.left + r.width / 2}px` }
}
showZoomMenu.value = !showZoomMenu.value
showViewMenu.value = false
}
function openViewMenu() {
if (viewBtnRef.value) {
const r = viewBtnRef.value.getBoundingClientRect()
viewMenuStyle.value = { top: `${r.bottom + 4}px`, right: `${window.innerWidth - r.right}px` }
}
showViewMenu.value = !showViewMenu.value
showZoomMenu.value = false
}
function closeAllMenus() {
showZoomMenu.value = false
showViewMenu.value = false
}
// ── Keyboard ──────────────────────────────────────────────────────────────────
@@ -276,75 +149,52 @@ function onKeyDown(e: KeyboardEvent) {
} else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
e.preventDefault()
el.scrollBy({ top: -ph, behavior: 'smooth' })
} else if (e.key === 'Home') goToPage(1)
else if (e.key === 'End') goToPage(totalPages.value)
else if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
} else if (e.key === 'Home') {
goToPage(1)
} else if (e.key === 'End') {
goToPage(totalPages.value)
} else if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault()
showFind.value = true
} else if (e.key === 'Escape') {
closeFind()
showZoomMenu.value = false
closeAllMenus()
}
}
// ── Rotation ──────────────────────────────────────────────────────────────────
function rotate() {
rotation.value = ((rotation.value + 90) % 360) as 0 | 90 | 180 | 270
function onPageCommit() {
const n = parseInt(String(pageInput.value))
if (!isNaN(n)) goToPage(n)
}
function closeFind() {
showFind.value = false
findQuery.value = ''
}
// ── Find ──────────────────────────────────────────────────────────────────────
watch(findQuery, (q) => {
if (!q.trim()) {
findMatches.value = 0
return
}
// Naive: count rendered text spans containing query
let count = 0
scrollRef.value?.querySelectorAll('[data-text-layer] span').forEach((el) => {
if (el.textContent?.toLowerCase().includes(q.toLowerCase())) count++
})
findMatches.value = count
})
// ── Resize ────────────────────────────────────────────────────────────────────
let ro: ResizeObserver | null = null
function measure() {
if (!scrollRef.value) return
containerW.value = scrollRef.value.clientWidth
containerH.value = scrollRef.value.clientHeight
}
// ── Mount ─────────────────────────────────────────────────────────────────────
// ── Mount / unmount ───────────────────────────────────────────────────────────
onMounted(async () => {
window.addEventListener('keydown', onKeyDown)
const onFsChange = () => { isFullscreen.value = !!document.fullscreenElement }
document.addEventListener('fullscreenchange', onFsChange)
onUnmounted(() => document.removeEventListener('fullscreenchange', onFsChange))
await progress.load()
await load(props.fileId)
if (!pdfDoc.value) return
pageDims.value = await getAllPageDims()
canvasMap.value = new Map()
textLayerMap.value = new Map()
reset()
await nextTick()
measure()
ro = new ResizeObserver(() => {
let ro: ResizeObserver | null = new ResizeObserver(async () => {
measure()
rendered.clear()
renderQueue = []
isRendering = false
nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
invalidate()
await nextTick()
if (scrollRef.value) setupIO(scrollRef.value)
})
if (scrollRef.value) ro.observe(scrollRef.value)
setupIO()
if (scrollRef.value) {
ro.observe(scrollRef.value)
setupIO(scrollRef.value)
}
onUnmounted(() => { ro?.disconnect(); ro = null })
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
await nextTick()
@@ -354,32 +204,28 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
io?.disconnect()
ro?.disconnect()
destroy()
if (saveTimer) clearTimeout(saveTimer)
})
// ── Derived ───────────────────────────────────────────────────────────────────
const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0))
</script>
<template>
<div class="fixed inset-0 bg-[#525659] flex flex-col overflow-hidden select-none" @mousemove="showHeader()" @click="showZoomMenu = false">
<div class="fixed inset-0 bg-[#525659] flex flex-col overflow-hidden select-none" @mousemove="showHeader()" @click="closeAllMenus()">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<div
class="absolute top-0 left-0 right-0 z-50 flex flex-col transition-all duration-300"
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
>
<!-- Main toolbar -->
<!-- Toolbar -->
<div
class="h-12 flex items-center px-2 gap-0.5 overflow-x-auto"
class="h-12 flex items-center px-2 gap-0.5"
style="background: rgba(50, 54, 57, 0.96); backdrop-filter: blur(8px); border-bottom: 1px solid rgba(255, 255, 255, 0.08)"
>
<!-- Back -->
<button class="pdf-btn" @click="router.back()" title="Back"><ArrowLeft :size="16" /></button>
<div class="sep" />
<!-- Page navigation -->
<button class="pdf-btn" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)" title="Previous page">
<ChevronLeft :size="16" />
</button>
@@ -401,70 +247,43 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
</button>
<div class="sep" />
<!-- Zoom: − [label/dropdown] + -->
<button class="pdf-btn" @click="adjustZoom(-0.1)" title="Zoom out (−)"><Minus :size="14" /></button>
<div class="relative shrink-0">
<button class="pdf-btn" @click.stop="adjustZoom(-0.1)" title="Zoom out"><Minus :size="14" /></button>
<div class="shrink-0">
<button
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-white/80 hover:text-white hover:bg-white/10 transition-colors min-w-[80px] justify-center font-mono"
@click="showZoomMenu = !showZoomMenu"
title="Zoom level — click to pick preset"
ref="zoomBtnRef"
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-white/80 hover:text-white hover:bg-white/10 transition-colors min-w-[72px] justify-center font-mono"
@click.stop="openZoomMenu"
title="Zoom level"
>
{{ zoomLabel }}
<span class="text-white/30 text-[10px]">▾</span>
</button>
<!-- Dropdown -->
<div
v-if="showZoomMenu"
class="absolute top-full left-1/2 -translate-x-1/2 mt-1 w-36 rounded-lg shadow-2xl z-50 py-1 overflow-hidden"
style="background: rgba(35, 38, 41, 0.98); border: 1px solid rgba(255, 255, 255, 0.1)"
>
<button
v-for="preset in ZOOM_PRESETS"
:key="preset.label"
class="w-full text-left px-3 py-1.5 text-xs text-white/70 hover:text-white hover:bg-white/10 transition-colors"
@click="applyZoomPreset(preset.value)"
>
{{ preset.label }}
</button>
</div>
</div>
<button class="pdf-btn" @click="adjustZoom(0.1)" title="Zoom in (+)"><Plus :size="14" /></button>
<button class="pdf-btn" @click.stop="adjustZoom(0.1)" title="Zoom in"><Plus :size="14" /></button>
<div class="sep" />
<!-- Fit shortcuts -->
<button class="pdf-btn-label" :class="zoomMode === 'fit-width' ? 'active' : ''" @click="zoomMode = 'fit-width'" title="Fit Width">
Fit Width
</button>
<button class="pdf-btn-label" :class="zoomMode === 'fit-page' ? 'active' : ''" @click="zoomMode = 'fit-page'" title="Fit Page">
Fit Page
</button>
<button
class="pdf-btn-label"
:class="zoomMode === 'custom' && customScale === 1 ? 'active' : ''"
@click="applyZoomPreset('1')"
title="Actual size (100%)"
>
Actual
</button>
<div class="shrink-0">
<button
ref="viewBtnRef"
class="flex items-center gap-1 px-2 py-1 rounded text-xs text-white/80 hover:text-white hover:bg-white/10 transition-colors"
@click.stop="openViewMenu"
title="View options"
>
<LayoutGrid :size="14" />
<span class="text-white/30 text-[10px]">▾</span>
</button>
</div>
<div class="sep" />
<!-- Rotation -->
<button class="pdf-btn" @click="rotate" title="Rotate 90° clockwise"><RotateCw :size="14" /></button>
<div class="sep" />
<!-- Spread / page layout -->
<button class="pdf-btn-label" :class="spread === 'none' ? 'active' : ''" @click="spread = 'none'" title="Single page">
<BookOpen :size="13" class="mr-1" />Single
</button>
<button class="pdf-btn-label" :class="spread === 'odd' ? 'active' : ''" @click="spread = 'odd'" title="Two-page spread (book with cover)">
Spread
</button>
<div class="sep" />
<!-- Find -->
<button class="pdf-btn" :class="showFind ? 'bg-white/20 text-white' : ''" @click="showFind = !showFind" title="Find in document (⌘F)">
<button class="pdf-btn" :class="showFind ? 'bg-white/20 text-white' : ''" @click="showFind = !showFind" title="Find (⌘F)">
<Search :size="14" />
</button>
<div class="sep" />
<button class="pdf-btn" @click="toggleFullscreen" :title="isFullscreen ? 'Exit fullscreen' : 'Fullscreen'">
<Minimize v-if="isFullscreen" :size="14" />
<Maximize v-else :size="14" />
</button>
</div>
<!-- Find bar -->
@@ -483,16 +302,13 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
@keydown.escape="showFind = false"
/>
<span v-if="findQuery" class="text-xs text-white/40 tabular-nums shrink-0">{{ findMatches }} match{{ findMatches !== 1 ? 'es' : '' }}</span>
<button class="pdf-btn w-6 h-6" @click="closeFind">
<X :size="12" />
</button>
<button class="pdf-btn w-6 h-6" @click="closeFind"><X :size="12" /></button>
</div>
</div>
<!-- Hover zone -->
<!-- Hover zone to reveal header -->
<div class="absolute top-0 left-0 right-0 z-40 h-16 pointer-events-auto" @mouseenter="showHeader()" />
<!-- Loading -->
<div v-if="loading" class="flex-1 flex items-center justify-center">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-white/20 border-t-white/80 animate-spin" />
@@ -500,7 +316,6 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
</div>
</div>
<!-- Error -->
<div v-else-if="error" class="flex-1 flex items-center justify-center p-8 text-center">
<div>
<p class="text-sm font-medium text-white/80 mb-1">Failed to load PDF</p>
@@ -508,16 +323,28 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
</div>
</div>
<!-- ── Pages ─────────────────────────────────────────────────────────── -->
<div v-else ref="scrollRef" class="flex-1 overflow-auto" @scroll.passive="onScroll">
<div class="flex flex-col items-center py-4" :style="{ gap: `${PAGE_GAP}px`, paddingTop: '56px' }">
<!-- Each row = one or two pages side-by-side -->
<!-- ── Pages ──────────────────────────────────────────────────────────── -->
<div
v-else
ref="scrollRef"
class="flex-1 overflow-auto"
:style="scrollMode === 'page' ? { scrollSnapType: 'y mandatory' } : {}"
@scroll.passive="onScroll"
>
<div
class="flex flex-col items-center"
:style="scrollMode === 'page' ? { gap: '0px', paddingTop: '0' } : { gap: `${PAGE_GAP}px`, paddingTop: '56px' }"
>
<div
v-for="(row, ri) in pageRows"
:key="ri"
:data-pages="row.join(',')"
class="flex gap-1 items-start"
:style="{ height: `${rowHeights[ri] ?? 0}px` }"
class="flex gap-1"
:style="
scrollMode === 'page'
? { height: `${containerH}px`, scrollSnapAlign: 'start', alignItems: 'center', justifyContent: 'center' }
: { height: `${rowHeights[ri] ?? 0}px`, alignItems: 'flex-start' }
"
>
<div
v-for="pageNum in row"
@@ -528,28 +355,16 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
height: `${Math.round((effectiveDims[pageNum - 1]?.height ?? 842) * scale)}px`,
}"
>
<!-- Canvas -->
<canvas
class="block"
:ref="
(el) => {
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
}
"
:ref="(el) => { if (el) canvasMap.set(pageNum, el as HTMLCanvasElement) }"
/>
<!-- Text layer (for selection / find highlight) -->
<div
data-text-layer
class="absolute inset-0 overflow-hidden"
style="pointer-events: none"
:ref="
(el) => {
if (el) textLayerMap.set(pageNum, el as HTMLElement)
}
"
:ref="(el) => { if (el) textLayerMap.set(pageNum, el as HTMLElement) }"
/>
<!-- Find highlight overlay -->
<div v-if="showFind && findQuery" class="absolute inset-0 pointer-events-none" />
</div>
</div>
</div>
@@ -560,6 +375,96 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
<div class="h-full bg-blue-400/60 transition-all duration-500" :style="{ width: `${progressPct}%` }" />
</div>
</div>
<!-- Zoom dropdown — teleported to escape stacking context created by header -->
<Teleport to="body">
<div
v-if="showZoomMenu"
class="fixed w-40 rounded-lg shadow-2xl py-1 overflow-hidden"
:style="{ zIndex: 9999, background: 'rgba(35,38,41,0.98)', border: '1px solid rgba(255,255,255,0.1)', transform: 'translateX(-50%)', top: zoomMenuStyle.top, left: zoomMenuStyle.left }"
@click.stop
>
<button
v-for="preset in ZOOM_PRESETS"
:key="preset.label"
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="zoomMode === 'custom' && customScale === parseFloat(preset.value) ? 'text-white' : 'text-white/60'"
@click="applyZoomPreset(preset.value)"
>
{{ preset.label }}
<span v-if="zoomMode === 'custom' && customScale === parseFloat(preset.value)" class="text-blue-400 text-[10px]">✓</span>
</button>
</div>
</Teleport>
<!-- View dropdown — teleported to escape stacking context created by header -->
<Teleport to="body">
<div
v-if="showViewMenu"
class="fixed w-44 rounded-lg shadow-2xl py-1 overflow-hidden"
:style="{ zIndex: 9999, background: 'rgba(35,38,41,0.98)', border: '1px solid rgba(255,255,255,0.1)', top: viewMenuStyle.top, right: viewMenuStyle.right }"
@click.stop
>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="zoomMode === 'fit-width' ? 'text-white' : 'text-white/60'"
@click="applyZoomPreset('fit-width'); showViewMenu = false"
>
Fit Width<span v-if="zoomMode === 'fit-width'" class="text-blue-400 text-[10px]">✓</span>
</button>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="zoomMode === 'fit-page' ? 'text-white' : 'text-white/60'"
@click="applyZoomPreset('fit-page'); showViewMenu = false"
>
Fit Page<span v-if="zoomMode === 'fit-page'" class="text-blue-400 text-[10px]">✓</span>
</button>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="zoomMode === 'custom' && customScale === 1 ? 'text-white' : 'text-white/60'"
@click="applyZoomPreset('1'); showViewMenu = false"
>
Actual Size<span v-if="zoomMode === 'custom' && customScale === 1" class="text-blue-400 text-[10px]">✓</span>
</button>
<div class="my-1 border-t border-white/10" />
<button
class="w-full text-left px-3 py-1.5 text-xs text-white/60 hover:text-white hover:bg-white/10 transition-colors flex items-center justify-between"
@click="rotate(); showViewMenu = false"
>
Rotate CW <RotateCw :size="12" />
</button>
<div class="my-1 border-t border-white/10" />
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="spread === 'none' ? 'text-white' : 'text-white/60'"
@click="spread = 'none'; showViewMenu = false"
>
Single Page<span v-if="spread === 'none'" class="text-blue-400 text-[10px]">✓</span>
</button>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="spread === 'odd' ? 'text-white' : 'text-white/60'"
@click="spread = 'odd'; showViewMenu = false"
>
Two-Page Spread<span v-if="spread === 'odd'" class="text-blue-400 text-[10px]">✓</span>
</button>
<div class="my-1 border-t border-white/10" />
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="scrollMode === 'continuous' ? 'text-white' : 'text-white/60'"
@click="scrollMode = 'continuous'; showViewMenu = false"
>
Continuous Scroll<span v-if="scrollMode === 'continuous'" class="text-blue-400 text-[10px]">✓</span>
</button>
<button
class="w-full text-left px-3 py-1.5 text-xs hover:bg-white/10 transition-colors flex items-center justify-between"
:class="scrollMode === 'page' ? 'text-white' : 'text-white/60'"
@click="scrollMode = 'page'; showViewMenu = false"
>
Page by Page<span v-if="scrollMode === 'page'" class="text-blue-400 text-[10px]">✓</span>
</button>
</div>
</Teleport>
</template>
<style scoped>
@@ -567,12 +472,6 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
.pdf-btn {
@apply flex items-center justify-center w-7 h-7 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
}
.pdf-btn-label {
@apply flex items-center px-2 py-1 rounded text-xs text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 whitespace-nowrap;
}
.pdf-btn-label.active {
@apply bg-white/20 text-white;
}
.sep {
@apply w-px h-5 mx-1 bg-white/15 shrink-0;
}
@@ -22,10 +22,13 @@ export function usePdf() {
loading.value = true
error.value = null
try {
const res = await fetch(`/api/books/files/${fileId}/serve`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.arrayBuffer()
const doc = await pdfjsLib.getDocument({ data }).promise
// Pass URL directly so PDF.js can use HTTP range requests (206 Partial Content).
// This avoids downloading the entire file upfront and allows rendering to start immediately.
const doc = await pdfjsLib.getDocument({
url: `/api/books/files/${fileId}/serve`,
rangeChunkSize: 512 * 1024,
disableAutoFetch: true,
}).promise
pdfDoc.value = doc
totalPages.value = doc.numPages
} catch (e) {
@@ -0,0 +1,92 @@
import { computed, ref, watch } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { PageDim } from './usePdf'
export const PAGE_GAP = 16
export function usePdfLayout(
scrollRef: Ref<HTMLElement | null>,
totalPages: Ref<number>,
effectiveDims: ComputedRef<PageDim[]>,
scale: ComputedRef<number>,
containerH: Ref<number>,
spread: Ref<'none' | 'odd' | 'even'>,
) {
const scrollMode = ref<'continuous' | 'page'>('continuous')
const currentPage = ref(1)
const pageInput = ref(1)
// Each row is [pageNum] in single mode, or [pageNum, pageNum+1] in spread.
const pageRows = computed<number[][]>(() => {
const rows: number[][] = []
const total = totalPages.value
if (spread.value === 'none') {
for (let i = 1; i <= total; i++) rows.push([i])
return rows
}
// 'odd' spread: page 1 alone, then pairs [2,3], [4,5]...
let i = 0
if (spread.value === 'odd' && total >= 1) {
rows.push([1])
i = 2
}
while (i < total) {
const a = i + 1
const b = i + 2
rows.push(b <= total ? [a, b] : [a])
i += 2
}
return rows
})
// In page mode every row fills the viewport; in continuous mode rows have natural height.
const rowHeights = computed(() => {
if (scrollMode.value === 'page') return pageRows.value.map(() => containerH.value)
return pageRows.value.map((row) =>
Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value))),
)
})
watch(currentPage, (v) => {
pageInput.value = v
})
function goToPage(n: number) {
const el = scrollRef.value
if (!el) return
const clamped = Math.max(1, Math.min(n, totalPages.value))
const rowIdx = pageRows.value.findIndex((row) => row.includes(clamped))
if (rowIdx < 0) return
let top = 0
if (scrollMode.value === 'page') {
top = rowIdx * containerH.value
} else {
for (let i = 0; i < rowIdx; i++) top += (rowHeights.value[i] ?? 0) + PAGE_GAP
}
el.scrollTo({ top, behavior: 'smooth' })
}
function onScroll() {
const el = scrollRef.value
if (!el || !rowHeights.value.length) return
let rowIdx = 0
if (scrollMode.value === 'page') {
rowIdx = Math.round(el.scrollTop / containerH.value)
} else {
let offset = el.scrollTop
for (let i = 0; i < rowHeights.value.length; i++) {
const h = (rowHeights.value[i] ?? 0) + PAGE_GAP
if (offset < h) {
rowIdx = i
break
}
offset -= h
rowIdx = i + 1
}
}
const row = pageRows.value[rowIdx]
currentPage.value = row?.[0] ?? totalPages.value
}
return { scrollMode, currentPage, pageInput, pageRows, rowHeights, goToPage, onScroll }
}
@@ -0,0 +1,112 @@
import { nextTick, ref } from 'vue'
import type * as pdfjsLib from 'pdfjs-dist'
import type { ComputedRef, Ref } from 'vue'
type RenderPageFn = (pageNum: number, canvas: HTMLCanvasElement, scale: number) => Promise<void>
type GetTextContentFn = (pageNum: number) => Promise<{ content: pdfjsLib.TextContent; viewport: pdfjsLib.PageViewport } | null>
export function usePdfRenderer(
renderPageFn: RenderPageFn,
getTextContentFn: GetTextContentFn,
scale: ComputedRef<number>,
rotation: Ref<0 | 90 | 180 | 270>,
totalPages: Ref<number>,
) {
const canvasMap = ref(new Map<number, HTMLCanvasElement>())
const textLayerMap = ref(new Map<number, HTMLElement>())
const rendered = new Set<number>()
let renderQueue: number[] = []
let isRendering = false
let ioEntries: IntersectionObserverEntry[] = []
let io: IntersectionObserver | null = null
function enqueue(pageNum: number) {
if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return
renderQueue.push(pageNum)
flushQueue()
}
async function flushQueue() {
if (isRendering) return
isRendering = true
while (renderQueue.length > 0) {
const pageNum = renderQueue.shift()!
const canvas = canvasMap.value.get(pageNum)
if (!canvas) continue
await renderPageFn(pageNum, canvas, scale.value)
rendered.add(pageNum)
await buildTextLayer(pageNum)
}
isRendering = false
}
async function buildTextLayer(pageNum: number) {
const container = textLayerMap.value.get(pageNum)
if (!container) return
const result = await getTextContentFn(pageNum)
if (!result) return
const { content, viewport } = result
const scaledVp = viewport.clone({ scale: scale.value, rotation: rotation.value })
container.innerHTML = ''
container.style.width = `${scaledVp.width}px`
container.style.height = `${scaledVp.height}px`
const s = scale.value
for (const item of content.items) {
if (!('str' in item) || !(item as pdfjsLib.TextItem).str.trim()) continue
const { str, transform, height } = item as pdfjsLib.TextItem
const [a, b, , , e, f] = transform
const span = document.createElement('span')
span.textContent = str
span.style.cssText =
`position:absolute;` +
`left:${e * s}px;` +
`top:${scaledVp.height - f * s - (height ?? 0) * s}px;` +
`font-size:${Math.abs(a * s) || Math.abs(b * s)}px;` +
`transform-origin:0% 0%;white-space:pre;color:transparent;cursor:text;`
container.appendChild(span)
}
}
function ioCallback(entries: IntersectionObserverEntry[]) {
for (const e of entries) {
if (!e.isIntersecting) continue
const nums = (e.target as HTMLElement).dataset.pages?.split(',').map(Number) ?? []
for (const n of nums) {
enqueue(n)
if (n > 1) enqueue(n - 1)
if (n < totalPages.value) enqueue(n + 1)
}
}
ioEntries = entries
}
// Clear render state and re-trigger visible pages — call after zoom, rotation, or resize.
function invalidate() {
rendered.clear()
renderQueue = []
isRendering = false
nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
}
function setupIO(scrollEl: HTMLElement) {
io?.disconnect()
io = new IntersectionObserver(ioCallback, { root: scrollEl, rootMargin: '300px', threshold: 0 })
nextTick(() => scrollEl.querySelectorAll('[data-pages]').forEach((el) => io!.observe(el)))
}
// Call when a new document is loaded to clear stale canvas/text-layer refs.
function reset() {
canvasMap.value = new Map()
textLayerMap.value = new Map()
rendered.clear()
renderQueue = []
isRendering = false
}
function destroy() {
io?.disconnect()
}
return { canvasMap, textLayerMap, invalidate, setupIO, reset, destroy }
}
@@ -0,0 +1,66 @@
import { computed, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { PageDim } from './usePdf'
export type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
export const ZOOM_PRESETS = [
{ label: '50%', value: '0.5' },
{ label: '75%', value: '0.75' },
{ label: '100%', value: '1' },
{ label: '125%', value: '1.25' },
{ label: '150%', value: '1.5' },
{ label: '200%', value: '2' },
] as const
// Horizontal gap between paired pages in spread mode
const SPREAD_GAP = 16
export function usePdfZoom(
containerW: Ref<number>,
containerH: Ref<number>,
effectiveDims: ComputedRef<PageDim[]>,
spread: Ref<'none' | 'odd' | 'even'>,
) {
const zoomMode = ref<ZoomMode>('fit-page')
const customScale = ref(1.0)
const scale = computed(() => {
const first = effectiveDims.value[0]
if (!first || !containerW.value) return 1
if (spread.value !== 'none') {
const sw = (containerW.value - 48) / (first.width * 2 + SPREAD_GAP)
if (zoomMode.value === 'fit-page') return Math.min(sw, (containerH.value - 48) / first.height)
if (zoomMode.value === 'fit-width') return sw
}
if (zoomMode.value === 'fit-width') return (containerW.value - 32) / first.width
if (zoomMode.value === 'fit-page') return Math.min((containerW.value - 32) / first.width, (containerH.value - 48) / first.height)
return customScale.value
})
const zoomPct = computed(() => Math.round(scale.value * 100))
const zoomLabel = computed(() => {
if (zoomMode.value === 'fit-width') return 'Fit Width'
if (zoomMode.value === 'fit-page') return 'Fit Page'
return `${zoomPct.value}%`
})
function adjustZoom(delta: number) {
customScale.value = Math.round(
Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20,
)
zoomMode.value = 'custom'
}
function applyZoomPreset(value: string) {
if (value === 'fit-width' || value === 'fit-page') {
zoomMode.value = value
} else {
customScale.value = parseFloat(value)
zoomMode.value = 'custom'
}
}
return { zoomMode, customScale, scale, zoomPct, zoomLabel, adjustZoom, applyZoomPreset }
}
+3 -4
View File
@@ -14,7 +14,6 @@
"start:prod": "node dist/main",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "ts-node -r tsconfig-paths/register src/db/seed.ts",
"db:studio": "drizzle-kit studio",
"scan:test": "ts-node -r tsconfig-paths/register src/scripts/scan-test.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
@@ -25,11 +24,11 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@fastify/static": "^8.3.0",
"@fastify/static": "^9.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-fastify": "^11.1.10",
"@nestjs/platform-fastify": "^11.1.11",
"@types/pdf-parse": "^1.1.5",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1",
@@ -50,7 +49,7 @@
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@swc/cli": "^0.7.9",
"@swc/core": "^1.15.7",
"@swc/core": "^1.15.8",
"@types/chokidar": "^1.7.5",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
+4 -4
View File
@@ -5,28 +5,28 @@
{
"idx": 0,
"version": "7",
"when": 1767351466197,
"when": 1767368411691,
"tag": "0000_init_schema",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1767351466197,
"when": 1767368411691,
"tag": "0001_lethal_pixie",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1767351466197,
"when": 1767368411691,
"tag": "0002_powerful_thundra",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767351466197,
"when": 1767368411691,
"tag": "0003_reading_progress_by_file",
"breakpoints": true
}
-80
View File
@@ -1,80 +0,0 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
import { authors, bookAuthors, bookMetadata, books, libraryFolders, libraries } from './schema';
async function seed() {
const pool = new Pool({
connectionString: process.env.DATABASE_URL ?? 'postgres://projectx:projectx@postgres:5432/projectx',
});
const db = drizzle(pool, { schema });
console.log('Clearing existing data...');
await db.delete(bookAuthors);
await db.delete(bookMetadata);
await db.delete(books);
await db.delete(authors);
await db.delete(libraryFolders);
await db.delete(libraries);
console.log('Seeding library...');
const [library] = await db.insert(libraries).values({ name: 'My Library', scanMode: 'manual' }).returning();
const [folder] = await db.insert(libraryFolders).values({ libraryId: library.id, path: '/books' }).returning();
console.log('Seeding authors...');
const authorNames = [
{ name: 'Andy Weir', sortName: 'Weir, Andy' },
{ name: 'N.K. Jemisin', sortName: 'Jemisin, N.K.' },
{ name: 'Becky Chambers', sortName: 'Chambers, Becky' },
{ name: 'Liu Cixin', sortName: 'Liu, Cixin' },
];
const insertedAuthors = await db.insert(authors).values(authorNames).returning();
console.log('Seeding books...');
const seedBooks = [
{ title: 'Project Hail Mary', authorIdx: 0, year: 2021 },
{ title: 'The Fifth Season', authorIdx: 1, year: 2015 },
{ title: 'A Long Way to a Small Angry Planet', authorIdx: 2, year: 2014 },
{ title: 'The Three-Body Problem', authorIdx: 3, year: 2008 },
];
const BATCH = 500;
const allBooks = seedBooks.map((b, i) => ({
libraryId: library.id,
libraryFolderId: folder.id,
folderPath: `/books/${b.title.toLowerCase().replace(/\s+/g, '-')}`,
status: 'present' as const,
}));
const insertedBooks: (typeof books.$inferSelect)[] = [];
for (let i = 0; i < allBooks.length; i += BATCH) {
const batch = await db
.insert(books)
.values(allBooks.slice(i, i + BATCH))
.returning();
insertedBooks.push(...batch);
}
const metaValues = seedBooks.map((b, i) => ({
bookId: insertedBooks[i]!.id,
title: b.title,
publishedYear: b.year,
}));
await db.insert(bookMetadata).values(metaValues);
const authorJoins = seedBooks.map((b, i) => ({
bookId: insertedBooks[i]!.id,
authorId: insertedAuthors[b.authorIdx]!.id,
displayOrder: 0,
}));
await db.insert(bookAuthors).values(authorJoins);
await pool.end();
console.log(`Done. Seeded ${insertedBooks.length} books.`);
}
seed().catch((err) => {
console.error(err);
process.exit(1);
});
+24 -6
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, NotFoundException, Param, ParseIntPipe, Post, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Headers, NotFoundException, Param, ParseIntPipe, Post, Query, Res } from '@nestjs/common';
import { createReadStream } from 'fs';
import type { FastifyReply } from 'fastify';
import { BookService } from './book.service';
@@ -29,14 +29,32 @@ export class BookController {
// These MUST come before `:id/*` routes to avoid NestJS matching 'files' as :id.
@Get('files/:fileId/serve')
async serveFile(@Param('fileId', ParseIntPipe) fileId: number, @Res() reply: FastifyReply) {
const { stream, size, format } = await this.bookService.getFileStream(fileId);
async serveFile(
@Param('fileId', ParseIntPipe) fileId: number,
@Headers('range') rangeHeader: string | undefined,
@Res() reply: FastifyReply,
) {
const { path, size, format } = await this.bookService.getFileInfo(fileId);
const mimeType = format === 'pdf' ? 'application/pdf' : format === 'cbz' ? 'application/zip' : 'application/epub+zip';
reply.header('Content-Length', size);
reply.header('Content-Disposition', 'inline');
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Disposition', 'inline');
reply.type(mimeType);
reply.send(stream);
if (rangeHeader) {
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
if (match) {
const start = parseInt(match[1]);
const end = match[2] ? parseInt(match[2]) : size - 1;
reply.status(206);
reply.header('Content-Range', `bytes ${start}-${end}/${size}`);
reply.header('Content-Length', end - start + 1);
reply.send(createReadStream(path, { start, end }));
return;
}
}
reply.header('Content-Length', size);
reply.send(createReadStream(path));
}
@Get('files/:fileId/progress')
+2 -2
View File
@@ -62,11 +62,11 @@ export class BookService {
}
}
async getFileStream(fileId: number): Promise<{ stream: ReturnType<typeof createReadStream>; size: number; format: string }> {
async getFileInfo(fileId: number): Promise<{ path: string; size: number; format: string }> {
const file = await this.bookRepo.findFileById(fileId);
if (!file) throw new NotFoundException(`No file with id ${fileId}`);
const { size } = await stat(file.absolutePath);
return { stream: createReadStream(file.absolutePath), size, format: file.format ?? 'unknown' };
return { path: file.absolutePath, size, format: file.format ?? 'unknown' };
}
async getProgress(fileId: number) {
@@ -1,11 +1,11 @@
import { IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsInt, IsNumber, IsString, Max, Min, ValidateIf } from 'class-validator';
export class SaveProgressDto {
@IsOptional()
@ValidateIf((o: SaveProgressDto) => o.cfi != null)
@IsString()
cfi?: string | null;
@IsOptional()
@ValidateIf((o: SaveProgressDto) => o.pageNumber != null)
@IsInt()
pageNumber?: number | null;
@@ -0,0 +1,33 @@
import { basename, extname } from 'path';
export interface ParsedFilename {
title: string;
publishedYear: number | null;
}
/**
* Derives a display title and optional year from a filename when embedded
* metadata is absent. Does NOT attempt to extract author — filename
* conventions vary too much (title-first, author-first, series prefixes, etc.)
* and a wrong author is worse than no author.
*
* Transformations applied:
* - Strip file extension
* - Strip leading series index ("01. Title" → "Title")
* - Replace underscores with colons (common filename substitution)
* - Strip trailing year "(YYYY)" and return it separately
*/
export function parseBookFilename(absolutePath: string): ParsedFilename {
const stem = basename(absolutePath, extname(absolutePath))
.replace(/^\d+\.\s*/, '') // strip leading "01. "
.replace(/_/g, ':'); // underscore → colon
// Strip trailing "(YYYY)" or "(YYYY) (extra stuff)" — keep only first 4-digit year
const yearMatch = stem.match(/\((\d{4})\)/);
const year = yearMatch ? parseInt(yearMatch[1], 10) : null;
// Remove all parenthetical suffixes from the end: "(2020)", "(UK)", "(retail)", etc.
const title = stem.replace(/\s*\([^)]*\)\s*$/, '').trim();
return { title, publishedYear: year };
}
@@ -1,6 +1,5 @@
import { readFile } from 'fs/promises';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pdfParse: (buf: Buffer, opts?: { max?: number }) => Promise<{ info: unknown; numpages: number }> = require('pdf-parse');
import { PDFParse } from 'pdf-parse';
export interface PdfParsed {
title: string | null;
@@ -52,9 +51,9 @@ function extractFirstJpeg(buf: Buffer): Buffer | null {
export async function parsePdfFile(absolutePath: string): Promise<PdfParsed | null> {
try {
const buf = await readFile(absolutePath);
// pdf-parse can throw on malformed PDFs; run without text extraction for speed
const data = await pdfParse(buf, { max: 1 });
const parser = new PDFParse({ data: buf });
const data = await parser.getInfo();
await parser.destroy();
const info = (data.info ?? {}) as Record<string, unknown>;
@@ -65,7 +64,7 @@ export async function parsePdfFile(absolutePath: string): Promise<PdfParsed | nu
// PDFs rarely store publisher; some use 'Creator' for the authoring app
// and 'Producer' for the PDF engine — neither is a publisher field.
const publisher: string | null = null;
const pageCount = typeof data.numpages === 'number' ? data.numpages : null;
const pageCount = typeof data.total === 'number' ? data.total : null;
const authors: PdfParsed['authors'] = authorRaw
? authorRaw
@@ -9,6 +9,7 @@ import { authors, bookAuthors, bookMetadata, bookTags, tags } from '../../db/sch
import { extractCbzMetadata } from './lib/cbz-metadata';
import { extractAndSaveCover } from './lib/cover';
import { extractEpubMetadata } from './lib/epub';
import { parseBookFilename } from './lib/filename-parser';
import { parseMobiFile } from './lib/mobi-parser';
import { parsePdfFile } from './lib/pdf-parser';
@@ -92,14 +93,15 @@ export class MetadataService {
} else if (format === 'pdf') {
const pdf = await parsePdfFile(absolutePath);
if (pdf) {
const fb = !pdf.title ? parseBookFilename(absolutePath) : null;
parsed = {
title: pdf.title,
title: pdf.title ?? fb?.title ?? null,
subtitle: null,
description: pdf.subject,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
publishedYear: fb?.publishedYear ?? null,
language: null,
seriesName: null,
seriesIndex: null,