From dd5b2700da3d9a4fbacf9f8be2e15d5f7e52bf55 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 3 Jan 2026 07:43:11 -0600 Subject: [PATCH] feat(reader): add CBZ/CBR comic reader with backend page streaming --- client/src/features/reader/ReaderView.vue | 2 + .../reader/components/CbzReaderView.vue | 346 ++++++++++++++++++ .../reader/components/CbzSettingsPanel.vue | 122 ++++++ .../reader/components/PdfReaderView.vue | 43 ++- .../reader/components/ReaderSettingsPanel.vue | 5 +- .../reader/components/SelectionPopup.vue | 170 ++++----- .../src/features/reader/composables/useCbz.ts | 27 ++ .../reader/composables/useCbzSettings.ts | 71 ++++ .../reader/composables/usePdfLayout.ts | 4 +- .../features/reader/composables/usePdfZoom.ts | 4 +- server/package.json | 2 + server/src/app.module.ts | 2 + server/src/db/migrations/meta/_journal.json | 8 +- server/src/modules/book/book.module.ts | 1 + server/src/modules/cbz/cbz.controller.ts | 21 ++ server/src/modules/cbz/cbz.module.ts | 11 + server/src/modules/cbz/cbz.service.ts | 99 +++++ 17 files changed, 835 insertions(+), 103 deletions(-) create mode 100644 client/src/features/reader/components/CbzReaderView.vue create mode 100644 client/src/features/reader/components/CbzSettingsPanel.vue create mode 100644 client/src/features/reader/composables/useCbz.ts create mode 100644 client/src/features/reader/composables/useCbzSettings.ts create mode 100644 server/src/modules/cbz/cbz.controller.ts create mode 100644 server/src/modules/cbz/cbz.module.ts create mode 100644 server/src/modules/cbz/cbz.service.ts diff --git a/client/src/features/reader/ReaderView.vue b/client/src/features/reader/ReaderView.vue index 05bf0d246..d6e36666c 100644 --- a/client/src/features/reader/ReaderView.vue +++ b/client/src/features/reader/ReaderView.vue @@ -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) { diff --git a/client/src/features/reader/composables/useCbz.ts b/client/src/features/reader/composables/useCbz.ts new file mode 100644 index 000000000..aeca505ed --- /dev/null +++ b/client/src/features/reader/composables/useCbz.ts @@ -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(null) + + function pageUrl(n: number): string { + return `/api/cbz/files/${fileId}/pages/${n}` + } + + async function load(): Promise { + 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 } +} diff --git a/client/src/features/reader/composables/useCbzSettings.ts b/client/src/features/reader/composables/useCbzSettings.ts new file mode 100644 index 000000000..934c24c82 --- /dev/null +++ b/client/src/features/reader/composables/useCbzSettings.ts @@ -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 { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + } catch { + return {} + } +} + +export function useCbzSettings() { + const saved = loadSaved() + + const fitMode = ref(saved.fitMode ?? 'fit-page') + const viewMode = ref(saved.viewMode ?? 'single') + const scrollMode = ref(saved.scrollMode ?? 'paginated') + const direction = ref(saved.direction ?? 'ltr') + const bgColor = ref(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 } +} diff --git a/client/src/features/reader/composables/usePdfLayout.ts b/client/src/features/reader/composables/usePdfLayout.ts index ab96eb405..ae79b60cd 100644 --- a/client/src/features/reader/composables/usePdfLayout.ts +++ b/client/src/features/reader/composables/usePdfLayout.ts @@ -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) => { diff --git a/client/src/features/reader/composables/usePdfZoom.ts b/client/src/features/reader/composables/usePdfZoom.ts index bac5158f5..5779cdc36 100644 --- a/client/src/features/reader/composables/usePdfZoom.ts +++ b/client/src/features/reader/composables/usePdfZoom.ts @@ -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' } diff --git a/server/package.json b/server/package.json index 6904f2957..72d54b6fe 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 55a4bfd2e..b148dfabf 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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 {} diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index 8a1a92824..3c1685038 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -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 } diff --git a/server/src/modules/book/book.module.ts b/server/src/modules/book/book.module.ts index eb60f50fa..88be45ad6 100644 --- a/server/src/modules/book/book.module.ts +++ b/server/src/modules/book/book.module.ts @@ -7,5 +7,6 @@ import { BookService } from './book.service'; @Module({ controllers: [BookController], providers: [BookService, BookRepository], + exports: [BookService, BookRepository], }) export class BookModule {} diff --git a/server/src/modules/cbz/cbz.controller.ts b/server/src/modules/cbz/cbz.controller.ts new file mode 100644 index 000000000..52fc96899 --- /dev/null +++ b/server/src/modules/cbz/cbz.controller.ts @@ -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); + } +} diff --git a/server/src/modules/cbz/cbz.module.ts b/server/src/modules/cbz/cbz.module.ts new file mode 100644 index 000000000..0165a2f8d --- /dev/null +++ b/server/src/modules/cbz/cbz.module.ts @@ -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 {} diff --git a/server/src/modules/cbz/cbz.service.ts b/server/src/modules/cbz/cbz.service.ts new file mode 100644 index 000000000..92d67281e --- /dev/null +++ b/server/src/modules/cbz/cbz.service.ts @@ -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(); + + 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 { + 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 }; + } +}