mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user