+
+
+
Unable to open this PDF
+
{{ (engineError || documentError)?.message }}
+
-
-
Go back
-
-
-
-
-
-
- {{ fullscreenLabel }}
-
-
-
-
-
- Peeking
-
+
+
-
+
+
+
+
+ Preparing PDF reader
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfDocumentViewport.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfDocumentViewport.spec.ts
new file mode 100644
index 000000000..b88fdcc6d
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/__tests__/PdfDocumentViewport.spec.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import PdfDocumentViewport from '../components/PdfDocumentViewport.vue'
+
+const passthroughStub = (testId: string) =>
+ defineComponent({
+ inheritAttrs: false,
+ setup(_, { attrs, slots }) {
+ return () => h('div', { ...attrs, 'data-testid': testId }, slots.default?.())
+ },
+ })
+
+const layerStub = (testId: string) =>
+ defineComponent({
+ inheritAttrs: false,
+ setup(_, { attrs }) {
+ return () => h('div', { ...attrs, 'data-testid': testId })
+ },
+ })
+
+describe('PdfDocumentViewport', () => {
+ it('keeps rotation outside the sized pointer layer so page backdrops fill at every zoom', () => {
+ const wrapper = mount(PdfDocumentViewport, {
+ props: { documentId: 'doc-1' },
+ global: {
+ stubs: {
+ GlobalPointerProvider: passthroughStub('global-pointer'),
+ Viewport: passthroughStub('viewport'),
+ ZoomGestureWrapper: passthroughStub('zoom-wrapper'),
+ Scroller: defineComponent({
+ setup(_, { slots }) {
+ return () => h('div', { 'data-testid': 'scroller' }, slots.default?.({ page: { pageIndex: 3 } }))
+ },
+ }),
+ Rotate: passthroughStub('rotate'),
+ PagePointerProvider: passthroughStub('page-pointer'),
+ RenderLayer: layerStub('render'),
+ TilingLayer: layerStub('tiling'),
+ SearchLayer: layerStub('search'),
+ SelectionLayer: layerStub('selection'),
+ AnnotationLayer: layerStub('annotation'),
+ },
+ },
+ })
+
+ const rotate = wrapper.get('[data-testid="rotate"]')
+ const pointer = rotate.get('[data-testid="page-pointer"]')
+
+ expect(pointer.get('[data-testid="render"]').attributes('scale')).toBe('0.5')
+ expect(pointer.find('[data-testid="tiling"]').exists()).toBe(true)
+ expect(pointer.get('[data-testid="search"]').attributes('highlight-color')).toContain('var(--primary)')
+ })
+})
diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfReaderSettingsPanel.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSettingsPanel.spec.ts
new file mode 100644
index 000000000..904f1a609
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSettingsPanel.spec.ts
@@ -0,0 +1,63 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PdfReaderSettingsPanel from '../components/PdfReaderSettingsPanel.vue'
+
+function mountPanel() {
+ return mount(PdfReaderSettingsPanel, {
+ props: {
+ scrollMode: 'vertical',
+ spread: 'none',
+ rotation: 0,
+ zoomMode: 'fit-page',
+ customScale: 1,
+ },
+ })
+}
+
+describe('PdfReaderSettingsPanel', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('debounces zoom previews and persists only the committed slider value', async () => {
+ vi.useFakeTimers()
+ const wrapper = mountPanel()
+ const slider = wrapper.get('input[type="range"]')
+ const element = slider.element as HTMLInputElement
+
+ element.value = '1.5'
+ await slider.trigger('input')
+ element.value = '1.75'
+ await slider.trigger('input')
+
+ await vi.advanceTimersByTimeAsync(99)
+ expect(wrapper.emitted('previewZoom')).toBeUndefined()
+
+ await vi.advanceTimersByTimeAsync(1)
+ expect(wrapper.emitted('previewZoom')).toEqual([[1.75]])
+ expect(wrapper.emitted('setZoom')).toBeUndefined()
+
+ element.value = '2'
+ await slider.trigger('input')
+ await slider.trigger('change')
+ await vi.runAllTimersAsync()
+
+ expect(wrapper.emitted('previewZoom')).toEqual([[1.75]])
+ expect(wrapper.emitted('setZoom')).toEqual([['custom', 2]])
+ })
+
+ it('exposes page, horizontal, automatic zoom, and auto spread choices', async () => {
+ const wrapper = mountPanel()
+ const buttons = wrapper.findAll('button')
+ const clickLabel = async (label: string) => buttons.find((button) => button.text() === label)!.trigger('click')
+
+ await clickLabel('Page')
+ await clickLabel('Horizontal scroll')
+ await clickLabel('Automatic')
+ await clickLabel('Auto')
+
+ expect(wrapper.emitted('setScrollMode')).toEqual([['page'], ['horizontal']])
+ expect(wrapper.emitted('setZoom')).toEqual([['automatic']])
+ expect(wrapper.emitted('setSpread')).toEqual([['auto']])
+ })
+})
diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts
new file mode 100644
index 000000000..34f611cb0
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts
@@ -0,0 +1,231 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { MatchFlag, type SearchResult } from '@embedpdf/models'
+
+const mocks = vi.hoisted(() => ({
+ searchState: {
+ __v_isRef: true,
+ value: {
+ flags: [] as MatchFlag[],
+ results: [] as SearchResult[],
+ total: 0,
+ activeResultIndex: -1,
+ query: '',
+ loading: false,
+ active: false,
+ },
+ },
+ searchScope: {
+ searchAllPages: vi.fn<(query: string) => { onProgress: (callback: (progress: { page: number }) => void) => void } | undefined>(),
+ stopSearch: vi.fn<() => void>(),
+ setFlags: vi.fn<(flags: MatchFlag[]) => void>(),
+ goToResult: vi.fn<(index: number) => void>(),
+ },
+ scrollScope: {
+ scrollToPage: vi.fn<(options: Record
) => void>(),
+ },
+}))
+
+vi.mock('@embedpdf/plugin-search/vue', async () => {
+ const { defineComponent, h } = await import('vue')
+ return {
+ SearchLayer: defineComponent({
+ setup:
+ (_, { slots }) =>
+ () =>
+ h('div', slots.default?.()),
+ }),
+ useSearch: () => ({ state: mocks.searchState, provides: { value: mocks.searchScope } }),
+ }
+})
+
+vi.mock('@embedpdf/plugin-scroll/vue', () => ({
+ useScroll: () => ({
+ state: { __v_isRef: true, value: { currentPage: 1, totalPages: 10 } },
+ provides: { value: mocks.scrollScope },
+ }),
+}))
+
+vi.mock('@embedpdf/plugin-bookmark/vue', () => ({
+ useBookmarkCapability: () => ({ provides: { value: null } }),
+}))
+
+vi.mock('@embedpdf/plugin-annotation/vue', () => ({
+ useAnnotationCapability: () => ({ provides: { value: null } }),
+}))
+
+vi.mock('@embedpdf/plugin-thumbnail/vue', async () => {
+ const { defineComponent, h } = await import('vue')
+ return {
+ ThumbImg: defineComponent({ setup: () => () => h('img') }),
+ ThumbnailsPane: defineComponent({
+ setup:
+ (_, { slots }) =>
+ () =>
+ h('div', slots.default?.()),
+ }),
+ }
+})
+
+vi.mock('vue-virtual-scroller', async () => {
+ const { defineComponent, h } = await import('vue')
+ return {
+ RecycleScroller: defineComponent({
+ inheritAttrs: false,
+ props: { items: { type: Array, required: true } },
+ setup(props, { attrs, slots }) {
+ return () =>
+ h(
+ 'div',
+ { ...attrs, 'data-testid': 'search-results' },
+ props.items.map((item, index) => slots.default?.({ item, index })),
+ )
+ },
+ }),
+ }
+})
+
+import PdfReaderSidebar from '../components/PdfReaderSidebar.vue'
+
+function makeResult(rects: SearchResult['rects'] = []): SearchResult {
+ return {
+ pageIndex: 4,
+ charIndex: 10,
+ charCount: 4,
+ rects,
+ context: {
+ before: 'before',
+ match: 'term',
+ after: 'after',
+ truncatedLeft: false,
+ truncatedRight: false,
+ },
+ }
+}
+
+describe('PdfReaderSidebar', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.clearAllMocks()
+ mocks.searchScope.searchAllPages.mockReset()
+ mocks.searchState.value = {
+ flags: [],
+ results: [],
+ total: 0,
+ activeResultIndex: -1,
+ query: '',
+ loading: false,
+ active: false,
+ }
+ })
+
+ it('debounces full-document searches and cancels superseded work', async () => {
+ const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
+ const input = wrapper.get('input[type="search"]')
+ mocks.searchScope.stopSearch.mockClear()
+
+ await input.setValue('dis')
+ await input.setValue('distance')
+
+ expect(mocks.searchScope.stopSearch).toHaveBeenCalledTimes(2)
+ expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(249)
+ expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(1)
+ expect(mocks.searchScope.searchAllPages).toHaveBeenCalledTimes(1)
+ expect(mocks.searchScope.searchAllPages).toHaveBeenCalledWith('distance')
+ })
+
+ it('does not search for a single character', async () => {
+ const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
+ mocks.searchScope.stopSearch.mockClear()
+
+ await wrapper.get('input[type="search"]').setValue('a')
+ await vi.runAllTimersAsync()
+
+ expect(mocks.searchScope.stopSearch).toHaveBeenCalledOnce()
+ expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
+ expect(wrapper.text()).toContain('Enter at least 2 characters')
+ })
+
+ it('lets the plugin restart an active search once when flags change', async () => {
+ const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
+
+ await wrapper.get('input[type="checkbox"]').setValue(true)
+
+ expect(mocks.searchScope.setFlags).toHaveBeenCalledWith([MatchFlag.MatchCase])
+ expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
+ })
+
+ it('keeps virtual results bounded and navigates results without rectangles safely', async () => {
+ mocks.searchState.value = {
+ ...mocks.searchState.value,
+ results: [makeResult()],
+ total: 1,
+ active: true,
+ }
+ const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
+
+ expect(wrapper.get('aside').classes()).toContain('overflow-hidden')
+ expect(wrapper.get('[data-testid="search-results"]').classes()).toContain('overflow-x-hidden')
+
+ await wrapper.get('[data-testid="search-results"] button').trigger('click')
+
+ expect(mocks.searchScope.goToResult).toHaveBeenCalledWith(0)
+ expect(mocks.scrollScope.scrollToPage).toHaveBeenCalledWith({ pageNumber: 5 })
+ })
+
+ it('reports full-document search progress', async () => {
+ mocks.searchState.value = {
+ ...mocks.searchState.value,
+ query: 'distance',
+ loading: true,
+ }
+ mocks.searchScope.searchAllPages.mockReturnValue({
+ onProgress: (callback: (progress: { page: number }) => void) => callback({ page: 4 }),
+ })
+
+ const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
+ await vi.advanceTimersByTimeAsync(250)
+
+ expect(wrapper.text()).toContain('Searching page 5 of 10')
+ })
+
+ it('uses Enter and Shift+Enter to navigate search results', async () => {
+ mocks.searchState.value = {
+ ...mocks.searchState.value,
+ results: [makeResult(), makeResult()],
+ total: 2,
+ activeResultIndex: 0,
+ active: true,
+ }
+ const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
+ const input = wrapper.get('input[type="search"]')
+
+ await input.trigger('keydown', { key: 'Enter' })
+ expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(1)
+
+ mocks.searchState.value.activeResultIndex = 1
+ await input.trigger('keydown', { key: 'Enter', shiftKey: true })
+ expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(0)
+ })
+
+ it('clears the query, cancels search work, and restores input focus', async () => {
+ const wrapper = mount(PdfReaderSidebar, {
+ props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true },
+ attachTo: document.body,
+ })
+ const input = wrapper.get('input[type="search"]')
+ await input.setValue('distance')
+ mocks.searchScope.stopSearch.mockClear()
+
+ await wrapper.get('button[aria-label="Clear search"]').trigger('click')
+
+ expect((input.element as HTMLInputElement).value).toBe('')
+ expect(mocks.searchScope.stopSearch).toHaveBeenCalled()
+ expect(document.activeElement).toBe(input.element)
+ wrapper.unmount()
+ })
+})
diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts
new file mode 100644
index 000000000..2e6b09baf
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts
@@ -0,0 +1,84 @@
+import { defineComponent, h } from 'vue'
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PdfReaderToolbar from '../components/PdfReaderToolbar.vue'
+
+const passthrough = defineComponent({
+ setup:
+ (_, { slots }) =>
+ () =>
+ h('div', slots.default?.()),
+})
+
+function mountToolbar(overrides: Partial['$props']> = {}) {
+ return mount(PdfReaderToolbar, {
+ props: {
+ currentPageStart: 12,
+ currentPageEnd: 13,
+ totalPages: 240,
+ zoomPercent: 100,
+ sidebarOpen: false,
+ searchOpen: false,
+ settingsOpen: false,
+ panActive: true,
+ fullscreen: false,
+ fullscreenSupported: true,
+ headerPinned: false,
+ ...overrides,
+ },
+ global: {
+ stubs: {
+ Tooltip: passthrough,
+ TooltipTrigger: passthrough,
+ TooltipContent: passthrough,
+ DropdownMenu: passthrough,
+ DropdownMenuTrigger: passthrough,
+ DropdownMenuContent: passthrough,
+ },
+ },
+ })
+}
+
+describe('PdfReaderToolbar', () => {
+ it('shows the current spread and commits a clamped page number', async () => {
+ const wrapper = mountToolbar()
+ const input = wrapper.get('input[aria-label="Current page"]')
+
+ expect(wrapper.text()).toContain('-13')
+ expect(wrapper.text()).toContain('/ 240')
+
+ ;(input.element as HTMLInputElement).value = '999'
+ await input.trigger('change')
+
+ expect(wrapper.emitted('goToPage')).toEqual([[240]])
+ expect((input.element as HTMLInputElement).value).toBe('240')
+ })
+
+ it('keeps search available outside fullscreen and exposes responsive tools', async () => {
+ const wrapper = mountToolbar()
+
+ expect(wrapper.findAll('button[aria-label="Search document"]')).toHaveLength(1)
+ expect(wrapper.find('button[aria-label="More PDF tools"]').exists()).toBe(true)
+
+ const buttons = wrapper.findAll('button')
+ await buttons.find((button) => button.text().trim() === 'Search')!.trigger('click')
+ await buttons.find((button) => button.text().trim() === 'Select text')!.trigger('click')
+ await buttons.find((button) => button.text().trim() === 'Pan')!.trigger('click')
+
+ expect(wrapper.emitted('toggleSearch')).toHaveLength(1)
+ expect(wrapper.emitted('selectTool')).toHaveLength(1)
+ expect(wrapper.emitted('togglePan')).toHaveLength(1)
+ })
+
+ it('shows the header pin only while fullscreen', async () => {
+ const windowed = mountToolbar()
+ expect(windowed.find('button[aria-label="Pin reader header"]').exists()).toBe(false)
+
+ const fullscreen = mountToolbar({ fullscreen: true })
+ const pinButtons = fullscreen.findAll('button[aria-label="Pin reader header"]')
+ expect(pinButtons).toHaveLength(1)
+
+ await pinButtons[0]!.trigger('click')
+ expect(fullscreen.emitted('toggleHeaderPin')).toHaveLength(1)
+ })
+})
diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfV4ReaderView.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfV4ReaderView.spec.ts
index 69162b56c..7ac613a99 100644
--- a/client/src/features/reader/pdf-v4/__tests__/PdfV4ReaderView.spec.ts
+++ b/client/src/features/reader/pdf-v4/__tests__/PdfV4ReaderView.spec.ts
@@ -1,13 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
-import { createPinia, setActivePinia } from 'pinia'
-import { defineComponent, h, nextTick } from 'vue'
-import type { PDFViewerConfig } from '@embedpdf/vue-pdf-viewer'
+import { defineComponent, h, ref } from 'vue'
+import { ScrollStrategy } from '@embedpdf/plugin-scroll'
+import { SpreadMode } from '@embedpdf/plugin-spread'
+import { ZoomMode } from '@embedpdf/plugin-zoom'
+import type { PdfReaderSettings } from '@bookorbit/types'
+
+const mockRouterBack = vi.fn<() => void>()
+const mockRouterReplace = vi.fn<() => Promise>().mockResolvedValue(undefined)
+const mockRoute = {
+ params: { bookId: '42', fileId: '101' },
+ query: {} as Record,
+}
+
+vi.mock('vue-router', () => ({
+ useRoute: () => mockRoute,
+ useRouter: () => ({ back: mockRouterBack, replace: mockRouterReplace }),
+}))
+
+const mockApi = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>())
+vi.mock('@/lib/api', () => ({ api: mockApi }))
+
+const mockSettingsLoad = vi.fn<() => Promise>().mockResolvedValue(undefined)
+const mockUpdateBookSettings = vi.fn<(patch: Partial) => void>()
+const mockEffective = ref({
+ scrollMode: 'page' as const,
+ spread: 'none' as const,
+ zoomMode: 'fit-page' as const,
+ customScale: 1,
+ rotation: 0,
+})
+
+vi.mock('../../shared/composables/useReaderSettings', () => ({
+ useReaderSettings: () => ({
+ load: mockSettingsLoad,
+ effective: mockEffective,
+ updateBookSettings: mockUpdateBookSettings,
+ }),
+}))
const mockProgressLoad = vi.fn<() => Promise>().mockResolvedValue(undefined)
-const mockProgressSave = vi.fn<() => void>()
-const mockProgressPageNumber = { value: 5 }
-const mockProgressPercentage = { value: 25 }
+const mockProgressSave = vi.fn<() => Promise>().mockResolvedValue(undefined)
+const mockProgressPageNumber = ref(5)
+const mockProgressPercentage = ref(25)
vi.mock('../../shared/composables/useReaderProgress', () => ({
useReaderProgress: () => ({
@@ -18,342 +53,247 @@ vi.mock('../../shared/composables/useReaderProgress', () => ({
}),
}))
-const mockSettingsLoad = vi.fn<() => Promise>().mockResolvedValue(undefined)
-const mockUpdateBookSettings = vi.fn<() => void>()
-const mockEffective = {
- value: {
- scrollMode: 'vertical' as string,
- spread: 'none' as string,
- zoomMode: 'fit-page' as string,
- customScale: 1.0,
- rotation: 0 as number,
- },
-}
-
-vi.mock('../../shared/composables/useReaderSettings', () => ({
- useReaderSettings: () => ({
- load: mockSettingsLoad,
- updateBookSettings: mockUpdateBookSettings,
- effective: mockEffective,
- }),
-}))
-
const mockOnActivity = vi.fn<() => void>()
vi.mock('../../shared/composables/useReadingSession', () => ({
useReadingSession: () => ({
onActivity: mockOnActivity,
+ elapsedMinutes: ref(0),
}),
}))
-const requestFullscreenMock = vi.fn<() => Promise>().mockResolvedValue(undefined)
-const exitFullscreenMock = vi.fn<() => Promise>().mockResolvedValue(undefined)
-
-vi.mock('@/lib/api', () => ({
- getAccessToken: () => 'test-token-abc',
+const mockUsePdfiumEngine = vi.fn<() => unknown>(() => ({
+ engine: ref({ id: 'engine' }),
+ isLoading: ref(false),
+ error: ref(null),
+}))
+vi.mock('@embedpdf/engines/vue', () => ({
+ usePdfiumEngine: mockUsePdfiumEngine,
}))
-let capturedConfig: PDFViewerConfig | null = null
-let readyCallback: ((registry: unknown) => void) | null = null
-let initCallback: ((container: unknown) => void) | null = null
+const mockCreatePluginRegistration = vi.fn<(pluginPackage: unknown, config?: unknown) => { pluginPackage: unknown; config?: unknown }>(
+ (pluginPackage, config) => ({ pluginPackage, config }),
+)
+const mockOpenDocumentBuffer = vi.hoisted(() => vi.fn<(options: unknown) => unknown>())
+const mockCloseDocument = vi.hoisted(() => vi.fn<(documentId: string) => unknown>())
+const mockGetDocumentState = vi.hoisted(() => vi.fn<(documentId: string) => unknown>())
+const mockDocumentManager = {
+ openDocumentBuffer: mockOpenDocumentBuffer,
+ closeDocument: mockCloseDocument,
+ getDocumentState: mockGetDocumentState,
+}
+const mockRegistry = {
+ pluginsReady: vi.fn<() => Promise>().mockResolvedValue(undefined),
+ isDestroyed: vi.fn<() => boolean>().mockReturnValue(false),
+ getPlugin: vi.fn<() => { provides: () => typeof mockDocumentManager }>(() => ({ provides: () => mockDocumentManager })),
+}
+vi.mock('@embedpdf/core', async (importOriginal) => {
+ const original = await importOriginal()
+ return { ...original, createPluginRegistration: mockCreatePluginRegistration }
+})
-vi.mock('@embedpdf/vue-pdf-viewer', () => ({
- PDFViewer: defineComponent({
- props: ['config'],
- emits: ['ready', 'init'],
- setup(props, { emit }) {
- capturedConfig = props.config
- readyCallback = (registry: unknown) => emit('ready', registry)
- initCallback = (container: unknown) => emit('init', container)
- return () => h('div', { class: 'mock-pdf-viewer' })
+vi.mock('@embedpdf/core/vue', () => ({
+ EmbedPDF: defineComponent({
+ props: ['engine', 'plugins', 'onInitialized'],
+ setup(props, { slots }) {
+ void props.onInitialized?.(mockRegistry)
+ return () =>
+ h(
+ 'div',
+ { class: 'mock-embed-pdf' },
+ slots.default?.({ pluginsReady: true, activeDocumentId: 'document-1', activeDocument: { status: 'loaded' } }),
+ )
},
}),
- ScrollPlugin: { id: 'scroll' },
- ScrollStrategy: { Vertical: 'vertical', Horizontal: 'horizontal' },
- SpreadMode: { None: 'none', Odd: 'odd', Even: 'even' },
- ZoomMode: { Automatic: 'automatic', FitPage: 'fit-page', FitWidth: 'fit-width' },
}))
-// Must import AFTER mocks are set up
const { default: PdfV4ReaderView } = await import('../PdfV4ReaderView.vue')
describe('PdfV4ReaderView', () => {
beforeEach(() => {
vi.clearAllMocks()
- capturedConfig = null
- readyCallback = null
- initCallback = null
- document.documentElement.className = ''
- localStorage.clear()
- setActivePinia(createPinia())
- requestFullscreenMock.mockClear()
- exitFullscreenMock.mockClear()
- Object.defineProperty(document.documentElement, 'requestFullscreen', {
- configurable: true,
- value: requestFullscreenMock,
- })
- Object.defineProperty(document, 'exitFullscreen', {
- configurable: true,
- value: exitFullscreenMock,
- })
+ mockSettingsLoad.mockResolvedValue(undefined)
+ mockApi.mockReset()
+ mockApi.mockImplementation(async () => new Response(new Uint8Array([37, 80, 68, 70]), { status: 200 }))
+ mockOpenDocumentBuffer.mockReset()
+ mockOpenDocumentBuffer.mockImplementation(() => ({
+ toPromise: async () => ({
+ documentId: 'document-1',
+ task: { toPromise: async () => ({ pageCount: 12 }) },
+ }),
+ }))
+ mockCloseDocument.mockReset()
+ mockCloseDocument.mockImplementation(() => ({ toPromise: async () => undefined }))
+ mockGetDocumentState.mockReset()
+ mockRegistry.pluginsReady.mockResolvedValue(undefined)
+ mockRegistry.isDestroyed.mockReturnValue(false)
+ mockRegistry.getPlugin.mockReturnValue({ provides: () => mockDocumentManager })
+ mockRoute.query = {}
+ mockProgressPageNumber.value = 5
+ mockProgressPercentage.value = 25
+ mockEffective.value = {
+ scrollMode: 'page',
+ spread: 'none',
+ zoomMode: 'fit-page',
+ customScale: 1,
+ rotation: 0,
+ }
})
- async function mountComponent() {
- const pinia = createPinia()
+ async function mountReader(peekMode = false) {
const wrapper = mount(PdfV4ReaderView, {
- props: { bookId: 42, fileId: 101 },
+ props: { bookId: 42, fileId: 101, peekMode },
global: {
- plugins: [pinia],
stubs: {
- Tooltip: { template: '
' },
- TooltipTrigger: { template: '
' },
- TooltipContent: { template: '
' },
+ PdfReaderContent: {
+ name: 'PdfReaderContent',
+ props: ['documentId', 'initialPage', 'settings', 'peekMode'],
+ emits: ['back', 'pageChange', 'retry', 'settingsChange', 'startReading'],
+ template: '',
+ },
},
},
})
await flushPromises()
- await nextTick()
return wrapper
}
- describe('initialization', () => {
- it('loads book settings on mount', async () => {
- await mountComponent()
- expect(mockSettingsLoad).toHaveBeenCalled()
+ it('loads settings and progress before rendering document content', async () => {
+ const wrapper = await mountReader()
+
+ expect(mockSettingsLoad).toHaveBeenCalledOnce()
+ expect(mockProgressLoad).toHaveBeenCalledOnce()
+ expect(wrapper.find('.mock-reader-content').exists()).toBe(true)
+ })
+
+ it('opens the verified document buffer after every plugin is initialized', async () => {
+ await mountReader()
+
+ const documentConfig = mockCreatePluginRegistration.mock.calls[0]?.[1] as {
+ maxDocuments: number
+ }
+ expect(documentConfig.maxDocuments).toBe(1)
+ expect(documentConfig).not.toHaveProperty('initialDocuments')
+ expect(mockRegistry.pluginsReady).toHaveBeenCalledOnce()
+ expect(mockOpenDocumentBuffer).toHaveBeenCalledWith({
+ name: 'PDF',
+ buffer: expect.any(ArrayBuffer),
+ rotation: 0,
})
+ expect(mockApi).toHaveBeenCalledWith('/api/v1/books/files/101/serve', { signal: expect.any(AbortSignal) })
+ })
- it('renders viewer only after config is ready', async () => {
- const wrapper = await mountComponent()
- expect(wrapper.find('.mock-pdf-viewer').exists()).toBe(true)
- })
+ it('retries a zero-page engine result once before showing an error', async () => {
+ mockOpenDocumentBuffer.mockImplementation(() => ({
+ toPromise: async () => ({
+ documentId: 'document-zero',
+ task: { toPromise: async () => ({ pageCount: 0 }) },
+ }),
+ }))
- it('requests fullscreen from the floating fullscreen control', async () => {
- const wrapper = await mountComponent()
- await wrapper.get('button[aria-label="Enter fullscreen"]').trigger('click')
- await flushPromises()
+ const wrapper = await mountReader()
- expect(requestFullscreenMock).toHaveBeenCalled()
+ expect(mockOpenDocumentBuffer).toHaveBeenCalledTimes(2)
+ expect(mockCloseDocument).toHaveBeenCalledTimes(2)
+ expect(wrapper.text()).toContain('The PDF engine opened this document without any pages.')
+ })
+
+ it('shows a recoverable error when the PDF response is unsuccessful', async () => {
+ mockApi.mockResolvedValueOnce(new Response(null, { status: 503 }))
+
+ const wrapper = await mountReader()
+
+ expect(wrapper.text()).toContain('The PDF request failed with status 503.')
+ expect(wrapper.text()).toContain('Retry')
+ })
+
+ it('rejects an empty PDF response instead of mounting a zero-page document', async () => {
+ mockApi.mockResolvedValueOnce(new Response(null, { status: 200 }))
+
+ const wrapper = await mountReader()
+
+ expect(wrapper.text()).toContain('The PDF request returned an empty document.')
+ expect(wrapper.find('.mock-embed-pdf').exists()).toBe(false)
+ })
+
+ it('refetches the PDF when document content requests recovery', async () => {
+ const wrapper = await mountReader()
+ mockApi.mockClear()
+
+ wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('retry')
+ await flushPromises()
+
+ expect(mockApi).toHaveBeenCalledOnce()
+ expect(wrapper.find('.mock-reader-content').exists()).toBe(true)
+ })
+
+ it('runs PDFium in a worker with an absolute WASM URL', async () => {
+ await mountReader()
+
+ expect(mockUsePdfiumEngine).toHaveBeenCalledWith({
+ wasmUrl: expect.stringMatching(/^https?:\/\//),
+ worker: true,
})
})
- describe('config construction', () => {
- it('sets tabBar to never', async () => {
- await mountComponent()
- expect(capturedConfig?.tabBar).toBe('never')
- })
+ it('shows a recoverable error when reader state fails to load', async () => {
+ mockSettingsLoad.mockRejectedValueOnce(new Error('Settings unavailable'))
- it('disables annotation, signature, form, print, export, insert, and redaction categories', async () => {
- await mountComponent()
- expect(capturedConfig?.disabledCategories).toEqual(
- expect.arrayContaining(['annotation', 'signature', 'form', 'document-print', 'export', 'insert', 'redaction']),
- )
- })
+ const wrapper = await mountReader()
- it('includes auth header in document request options', async () => {
- await mountComponent()
- const docs = capturedConfig?.documentManager?.initialDocuments
- expect(docs).toHaveLength(1)
- const doc = docs?.[0] as Record | undefined
- expect((doc?.requestOptions as Record)?.headers).toEqual({
- Authorization: 'Bearer test-token-abc',
- })
- })
-
- it('sets document URL using fileId prop', async () => {
- await mountComponent()
- const docs = capturedConfig?.documentManager?.initialDocuments
- const doc = docs?.[0] as Record | undefined
- expect(doc?.url).toBe('/api/v1/books/files/101/serve')
- })
-
- it('maps scroll mode from settings', async () => {
- mockEffective.value.scrollMode = 'horizontal'
- await mountComponent()
- expect(capturedConfig?.scroll?.defaultStrategy).toBe('horizontal')
- mockEffective.value.scrollMode = 'vertical'
- })
-
- it('maps spread mode from settings', async () => {
- mockEffective.value.spread = 'odd'
- await mountComponent()
- expect(capturedConfig?.spread?.defaultSpreadMode).toBe('odd')
- mockEffective.value.spread = 'none'
- })
-
- it('maps fit-page zoom from settings', async () => {
- mockEffective.value.zoomMode = 'fit-page'
- await mountComponent()
- expect(capturedConfig?.zoom?.defaultZoomLevel).toBe('fit-page')
- })
-
- it('maps custom zoom scale from settings', async () => {
- mockEffective.value.zoomMode = 'custom'
- mockEffective.value.customScale = 1.5
- await mountComponent()
- expect(capturedConfig?.zoom?.defaultZoomLevel).toBe(1.5)
- mockEffective.value.zoomMode = 'fit-page'
- mockEffective.value.customScale = 1.0
- })
+ expect(wrapper.text()).toContain('Unable to open this PDF')
+ expect(wrapper.text()).toContain('Settings unavailable')
+ expect(wrapper.text()).toContain('Retry')
+ expect(wrapper.find('.mock-embed-pdf').exists()).toBe(false)
})
- describe('theme configuration', () => {
- it('sets light preference when not in dark mode', async () => {
- localStorage.setItem('theme', JSON.stringify('light'))
- await mountComponent()
- expect(capturedConfig?.theme?.preference).toBe('light')
- })
+ it('maps persisted layout settings into headless plugin configuration', async () => {
+ mockEffective.value = {
+ scrollMode: 'horizontal',
+ spread: 'odd',
+ zoomMode: 'fit-width',
+ customScale: 1.5,
+ rotation: 0,
+ }
+ await mountReader()
- it('sets dark preference when in dark mode', async () => {
- document.documentElement.classList.add('dark')
- await mountComponent()
- expect(capturedConfig?.theme?.preference).toBe('dark')
- })
-
- it('includes accent color overrides from theme store', async () => {
- await mountComponent()
- expect(capturedConfig?.theme?.light?.accent?.primary).toBeDefined()
- expect(capturedConfig?.theme?.dark?.accent?.primary).toBeDefined()
- expect(capturedConfig?.theme?.light?.accent?.primary).toBe(capturedConfig?.theme?.dark?.accent?.primary)
- })
-
- it('syncs theme on class attribute change via setTheme', async () => {
- const mockSetTheme = vi.fn<() => void>()
- const mockContainer = { setTheme: mockSetTheme }
-
- await mountComponent()
- initCallback?.(mockContainer)
-
- const mockRegistry = {
- getPlugin: () => ({
- provides: () => ({
- onLayoutReady: () => () => {},
- onPageChange: () => () => {},
- onStateChange: () => () => {},
- scrollToPage: vi.fn<() => void>(),
- }),
- }),
- }
- readyCallback?.(mockRegistry)
- await flushPromises()
-
- document.documentElement.classList.add('dark')
- await flushPromises()
- await new Promise((r) => setTimeout(r, 50))
-
- expect(mockSetTheme).toHaveBeenCalledWith(expect.objectContaining({ preference: 'dark' }))
- })
+ expect(mockCreatePluginRegistration.mock.calls[2]?.[1]).toMatchObject({ defaultStrategy: ScrollStrategy.Horizontal })
+ expect(mockCreatePluginRegistration.mock.calls[4]?.[1]).toMatchObject({ defaultZoomLevel: ZoomMode.FitWidth })
+ expect(mockCreatePluginRegistration.mock.calls[6]?.[1]).toMatchObject({ defaultSpreadMode: SpreadMode.Odd })
})
- describe('progress tracking', () => {
- it('loads progress when viewer becomes ready', async () => {
- await mountComponent()
- const mockRegistry = {
- getPlugin: () => ({
- provides: () => ({
- onLayoutReady: vi.fn<() => void>(),
- onPageChange: vi.fn<() => void>(),
- onStateChange: vi.fn<() => void>(),
- }),
- }),
- }
- readyCallback?.(mockRegistry)
- await flushPromises()
- expect(mockProgressLoad).toHaveBeenCalled()
- })
+ it('uses a deep-linked page before saved progress', async () => {
+ mockRoute.query = { page: '12' }
+ const wrapper = await mountReader()
- it('scrolls to saved page on layout ready', async () => {
- const mockScrollToPage = vi.fn<() => void>()
- const layoutReadyHandlers: ((event: { isInitial: boolean; totalPages: number }) => void)[] = []
- const mockRegistry = {
- getPlugin: () => ({
- provides: () => ({
- onLayoutReady: (handler: (e: { isInitial: boolean; totalPages: number }) => void) => {
- layoutReadyHandlers.push(handler)
- return () => {}
- },
- onPageChange: () => () => {},
- onStateChange: () => () => {},
- scrollToPage: mockScrollToPage,
- }),
- }),
- }
-
- await mountComponent()
- readyCallback?.(mockRegistry)
- await flushPromises()
-
- layoutReadyHandlers[0]?.({ isInitial: true, totalPages: 20 })
- expect(mockScrollToPage).toHaveBeenCalledWith({ pageNumber: 5 })
- })
-
- it('does not scroll if saved page is 1', async () => {
- mockProgressPageNumber.value = 1
- const mockScrollToPage = vi.fn<() => void>()
- const layoutReadyHandlers: ((event: { isInitial: boolean; totalPages: number }) => void)[] = []
- const mockRegistry = {
- getPlugin: () => ({
- provides: () => ({
- onLayoutReady: (handler: (e: { isInitial: boolean; totalPages: number }) => void) => {
- layoutReadyHandlers.push(handler)
- return () => {}
- },
- onPageChange: () => () => {},
- onStateChange: () => () => {},
- scrollToPage: mockScrollToPage,
- }),
- }),
- }
-
- await mountComponent()
- readyCallback?.(mockRegistry)
- await flushPromises()
-
- layoutReadyHandlers[0]?.({ isInitial: true, totalPages: 20 })
- expect(mockScrollToPage).not.toHaveBeenCalled()
- mockProgressPageNumber.value = 5
- })
-
- it('calls onActivity on page change', async () => {
- const pageChangeHandlers: ((event: { pageNumber: number; totalPages: number }) => void)[] = []
- const mockRegistry = {
- getPlugin: () => ({
- provides: () => ({
- onLayoutReady: () => () => {},
- onPageChange: (handler: (e: { pageNumber: number; totalPages: number }) => void) => {
- pageChangeHandlers.push(handler)
- return () => {}
- },
- onStateChange: () => () => {},
- scrollToPage: vi.fn<() => void>(),
- }),
- }),
- }
-
- await mountComponent()
- readyCallback?.(mockRegistry)
- await flushPromises()
-
- pageChangeHandlers[0]?.({ pageNumber: 3, totalPages: 10 })
- expect(mockOnActivity).toHaveBeenCalled()
- })
+ expect(wrapper.getComponent({ name: 'PdfReaderContent' }).props('initialPage')).toBe(12)
})
- describe('cleanup', () => {
- it('disconnects mutation observer on unmount', async () => {
- const wrapper = await mountComponent()
- const mockRegistry = {
- getPlugin: () => ({
- provides: () => ({
- onLayoutReady: () => () => {},
- onPageChange: () => () => {},
- onStateChange: () => () => {},
- scrollToPage: vi.fn<() => void>(),
- }),
- }),
- }
- readyCallback?.(mockRegistry)
- await flushPromises()
+ it('debounces page progress saves', async () => {
+ vi.useFakeTimers()
+ const wrapper = await mountReader()
+ wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('pageChange', 8, 20)
+ await vi.advanceTimersByTimeAsync(2000)
- expect(() => wrapper.unmount()).not.toThrow()
- })
+ expect(mockProgressPageNumber.value).toBe(8)
+ expect(mockProgressPercentage.value).toBe(40)
+ expect(mockProgressSave).toHaveBeenCalledOnce()
+ expect(mockOnActivity).toHaveBeenCalledOnce()
+ vi.useRealTimers()
+ })
+
+ it('keeps peek mode out of tracked progress and starts reading on request', async () => {
+ const wrapper = await mountReader(true)
+ wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('startReading')
+ await flushPromises()
+
+ expect(mockRouterReplace).toHaveBeenCalledWith({ name: 'reader', params: mockRoute.params, query: {} })
+ expect(mockProgressSave).toHaveBeenCalledOnce()
+ expect(mockOnActivity).toHaveBeenCalledOnce()
+ })
+
+ it('routes back from the BookOrbit reader shell', async () => {
+ const wrapper = await mountReader()
+ wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('back')
+ expect(mockRouterBack).toHaveBeenCalledOnce()
})
})
diff --git a/client/src/features/reader/pdf-v4/__tests__/embedpdf-lifecycle.spec.ts b/client/src/features/reader/pdf-v4/__tests__/embedpdf-lifecycle.spec.ts
new file mode 100644
index 000000000..5284d9e99
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/__tests__/embedpdf-lifecycle.spec.ts
@@ -0,0 +1,27 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import { defineComponent, h } from 'vue'
+import { EmbedPDF } from '@embedpdf/core/vue'
+import type { PdfEngine } from '@embedpdf/models'
+
+describe('patched EmbedPDF lifecycle', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('registers teardown before asynchronous plugin initialization completes', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const engine = {} as PdfEngine
+ const Host = defineComponent({
+ setup() {
+ return () => h(EmbedPDF, { engine, plugins: [] }, () => h('div', 'reader'))
+ },
+ })
+
+ const wrapper = mount(Host)
+ await flushPromises()
+ wrapper.unmount()
+
+ expect(warn.mock.calls.flat().join(' ')).not.toContain('onBeforeUnmount is called when there is no active component instance')
+ })
+})
diff --git a/client/src/features/reader/pdf-v4/__tests__/embedpdf-worker.spec.ts b/client/src/features/reader/pdf-v4/__tests__/embedpdf-worker.spec.ts
new file mode 100644
index 000000000..91c3056f0
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/__tests__/embedpdf-worker.spec.ts
@@ -0,0 +1,53 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { PdfErrorCode } from '@embedpdf/models'
+import { createPdfiumEngine } from '@embedpdf/engines/pdfium-worker-engine'
+
+class FailingWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null
+ onerror: ((event: ErrorEvent) => void) | null = null
+ private readonly listeners = new Map>()
+
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
+ const listeners = this.listeners.get(type) ?? new Set()
+ listeners.add(listener)
+ this.listeners.set(type, listeners)
+ }
+
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject) {
+ this.listeners.get(type)?.delete(listener)
+ }
+
+ postMessage(message: { type?: string }) {
+ if (message.type !== 'wasmInit') return
+ queueMicrotask(() => {
+ const event = new MessageEvent('message', { data: { type: 'wasmError', error: 'WASM unavailable' } })
+ for (const listener of this.listeners.get('message') ?? []) {
+ if (typeof listener === 'function') listener(event)
+ else listener.handleEvent(event)
+ }
+ })
+ }
+
+ terminate() {}
+}
+
+describe('patched PDFium worker initialization', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('rejects queued document work instead of hanging on a WASM error', async () => {
+ vi.stubGlobal('Worker', FailingWorker)
+ vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:pdf-worker')
+ const engine = createPdfiumEngine('https://bookorbit.test/pdfium.wasm', { fontFallback: null })
+
+ const task = engine.openDocumentBuffer({ id: 'doc-1', content: new ArrayBuffer(0) })
+
+ await expect(task.toPromise()).rejects.toMatchObject({
+ reason: {
+ code: PdfErrorCode.Initialization,
+ message: 'Worker initialization failed',
+ },
+ })
+ })
+})
diff --git a/client/src/features/reader/pdf-v4/__tests__/pdf-pagination.spec.ts b/client/src/features/reader/pdf-v4/__tests__/pdf-pagination.spec.ts
new file mode 100644
index 000000000..2d3462683
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/__tests__/pdf-pagination.spec.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import { Rotation, type PdfPageObjectWithRotatedSize } from '@embedpdf/models'
+import type { PageVisibilityMetrics } from '@embedpdf/plugin-scroll'
+import { canTurnFromVisiblePage, findAdjacentPage, findPageRange, shouldUseResponsiveSpread } from '../pdf-pagination'
+
+function page(index: number, width = 600, height = 800): PdfPageObjectWithRotatedSize {
+ return {
+ index,
+ size: { width, height },
+ rotatedSize: { width, height },
+ rotation: Rotation.Degree0,
+ objectNumber: index + 1,
+ }
+}
+
+function visibility(pageNumber: number, pageX: number, pageY: number, visibleWidth: number, visibleHeight: number): PageVisibilityMetrics {
+ return {
+ pageNumber,
+ viewportX: 0,
+ viewportY: 0,
+ visiblePercentage: 100,
+ original: { pageX, pageY, visibleWidth, visibleHeight, scale: 1 },
+ scaled: { pageX, pageY, visibleWidth, visibleHeight, scale: 1 },
+ }
+}
+
+describe('PDF pagination helpers', () => {
+ const spreads = [[page(0)], [page(1), page(2)], [page(3), page(4)]]
+
+ it('finds the visible logical spread for any page in it', () => {
+ expect(findPageRange(spreads, 3, 5)).toEqual({ start: 2, end: 3 })
+ expect(findPageRange(spreads, 5, 5)).toEqual({ start: 4, end: 5 })
+ })
+
+ it('moves by logical spreads and stops at document boundaries', () => {
+ expect(findAdjacentPage(spreads, 1, 1, 5)).toBe(2)
+ expect(findAdjacentPage(spreads, 2, 1, 5)).toBe(4)
+ expect(findAdjacentPage(spreads, 4, 1, 5)).toBe(4)
+ expect(findAdjacentPage(spreads, 1, -1, 5)).toBe(1)
+ })
+
+ it('requires an enlarged page to reach the relevant pan boundary before turning', () => {
+ const range = { start: 1, end: 1 }
+ expect(canTurnFromVisiblePage(1, range, [visibility(1, 0, 100, 600, 600)], spreads)).toBe(false)
+ expect(canTurnFromVisiblePage(1, range, [visibility(1, 0, 200, 600, 600)], spreads)).toBe(true)
+ expect(canTurnFromVisiblePage(1, range, [visibility(1, 100, 0, 500, 800)], spreads, 'horizontal')).toBe(true)
+ expect(canTurnFromVisiblePage(-1, range, [visibility(1, 20, 0, 500, 800)], spreads, 'horizontal')).toBe(false)
+ })
+
+ it('uses responsive spreads only for sufficiently wide landscape viewports', () => {
+ expect(shouldUseResponsiveSpread(1200, 800)).toBe(true)
+ expect(shouldUseResponsiveSpread(800, 1200)).toBe(false)
+ expect(shouldUseResponsiveSpread(850, 700)).toBe(false)
+ })
+})
diff --git a/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts b/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts
index a632f1e87..ad9dba8d2 100644
--- a/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts
+++ b/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts
@@ -1,26 +1,71 @@
import { describe, expect, it } from 'vitest'
-import { lookupAccentHex } from '../pdf-viewer-utils'
+import { Rotation } from '@embedpdf/models'
+import { ScrollStrategy } from '@embedpdf/plugin-scroll'
+import { SpreadMode } from '@embedpdf/plugin-spread'
+import { ZoomMode } from '@embedpdf/plugin-zoom'
+import type { PdfReaderSettings } from '@bookorbit/types'
+import { flattenPdfBookmarks, fromRotation, safeExternalPdfUrl, toRotation, toScrollStrategy, toSpreadMode, toZoomLevel } from '../pdf-viewer-utils'
-describe('lookupAccentHex', () => {
- const options = [
- { id: 'rose', label: 'Rose', color: '#e11d48' },
- { id: 'blue', label: 'Blue', color: '#2563eb' },
- { id: 'green', label: 'Green', color: '#16a34a' },
- ]
+const settings: PdfReaderSettings = {
+ scrollMode: 'vertical',
+ spread: 'none',
+ zoomMode: 'fit-page',
+ customScale: 1,
+ rotation: 0,
+}
- it('returns color for matching accent id', () => {
- expect(lookupAccentHex('rose', options)).toBe('#e11d48')
+describe('PDF viewer settings mapping', () => {
+ it('maps page mode to the vertical strategy used by paginated navigation', () => {
+ expect(toScrollStrategy('page')).toBe(ScrollStrategy.Vertical)
+ expect(toScrollStrategy('horizontal')).toBe(ScrollStrategy.Horizontal)
})
- it('returns color for another accent id', () => {
- expect(lookupAccentHex('green', options)).toBe('#16a34a')
+ it('maps spread modes', () => {
+ expect(toSpreadMode('none')).toBe(SpreadMode.None)
+ expect(toSpreadMode('odd')).toBe(SpreadMode.Odd)
+ expect(toSpreadMode('even')).toBe(SpreadMode.Even)
+ expect(toSpreadMode('auto')).toBe(SpreadMode.None)
})
- it('returns fallback blue for unknown accent id', () => {
- expect(lookupAccentHex('unknown', options)).toBe('#3b82f6')
+ it('maps fit and custom zoom levels', () => {
+ expect(toZoomLevel(settings)).toBe(ZoomMode.FitPage)
+ expect(toZoomLevel({ ...settings, zoomMode: 'fit-width' })).toBe(ZoomMode.FitWidth)
+ expect(toZoomLevel({ ...settings, zoomMode: 'automatic' })).toBe(ZoomMode.Automatic)
+ expect(toZoomLevel({ ...settings, zoomMode: 'custom', customScale: 1.75 })).toBe(1.75)
})
- it('returns fallback blue for empty options', () => {
- expect(lookupAccentHex('rose', [])).toBe('#3b82f6')
+ it('round trips persisted rotation values', () => {
+ expect(toRotation(0)).toBe(Rotation.Degree0)
+ expect(toRotation(90)).toBe(Rotation.Degree90)
+ expect(toRotation(180)).toBe(Rotation.Degree180)
+ expect(toRotation(270)).toBe(Rotation.Degree270)
+ expect(fromRotation(Rotation.Degree270)).toBe(270)
+ })
+})
+
+describe('flattenPdfBookmarks', () => {
+ it('preserves hierarchy depth in document order', () => {
+ const result = flattenPdfBookmarks([
+ {
+ title: 'Part one',
+ children: [{ title: 'Chapter one' }, { title: 'Chapter two', children: [{ title: 'Section' }] }],
+ },
+ ])
+
+ expect(result.map(({ bookmark, depth }) => [bookmark.title, depth])).toEqual([
+ ['Part one', 0],
+ ['Chapter one', 1],
+ ['Chapter two', 1],
+ ['Section', 2],
+ ])
+ })
+})
+
+describe('safeExternalPdfUrl', () => {
+ it('allows only HTTP and HTTPS links', () => {
+ expect(safeExternalPdfUrl('https://example.com/reference')?.hostname).toBe('example.com')
+ expect(safeExternalPdfUrl('http://example.com')?.protocol).toBe('http:')
+ expect(safeExternalPdfUrl('javascript:alert(1)')).toBeNull()
+ expect(safeExternalPdfUrl('mailto:test@example.com')).toBeNull()
})
})
diff --git a/client/src/features/reader/pdf-v4/__tests__/usePdfFullscreenChrome.spec.ts b/client/src/features/reader/pdf-v4/__tests__/usePdfFullscreenChrome.spec.ts
new file mode 100644
index 000000000..c7f6fd0ef
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/__tests__/usePdfFullscreenChrome.spec.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { defineComponent, h, ref } from 'vue'
+import { usePdfFullscreenChrome } from '../composables/usePdfFullscreenChrome'
+
+describe('usePdfFullscreenChrome', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('auto-hides only in fullscreen and keeps a session pin', async () => {
+ vi.useFakeTimers()
+ const fullscreen = ref(false)
+ const openUi = ref(false)
+ let chrome!: ReturnType
+ const wrapper = mount(
+ defineComponent({
+ setup() {
+ chrome = usePdfFullscreenChrome(fullscreen, openUi)
+ return () => h('div')
+ },
+ }),
+ )
+
+ await vi.advanceTimersByTimeAsync(3000)
+ expect(chrome.visible.value).toBe(true)
+
+ fullscreen.value = true
+ await vi.runAllTicks()
+ await vi.advanceTimersByTimeAsync(2500)
+ expect(chrome.visible.value).toBe(false)
+
+ chrome.reveal()
+ chrome.togglePinned()
+ await vi.advanceTimersByTimeAsync(3000)
+ expect(chrome.visible.value).toBe(true)
+
+ fullscreen.value = false
+ await vi.runAllTicks()
+ expect(chrome.pinned.value).toBe(true)
+
+ fullscreen.value = true
+ await vi.runAllTicks()
+ await vi.advanceTimersByTimeAsync(3000)
+ expect(chrome.visible.value).toBe(true)
+
+ chrome.togglePinned()
+ await vi.advanceTimersByTimeAsync(2500)
+ expect(chrome.visible.value).toBe(false)
+ wrapper.unmount()
+ })
+})
diff --git a/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts b/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts
new file mode 100644
index 000000000..8ff9c86d6
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts
@@ -0,0 +1,39 @@
+import { defineComponent, h, ref } from 'vue'
+import { describe, expect, it } from 'vitest'
+import { mount } from '@vue/test-utils'
+import type { ScrollScope } from '@embedpdf/plugin-scroll'
+import { usePdfPagination } from '../composables/usePdfPagination'
+
+describe('usePdfPagination', () => {
+ it('stays stable while EmbedPDF unloads the document scope', () => {
+ const scroll = ref({
+ getSpreadPagesWithRotatedSize() {
+ throw new Error('Document doc-1 not loaded')
+ },
+ scrollToPage() {
+ throw new Error('Document doc-1 not loaded')
+ },
+ } as unknown as Readonly)
+ let pagination!: ReturnType
+
+ const wrapper = mount(
+ defineComponent({
+ setup() {
+ pagination = usePdfPagination({
+ mode: ref('page'),
+ scrollState: ref({ currentPage: 20, totalPages: 240 }),
+ scroll,
+ onActivity: () => {},
+ })
+ return () => h('div')
+ },
+ }),
+ )
+
+ expect(pagination.pageRange.value).toEqual({ start: 20, end: 20 })
+ expect(() => pagination.goToPage(21)).not.toThrow()
+ expect(() => pagination.nextPage()).not.toThrow()
+
+ wrapper.unmount()
+ })
+})
diff --git a/client/src/features/reader/pdf-v4/components/PdfDocumentViewport.vue b/client/src/features/reader/pdf-v4/components/PdfDocumentViewport.vue
new file mode 100644
index 000000000..3ce7b5afb
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/components/PdfDocumentViewport.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/reader/pdf-v4/components/PdfPasswordPrompt.vue b/client/src/features/reader/pdf-v4/components/PdfPasswordPrompt.vue
new file mode 100644
index 000000000..4be3b8fd1
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/components/PdfPasswordPrompt.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
Password required
+
Enter the document password to open this PDF.
+
+
+
+
+
diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue b/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue
new file mode 100644
index 000000000..9df9c1386
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue
@@ -0,0 +1,496 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Unable to open this PDF
+
{{ documentState.error || 'The document could not be loaded.' }}
+
+
+
+
+
+
+
+
+
+
This PDF opened without any pages
+
Reload the document to recover from the incomplete PDF engine response.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Open external link?
+
This PDF wants to open {{ externalHost }} in a new tab.
+
{{ pendingExternalUrl.href }}
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderSettingsPanel.vue b/client/src/features/reader/pdf-v4/components/PdfReaderSettingsPanel.vue
new file mode 100644
index 000000000..099f3c563
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/components/PdfReaderSettingsPanel.vue
@@ -0,0 +1,239 @@
+
+
+
+
+
+
Zoom
+
+
+
+
+
+
+
+ {{ Math.round(props.customScale * 100) }}%
+
+
+
+
+
Layout
+
+
+
+
+
+
+
+
+
Page spread
+
+
+
+
+
+
+
+
+
+
+
Rotation
+
{{ props.rotation }}°
+
+
+
+
+
+
+
+
diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue b/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue
new file mode 100644
index 000000000..984a5e92e
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue
@@ -0,0 +1,377 @@
+
+
+
+
+
+
+
diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue b/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue
new file mode 100644
index 000000000..ffa16383f
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue
@@ -0,0 +1,346 @@
+
+
+
+
+
diff --git a/client/src/features/reader/pdf-v4/composables/usePdfFullscreenChrome.ts b/client/src/features/reader/pdf-v4/composables/usePdfFullscreenChrome.ts
new file mode 100644
index 000000000..43b19785f
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/composables/usePdfFullscreenChrome.ts
@@ -0,0 +1,45 @@
+import { computed, onUnmounted, ref, watch, type Ref } from 'vue'
+
+const AUTO_HIDE_DELAY_MS = 2500
+
+export function usePdfFullscreenChrome(isFullscreen: Readonly[>, hasOpenUi: Readonly][>) {
+ const pinned = ref(false)
+ const visible = ref(true)
+ let hideTimer: ReturnType | null = null
+
+ const canAutoHide = computed(() => isFullscreen.value && !pinned.value && !hasOpenUi.value)
+
+ function clearHideTimer() {
+ if (!hideTimer) return
+ clearTimeout(hideTimer)
+ hideTimer = null
+ }
+
+ function scheduleHide() {
+ clearHideTimer()
+ if (!canAutoHide.value) return
+ hideTimer = setTimeout(() => {
+ visible.value = false
+ hideTimer = null
+ }, AUTO_HIDE_DELAY_MS)
+ }
+
+ function reveal() {
+ visible.value = true
+ scheduleHide()
+ }
+
+ function togglePinned() {
+ pinned.value = !pinned.value
+ reveal()
+ }
+
+ watch([isFullscreen, hasOpenUi, pinned], () => {
+ visible.value = true
+ scheduleHide()
+ })
+
+ onUnmounted(clearHideTimer)
+
+ return { pinned, visible, reveal, togglePinned }
+}
diff --git a/client/src/features/reader/pdf-v4/composables/usePdfPagination.ts b/client/src/features/reader/pdf-v4/composables/usePdfPagination.ts
new file mode 100644
index 000000000..3bc404c29
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/composables/usePdfPagination.ts
@@ -0,0 +1,150 @@
+import { computed, onUnmounted, type Ref } from 'vue'
+import type { ScrollScope } from '@embedpdf/plugin-scroll'
+import type { PdfReaderSettings } from '@bookorbit/types'
+import { canTurnFromVisiblePage, findAdjacentPage, findPageRange } from '../pdf-pagination'
+
+type ReadonlyRef = Readonly][>
+
+export interface UsePdfPaginationOptions {
+ mode: ReadonlyRef
+ scrollState: ReadonlyRef<{ currentPage: number; totalPages: number }>
+ scroll: ReadonlyRef | null>
+ onActivity: () => void
+}
+
+export function usePdfPagination({ mode, scrollState, scroll, onActivity }: UsePdfPaginationOptions) {
+ let wheelDelta = 0
+ let wheelUnlockTimer: ReturnType | null = null
+ let touchStart: { x: number; y: number } | null = null
+
+ function getScrollScope() {
+ try {
+ return scroll.value
+ } catch {
+ return null
+ }
+ }
+
+ function getSpreads(scope = getScrollScope()) {
+ try {
+ return scope?.getSpreadPagesWithRotatedSize() ?? []
+ } catch {
+ return []
+ }
+ }
+
+ function getPageRange(pageNumber: number, totalPages: number) {
+ return findPageRange(getSpreads(), pageNumber, totalPages)
+ }
+
+ const pageRange = computed(() => getPageRange(scrollState.value.currentPage, scrollState.value.totalPages))
+ const behavior = computed(() => (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth'))
+
+ function goToPage(page: number) {
+ const clamped = Math.min(Math.max(page, 1), scrollState.value.totalPages || 1)
+ try {
+ getScrollScope()?.scrollToPage({ pageNumber: clamped, behavior: behavior.value })
+ } catch {
+ return
+ }
+ }
+
+ function turnPage(direction: -1 | 1) {
+ const scope = getScrollScope()
+ if (!scope) return
+ try {
+ if (scope.getPageChangeState().isChanging) return
+ const spreads = getSpreads(scope)
+ if (spreads.length === 0) return
+ const target = findAdjacentPage(spreads, pageRange.value.start, direction, scrollState.value.totalPages)
+ if (target === pageRange.value.start) return
+ scope.scrollToPage({ pageNumber: target, behavior: behavior.value })
+ } catch {
+ return
+ }
+ }
+
+ function previousPage() {
+ onActivity()
+ if (mode.value === 'page') turnPage(-1)
+ else {
+ try {
+ getScrollScope()?.scrollToPreviousPage(behavior.value)
+ } catch {
+ return
+ }
+ }
+ }
+
+ function nextPage() {
+ onActivity()
+ if (mode.value === 'page') turnPage(1)
+ else {
+ try {
+ getScrollScope()?.scrollToNextPage(behavior.value)
+ } catch {
+ return
+ }
+ }
+ }
+
+ function canTurnPage(direction: -1 | 1, axis: 'horizontal' | 'vertical' = 'vertical') {
+ const scope = getScrollScope()
+ if (!scope) return false
+ const spreads = getSpreads(scope)
+ if (spreads.length === 0) return false
+ try {
+ return canTurnFromVisiblePage(direction, pageRange.value, scope.getMetrics().pageVisibilityMetrics, spreads, axis)
+ } catch {
+ return false
+ }
+ }
+
+ function handleWheel(event: WheelEvent) {
+ onActivity()
+ if (mode.value !== 'page' || event.ctrlKey || event.metaKey) return
+ const axis = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? 'horizontal' : 'vertical'
+ const delta = axis === 'horizontal' ? event.deltaX : event.deltaY
+ const direction: -1 | 1 = delta < 0 ? -1 : 1
+ if (!canTurnPage(direction, axis)) return
+ event.preventDefault()
+ wheelDelta += delta
+ if (Math.abs(wheelDelta) < 80 || wheelUnlockTimer) return
+ turnPage(direction)
+ wheelDelta = 0
+ wheelUnlockTimer = setTimeout(() => {
+ wheelUnlockTimer = null
+ }, 350)
+ }
+
+ function handleTouchStart(event: TouchEvent) {
+ onActivity()
+ if (event.touches.length !== 1) {
+ touchStart = null
+ return
+ }
+ touchStart = { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY }
+ }
+
+ function handleTouchEnd(event: TouchEvent) {
+ if (mode.value !== 'page' || !touchStart || event.changedTouches.length !== 1) {
+ touchStart = null
+ return
+ }
+ const touch = event.changedTouches[0]!
+ const deltaX = touch.clientX - touchStart.x
+ const deltaY = touch.clientY - touchStart.y
+ touchStart = null
+ if (Math.abs(deltaX) < 60 || Math.abs(deltaX) < Math.abs(deltaY) * 1.2) return
+ const direction: -1 | 1 = deltaX > 0 ? -1 : 1
+ if (!canTurnPage(direction, 'horizontal')) return
+ event.preventDefault()
+ turnPage(direction)
+ }
+
+ onUnmounted(() => {
+ if (wheelUnlockTimer) clearTimeout(wheelUnlockTimer)
+ })
+
+ return { pageRange, getPageRange, goToPage, previousPage, nextPage, handleWheel, handleTouchStart, handleTouchEnd }
+}
diff --git a/client/src/features/reader/pdf-v4/composables/usePdfResponsiveSpread.ts b/client/src/features/reader/pdf-v4/composables/usePdfResponsiveSpread.ts
new file mode 100644
index 000000000..c4c8557a7
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/composables/usePdfResponsiveSpread.ts
@@ -0,0 +1,45 @@
+import { onMounted, onUnmounted, watch, type Ref } from 'vue'
+import { SpreadMode, type SpreadScope } from '@embedpdf/plugin-spread'
+import type { PdfReaderSettings } from '@bookorbit/types'
+import { shouldUseResponsiveSpread } from '../pdf-pagination'
+
+type ReadonlyRef = Readonly][>
+
+export function usePdfResponsiveSpread(
+ surface: ReadonlyRef,
+ preference: ReadonlyRef,
+ spread: ReadonlyRef | null>,
+) {
+ let resizeObserver: ResizeObserver | null = null
+
+ function getSpreadScope() {
+ try {
+ return spread.value
+ } catch {
+ return null
+ }
+ }
+
+ function apply() {
+ if (preference.value !== 'auto' || !surface.value) return
+ const { width, height } = surface.value.getBoundingClientRect()
+ try {
+ getSpreadScope()?.setSpreadMode(shouldUseResponsiveSpread(width, height) ? SpreadMode.Odd : SpreadMode.None)
+ } catch {
+ return
+ }
+ }
+
+ onMounted(() => {
+ if (!surface.value) return
+ resizeObserver = new ResizeObserver(apply)
+ resizeObserver.observe(surface.value)
+ apply()
+ })
+
+ watch([surface, preference, getSpreadScope], apply, { flush: 'post' })
+
+ onUnmounted(() => resizeObserver?.disconnect())
+
+ return { apply }
+}
diff --git a/client/src/features/reader/pdf-v4/pdf-pagination.ts b/client/src/features/reader/pdf-v4/pdf-pagination.ts
new file mode 100644
index 000000000..7606a9fbf
--- /dev/null
+++ b/client/src/features/reader/pdf-v4/pdf-pagination.ts
@@ -0,0 +1,58 @@
+import type { PdfPageObjectWithRotatedSize } from '@embedpdf/models'
+import type { PageVisibilityMetrics } from '@embedpdf/plugin-scroll'
+
+export interface PdfPageRange {
+ start: number
+ end: number
+}
+
+export function findPageRange(spreads: PdfPageObjectWithRotatedSize[][], pageNumber: number, totalPages: number): PdfPageRange {
+ const spread = spreads.find((pages) => pages.some((page) => page.index + 1 === pageNumber))
+ if (!spread?.length) {
+ const page = Math.min(Math.max(pageNumber, 1), Math.max(totalPages, 1))
+ return { start: page, end: page }
+ }
+
+ return {
+ start: Math.min(...spread.map((page) => page.index + 1)),
+ end: Math.max(...spread.map((page) => page.index + 1)),
+ }
+}
+
+export function findAdjacentPage(spreads: PdfPageObjectWithRotatedSize[][], pageNumber: number, direction: -1 | 1, totalPages: number): number {
+ const currentIndex = spreads.findIndex((pages) => pages.some((page) => page.index + 1 === pageNumber))
+ if (currentIndex < 0) return Math.min(Math.max(pageNumber + direction, 1), Math.max(totalPages, 1))
+ const target = spreads[currentIndex + direction]
+ if (!target?.length) return Math.min(...spreads[currentIndex]!.map((page) => page.index + 1))
+ return Math.min(...target.map((page) => page.index + 1))
+}
+
+export function canTurnFromVisiblePage(
+ direction: -1 | 1,
+ pageRange: PdfPageRange,
+ visibility: PageVisibilityMetrics[],
+ spreads: PdfPageObjectWithRotatedSize[][],
+ axis: 'horizontal' | 'vertical' = 'vertical',
+): boolean {
+ const pageNumbers = new Set(Array.from({ length: pageRange.end - pageRange.start + 1 }, (_, index) => pageRange.start + index))
+ const relevantMetrics = visibility.filter((metric) => pageNumbers.has(metric.pageNumber))
+ if (relevantMetrics.length === 0) return true
+
+ const pages = spreads.flat().filter((page) => pageNumbers.has(page.index + 1))
+ if (pages.length === 0) return true
+
+ return relevantMetrics.every((metric) => {
+ const page = pages.find((candidate) => candidate.index + 1 === metric.pageNumber)
+ if (!page) return true
+ if (axis === 'horizontal') {
+ if (direction < 0) return metric.original.pageX <= 1
+ return metric.original.pageX + metric.original.visibleWidth >= page.rotatedSize.width - 1
+ }
+ if (direction < 0) return metric.original.pageY <= 1
+ return metric.original.pageY + metric.original.visibleHeight >= page.rotatedSize.height - 1
+ })
+}
+
+export function shouldUseResponsiveSpread(width: number, height: number): boolean {
+ return width >= 900 && width / Math.max(height, 1) >= 1.1
+}
diff --git a/client/src/features/reader/pdf-v4/pdf-viewer-utils.ts b/client/src/features/reader/pdf-v4/pdf-viewer-utils.ts
index 773b60e94..70da9b93d 100644
--- a/client/src/features/reader/pdf-v4/pdf-viewer-utils.ts
+++ b/client/src/features/reader/pdf-v4/pdf-viewer-utils.ts
@@ -1,7 +1,54 @@
-export function lookupAccentHex(accentId: string, options: readonly { id: string; color: string }[]): string {
- return options.find((o) => o.id === accentId)?.color ?? '#3b82f6'
+import { Rotation, type PdfBookmarkObject } from '@embedpdf/models'
+import { ScrollStrategy } from '@embedpdf/plugin-scroll'
+import { SpreadMode } from '@embedpdf/plugin-spread'
+import { ZoomMode, type ZoomLevel } from '@embedpdf/plugin-zoom'
+import type { PdfReaderSettings } from '@bookorbit/types'
+
+export interface FlatPdfBookmark {
+ bookmark: PdfBookmarkObject
+ depth: number
}
-export function getIsDark(): boolean {
- return document.documentElement.classList.contains('dark')
+export function toScrollStrategy(mode: PdfReaderSettings['scrollMode']): ScrollStrategy {
+ return mode === 'horizontal' ? ScrollStrategy.Horizontal : ScrollStrategy.Vertical
+}
+
+export function toSpreadMode(mode: PdfReaderSettings['spread']): SpreadMode {
+ if (mode === 'odd') return SpreadMode.Odd
+ if (mode === 'even') return SpreadMode.Even
+ return SpreadMode.None
+}
+
+export function toZoomLevel(settings: PdfReaderSettings): ZoomLevel {
+ if (settings.zoomMode === 'fit-width') return ZoomMode.FitWidth
+ if (settings.zoomMode === 'automatic') return ZoomMode.Automatic
+ if (settings.zoomMode === 'custom') return settings.customScale
+ return ZoomMode.FitPage
+}
+
+export function toRotation(rotation: PdfReaderSettings['rotation']): Rotation {
+ if (rotation === 90) return Rotation.Degree90
+ if (rotation === 180) return Rotation.Degree180
+ if (rotation === 270) return Rotation.Degree270
+ return Rotation.Degree0
+}
+
+export function fromRotation(rotation: Rotation): PdfReaderSettings['rotation'] {
+ if (rotation === Rotation.Degree90) return 90
+ if (rotation === Rotation.Degree180) return 180
+ if (rotation === Rotation.Degree270) return 270
+ return 0
+}
+
+export function flattenPdfBookmarks(bookmarks: PdfBookmarkObject[], depth = 0): FlatPdfBookmark[] {
+ return bookmarks.flatMap((bookmark) => [{ bookmark, depth }, ...flattenPdfBookmarks(bookmark.children ?? [], depth + 1)])
+}
+
+export function safeExternalPdfUrl(uri: string): URL | null {
+ try {
+ const url = new URL(uri, window.location.href)
+ return url.protocol === 'http:' || url.protocol === 'https:' ? url : null
+ } catch {
+ return null
+ }
}
diff --git a/client/src/features/reader/shared/composables/useReaderSettings.test.ts b/client/src/features/reader/shared/composables/useReaderSettings.test.ts
index 16ed5aa28..f19e87729 100644
--- a/client/src/features/reader/shared/composables/useReaderSettings.test.ts
+++ b/client/src/features/reader/shared/composables/useReaderSettings.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
-import { CBX_READER_DEFAULTS, READER_GROUP_DEFAULTS } from '@bookorbit/types'
+import { CBX_READER_DEFAULTS, READER_GROUP_DEFAULTS, type PdfReaderSettings } from '@bookorbit/types'
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>())
vi.mock('@/lib/api', () => ({ api: apiMock }))
@@ -108,6 +108,47 @@ describe('useReaderSettings - load() with invalid localStorage value', () => {
})
})
+describe('useReaderSettings - PDF validation', () => {
+ it('keeps valid PDF settings and removes values that can break reader layout', async () => {
+ localStorage.setItem(
+ `reader:book:${BOOK_FILE_ID}`,
+ JSON.stringify({
+ scrollMode: 'horizontal',
+ spread: 'odd',
+ zoomMode: 'custom',
+ customScale: 40,
+ rotation: 45,
+ }),
+ )
+
+ const settings = useReaderSettings(BOOK_FILE_ID, 'pdf')
+ await settings.load()
+
+ expect(settings.bookDelta.value).toEqual({
+ scrollMode: 'horizontal',
+ spread: 'odd',
+ zoomMode: 'custom',
+ })
+ const effective = settings.effective.value as PdfReaderSettings
+ expect(effective.customScale).toBe(1)
+ expect(effective.rotation).toBe(0)
+ })
+
+ it('migrates wrapped mode and retains new read-only layout choices', async () => {
+ localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ scrollMode: 'wrapped', spread: 'auto', zoomMode: 'automatic' }))
+
+ const settings = useReaderSettings(BOOK_FILE_ID, 'pdf')
+ await settings.load()
+
+ expect(settings.bookDelta.value).toEqual({ scrollMode: 'vertical', spread: 'auto', zoomMode: 'automatic' })
+ expect(JSON.parse(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`) ?? '{}')).toEqual({
+ scrollMode: 'vertical',
+ spread: 'auto',
+ zoomMode: 'automatic',
+ })
+ })
+})
+
describe('useReaderSettings - load() with valid cbx localStorage delta', () => {
it('filters out invalid fitMode and keeps valid fields', async () => {
const raw = { fitMode: 'stretch', viewMode: 'single' }
diff --git a/client/src/features/reader/shared/composables/useReaderSettings.ts b/client/src/features/reader/shared/composables/useReaderSettings.ts
index f78fd17fc..380b03034 100644
--- a/client/src/features/reader/shared/composables/useReaderSettings.ts
+++ b/client/src/features/reader/shared/composables/useReaderSettings.ts
@@ -5,8 +5,10 @@ import { useAuth } from '@/features/auth/composables/useAuth'
import {
CBX_READER_DEFAULTS,
EPUB_READER_DEFAULTS,
+ PDF_READER_DEFAULTS,
type CbxReaderSettings,
type EpubReaderSettings,
+ type PdfReaderSettings,
type ReaderFormatGroup,
type ReaderSettings,
READER_GROUP_DEFAULTS,
@@ -138,8 +140,35 @@ function sanitizeCbxPartialSettings(settings: unknown): Partial | null {
+ if (!isRecord(settings)) return null
+
+ const out: Partial = {}
+
+ if (settings.scrollMode === 'vertical' || settings.scrollMode === 'horizontal' || settings.scrollMode === 'page') {
+ out.scrollMode = settings.scrollMode
+ } else if (settings.scrollMode === 'wrapped') {
+ out.scrollMode = 'vertical'
+ }
+ if (settings.spread === 'none' || settings.spread === 'odd' || settings.spread === 'even' || settings.spread === 'auto') {
+ out.spread = settings.spread
+ }
+ if (settings.zoomMode === 'fit-width' || settings.zoomMode === 'fit-page' || settings.zoomMode === 'automatic' || settings.zoomMode === 'custom') {
+ out.zoomMode = settings.zoomMode
+ }
+ if (isNumberInRange(settings.customScale, 0.25, 4)) {
+ out.customScale = settings.customScale
+ }
+ if (settings.rotation === 0 || settings.rotation === 90 || settings.rotation === 180 || settings.rotation === 270) {
+ out.rotation = settings.rotation
+ }
+
+ return out
+}
+
function sanitizeBookDelta(group: ReaderFormatGroup, raw: unknown): Partial | null {
if (group === 'epub') return sanitizeEpubPartialSettings(raw) as Partial | null
+ if (group === 'pdf') return sanitizePdfPartialSettings(raw) as Partial | null
if (group !== 'cbx') return isRecord(raw) ? (raw as Partial) : null
const sanitized = sanitizeCbxPartialSettings(raw)
return sanitized as Partial | null
@@ -154,6 +183,14 @@ function sanitizeDefaultSettings(group: ReaderFormatGroup, raw: unknown): Reader
...sanitized,
} as ReaderSettings
}
+ if (group === 'pdf') {
+ const sanitized = sanitizePdfPartialSettings(raw)
+ if (!sanitized) return null
+ return {
+ ...PDF_READER_DEFAULTS,
+ ...sanitized,
+ } as ReaderSettings
+ }
if (group !== 'cbx') return isRecord(raw) ? (raw as unknown as ReaderSettings) : null
const sanitized = sanitizeCbxPartialSettings(raw)
diff --git a/client/src/features/settings/PdfSettings.vue b/client/src/features/settings/PdfSettings.vue
index c77038268..be7c17661 100644
--- a/client/src/features/settings/PdfSettings.vue
+++ b/client/src/features/settings/PdfSettings.vue
@@ -18,6 +18,47 @@ const { effective, load, update, reset } = useReaderDefaultSettings effective.value.zoomMode === 'custom')
+
+function resetSettings() {
+ reset()
+}
+
+function selectPageLayout() {
+ update({ scrollMode: 'page' })
+}
+
+function selectVerticalLayout() {
+ update({ scrollMode: 'vertical' })
+}
+
+function selectHorizontalLayout() {
+ update({ scrollMode: 'horizontal' })
+}
+
+function selectSingleSpread() {
+ update({ spread: 'none' })
+}
+
+function selectOddSpread() {
+ update({ spread: 'odd' })
+}
+
+function selectEvenSpread() {
+ update({ spread: 'even' })
+}
+
+function selectAutoSpread() {
+ update({ spread: 'auto' })
+}
+
+function selectZoomMode(event: Event) {
+ const mode = (event.currentTarget as HTMLButtonElement).dataset.zoomMode as PdfReaderSettings['zoomMode']
+ update({ zoomMode: mode })
+}
+
+function handleCustomScale(event: Event) {
+ update({ customScale: Number((event.target as HTMLInputElement).value) })
+}
@@ -25,7 +66,7 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
class="[&_.settings-hint]:overflow-hidden [&_.settings-hint]:text-ellipsis [&_.settings-hint]:whitespace-nowrap md:[&_.settings-hint]:overflow-visible md:[&_.settings-hint]:whitespace-normal"
>
-
@@ -33,12 +74,12 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
]
-
+
Reset to defaults
-
+
Reset to defaults
@@ -60,17 +101,26 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
Page
Scrolled
+
+ Horizontal
+