feat(reader): open DjVu documents in the web reader

Scanned books are commonly distributed as DjVu, which the scanner only stored as a supplement.
Decode pages on the server with DjVuLibre so DjVu imports with a cover and page count, opens in
the image reader with its embedded OCR text, and can be the preferred format of an ordinary email
recipient. Migration 0113 widens the recipient format check to allow it.

Decoding runs on validated private snapshots behind a bounded, reader-first queue, and
revalidation and cached pages are answered without a decoder. Released iOS builds cannot open
DjVu, so series continuation offers it only to callers sending includeDjvu=true, and the
dashboard and notebook read file never name it. The first full scan after upgrading promotes
stored .djvu supplements to content.
This commit is contained in:
neon
2026-10-14 21:37:41 +00:00
parent 2cbd76bc7f
commit 97cd03325f
96 changed files with 26351 additions and 146 deletions
@@ -115,6 +115,14 @@ describe('useBookDockUpload client-side validation', () => {
expect(upload.failures.value).toHaveLength(0)
})
it('sends DjVu to the Book Dock instead of rejecting it as unsupported', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('DjVuLibre-book.DJVU')])
await flushPromises()
expect(FakeXhr.sent).toHaveLength(1)
expect(upload.failures.value).toHaveLength(0)
})
it('rejects a file over the configured upload limit before sending it', async () => {
const { upload, appInfo } = await loadComposable()
appInfo.maxUploadSizeMb.value = 1
@@ -1,11 +1,11 @@
import { computed, ref } from 'vue'
import { getValidToken } from '@/lib/api'
import type { BookDockFile } from '@bookorbit/types'
import { UPLOAD_SUPPORTED_FORMATS, type BookDockFile } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { isRetryableUploadFailure, type BookDockUploadFailure } from '../lib/upload-failure'
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
export const SUPPORTED_FORMATS: readonly string[] = UPLOAD_SUPPORTED_FORMATS
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
export type FileUploadStatus = 'pending' | 'uploading' | 'done' | 'error'
@@ -15,7 +15,7 @@ import { useCoverVersions } from '../composables/useCoverVersions'
import BookFormatChip from './BookFormatChip.vue'
import { bookFormatEntries, fileFormatKey } from '../lib/book-formats'
import { displayPublishedDate } from '../lib/published-date'
import { FORMAT_TO_GROUP, getPrimaryBookFile } from '@bookorbit/types'
import { FORMAT_TO_GROUP, getPrimaryBookFile, isComicFormat } from '@bookorbit/types'
import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
@@ -98,7 +98,7 @@ const quickViewCoverAspectRatio = computed(() => {
const primaryFile = computed(() => (detail.value ? getPrimaryBookFile(detail.value.files) : null))
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
const formatEntries = computed(() => (detail.value ? bookFormatEntries(detail.value.files, detail.value.formatPriority) : []))
const publishedDisplay = computed(() => (detail.value ? displayPublishedDate(detail.value.publishedDate, detail.value.publishedYear) : null))
@@ -32,7 +32,7 @@ import { readingDateToDateKey } from '@/features/book/lib/reading-date'
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useBookCoverAspectRatio } from '@/features/book/composables/useBookCoverAspectRatio'
import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS, isComicFormat } from '@bookorbit/types'
import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadAloudProgressSync, ReadStatus, UserBookStatus } from '@bookorbit/types'
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue'
@@ -364,7 +364,7 @@ const isFaceAudio = computed(() => {
if (!props.book.covers.ebook && !props.book.covers.audio) return isPrimaryAudio.value
return faceMedium(props.book, coverAspectRatio.value) === 'audio'
})
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
const formatEntries = computed(() => bookFormatEntries(props.book.files, props.book.formatPriority))
const isMultiTrackAudio = computed(() => formatEntries.value.some((entry) => entry.audio && entry.files.length > 1))
// A multi-file audiobook opens from its first track, so it is one entry in the menu.
@@ -25,7 +25,14 @@ import type {
MetadataProviderInfo,
WriteResult,
} from '@bookorbit/types'
import { BOOK_FILE_WRITE_FIELD_LABELS, FORMAT_TO_GROUP, getPrimaryBookFile, isValidSeriesIndex, parseSeriesIndex } from '@bookorbit/types'
import {
BOOK_FILE_WRITE_FIELD_LABELS,
FORMAT_TO_GROUP,
getPrimaryBookFile,
isValidSeriesIndex,
parseSeriesIndex,
isComicFormat,
} from '@bookorbit/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { api } from '@/lib/api'
@@ -119,7 +126,7 @@ const COMIC_FIELD_MAP = {
const primaryFile = computed(() => getPrimaryBookFile(props.book.files))
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const isPrimaryComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
const fileWriteStatus = computed(() => props.book.fileWriteStatus ?? null)
const fileWriteWritableFormats = computed(() => fileWriteStatus.value?.writableFormats ?? [])
const fileWriteFieldLabels = computed(() => (fileWriteStatus.value?.writableFields ?? []).map((field) => BOOK_FILE_WRITE_FIELD_LABELS[field]))
@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Lock, LockOpen } from '@lucide/vue'
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
import { FORMAT_TO_GROUP, isComicFormat, type BookCard } from '@bookorbit/types'
import BookTableCoverCell from './BookTableCoverCell.vue'
import BookTableTextCell from './BookTableTextCell.vue'
import BookTableNumberCell from './BookTableNumberCell.vue'
@@ -97,7 +97,7 @@ function handleToggleAllLocks() {
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const isComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
</script>
<template>
@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronRight, LibraryBig } from '@lucide/vue'
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
import { FORMAT_TO_GROUP, isComicFormat, type BookCard } from '@bookorbit/types'
import BookCoverSurface from '../BookCoverSurface.vue'
import BookTableChipsCell from './BookTableChipsCell.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -27,7 +27,7 @@ const bookCount = computed(() => collapsed.value.bookCount)
const coverIds = computed(() => collapsed.value.coverBookIds.filter((id) => id > 0).slice(0, 4))
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'cbx')
const isComic = computed(() => primaryFile.value?.format != null && isComicFormat(primaryFile.value.format))
const { coverUrl } = useCoverVersions()
function thumbnailUrl(bookId: number): string {
@@ -122,8 +122,8 @@ describe('BookTableCellDispatcher lock row', () => {
expect(wrapper.find('[data-testid="cover-cell"]').attributes('data-comic')).toBe('true')
})
it('does not flag non-comic primary files as comics in the cover cell', () => {
const book = makeBook({ files: [{ id: 1, format: 'epub', role: 'primary', sizeBytes: null }] })
it.each(['epub', 'djvu'])('does not flag %s primary files as comics in the cover cell', (format) => {
const book = makeBook({ files: [{ id: 1, format, role: 'primary', sizeBytes: null }] })
const wrapper = mountDispatcher({ book, colId: 'cover' as const, cellType: 'cover' as const })
expect(wrapper.find('[data-testid="cover-cell"]').attributes('data-comic')).toBe('false')
@@ -86,6 +86,13 @@ describe('useAddBookFile', () => {
expect(files.value[0].file.name).toBe('book.epub')
})
it('accepts DjVu as an additional edition regardless of extension case', () => {
const { files, addFiles } = useAddBookFile()
addFiles([makeFile('scanned-book.DJVU')])
expect(files.value[0].status).toBe('pending')
expect(files.value[0].validationError).toBe(false)
})
it('marks unsupported format as error without attempting upload', () => {
const { files, addFiles } = useAddBookFile()
addFiles([makeFile('book.xyz')])
@@ -1,9 +1,9 @@
import { computed, ref } from 'vue'
import { getValidToken } from '@/lib/api'
import type { AddBookFileResult } from '@bookorbit/types'
import { UPLOAD_SUPPORTED_FORMATS, type AddBookFileResult } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
export const SUPPORTED_FORMATS: readonly string[] = UPLOAD_SUPPORTED_FORMATS
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
export const MAX_FILE_BYTES = 500 * 1024 * 1024
@@ -64,7 +64,7 @@ function groupOf(file: BookDetailFile): FileGroupKey {
if (!isContentBookFile(file)) return 'extras'
const format = file.format!.toLowerCase()
if (isAudioFormat(format)) return 'audio'
if (format === 'pdf') return 'document'
if (format === 'pdf' || format === 'djvu') return 'document'
if (isComicFormat(format)) return 'comic'
return 'ebook'
}
@@ -67,6 +67,6 @@ describe('format labels', () => {
it('names formats from the translated catalog', () => {
expect(formatKeyName('epub')).toBe('EPUB e-book')
expect(formatKeyName(READ_ALONG_FORMAT_PRIORITY)).toBe('Read-along EPUB')
expect(formatKeyName('djvu')).toBe('DJVU')
expect(formatKeyName('djvu')).toBe('DjVu document')
})
})
@@ -2,7 +2,7 @@
import { BookOpen, Play } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { FORMAT_TO_GROUP } from '@bookorbit/types'
import { isComicFormat } from '@bookorbit/types'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
@@ -19,7 +19,7 @@ function goToBook(bookId: number) {
}
function isComic(fileFormat: string | null): boolean {
return fileFormat != null && FORMAT_TO_GROUP[fileFormat] === 'cbx'
return fileFormat != null && isComicFormat(fileFormat)
}
function continueReading(bookId: number, fileId: number | null, fileFormat: string | null) {
@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Clock, Play } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { FORMAT_TO_GROUP } from '@bookorbit/types'
import { isComicFormat } from '@bookorbit/types'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import BookCoverArtwork from '@/features/book/components/BookCoverArtwork.vue'
@@ -17,7 +17,7 @@ const { coverUrl } = useCoverVersions()
const isComic = computed(() => {
const format = data.value?.fileFormat
return format != null && FORMAT_TO_GROUP[format] === 'cbx'
return format != null && isComicFormat(format)
})
function goToBook() {
@@ -16,6 +16,8 @@ describe('email file rules', () => {
it('offers a Kindle only the formats Amazon takes by email', () => {
expect(formatsFor('kindle')).toEqual(['epub', 'pdf'])
expect(formatsFor(null)).toContain('azw3')
expect(formatsFor(null)).toContain('djvu')
expect(kindleAccepts('djvu')).toBe(false)
expect(kindleAccepts('EPUB')).toBe(true)
expect(kindleAccepts('mobi')).toBe(false)
})
+1 -1
View File
@@ -5,7 +5,7 @@ import { hasReadAlong } from '@/features/book/lib/file-capabilities'
/** What Amazon's Send to Kindle takes by email since 2023, among the formats a recipient can prefer. */
export const KINDLE_EMAIL_FORMATS = ['epub', 'pdf'] as const
export const RECIPIENT_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr'] as const
export const RECIPIENT_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'djvu'] as const
const MB = 1024 * 1024
const KINDLE_LIMIT_BYTES = 50 * MB
@@ -37,7 +37,7 @@ const ADDED_AT_SOURCES: AddedAtSource[] = ['imported', 'file_modified', 'file_cr
const FORMAT_GROUPS: { id: 'ebook' | 'kindle' | 'document' | 'comic' | 'audio'; formats: string[] }[] = [
{ id: 'ebook', formats: ['epub', 'kepub', 'fb2'] },
{ id: 'kindle', formats: ['mobi', 'azw3', 'azw'] },
{ id: 'document', formats: ['pdf'] },
{ id: 'document', formats: ['pdf', 'djvu'] },
{ id: 'comic', formats: ['cbz', 'cbr', 'cb7'] },
{ id: 'audio', formats: ['m4b', 'mp3', 'm4a', 'opus', 'ogg', 'flac'] },
]
@@ -63,6 +63,16 @@ describe('LibraryCreatorScanner', () => {
expect(wrapper.emitted('update:allowedFormats')).toBeUndefined()
})
it('allows DjVu to be selected when imports are restricted to specific formats', async () => {
const wrapper = mountScanner({ allowedFormats: ['pdf'] })
await wrapper
.findAll('button[aria-pressed]')
.find((button) => button.text() === 'DJVU')!
.trigger('click')
expect(wrapper.emitted('update:allowedFormats')).toEqual([[['pdf', 'djvu']]])
wrapper.unmount()
})
it('adds skip patterns on Enter and removes them by name', async () => {
const wrapper = mountScanner({ excludePatterns: ['**/samples/**'] })
@@ -0,0 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useBookUpload, SUPPORTED_FORMATS_ACCEPT } from '../useBookUpload'
vi.mock('@/lib/api', () => ({ getValidToken: vi.fn<() => Promise<string>>().mockResolvedValue('test-token') }))
vi.mock('@/features/settings/composables/useAppInfo', () => ({ useAppInfo: () => ({ maxUploadSizeMb: ref(500) }) }))
describe('library DjVu upload', () => {
beforeEach(() => vi.clearAllMocks())
it('accepts DjVu in file selection and drag-and-drop validation regardless of case', () => {
const upload = useBookUpload()
upload.addFiles([new File(['document'], 'scanned-book.djvu'), new File(['document'], 'second.DJVU'), new File(['document'], 'notes.docx')])
expect(SUPPORTED_FORMATS_ACCEPT.split(',')).toContain('.djvu')
expect(upload.files.value.map((file) => [file.file.name, file.status])).toEqual([
['scanned-book.djvu', 'pending'],
['second.DJVU', 'pending'],
['notes.docx', 'error'],
])
expect(upload.pendingCount.value).toBe(2)
})
})
@@ -1,9 +1,9 @@
import { computed, ref } from 'vue'
import { getValidToken } from '@/lib/api'
import type { UploadResult } from '@bookorbit/types'
import { UPLOAD_SUPPORTED_FORMATS, type UploadResult } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
export const SUPPORTED_FORMATS: readonly string[] = UPLOAD_SUPPORTED_FORMATS
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024 // 500 MB
+4 -2
View File
@@ -70,7 +70,9 @@ useReaderPageTitle(bookId)
const normalizedFileFormat = fileFormat.toLowerCase()
const isAudioFormat = getFormatGroup(fileFormat) === 'audio'
const isPdfFormat = fileFormat === 'pdf'
const isComicFormat = fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7'
const isDjvuFormat = normalizedFileFormat === 'djvu'
const isComicFormat = fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7' || isDjvuFormat
const pageImageSource = isDjvuFormat ? 'djvu' : 'cbz'
const isPeekMode = computed(() => {
const mode = route.query.mode
return (Array.isArray(mode) ? mode[0] : mode) === 'peek'
@@ -1206,7 +1208,7 @@ onUnmounted(() => {
<template>
<PdfV4ReaderView v-if="isPdfFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" :source="pageImageSource" />
<AudiobookReaderView v-else-if="isAudioFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
<div
v-else
@@ -170,6 +170,22 @@ describe('CbzReaderView', () => {
vi.stubGlobal('cancelAnimationFrame', vi.fn<(handle: number) => void>())
})
it('offers DjVu embedded text and recovers from a failed page image', async () => {
mocks.savedMode = 'paginated'
mocks.savedPageNumber = 1
mocks.pageCount = 2
const wrapper = shallowMount(CbzReaderView, { props: { bookId: 11, fileId: 22, source: 'djvu' } })
await flushPromises()
expect(wrapper.find('[aria-label="reader.djvu.pageText"]').exists()).toBe(true)
await wrapper.get('img').trigger('error')
expect(wrapper.text()).toContain('reader.pageImages.pageFailed')
const retry = wrapper.findAll('button').find((button) => button.text() === 'common.retry')!
await retry.trigger('click')
expect(wrapper.text()).not.toContain('reader.pageImages.pageFailed')
expect(wrapper.get('img').attributes('alt')).toBe('reader.pageImages.pageAlt')
wrapper.unmount()
})
it.each(['infinite', 'long-strip'] as const)('restores %s mode by index while rendering only a bounded page window', async (mode) => {
mocks.savedMode = mode
const wrapper = shallowMount(CbzReaderView, {
@@ -347,6 +363,29 @@ describe('CbzReaderView', () => {
wrapper.unmount()
})
it('keeps arrow keys turning pages after a reader button is clicked, leaving activation keys to the button', async () => {
mocks.savedMode = 'paginated'
mocks.pageCount = 5
mocks.savedPageNumber = 2
const wrapper = shallowMount(CbzReaderView, { props: { bookId: 11, fileId: 22 }, attachTo: document.body })
await flushPromises()
await nextTick()
const pages = () => wrapper.findAll('img').map((image) => image.attributes('src'))
const opened = pages()
const button = wrapper.get('button').element as HTMLButtonElement
button.focus()
button.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
await nextTick()
expect(pages()).toEqual(opened)
button.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
await nextTick()
expect(pages()).not.toEqual(opened)
wrapper.unmount()
})
it('leaves the page turn inert past the last page while auto-advance is off', async () => {
mocks.savedMode = 'paginated'
mocks.pageCount = 3
+249 -59
View File
@@ -17,6 +17,7 @@ import {
PinOff,
Plus,
Settings,
FileText,
} from '@lucide/vue'
import { useVisibility } from '../shared/composables/useVisibility'
import { useReaderProgress } from '../shared/composables/useReaderProgress'
@@ -33,6 +34,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import ReaderSettingsSheet from '@/features/reader/shared/components/ReaderSettingsSheet.vue'
import CbzSettingsPanel from './components/CbzSettingsPanel.vue'
import NextIssueCard from './components/NextIssueCard.vue'
import DjvuTextSheet from '../djvu/DjvuTextSheet.vue'
const TWO_PAGE_BREAKPOINT = 900
// A page turn that only just landed on the last page must not carry through into the next book:
@@ -44,7 +46,7 @@ const ZOOM_STEP = 0.25
const { t } = useI18n()
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean; source?: 'cbz' | 'djvu' }>()
const route = useRoute()
const router = useRouter()
const trackingEnabled = computed(() => !props.peekMode)
@@ -61,7 +63,7 @@ const { onActivity, elapsedMinutes } = useReadingSession(
{ trackingEnabled },
)
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes, 0, { trackingEnabled })
const { pageCount, bookTitle, seriesId, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId)
const { pageCount, bookTitle, seriesId, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId, props.source ?? 'cbz')
const {
fitMode,
viewMode,
@@ -77,9 +79,47 @@ const {
imgFitClass,
} = useCbzSettings()
const { nextBook, load: loadNextBook } = useSeriesNextBook('cbx')
const bookSettings = useReaderSettings(props.fileId, 'cbz')
const bookSettings = useReaderSettings(props.fileId, props.source === 'djvu' ? 'djvu' : 'cbz')
const currentPage = ref(0)
const imageError = ref(false)
const imageGeneration = ref(0)
const showPageText = ref(false)
function goBack() {
router.back()
}
function firstPage() {
goToPage(0)
}
function lastPage() {
goToPage(pageCount.value - 1)
}
function onPageSlider(event: Event) {
goToPage(Number((event.target as HTMLInputElement).value))
}
function openPageText() {
showPageText.value = true
}
function onTextOpenChange(open: boolean) {
showPageText.value = open
}
function onImageError() {
imageError.value = true
}
async function retryImages() {
await load()
if (disposed || error.value || pageCount.value <= 0) return
currentPage.value = Math.min(currentPage.value, pageCount.value - 1)
imageError.value = false
currentImageLoaded.value = false
loadedImageCount.value = 0
imageGeneration.value++
preloadCache.clear()
}
function onPaginatedLoad(event: Event) {
const image = event.target
if (image instanceof HTMLImageElement) onPaginatedImageLoad(Number(image.dataset.page), event)
}
const showSettings = ref(false)
const scrollContainer = ref<HTMLElement | null>(null)
const paginatedViewport = ref<HTMLElement | null>(null)
@@ -93,7 +133,20 @@ const pageDimensions = ref<Array<{ width: number; height: number } | undefined>>
const stripImagesReady = ref(false)
const zoomScale = ref(1)
watch(showSettings, setVisibilityLock)
// Only keyboard focus holds the controls open; a clicked or tapped button keeps focus too, and
// must not stop the bars from hiding.
const controlsFocused = ref(false)
function onControlsFocus(event: FocusEvent) {
controlsFocused.value = event.target instanceof HTMLElement && event.target.matches(':focus-visible')
}
function onControlsBlur(event: FocusEvent) {
const next = event.relatedTarget
if (!(next instanceof HTMLElement && next.closest('[data-reader-controls]'))) controlsFocused.value = false
}
watch([showSettings, showPageText, controlsFocused], ([settings, text, focused]) => {
setVisibilityLock(settings || text || focused)
if (focused) footerVisible.value = true
})
// The settings surface is one panel in two containers: an anchored popover where there
// is room beside the page, a bottom sheet where the thumb is and the page must stay visible.
@@ -331,6 +384,7 @@ const canGoNext = computed(() => {
})
watch(renderKey, () => {
imageError.value = false
const spread = currentSpread.value
const expected = spread?.pages.length ?? 0
pendingImageLoads.value = expected
@@ -361,12 +415,16 @@ function preload(n: number) {
preloadCache.set(n, img)
}
// Every DjVu page is rendered on the server by a small shared decoder pool, so it looks one spread
// ahead instead of four and leaves the pool free for the page on screen.
const PRELOAD_SPREAD_OFFSETS = props.source === 'djvu' ? [0, 1] : [-1, 0, 1, 2]
function schedulePreload(anchorPage: number) {
const layout = spreadLayout.value
const centerSpreadIndex = layout.spreadIndexForPage(anchorPage)
const pagesToPreload = new Set<number>()
for (const offset of [-1, 0, 1, 2]) {
for (const offset of PRELOAD_SPREAD_OFFSETS) {
const spread = layout.spreads[centerSpreadIndex + offset]
if (!spread) continue
for (const page of spread.pages) pagesToPreload.add(page)
@@ -567,7 +625,14 @@ function onWheel(e: WheelEvent) {
// ── Keyboard ───────────────────────────────────────────────────────────────────
function onKeyDown(e: KeyboardEvent) {
const target = (e.composedPath?.()[0] || e.target) as HTMLElement | null
if (target?.tagName === 'INPUT') return
if (e.defaultPrevented || showPageText.value) return
if (showSettings.value) {
if (e.key === 'Escape') showSettings.value = false
return
}
if (target instanceof HTMLElement && target.closest('input, textarea, select, [contenteditable=true], [role=dialog]')) return
// A clicked button keeps focus, so arrows must still turn pages; only its own activation keys are left to it.
if ((e.key === ' ' || e.key === 'Enter') && target instanceof HTMLElement && target.closest('button, a')) return
const isRtl = direction.value === 'rtl'
switch (e.key) {
@@ -596,9 +661,6 @@ function onKeyDown(e: KeyboardEvent) {
e.preventDefault()
goToPage(pageCount.value - 1)
break
case 'Escape':
showSettings.value = false
break
case '+':
case '=':
e.preventDefault()
@@ -616,6 +678,7 @@ function onKeyDown(e: KeyboardEvent) {
}
let readerReady = false
let disposed = false
let restoringContinuousPosition = false
let stripScrollFrame: number | null = null
@@ -741,38 +804,52 @@ async function startTrackedReading() {
}
// ── Mount / unmount ────────────────────────────────────────────────────────────
onMounted(async () => {
async function initializeReader() {
viewportWidth.value = window.innerWidth
viewportHeight.value = window.innerHeight
loading.value = true
error.value = null
try {
await progress.load()
if (disposed) return
await bookSettings.load()
if (disposed) return
applySettings(bookSettings.effective.value as CbxReaderSettings)
await load()
if (disposed || error.value || pageCount.value <= 0) return
const saved = progress.pageNumber.value
if (saved && saved > 1) {
currentPage.value = Math.min(saved - 1, pageCount.value - 1)
} else if (progress.percentage.value > 0 && pageCount.value > 1) {
const estimated = Math.round((progress.percentage.value / 100) * pageCount.value)
currentPage.value = Math.max(0, Math.min(estimated - 1, pageCount.value - 1))
}
if (scrollMode.value === 'paginated' && isTwoPageEffective.value) {
currentPage.value = spreadLayout.value.anchorForPage(currentPage.value)
}
if (seriesFlowEnabled.value) void loadNextBook(seriesId.value, props.bookId)
readerReady = true
if (scrollMode.value !== 'paginated') {
await scrollContinuousToPage(currentPage.value, true)
stripImagesReady.value = true
}
schedulePreload(currentPage.value)
} catch {
error.value = 'reader.pageImages.loadFailed'
} finally {
loading.value = false
}
}
onMounted(() => {
window.addEventListener('resize', onResize)
window.addEventListener('keydown', onKeyDown)
await progress.load()
await bookSettings.load()
applySettings(bookSettings.effective.value as CbxReaderSettings)
await load()
const saved = progress.pageNumber.value
if (saved && saved > 1) {
currentPage.value = Math.min(saved - 1, pageCount.value - 1)
} else if (progress.percentage.value > 0 && pageCount.value > 1) {
const estimated = Math.round((progress.percentage.value / 100) * pageCount.value)
currentPage.value = Math.max(0, Math.min(estimated - 1, pageCount.value - 1))
}
if (scrollMode.value === 'paginated' && isTwoPageEffective.value) {
currentPage.value = spreadLayout.value.anchorForPage(currentPage.value)
}
if (seriesFlowEnabled.value) void loadNextBook(seriesId.value, props.bookId)
readerReady = true
if (scrollMode.value !== 'paginated') {
await scrollContinuousToPage(currentPage.value, true)
stripImagesReady.value = true
}
schedulePreload(currentPage.value)
void initializeReader()
})
onBeforeRouteLeave(async () => {
@@ -782,6 +859,9 @@ onBeforeRouteLeave(async () => {
// Before unmount, because useReadingSession ends the session from its own unmounted hook, which
// runs first and would otherwise miss a page turn still inside the save debounce.
onBeforeUnmount(() => {
disposed = true
for (const image of preloadCache.values()) image.onload = null
preloadCache.clear()
void flushPendingProgress()
})
@@ -794,13 +874,23 @@ onUnmounted(() => {
<template>
<div class="fixed inset-0 select-none overflow-hidden" :style="{ background: bgValue }">
<span class="sr-only" role="status" aria-live="polite">{{ pageLabel }}</span>
<!-- ── Header ──────────────────────────────────────────────────────────── -->
<div
data-reader-controls
@focusin="onControlsFocus"
@focusout="onControlsBlur"
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-1 px-3 bg-background/90 backdrop-blur-md border-b border-border">
<button class="viewer-btn" @click="router.back()"><ArrowLeft :size="16" /></button>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="t('common.back')"
@click="goBack"
>
<ArrowLeft :size="16" />
</button>
<div class="flex-1 min-w-0 flex flex-col justify-center px-2">
<span v-if="bookTitle" class="text-sm font-serif text-foreground truncate leading-tight">{{ bookTitle }}</span>
<span class="text-xs text-muted-foreground tabular-nums">{{ pageLabel }}</span>
@@ -817,7 +907,12 @@ onUnmounted(() => {
<div class="hidden items-center md:flex" :aria-label="t('reader.cbz.zoomControls')" role="group">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :disabled="!canZoomOut" :aria-label="t('reader.cbz.zoomOut')" @click="zoomOut">
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:disabled="!canZoomOut"
:aria-label="t('reader.cbz.zoomOut')"
@click="zoomOut"
>
<Minus :size="15" />
</button>
</TooltipTrigger>
@@ -826,7 +921,12 @@ onUnmounted(() => {
<span class="min-w-12 text-center text-xs tabular-nums text-muted-foreground" aria-live="polite">{{ zoomPercent }}%</span>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :disabled="!canZoomIn" :aria-label="t('reader.cbz.zoomIn')" @click="zoomIn">
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:disabled="!canZoomIn"
:aria-label="t('reader.cbz.zoomIn')"
@click="zoomIn"
>
<Plus :size="15" />
</button>
</TooltipTrigger>
@@ -835,7 +935,11 @@ onUnmounted(() => {
</div>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :aria-label="fullscreenLabel" @click="toggleFullscreen">
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="fullscreenLabel"
@click="toggleFullscreen"
>
<Minimize v-if="isFullscreen" :size="15" />
<Maximize v-else :size="15" />
</button>
@@ -846,20 +950,30 @@ onUnmounted(() => {
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn"
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:class="isPinned ? '!bg-muted !text-primary' : ''"
:aria-label="isPinned ? 'Unpin menu' : 'Pin menu'"
:aria-label="t(isPinned ? 'reader.pageImages.unpin' : 'reader.pageImages.pin')"
@click="togglePinned"
>
<PinOff v-if="isPinned" :size="15" />
<Pin v-else :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{ isPinned ? 'Unpin menu' : 'Pin menu' }}</TooltipContent>
<TooltipContent>{{ t(isPinned ? 'reader.pageImages.unpin' : 'reader.pageImages.pin') }}</TooltipContent>
</Tooltip>
<button
v-if="props.source === 'djvu'"
type="button"
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="t('reader.djvu.pageText')"
:title="t('reader.djvu.pageText')"
@click="openPageText"
>
<FileText :size="16" />
</button>
<template v-if="isCompact">
<button
class="viewer-btn"
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:class="showSettings ? '!bg-muted !text-foreground' : ''"
:title="t('reader.settings.title')"
:aria-label="t('reader.settings.ariaLabel')"
@@ -881,7 +995,7 @@ onUnmounted(() => {
<Popover v-else :open="showSettings" @update:open="onSettingsOpenChange">
<PopoverTrigger as-child>
<button
class="viewer-btn"
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:class="showSettings ? '!bg-muted !text-foreground' : ''"
:title="t('reader.settings.title')"
:aria-label="t('reader.settings.ariaLabel')"
@@ -933,11 +1047,14 @@ onUnmounted(() => {
<img
v-if="renderLeftPage !== null"
:src="pageUrl(renderLeftPage)"
:key="`${renderLeftPage}:${imageGeneration}`"
:data-page="renderLeftPage"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
:style="{ maxWidth: '100%', maxHeight: '100%' }"
alt=""
:alt="t('reader.pageImages.pageAlt', { page: renderLeftPage + 1 })"
draggable="false"
@load="onPaginatedImageLoad(renderLeftPage, $event)"
@load="onPaginatedLoad"
@error="onImageError"
/>
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
</div>
@@ -945,11 +1062,14 @@ onUnmounted(() => {
<img
v-if="renderRightPage !== null"
:src="pageUrl(renderRightPage)"
:key="`${renderRightPage}:${imageGeneration}`"
:data-page="renderRightPage"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
:style="{ maxWidth: '100%', maxHeight: '100%' }"
alt=""
:alt="t('reader.pageImages.pageAlt', { page: renderRightPage + 1 })"
draggable="false"
@load="onPaginatedImageLoad(renderRightPage, $event)"
@load="onPaginatedLoad"
@error="onImageError"
/>
<div v-else aria-hidden="true" class="h-[92%] w-[92%] rounded-sm border border-border/60 bg-background/30" />
</div>
@@ -958,10 +1078,13 @@ onUnmounted(() => {
<img
v-else-if="renderSinglePage !== null"
:src="pageUrl(renderSinglePage)"
:key="`${renderSinglePage}:${imageGeneration}`"
:data-page="renderSinglePage"
:class="[imgFitClass, 'pointer-events-none transition-opacity duration-150', currentImageLoaded ? 'opacity-100' : 'opacity-0']"
alt=""
:alt="t('reader.pageImages.pageAlt', { page: renderSinglePage + 1 })"
draggable="false"
@load="onPaginatedImageLoad(renderSinglePage, $event)"
@load="onPaginatedLoad"
@error="onImageError"
/>
</div>
</div>
@@ -982,12 +1105,14 @@ onUnmounted(() => {
>
<img
:src="pageUrl(page.index)"
:key="`${page.index}:${imageGeneration}`"
:class="stripImageClass"
:style="stripImageStyle(page.index)"
alt=""
:alt="t('reader.pageImages.pageAlt', { page: page.index + 1 })"
decoding="async"
draggable="false"
@load="onStripImageLoad(page.index, $event)"
@error="onImageError"
/>
</div>
</div>
@@ -1003,6 +1128,9 @@ onUnmounted(() => {
<!-- ── Footer ──────────────────────────────────────────────────────────── -->
<div
data-reader-controls
@focusin="onControlsFocus"
@focusout="onControlsBlur"
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'"
>
@@ -1010,12 +1138,25 @@ onUnmounted(() => {
<div class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="goToPage(0)"><ChevronsLeft :size="16" /></button>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="t('reader.cbz.firstPage')"
@click="firstPage"
>
<ChevronsLeft :size="16" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.cbz.firstPage') }}</TooltipContent>
</Tooltip>
</div>
<button class="viewer-btn" :disabled="!canGoPrev" @click="prevPage"><ChevronLeft :size="16" /></button>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:disabled="!canGoPrev"
:aria-label="t('reader.pageImages.previousPage')"
@click="prevPage"
>
<ChevronLeft :size="16" />
</button>
<div class="relative flex-1 min-w-0 flex items-center h-6">
<input
@@ -1029,18 +1170,32 @@ onUnmounted(() => {
accentColor: 'var(--primary)',
background: `linear-gradient(to right, var(--primary) ${sliderFillPercent}%, var(--border) ${sliderFillPercent}%)`,
}"
@input="goToPage(Number(($event.target as HTMLInputElement).value))"
:aria-label="t('reader.pageImages.goToPage')"
@input="onPageSlider"
/>
<datalist id="cbz-ticks">
<option v-for="t in sliderTicks" :key="t" :value="t" />
</datalist>
</div>
<button class="viewer-btn" :disabled="!canGoNext" @click="nextPage"><ChevronRight :size="16" /></button>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:disabled="!canGoNext"
:aria-label="t('reader.pageImages.nextPage')"
@click="nextPage"
>
<ChevronRight :size="16" />
</button>
<div class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="goToPage(pageCount - 1)"><ChevronsRight :size="16" /></button>
<button
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
:aria-label="t('reader.cbz.lastPage')"
@click="lastPage"
>
<ChevronsRight :size="16" />
</button>
</TooltipTrigger>
<TooltipContent>{{ t('reader.cbz.lastPage') }}</TooltipContent>
</Tooltip>
@@ -1061,9 +1216,44 @@ onUnmounted(() => {
</div>
<div v-if="error" class="absolute inset-0 flex items-center justify-center z-50 p-8 text-center bg-background">
<p class="text-sm text-foreground">{{ error }}</p>
<div class="flex flex-col items-center gap-4" role="alert">
<p class="text-sm text-foreground">{{ t(error) }}</p>
<button
type="button"
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
@click="initializeReader"
>
{{ t('common.retry') }}
</button>
<button type="button" class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2" @click="goBack">
{{ t('common.back') }}
</button>
</div>
</div>
<div v-if="imageError && !error && !loading" class="absolute inset-0 z-40 flex items-center justify-center bg-background/95 p-8">
<div class="flex flex-col items-center gap-4 text-center" role="alert">
<p class="text-sm text-foreground">{{ t('reader.pageImages.pageFailed') }}</p>
<button
type="button"
class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2"
@click="retryImages"
>
{{ t('common.retry') }}
</button>
<button type="button" class="viewer-btn focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2" @click="goBack">
{{ t('common.back') }}
</button>
</div>
</div>
<DjvuTextSheet
v-if="props.source === 'djvu'"
:open="showPageText"
:file-id="props.fileId"
:page-index="currentPage"
@update:open="onTextOpenChange"
/>
<!-- Progress bar -->
<div v-if="!loading && !error && pageCount > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-border z-30">
<div class="h-full bg-primary/60 transition-all duration-300" :style="{ width: `${progressPercent}%` }" />
@@ -0,0 +1,84 @@
import { effectScope } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useCbz } from './useCbz'
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof api>() }))
describe('page image document loading', () => {
const scopes: ReturnType<typeof effectScope>[] = []
beforeEach(() => vi.resetAllMocks())
afterEach(() => {
for (const scope of scopes.splice(0)) scope.stop()
})
function reader(source: 'djvu' | 'cbz' = 'djvu') {
const scope = effectScope()
scopes.push(scope)
return scope.run(() => useCbz(22, 11, source))!
}
it('uses DjVu routes and loads title and primary series', async () => {
vi.mocked(api)
.mockResolvedValueOnce(new Response(JSON.stringify({ pageCount: 2 })))
.mockResolvedValueOnce(new Response(JSON.stringify({ title: 'Scanned book', seriesMemberships: [{ displayOrder: 1, seriesId: 9 }] })))
const document = reader()
await document.load()
expect(document.loading.value).toBe(false)
expect(document.error.value).toBeNull()
expect(document.pageCount.value).toBe(2)
expect(document.bookTitle.value).toBe('Scanned book')
expect(document.seriesId.value).toBe(9)
expect(document.pageUrl(1)).toBe('/api/v1/djvu/files/22/pages/1?size=2560')
})
it('opens the pages when only the book detail request fails', async () => {
vi.mocked(api)
.mockResolvedValueOnce(new Response(JSON.stringify({ pageCount: 4 })))
.mockResolvedValueOnce(new Response('', { status: 500 }))
const document = reader('cbz')
await document.load()
expect(document.error.value).toBeNull()
expect(document.pageCount.value).toBe(4)
expect(document.bookTitle.value).toBe('')
expect(document.seriesId.value).toBeNull()
})
it('keeps existing comic routes', () => {
expect(reader('cbz').pageUrl(0)).toBe('/api/v1/cbz/files/22/pages/0')
})
it('exposes a translated error key and allows retry after failed requests', async () => {
vi.mocked(api).mockRejectedValue(new Error('offline'))
const document = reader()
await document.load()
expect(document.loading.value).toBe(false)
expect(document.error.value).toBe('reader.pageImages.loadFailed')
vi.mocked(api)
.mockResolvedValueOnce(new Response(JSON.stringify({ pageCount: 3 })))
.mockResolvedValueOnce(new Response(JSON.stringify({ title: 'Restored book' })))
await document.load()
expect(document.error.value).toBeNull()
expect(document.pageCount.value).toBe(3)
})
it('aborts outstanding requests when disposed and ignores late responses', async () => {
const releases: Array<(value: Response) => void> = []
vi.mocked(api).mockImplementation(
() =>
new Promise((resolve) => {
releases.push(resolve)
}),
)
const document = reader()
const loading = document.load()
const firstSignal = vi.mocked(api).mock.calls[0]?.[1]?.signal
scopes[0]!.stop()
expect(firstSignal?.aborted).toBe(true)
releases[0]!(new Response(JSON.stringify({ pageCount: 2 })))
releases[1]!(new Response(JSON.stringify({ title: 'late' })))
await loading
expect(document.bookTitle.value).toBe('')
expect(document.pageCount.value).toBe(0)
})
})
@@ -1,4 +1,4 @@
import { ref } from 'vue'
import { onScopeDispose, ref } from 'vue'
import type { BookDetail, ComicPageCountResponse } from '@bookorbit/types'
import { api } from '@/lib/api'
@@ -9,29 +9,51 @@ function primarySeriesId(book: BookDetail | null): number | null {
return primary?.seriesId ?? book?.seriesId ?? null
}
export function useCbz(fileId: number, bookId: number) {
export function useCbz(fileId: number, bookId: number, source: 'cbz' | 'djvu' = 'cbz') {
const pageCount = ref(0)
const bookTitle = ref('')
const seriesId = ref<number | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
let controller: AbortController | null = null
let disposed = false
onScopeDispose(() => {
disposed = true
controller?.abort()
})
function pageUrl(n: number): string {
return `/api/v1/cbz/files/${fileId}/pages/${n}`
return `/api/v1/${source}/files/${fileId}/pages/${n}${source === 'djvu' ? '?size=2560' : ''}`
}
async function load(): Promise<void> {
const [pagesRes, bookRes] = await Promise.all([api(`/api/v1/cbz/files/${fileId}/pages`), api(`/api/v1/books/${bookId}`)])
if (!pagesRes.ok) {
error.value = 'Failed to load comic'
loading.value = false
return
controller?.abort()
const request = new AbortController()
controller = request
loading.value = true
error.value = null
try {
// The book detail only supplies the title and series; the pages open without it.
const [pagesRes, bookRes] = await Promise.all([
api(`/api/v1/${source}/files/${fileId}/pages`, { signal: request.signal }),
api(`/api/v1/books/${bookId}`, { signal: request.signal }).catch(() => null),
])
if (!pagesRes.ok) throw new Error('Reader request failed')
const [pagesData, bookData] = await Promise.all([
pagesRes.json() as Promise<ComicPageCountResponse>,
bookRes?.ok ? (bookRes.json() as Promise<BookDetail>).catch(() => null) : Promise.resolve(null),
])
if (!Number.isInteger(pagesData.pageCount) || pagesData.pageCount < 1) throw new Error('Invalid page count')
if (disposed || controller !== request) return
pageCount.value = pagesData.pageCount
bookTitle.value = bookData?.title || bookData?.files?.find((file) => file.id === fileId)?.filename?.replace(/\.[^.]+$/, '') || ''
seriesId.value = primarySeriesId(bookData)
} catch {
if (!disposed && controller === request && !request.signal.aborted) error.value = 'reader.pageImages.loadFailed'
} finally {
if (!disposed && controller === request) loading.value = false
}
const [pagesData, bookData] = await Promise.all([pagesRes.json() as Promise<ComicPageCountResponse>, bookRes.ok ? bookRes.json() : null])
pageCount.value = pagesData.pageCount
bookTitle.value = bookData?.title ?? ''
seriesId.value = primarySeriesId(bookData)
loading.value = false
}
return { pageCount, bookTitle, seriesId, loading, error, pageUrl, load }
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDjvuPageText } from './composables/useDjvuPageText'
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
const props = defineProps<{ open: boolean; fileId: number; pageIndex: number }>()
const emit = defineEmits<{ 'update:open': [open: boolean] }>()
const { t } = useI18n()
const { text, loading, failed, loadText } = useDjvuPageText(props)
// Announces state changes only; the page text itself is left for the reader to navigate.
const statusMessage = computed(() => {
if (loading.value) return t('common.loading')
if (failed.value) return t('reader.djvu.textFailed')
if (!text.value) return t('reader.djvu.noText')
return ''
})
function onOpenChange(open: boolean) {
emit('update:open', open)
}
</script>
<template>
<Sheet :open="open" @update:open="onOpenChange">
<SheetContent side="bottom" hide-close class="max-h-[85dvh] flex flex-col bg-card text-foreground">
<SheetHeader>
<SheetTitle>{{ t('reader.djvu.pageText') }}</SheetTitle>
<SheetDescription>{{ t('reader.djvu.textDescription') }}</SheetDescription>
</SheetHeader>
<SheetClose as-child>
<button type="button" class="self-end rounded-md border border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-primary">
{{ t('common.close') }}
</button>
</SheetClose>
<p class="sr-only" role="status" aria-live="polite">{{ statusMessage }}</p>
<div class="min-h-0 overflow-auto select-text p-4">
<p v-if="loading" class="text-muted-foreground">{{ t('common.loading') }}</p>
<div v-else-if="failed" class="space-y-3">
<p>{{ t('reader.djvu.textFailed') }}</p>
<button
type="button"
class="rounded-md border border-border px-3 py-2 focus-visible:outline-2 focus-visible:outline-primary"
@click="loadText"
>
{{ t('common.retry') }}
</button>
</div>
<p v-else-if="!text" class="text-muted-foreground">{{ t('reader.djvu.noText') }}</p>
<p v-else class="whitespace-pre-wrap break-words" dir="auto">{{ text }}</p>
</div>
</SheetContent>
</Sheet>
</template>
@@ -0,0 +1,62 @@
import { effectScope, nextTick, reactive } from 'vue'
import { flushPromises } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useDjvuPageText } from './useDjvuPageText'
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof api>() }))
describe('DjVu embedded page text', () => {
let scope: ReturnType<typeof effectScope>
beforeEach(() => {
scope = effectScope()
vi.resetAllMocks()
})
afterEach(() => scope.stop())
it('loads only while open and preserves multilingual text', async () => {
vi.mocked(api).mockResolvedValue(new Response(JSON.stringify({ text: '日本語の本' })))
const props = reactive({ open: false, fileId: 22, pageIndex: 0 })
const reader = scope.run(() => useDjvuPageText(props))!
expect(api).not.toHaveBeenCalled()
props.open = true
await flushPromises()
expect(api).toHaveBeenCalledWith('/api/v1/djvu/files/22/pages/0/text', expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(reader.text.value).toBe('日本語の本')
expect(reader.loading.value).toBe(false)
expect(reader.failed.value).toBe(false)
})
it('allows retry when decoding is unavailable and accepts pages without OCR', async () => {
vi.mocked(api).mockResolvedValue(new Response('', { status: 503 }))
const reader = scope.run(() => useDjvuPageText(reactive({ open: true, fileId: 22, pageIndex: 0 })))!
await flushPromises()
expect(reader.failed.value).toBe(true)
vi.mocked(api).mockResolvedValue(new Response(JSON.stringify({ text: '' })))
await reader.loadText()
expect(reader.failed.value).toBe(false)
expect(reader.text.value).toBe('')
})
it('aborts stale requests and ignores text from an earlier page', async () => {
let release!: (response: Response) => void
vi.mocked(api)
.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve
}),
)
.mockResolvedValueOnce(new Response(JSON.stringify({ text: 'Current page' })))
const props = reactive({ open: true, fileId: 22, pageIndex: 0 })
const reader = scope.run(() => useDjvuPageText(props))!
const oldSignal = vi.mocked(api).mock.calls[0]![1]!.signal
props.pageIndex = 1
await nextTick()
expect(oldSignal?.aborted).toBe(true)
await flushPromises()
release(new Response(JSON.stringify({ text: 'Stale page' })))
await flushPromises()
expect(reader.text.value).toBe('Current page')
})
})
@@ -0,0 +1,36 @@
import { onScopeDispose, ref, watch } from 'vue'
import type { DjvuPageTextResponse } from '@bookorbit/types'
import { api } from '@/lib/api'
export function useDjvuPageText(props: Readonly<{ open: boolean; fileId: number; pageIndex: number }>) {
const text = ref('')
const loading = ref(false)
const failed = ref(false)
let request: AbortController | null = null
async function loadText() {
request?.abort()
if (!props.open) return
text.value = ''
failed.value = false
const controller = new AbortController()
request = controller
loading.value = true
try {
const response = await api(`/api/v1/djvu/files/${props.fileId}/pages/${props.pageIndex}/text`, { signal: controller.signal })
if (!response.ok) throw new Error('DjVu text request failed')
const data = (await response.json()) as DjvuPageTextResponse
if (typeof data.text !== 'string') throw new Error('Invalid DjVu text response')
if (request === controller && !controller.signal.aborted) text.value = data.text
} catch {
if (request === controller && !controller.signal.aborted) failed.value = true
} finally {
if (request === controller) loading.value = false
}
}
watch(() => [props.open, props.fileId, props.pageIndex], loadText, { immediate: true })
onScopeDispose(() => request?.abort())
return { text, loading, failed, loadText }
}
@@ -21,7 +21,7 @@ describe('useSeriesNextBook', () => {
await load(42, 90)
expect(api).toHaveBeenCalledWith('/api/v1/series/42/books/90/next?formatGroup=cbx')
expect(api).toHaveBeenCalledWith('/api/v1/series/42/books/90/next?formatGroup=cbx&includeDjvu=true')
})
it('exposes the resolved next book', async () => {
@@ -16,7 +16,7 @@ export function useSeriesNextBook(formatGroup: ReaderFormatGroup) {
if (seriesId === null) return
try {
const res = await api(`/api/v1/series/${seriesId}/books/${bookId}/next?formatGroup=${formatGroup}`)
const res = await api(`/api/v1/series/${seriesId}/books/${bookId}/next?formatGroup=${formatGroup}&includeDjvu=true`)
if (!res.ok) return
const data = (await res.json()) as SeriesNextBookResponse
@@ -65,7 +65,8 @@ describe('formatFamily', () => {
it('is case insensitive and falls back for unknown extensions', () => {
expect(formatFamily('EPUB')).toBe('ebook')
expect(formatFamily('djvu')).toBe('other')
expect(formatFamily('DJVU')).toBe('document')
expect(formatFamily('unknown')).toBe('other')
})
})
@@ -13,6 +13,7 @@ const FAMILY_BY_FORMAT: Record<string, FormatFamily> = {
azw3: 'kindle',
azw: 'kindle',
pdf: 'document',
djvu: 'document',
cbz: 'comic',
cbr: 'comic',
cb7: 'comic',
+17
View File
@@ -5099,6 +5099,7 @@
"audiobook": "Audiobook"
},
"formats": {
"djvu": "DjVu document",
"epub": "EPUB e-book",
"kepub": "KEPUB e-book",
"pdf": "PDF document",
@@ -10472,6 +10473,22 @@
}
},
"reader": {
"pageImages": {
"loadFailed": "Could not open this document. Check your access or try again.",
"pageFailed": "This page could not be loaded. Retry when the server is available.",
"pageAlt": "Page {page}",
"previousPage": "Previous page",
"nextPage": "Next page",
"goToPage": "Go to page",
"pin": "Pin reader menu",
"unpin": "Unpin reader menu"
},
"djvu": {
"pageText": "Embedded page text",
"textDescription": "Text stored in the DjVu document. OCR may contain mistakes.",
"noText": "This page has no embedded text.",
"textFailed": "Could not load this page's embedded text."
},
"retry": "Retry",
"pdf": {
"openError": "Unable to open this PDF",