mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): add CBZ/CBR comic reader with backend page streaming
This commit is contained in:
@@ -18,6 +18,7 @@ import SelectionPopup from './components/SelectionPopup.vue'
|
||||
import ReaderSearchPanel from './components/ReaderSearchPanel.vue'
|
||||
import NoteDialog from './components/NoteDialog.vue'
|
||||
import PdfReaderView from './components/PdfReaderView.vue'
|
||||
import CbzReaderView from './components/CbzReaderView.vue'
|
||||
import type { ReaderState } from './composables/useReaderState'
|
||||
import type { FoliateRenderer } from './composables/useFoliate'
|
||||
|
||||
@@ -221,6 +222,7 @@ function navigateSearch(cfiTarget: string) {
|
||||
|
||||
<template>
|
||||
<PdfReaderView v-if="fileFormat === 'pdf'" :bookId="bookId" :fileId="fileId" />
|
||||
<CbzReaderView v-else-if="fileFormat === 'cbz' || fileFormat === 'cbr'" :bookId="bookId" :fileId="fileId" />
|
||||
<div
|
||||
v-else
|
||||
class="fixed inset-0 overflow-hidden"
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Settings } from 'lucide-vue-next'
|
||||
import { useVisibility } from '../composables/useVisibility'
|
||||
import { useReaderProgress } from '../composables/useReaderProgress'
|
||||
import { useCbz } from '../composables/useCbz'
|
||||
import { useCbzSettings } from '../composables/useCbzSettings'
|
||||
import CbzSettingsPanel from './CbzSettingsPanel.vue'
|
||||
|
||||
const props = defineProps<{ bookId: number; fileId: number }>()
|
||||
const router = useRouter()
|
||||
|
||||
const progress = useReaderProgress(props.bookId, props.fileId)
|
||||
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter } = useVisibility()
|
||||
const { pageCount, bookTitle, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId)
|
||||
const { fitMode, viewMode, scrollMode, direction, bgColor, bgValue, isTwoPage, imgFitClass } = useCbzSettings()
|
||||
|
||||
const currentPage = ref(0)
|
||||
const currentImageLoaded = ref(false)
|
||||
const showSettings = ref(false)
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────────
|
||||
// Pages shown in paginated mode (1 or 2), in display order for RTL.
|
||||
const visiblePages = computed(() => {
|
||||
if (!isTwoPage.value) return [currentPage.value]
|
||||
const a = currentPage.value
|
||||
const b = a + 1
|
||||
if (direction.value === 'ltr') return b < pageCount.value ? [a, b] : [a]
|
||||
return b < pageCount.value ? [b, a] : [a] // RTL: second page on left
|
||||
})
|
||||
|
||||
// ── Preloading ─────────────────────────────────────────────────────────────────
|
||||
const preloadCache = new Map<number, HTMLImageElement>()
|
||||
|
||||
function preload(n: number) {
|
||||
if (n < 0 || n >= pageCount.value || preloadCache.has(n)) return
|
||||
const img = new Image()
|
||||
img.src = pageUrl(n)
|
||||
preloadCache.set(n, img)
|
||||
}
|
||||
|
||||
function schedulePreload(center: number) {
|
||||
for (let i = center - 2; i <= center + 4; i++) preload(i)
|
||||
for (const [k] of preloadCache) {
|
||||
if (Math.abs(k - center) > 8) preloadCache.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────────
|
||||
function goToPage(n: number) {
|
||||
const target = Math.max(0, Math.min(n, pageCount.value - 1))
|
||||
if (target === currentPage.value) return
|
||||
currentPage.value = target
|
||||
currentImageLoaded.value = false
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
goToPage(currentPage.value + (isTwoPage.value ? 2 : 1))
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
goToPage(currentPage.value - (isTwoPage.value ? 2 : 1))
|
||||
}
|
||||
|
||||
// ── Click zones (left / middle / right) ───────────────────────────────────────
|
||||
function handleImageClick(e: MouseEvent) {
|
||||
if (showSettings.value) {
|
||||
showSettings.value = false
|
||||
return
|
||||
}
|
||||
const x = e.clientX / window.innerWidth
|
||||
if (x < 0.25) direction.value === 'rtl' ? nextPage() : prevPage()
|
||||
else if (x > 0.75) direction.value === 'rtl' ? prevPage() : nextPage()
|
||||
else handleMiddleTap()
|
||||
}
|
||||
|
||||
// ── Touch / swipe ──────────────────────────────────────────────────────────────
|
||||
let touchStartX = 0
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX
|
||||
}
|
||||
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX
|
||||
if (Math.abs(dx) < 50) return
|
||||
if (dx < 0) direction.value === 'rtl' ? prevPage() : nextPage()
|
||||
else direction.value === 'rtl' ? nextPage() : prevPage()
|
||||
}
|
||||
|
||||
// ── Wheel (paginated mode only) ────────────────────────────────────────────────
|
||||
// Note: this handler is only attached on the paginated view div (v-if), so it is
|
||||
// never active during infinite/long-strip modes — no need to guard with scrollMode check.
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
if (e.deltaY > 0) nextPage()
|
||||
else if (e.deltaY < 0) prevPage()
|
||||
}
|
||||
|
||||
// ── Keyboard ───────────────────────────────────────────────────────────────────
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.target as HTMLElement).tagName === 'INPUT') return
|
||||
const isRtl = direction.value === 'rtl'
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'PageDown':
|
||||
e.preventDefault()
|
||||
isRtl ? prevPage() : nextPage()
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
case 'PageUp':
|
||||
e.preventDefault()
|
||||
isRtl ? nextPage() : prevPage()
|
||||
break
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
e.shiftKey ? prevPage() : nextPage()
|
||||
break
|
||||
case 'Home':
|
||||
goToPage(0)
|
||||
break
|
||||
case 'End':
|
||||
goToPage(pageCount.value - 1)
|
||||
break
|
||||
case 'Escape':
|
||||
showSettings.value = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ── Infinite scroll page tracking ─────────────────────────────────────────────
|
||||
let io: IntersectionObserver | null = null
|
||||
|
||||
function setupScrollObserver() {
|
||||
io?.disconnect()
|
||||
if (!scrollContainer.value) return
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
currentPage.value = parseInt((entry.target as HTMLElement).dataset.page ?? '0')
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: scrollContainer.value, threshold: 0.5 },
|
||||
)
|
||||
nextTick(() => {
|
||||
scrollContainer.value?.querySelectorAll('[data-page]').forEach((el) => io!.observe(el))
|
||||
})
|
||||
}
|
||||
|
||||
watch(scrollMode, async (mode) => {
|
||||
if (mode !== 'paginated') {
|
||||
await nextTick()
|
||||
setupScrollObserver()
|
||||
scrollContainer.value?.querySelector(`[data-page="${currentPage.value}"]`)?.scrollIntoView()
|
||||
} else {
|
||||
io?.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Slider ticks (max 20, evenly spaced) ──────────────────────────────────────
|
||||
const sliderTicks = computed(() => {
|
||||
const max = pageCount.value - 1
|
||||
if (max <= 0) return []
|
||||
const count = Math.min(20, max)
|
||||
const ticks = new Set<number>()
|
||||
for (let i = 0; i <= count; i++) ticks.add(Math.round((i / count) * max))
|
||||
return [...ticks]
|
||||
})
|
||||
|
||||
// ── Progress save ──────────────────────────────────────────────────────────────
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(currentPage, (page) => {
|
||||
schedulePreload(page)
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
progress.pageNumber.value = page + 1
|
||||
progress.percentage.value = pageCount.value ? ((page + 1) / pageCount.value) * 100 : 0
|
||||
progress.save()
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
// ── Mount / unmount ────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
await progress.load()
|
||||
await load()
|
||||
const saved = progress.pageNumber.value
|
||||
if (saved && saved > 1) currentPage.value = Math.min(saved - 1, pageCount.value - 1)
|
||||
schedulePreload(currentPage.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
io?.disconnect()
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 select-none overflow-hidden" :style="{ background: bgValue }">
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 z-50 transition-all duration-300"
|
||||
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
|
||||
>
|
||||
<div class="h-12 flex items-center gap-2 px-3" style="background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px)">
|
||||
<button class="cbz-btn" @click="router.back()"><ArrowLeft :size="16" /></button>
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-center">
|
||||
<span v-if="bookTitle" class="text-sm text-white/90 truncate leading-tight">{{ bookTitle }}</span>
|
||||
<span class="text-xs text-white/40 tabular-nums">{{ currentPage + 1 }} / {{ pageCount }}</span>
|
||||
</div>
|
||||
<button class="cbz-btn" :class="showSettings ? 'bg-white/20 text-white' : ''" @click="showSettings = !showSettings">
|
||||
<Settings :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Paginated view ──────────────────────────────────────────────────── -->
|
||||
<div
|
||||
v-if="scrollMode === 'paginated'"
|
||||
class="absolute inset-0 flex items-center justify-center overflow-hidden"
|
||||
@click="handleImageClick"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchend.passive="onTouchEnd"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div v-if="!currentImageLoaded && !error" class="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||
<div class="w-8 h-8 rounded-full border-2 border-white/20 border-t-white/70 animate-spin" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-center h-full w-full"
|
||||
:class="isTwoPage ? 'gap-0.5' : ''"
|
||||
:style="isTwoPage && direction === 'rtl' ? { flexDirection: 'row-reverse' } : {}"
|
||||
>
|
||||
<img
|
||||
v-for="pg in visiblePages"
|
||||
:key="pg"
|
||||
:src="pageUrl(pg)"
|
||||
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
|
||||
:style="isTwoPage ? { maxWidth: '50%', maxHeight: '100%' } : {}"
|
||||
draggable="false"
|
||||
@load="currentImageLoaded = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Infinite / long-strip view ─────────────────────────────────────── -->
|
||||
<div
|
||||
v-else
|
||||
ref="scrollContainer"
|
||||
class="absolute inset-0 overflow-y-auto overflow-x-hidden"
|
||||
:class="scrollMode === 'long-strip' ? '' : 'flex flex-col items-center'"
|
||||
>
|
||||
<div :class="scrollMode === 'long-strip' ? '' : 'flex flex-col items-center w-full gap-2 py-4 px-2'">
|
||||
<img
|
||||
v-for="i in pageCount"
|
||||
:key="i - 1"
|
||||
:data-page="i - 1"
|
||||
:src="pageUrl(i - 1)"
|
||||
:class="scrollMode === 'long-strip' ? 'w-full block' : 'max-w-full'"
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Footer ──────────────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="absolute bottom-0 inset-x-0 z-50 transition-all duration-300"
|
||||
:class="footerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-full pointer-events-none'"
|
||||
>
|
||||
<div class="h-16 flex items-center gap-3 px-4" style="background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px)">
|
||||
<button class="cbz-btn" title="First page" @click="goToPage(0)"><ChevronsLeft :size="16" /></button>
|
||||
<button class="cbz-btn" :disabled="currentPage === 0" @click="prevPage"><ChevronLeft :size="16" /></button>
|
||||
|
||||
<div class="flex-1 flex flex-col justify-center gap-0.5">
|
||||
<input
|
||||
type="range"
|
||||
:min="0"
|
||||
:max="Math.max(0, pageCount - 1)"
|
||||
:value="currentPage"
|
||||
list="cbz-ticks"
|
||||
class="flex-1 w-full accent-blue-400 cursor-pointer"
|
||||
@input="goToPage(Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
<datalist id="cbz-ticks">
|
||||
<option v-for="t in sliderTicks" :key="t" :value="t" />
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<button class="cbz-btn" :disabled="currentPage >= pageCount - 1" @click="nextPage"><ChevronRight :size="16" /></button>
|
||||
<button class="cbz-btn" title="Last page" @click="goToPage(pageCount - 1)"><ChevronsRight :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hover zones to reveal header / footer -->
|
||||
<div class="absolute top-0 inset-x-0 h-16 z-40 pointer-events-auto" @mouseenter="showHeader()" />
|
||||
<div class="absolute bottom-0 inset-x-0 h-16 z-40 pointer-events-auto" @mouseenter="showFooter()" />
|
||||
|
||||
<!-- ── Loading / error overlays ─────────────────────────────────────────── -->
|
||||
<div v-if="loading" class="absolute inset-0 flex items-center justify-center z-50">
|
||||
<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" />
|
||||
<p class="text-sm text-white/50">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="absolute inset-0 flex items-center justify-center z-50 p-8 text-center">
|
||||
<p class="text-sm text-white/70">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Settings panel ────────────────────────────────────────────────────── -->
|
||||
<CbzSettingsPanel
|
||||
v-if="showSettings"
|
||||
:fitMode="fitMode"
|
||||
:viewMode="viewMode"
|
||||
:scrollMode="scrollMode"
|
||||
:direction="direction"
|
||||
:bgColor="bgColor"
|
||||
@close="showSettings = false"
|
||||
@update:fitMode="fitMode = $event"
|
||||
@update:viewMode="viewMode = $event"
|
||||
@update:scrollMode="scrollMode = $event"
|
||||
@update:direction="direction = $event"
|
||||
@update:bgColor="bgColor = $event"
|
||||
/>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-if="!loading && !error && pageCount > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-white/10 z-30">
|
||||
<div class="h-full bg-blue-400/60 transition-all duration-300" :style="{ width: `${((currentPage + 1) / pageCount) * 100}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
.cbz-btn {
|
||||
@apply flex items-center justify-center w-8 h-8 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import type { BgColor, Direction, FitMode, ScrollMode, ViewMode } from '../composables/useCbzSettings'
|
||||
|
||||
defineProps<{
|
||||
fitMode: FitMode
|
||||
viewMode: ViewMode
|
||||
scrollMode: ScrollMode
|
||||
direction: Direction
|
||||
bgColor: BgColor
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'update:fitMode': [v: FitMode]
|
||||
'update:viewMode': [v: ViewMode]
|
||||
'update:scrollMode': [v: ScrollMode]
|
||||
'update:direction': [v: Direction]
|
||||
'update:bgColor': [v: BgColor]
|
||||
}>()
|
||||
|
||||
const FIT_OPTIONS: { value: FitMode; label: string }[] = [
|
||||
{ value: 'fit-page', label: 'Page' },
|
||||
{ value: 'fit-width', label: 'Width' },
|
||||
{ value: 'fit-height', label: 'Height' },
|
||||
{ value: 'actual', label: 'Actual' },
|
||||
]
|
||||
|
||||
const SCROLL_OPTIONS: { value: ScrollMode; label: string }[] = [
|
||||
{ value: 'paginated', label: 'Paginated' },
|
||||
{ value: 'infinite', label: 'Infinite Scroll' },
|
||||
{ value: 'long-strip', label: 'Long Strip' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 z-50 w-64 overflow-y-auto"
|
||||
style="background: rgba(18, 18, 18, 0.97); border-left: 1px solid rgba(255, 255, 255, 0.08)"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<span class="text-sm font-medium text-white">Settings</span>
|
||||
<button class="cbz-btn w-7 h-7" @click="emit('close')"><X :size="14" /></button>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex flex-col gap-5">
|
||||
<!-- Fit Mode -->
|
||||
<div>
|
||||
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Fit</p>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
v-for="opt in FIT_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="setting-btn"
|
||||
:class="fitMode === opt.value ? 'active' : ''"
|
||||
@click="emit('update:fitMode', opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Mode -->
|
||||
<div>
|
||||
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">View</p>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button class="setting-btn" :class="viewMode === 'single' ? 'active' : ''" @click="emit('update:viewMode', 'single')">Single</button>
|
||||
<button class="setting-btn" :class="viewMode === 'two-page' ? 'active' : ''" @click="emit('update:viewMode', 'two-page')">Two-Page</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll Mode -->
|
||||
<div>
|
||||
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Scroll</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="opt in SCROLL_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="setting-btn"
|
||||
:class="scrollMode === opt.value ? 'active' : ''"
|
||||
@click="emit('update:scrollMode', opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direction -->
|
||||
<div>
|
||||
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Direction</p>
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<button class="setting-btn" :class="direction === 'ltr' ? 'active' : ''" @click="emit('update:direction', 'ltr')">LTR →</button>
|
||||
<button class="setting-btn" :class="direction === 'rtl' ? 'active' : ''" @click="emit('update:direction', 'rtl')">← RTL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Background -->
|
||||
<div>
|
||||
<p class="text-xs text-white/40 mb-2 uppercase tracking-wide">Background</p>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<button class="setting-btn" :class="bgColor === 'black' ? 'active' : ''" @click="emit('update:bgColor', 'black')">Black</button>
|
||||
<button class="setting-btn" :class="bgColor === 'gray' ? 'active' : ''" @click="emit('update:bgColor', 'gray')">Gray</button>
|
||||
<button class="setting-btn" :class="bgColor === 'white' ? 'active' : ''" @click="emit('update:bgColor', 'white')">White</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "tailwindcss";
|
||||
.cbz-btn {
|
||||
@apply flex items-center justify-center w-8 h-8 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0;
|
||||
}
|
||||
.setting-btn {
|
||||
@apply px-2 py-1.5 rounded text-xs text-white/60 hover:text-white hover:bg-white/10 transition-colors text-center;
|
||||
}
|
||||
.setting-btn.active {
|
||||
@apply bg-blue-500/20 text-blue-400 hover:bg-blue-500/30;
|
||||
}
|
||||
</style>
|
||||
@@ -99,7 +99,10 @@ const findQuery = ref('')
|
||||
const findMatches = ref(0)
|
||||
|
||||
watch(findQuery, (q) => {
|
||||
if (!q.trim()) { findMatches.value = 0; return }
|
||||
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++
|
||||
@@ -176,7 +179,9 @@ function onPageCommit() {
|
||||
// ── Mount / unmount ───────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
const onFsChange = () => { isFullscreen.value = !!document.fullscreenElement }
|
||||
const onFsChange = () => {
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
}
|
||||
document.addEventListener('fullscreenchange', onFsChange)
|
||||
onUnmounted(() => document.removeEventListener('fullscreenchange', onFsChange))
|
||||
|
||||
@@ -204,7 +209,10 @@ onMounted(async () => {
|
||||
ro.observe(scrollRef.value)
|
||||
setupIO(scrollRef.value)
|
||||
}
|
||||
onUnmounted(() => { ro?.disconnect(); ro = null })
|
||||
onUnmounted(() => {
|
||||
ro?.disconnect()
|
||||
ro = null
|
||||
})
|
||||
|
||||
if (progress.pageNumber.value && progress.pageNumber.value > 1) {
|
||||
await nextTick()
|
||||
@@ -367,13 +375,21 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
>
|
||||
<canvas
|
||||
class="block"
|
||||
:ref="(el) => { if (el) canvasMap.set(pageNum, el as HTMLCanvasElement) }"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
|
||||
}
|
||||
"
|
||||
/>
|
||||
<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)
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +407,14 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
<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 }"
|
||||
: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
|
||||
@@ -412,7 +435,13 @@ const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.v
|
||||
<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 }"
|
||||
: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
|
||||
|
||||
@@ -38,7 +38,10 @@ function formatGap(v: number) {
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex flex-col justify-end" @click.self="emit('close')">
|
||||
<div class="bg-card text-card-foreground rounded-t-xl max-h-[85vh] overflow-y-auto shadow-2xl border-t border-border w-full max-w-2xl mx-auto" @click.stop>
|
||||
<div
|
||||
class="bg-card text-card-foreground rounded-t-xl max-h-[85vh] overflow-y-auto shadow-2xl border-t border-border w-full max-w-2xl mx-auto"
|
||||
@click.stop
|
||||
>
|
||||
<div class="sticky top-0 bg-card z-10 flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
|
||||
@@ -63,97 +63,97 @@ function onCopy() {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<template v-if="visible">
|
||||
<div class="fixed inset-0 z-[59]" @click="emit('dismiss')" />
|
||||
<div
|
||||
class="fixed z-[60] select-none"
|
||||
:style="{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
transform: showBelow ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
|
||||
}"
|
||||
@mousedown.stop
|
||||
>
|
||||
<div class="bg-card text-card-foreground rounded-xl shadow-xl border border-border p-1.5 flex flex-col gap-1">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
@click="onCopy"
|
||||
title="Copy"
|
||||
>
|
||||
<Copy :size="15" />
|
||||
</button>
|
||||
<template v-if="visible">
|
||||
<div class="fixed inset-0 z-[59]" @click="emit('dismiss')" />
|
||||
<div
|
||||
class="fixed z-[60] select-none"
|
||||
:style="{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
transform: showBelow ? 'translateX(-50%)' : 'translateX(-50%) translateY(-100%)',
|
||||
}"
|
||||
@mousedown.stop
|
||||
>
|
||||
<div class="bg-card text-card-foreground rounded-xl shadow-xl border border-border p-1.5 flex flex-col gap-1">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
@click="onCopy"
|
||||
title="Copy"
|
||||
>
|
||||
<Copy :size="15" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors"
|
||||
:class="showColorPicker ? 'bg-yellow-100 text-yellow-600' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="onHighlightClick"
|
||||
title="Highlight"
|
||||
>
|
||||
<Highlighter :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors"
|
||||
:class="showColorPicker ? 'bg-yellow-100 text-yellow-600' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="onHighlightClick"
|
||||
title="Highlight"
|
||||
>
|
||||
<Highlighter :size="15" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
@click="emit('search')"
|
||||
title="Search"
|
||||
>
|
||||
<Search :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
@click="emit('search')"
|
||||
title="Search"
|
||||
>
|
||||
<Search :size="15" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
@click="emit('note')"
|
||||
title="Add note"
|
||||
>
|
||||
<FileText :size="15" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
@click="emit('note')"
|
||||
title="Add note"
|
||||
>
|
||||
<FileText :size="15" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="overlappingAnnotationId !== null"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-destructive hover:text-destructive/80"
|
||||
@click="emit('deleteAnnotation', overlappingAnnotationId!)"
|
||||
title="Delete annotation"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="overlappingAnnotationId !== null"
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-muted transition-colors text-destructive hover:text-destructive/80"
|
||||
@click="emit('deleteAnnotation', overlappingAnnotationId!)"
|
||||
title="Delete annotation"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showColorPicker" class="border-t border-border pt-1.5 space-y-1.5">
|
||||
<div class="flex gap-1 px-0.5">
|
||||
<button
|
||||
v-for="c in colors"
|
||||
:key="c.hex"
|
||||
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110"
|
||||
:class="selectedColor === c.hex ? 'border-foreground scale-110' : 'border-transparent'"
|
||||
:style="{ background: c.hex }"
|
||||
:title="c.label"
|
||||
@click="selectedColor = c.hex"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1 px-0.5">
|
||||
<button
|
||||
v-for="s in styles"
|
||||
:key="s.id"
|
||||
class="w-6 h-6 rounded text-xs font-bold transition-colors border"
|
||||
:class="
|
||||
selectedStyle === s.id ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted text-muted-foreground'
|
||||
"
|
||||
:title="s.id"
|
||||
@click="selectedStyle = s.id"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 ml-1 px-2 py-0.5 rounded text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
@click="applyHighlight(selectedColor, selectedStyle)"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<div v-if="showColorPicker" class="border-t border-border pt-1.5 space-y-1.5">
|
||||
<div class="flex gap-1 px-0.5">
|
||||
<button
|
||||
v-for="c in colors"
|
||||
:key="c.hex"
|
||||
class="w-6 h-6 rounded-full border-2 transition-all hover:scale-110"
|
||||
:class="selectedColor === c.hex ? 'border-foreground scale-110' : 'border-transparent'"
|
||||
:style="{ background: c.hex }"
|
||||
:title="c.label"
|
||||
@click="selectedColor = c.hex"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1 px-0.5">
|
||||
<button
|
||||
v-for="s in styles"
|
||||
:key="s.id"
|
||||
class="w-6 h-6 rounded text-xs font-bold transition-colors border"
|
||||
:class="
|
||||
selectedStyle === s.id ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted text-muted-foreground'
|
||||
"
|
||||
:title="s.id"
|
||||
@click="selectedStyle = s.id"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 ml-1 px-2 py-0.5 rounded text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
@click="applyHighlight(selectedColor, selectedStyle)"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useCbz(fileId: number, bookId: number) {
|
||||
const pageCount = ref(0)
|
||||
const bookTitle = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function pageUrl(n: number): string {
|
||||
return `/api/cbz/files/${fileId}/pages/${n}`
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const [pagesRes, bookRes] = await Promise.all([fetch(`/api/cbz/files/${fileId}/pages`), fetch(`/api/books/${bookId}`)])
|
||||
if (!pagesRes.ok) {
|
||||
error.value = 'Failed to load comic'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const [pagesData, bookData] = await Promise.all([pagesRes.json(), bookRes.ok ? bookRes.json() : null])
|
||||
pageCount.value = pagesData.pageCount
|
||||
bookTitle.value = bookData?.title ?? ''
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
return { pageCount, bookTitle, loading, error, pageUrl, load }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export type FitMode = 'fit-page' | 'fit-width' | 'fit-height' | 'actual'
|
||||
export type ViewMode = 'single' | 'two-page'
|
||||
export type ScrollMode = 'paginated' | 'infinite' | 'long-strip'
|
||||
export type Direction = 'ltr' | 'rtl'
|
||||
export type BgColor = 'black' | 'gray' | 'white'
|
||||
|
||||
interface CbzSettings {
|
||||
fitMode: FitMode
|
||||
viewMode: ViewMode
|
||||
scrollMode: ScrollMode
|
||||
direction: Direction
|
||||
bgColor: BgColor
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'cbz-reader-settings'
|
||||
|
||||
function loadSaved(): Partial<CbzSettings> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function useCbzSettings() {
|
||||
const saved = loadSaved()
|
||||
|
||||
const fitMode = ref<FitMode>(saved.fitMode ?? 'fit-page')
|
||||
const viewMode = ref<ViewMode>(saved.viewMode ?? 'single')
|
||||
const scrollMode = ref<ScrollMode>(saved.scrollMode ?? 'paginated')
|
||||
const direction = ref<Direction>(saved.direction ?? 'ltr')
|
||||
const bgColor = ref<BgColor>(saved.bgColor ?? 'black')
|
||||
|
||||
watch([fitMode, viewMode, scrollMode, direction, bgColor], () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
fitMode: fitMode.value,
|
||||
viewMode: viewMode.value,
|
||||
scrollMode: scrollMode.value,
|
||||
direction: direction.value,
|
||||
bgColor: bgColor.value,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const bgValue = computed(() => {
|
||||
if (bgColor.value === 'black') return '#0a0a0a'
|
||||
if (bgColor.value === 'gray') return '#525659'
|
||||
return '#e8e8e8'
|
||||
})
|
||||
|
||||
const isTwoPage = computed(() => viewMode.value === 'two-page' && scrollMode.value === 'paginated')
|
||||
|
||||
const imgFitClass = computed(() => {
|
||||
switch (fitMode.value) {
|
||||
case 'fit-width':
|
||||
return 'w-full h-auto max-w-full'
|
||||
case 'fit-height':
|
||||
return 'h-full w-auto max-h-full'
|
||||
case 'actual':
|
||||
return 'max-w-none max-h-none'
|
||||
default: // fit-page
|
||||
return 'max-w-full max-h-full object-contain'
|
||||
}
|
||||
})
|
||||
|
||||
return { fitMode, viewMode, scrollMode, direction, bgColor, bgValue, isTwoPage, imgFitClass }
|
||||
}
|
||||
@@ -42,9 +42,7 @@ export function usePdfLayout(
|
||||
// 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))),
|
||||
)
|
||||
return pageRows.value.map((row) => Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value))))
|
||||
})
|
||||
|
||||
watch(currentPage, (v) => {
|
||||
|
||||
@@ -47,9 +47,7 @@ export function usePdfZoom(
|
||||
})
|
||||
|
||||
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,
|
||||
)
|
||||
customScale.value = Math.round(Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta) * 20) / 20)
|
||||
zoomMode.value = 'custom'
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
@@ -36,6 +37,7 @@
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"node-7z": "^3.0.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { appConfig, authConfig, dbConfig, storageConfig } from './config/config'
|
||||
import { DbModule } from './db/db.module';
|
||||
import { AnnotationModule } from './modules/annotation/annotation.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CbzModule } from './modules/cbz/cbz.module';
|
||||
import { BookmarkModule } from './modules/bookmark/bookmark.module';
|
||||
import { BookModule } from './modules/book/book.module';
|
||||
import { KoboModule } from './modules/kobo/kobo.module';
|
||||
@@ -29,6 +30,7 @@ import { UserModule } from './modules/user/user.module';
|
||||
KoboModule,
|
||||
BookmarkModule,
|
||||
AnnotationModule,
|
||||
CbzModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -5,28 +5,28 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1767491359647,
|
||||
"when": 1767494438065,
|
||||
"tag": "0000_init_schema",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1767491359647,
|
||||
"when": 1767494438065,
|
||||
"tag": "0001_lethal_pixie",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1767491359647,
|
||||
"when": 1767494438065,
|
||||
"tag": "0002_powerful_thundra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1767491359647,
|
||||
"when": 1767494438065,
|
||||
"tag": "0003_reading_progress_by_file",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ import { BookService } from './book.service';
|
||||
@Module({
|
||||
controllers: [BookController],
|
||||
providers: [BookService, BookRepository],
|
||||
exports: [BookService, BookRepository],
|
||||
})
|
||||
export class BookModule {}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Param, ParseIntPipe, Res } from '@nestjs/common';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { CbzService } from './cbz.service';
|
||||
|
||||
@Controller('cbz')
|
||||
export class CbzController {
|
||||
constructor(private readonly cbzService: CbzService) {}
|
||||
|
||||
@Get('files/:fileId/pages')
|
||||
async getPageCount(@Param('fileId', ParseIntPipe) fileId: number) {
|
||||
return { pageCount: await this.cbzService.getPageCount(fileId) };
|
||||
}
|
||||
|
||||
@Get('files/:fileId/pages/:pageIndex')
|
||||
async getPage(@Param('fileId', ParseIntPipe) fileId: number, @Param('pageIndex', ParseIntPipe) pageIndex: number, @Res() reply: FastifyReply) {
|
||||
const { stream, mimeType } = await this.cbzService.streamPage(fileId, pageIndex);
|
||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
reply.type(mimeType);
|
||||
reply.send(stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BookModule } from '../book/book.module';
|
||||
import { CbzController } from './cbz.controller';
|
||||
import { CbzService } from './cbz.service';
|
||||
|
||||
@Module({
|
||||
imports: [BookModule],
|
||||
controllers: [CbzController],
|
||||
providers: [CbzService],
|
||||
})
|
||||
export class CbzModule {}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { createReadStream } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { createInflateRaw } from 'zlib';
|
||||
|
||||
import { BookRepository } from '../book/book.repository';
|
||||
|
||||
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.avif']);
|
||||
const LFH_SIG = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
|
||||
function isImage(name: string): boolean {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot !== -1 && IMAGE_EXTS.has(name.substring(dot).toLowerCase());
|
||||
}
|
||||
|
||||
function isHidden(name: string): boolean {
|
||||
return name.split('/').some((p) => p.startsWith('.'));
|
||||
}
|
||||
|
||||
interface PageEntry {
|
||||
name: string;
|
||||
dataStart: number; // byte offset of compressed data in the ZIP file
|
||||
compressedSize: number;
|
||||
compression: number; // 0 = STORED, 8 = DEFLATE
|
||||
}
|
||||
|
||||
// Scan all local file headers in the buffer and return image entries sorted naturally.
|
||||
// Same technique as cover-cbz.ts — bypasses the central directory, which unzipper
|
||||
// fails to locate when the ZIP has a large comment (e.g. ComicTagger JSON metadata).
|
||||
function buildPageIndex(buf: Buffer): PageEntry[] {
|
||||
const entries: PageEntry[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (pos < buf.length - 30) {
|
||||
const sigPos = buf.indexOf(LFH_SIG, pos);
|
||||
if (sigPos === -1) break;
|
||||
|
||||
const compression = buf.readUInt16LE(sigPos + 8);
|
||||
const compressedSize = buf.readUInt32LE(sigPos + 18);
|
||||
const fileNameLen = buf.readUInt16LE(sigPos + 26);
|
||||
const extraLen = buf.readUInt16LE(sigPos + 28);
|
||||
const dataStart = sigPos + 30 + fileNameLen + extraLen;
|
||||
const fileName = buf.subarray(sigPos + 30, sigPos + 30 + fileNameLen).toString('utf-8');
|
||||
|
||||
if (!fileName.endsWith('/') && !isHidden(fileName) && isImage(fileName)) {
|
||||
if (compression === 0 || compression === 8) {
|
||||
entries.push({ name: fileName, dataStart, compressedSize, compression });
|
||||
}
|
||||
}
|
||||
|
||||
pos = dataStart + compressedSize;
|
||||
if (compressedSize === 0) pos = sigPos + 4; // guard against infinite loop on data descriptors
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
return entries;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CbzService {
|
||||
// Caches sorted page entries per fileId — built once on first access, held for server lifetime.
|
||||
private readonly indexCache = new Map<number, PageEntry[]>();
|
||||
|
||||
constructor(private readonly bookRepo: BookRepository) {}
|
||||
|
||||
private async getIndex(fileId: number): Promise<{ path: string; entries: PageEntry[] }> {
|
||||
const file = await this.bookRepo.findFileById(fileId);
|
||||
if (!file) throw new NotFoundException(`File ${fileId} not found`);
|
||||
|
||||
if (!this.indexCache.has(fileId)) {
|
||||
const buf = await readFile(file.absolutePath);
|
||||
this.indexCache.set(fileId, buildPageIndex(buf));
|
||||
}
|
||||
|
||||
return { path: file.absolutePath, entries: this.indexCache.get(fileId)! };
|
||||
}
|
||||
|
||||
async getPageCount(fileId: number): Promise<number> {
|
||||
const { entries } = await this.getIndex(fileId);
|
||||
return entries.length;
|
||||
}
|
||||
|
||||
async streamPage(fileId: number, pageIndex: number): Promise<{ stream: NodeJS.ReadableStream; mimeType: string }> {
|
||||
const { path, entries } = await this.getIndex(fileId);
|
||||
|
||||
if (pageIndex < 0 || pageIndex >= entries.length) {
|
||||
throw new NotFoundException(`Page ${pageIndex} out of range (0–${entries.length - 1})`);
|
||||
}
|
||||
|
||||
const entry = entries[pageIndex];
|
||||
const ext = entry.name.substring(entry.name.lastIndexOf('.')).toLowerCase();
|
||||
const mimeType = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
||||
|
||||
const raw = createReadStream(path, { start: entry.dataStart, end: entry.dataStart + entry.compressedSize - 1 });
|
||||
const stream = entry.compression === 0 ? raw : raw.pipe(createInflateRaw());
|
||||
|
||||
return { stream, mimeType };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user