import type { MuPDFModule } from './mupdfLoader' import type { ViewerHost } from '../core/viewerHost' import { clear, h, icon, iconButton } from '../core/dom' import { toast } from '../core/toast' import { openModal } from '../ui/modal' import { showAbout } from '../ui/about' import { formatBytes, kindMeta, type FileKind } from '../core/files' interface PageSlot { index: number el: HTMLElement canvas: HTMLCanvasElement ctx: CanvasRenderingContext2D renderedKey: string } interface OutlineItem { title?: string uri?: string page?: number down?: OutlineItem[] } interface SearchHit { hitIndex: number page: number quads: number[][] } interface Size { w: number h: number } const INVERT_KEY = 'viewer.docInvert' const PAGE_GAP = 18 export class DocViewer { private host: ViewerHost private mupdf: MuPDFModule private bytes: Uint8Array private fileName: string private fileSize: number private kind: FileKind private magic?: string private doc: any = null private scroller: HTMLElement private pagesEl: HTMLElement private slots: PageSlot[] = [] private pageSizes = new Map() private defaultSize: Size = { w: 612, h: 792 } private pageCount = 0 private currentPage = 0 private zoom = 1 private rotate = 0 private invert = false private reflowable = false private fitMode: 'width' | 'page' | 'custom' = 'width' private epoch = 0 private renderQueue: Promise = Promise.resolve() private pendingRenders = new Set() private visiblePages = new Set() private intersectionObserver: IntersectionObserver | null = null private resizeObserver: ResizeObserver | null = null private resizeTimer = 0 private scrollRaf = 0 private searchToken = 0 private searchQuery = '' private searchHits: SearchHit[] = [] private currentHit = -1 private searchVisible = false private lastReflowWidth = 0 private lastReflowHeight = 0 private pinch: | { startDist: number startZoom: number startScrollTop: number startScrollLeft: number midX: number midY: number } | null = null private destroyed = false private pageInput: HTMLInputElement private pageTotal: HTMLElement private zoomLabel: HTMLButtonElement private searchBar: HTMLElement private searchInput: HTMLInputElement private searchCount: HTMLElement private searchPrev: HTMLButtonElement private searchNext: HTMLButtonElement private outlineBackdrop: HTMLElement private outlineList: HTMLElement private menu: HTMLElement private menuButtons: Record = {} private fullscreenBtn: HTMLButtonElement | null = null private firstRenderDone = false private onDocumentPointerDown: ((ev: PointerEvent) => void) | null = null constructor( host: ViewerHost, mupdf: MuPDFModule, bytes: Uint8Array, fileName: string, fileSize: number, kind: FileKind, magic?: string ) { this.host = host this.mupdf = mupdf this.bytes = bytes this.fileName = fileName this.fileSize = fileSize this.kind = kind this.magic = magic try { this.invert = localStorage.getItem(INVERT_KEY) === '1' } catch { this.invert = false } const meta = kindMeta(kind) host.setFileInfo(fileName, meta.label, meta.short, meta.tone, formatBytes(fileSize)) host.setStatus('') this.scroller = h('div', { class: 'doc-scroll' }) this.pagesEl = h('div', { class: 'doc-pages' }) this.pagesEl.classList.toggle('inverted', this.invert) this.scroller.appendChild(this.pagesEl) host.stage.appendChild(this.scroller) this.pageInput = h('input', { class: 'page-input', type: 'number', min: '1', inputMode: 'numeric', 'aria-label': '页码' }) this.pageTotal = h('span', { class: 'page-total', text: '/ 1' }) const prev = iconButton('chevron-left', '上一页', () => this.goToPage(this.currentPage - 1), { className: 'doc-btn' }) const next = iconButton('chevron-right', '下一页', () => this.goToPage(this.currentPage + 1), { className: 'doc-btn' }) const pageGroup = h('div', { class: 'doc-group' }, prev, this.pageInput, this.pageTotal, next) const zoomOut = iconButton('zoom-out', '缩小', () => this.zoomTo(this.zoom / 1.2), { className: 'doc-btn' }) const zoomIn = iconButton('zoom-in', '放大', () => this.zoomTo(this.zoom * 1.2), { className: 'doc-btn' }) this.zoomLabel = h('button', { class: 'zoom-label', type: 'button', text: '100%', title: '适合宽度' }) this.zoomLabel.addEventListener('click', () => this.fitWidth()) const zoomGroup = h('div', { class: 'doc-group' }, zoomOut, this.zoomLabel, zoomIn) const fitBtn = iconButton('fit', '适合宽度', () => this.fitWidth(), { className: 'doc-btn' }) const moreBtn = iconButton('more', '更多', () => this.toggleMenu(), { className: 'doc-btn' }) moreBtn.dataset.action = 'more' const actionGroup = h('div', { class: 'doc-group' }, fitBtn, moreBtn) const bottom = h('div', { class: 'doc-bottom' }, pageGroup, zoomGroup, actionGroup) host.stage.appendChild(bottom) this.menu = this.buildMenu() host.stage.appendChild(this.menu) this.searchInput = h('input', { class: 'doc-search-input', type: 'search', placeholder: '在文档中查找…', 'aria-label': '查找' }) this.searchCount = h('span', { class: 'doc-search-count', text: '' }) this.searchPrev = iconButton('chevron-up', '上一个结果', () => this.prevHit(), { className: 'doc-btn' }) this.searchNext = iconButton('chevron-down', '下一个结果', () => this.nextHit(), { className: 'doc-btn' }) const searchClose = iconButton('x', '关闭查找', () => this.closeSearch(), { className: 'doc-btn' }) this.searchBar = h( 'div', { class: 'doc-search' }, h('span', { class: 'doc-search-icon', html: icon('search', 18) }), this.searchInput, this.searchCount, this.searchPrev, this.searchNext, searchClose ) host.stage.appendChild(this.searchBar) this.outlineList = h('div', { class: 'outline-list' }) this.outlineBackdrop = h('div', { class: 'sheet-backdrop sheet-left' }) const outlineClose = iconButton('x', '关闭', () => this.closeOutline()) const outlineSheet = h( 'aside', { class: 'sheet sheet-outline', role: 'dialog', 'aria-label': '目录' }, h('div', { class: 'sheet-head' }, h('div', { class: 'sheet-title' }, h('span', { text: '目录' })), outlineClose), this.outlineList ) this.outlineBackdrop.appendChild(outlineSheet) this.outlineBackdrop.addEventListener('click', (ev) => { if (ev.target === this.outlineBackdrop) this.closeOutline() }) host.stage.appendChild(this.outlineBackdrop) this.buildHeaderActions() this.pageInput.addEventListener('change', () => { const value = Number(this.pageInput.value) if (Number.isFinite(value)) this.goToPage(value - 1) }) this.pageInput.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault() this.pageInput.blur() const value = Number(this.pageInput.value) if (Number.isFinite(value)) this.goToPage(value - 1) } }) this.searchInput.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault() const q = this.searchInput.value.trim() if (!q) return if (q === this.searchQuery && this.searchHits.length) { if (ev.shiftKey) this.prevHit() else this.nextHit() } else { void this.runSearch(q) } } else if (ev.key === 'Escape') { this.closeSearch() } }) this.onDocumentPointerDown = (ev) => { if (!this.menu.classList.contains('open')) return const target = ev.target as Element | null if (target?.closest('[data-action="more"]')) return if (!this.menu.contains(ev.target as Node)) this.closeMenu() } document.addEventListener('pointerdown', this.onDocumentPointerDown) this.zoomLabel.textContent = '100%' this.applyInvertUi() this.updateMenuUi() } async init(): Promise { this.host.showLoader('正在打开文档…', this.fileName) await this.waitForStage() try { this.doc = this.mupdf.Document.openDocument(this.bytes, this.magic) await this.unlockIfNeeded() this.reflowable = this.kind === 'epub' || this.kind === 'fb2' try { const reflow = (this.doc as { isReflowable?: () => unknown }).isReflowable?.() if (reflow === true) this.reflowable = true } catch { /* ignore */ } if (this.reflowable) this.layoutReflow() this.pageCount = this.doc.countPages() if (!this.pageCount) throw new Error('文档中没有可显示的页面') this.measureFirstPage() this.buildPages() this.buildOutline() this.pageTotal.textContent = `/ ${this.pageCount}` this.pageInput.max = String(this.pageCount) this.pageInput.value = '1' this.zoom = this.computeFitWidth() this.applyZoomLabel() this.layoutPageSizes() this.bindEvents() this.observe() this.scroller.scrollTop = 0 this.updateCurrentPage() this.renderVisible() this.host.setLoaderMessage('正在渲染页面…') window.setTimeout(() => { if (!this.firstRenderDone) this.host.hideLoader() }, 4000) } catch (error) { this.host.hideLoader() this.doc?.destroy?.() this.doc = null throw error } } private buildHeaderActions(): void { const searchBtn = iconButton('search', '查找', () => this.toggleSearch()) const outlineBtn = iconButton('list', '目录', () => this.openOutline()) const infoBtn = iconButton('info', '关于', () => showAbout()) this.host.actions.append(searchBtn, outlineBtn, infoBtn) } private buildMenu(): HTMLElement { const menu = h('div', { class: 'doc-menu', role: 'menu' }) const add = (key: string, iconName: string, label: string, onClick: () => void) => { const btn = h('button', { class: 'menu-item', type: 'button', role: 'menuitem' }) const iconEl = h('span', { class: 'menu-icon', html: icon(iconName, 18) }) const textEl = h('span', { class: 'menu-text', text: label }) btn.append(iconEl, textEl) btn.addEventListener('click', () => { this.closeMenu() onClick() }) menu.appendChild(btn) this.menuButtons[key] = btn return btn } add('fit-page', 'fit', '适合页面', () => this.fitPage()) add('rotate', 'rotate-cw', '旋转 90°', () => this.rotatePage()) add('invert', 'moon', '反色阅读', () => this.toggleInvert()) this.fullscreenBtn = add('fullscreen', 'maximize', '全屏', () => void this.toggleFullscreen()) add('about', 'info', '关于 EasyView', () => showAbout()) return menu } private updateMenuUi(): void { const invertBtn = this.menuButtons.invert if (invertBtn) { const iconEl = invertBtn.querySelector('.menu-icon') const textEl = invertBtn.querySelector('.menu-text') if (iconEl) iconEl.innerHTML = icon(this.invert ? 'sun' : 'moon', 18) if (textEl) textEl.textContent = this.invert ? '关闭反色' : '反色阅读' invertBtn.classList.toggle('active', this.invert) } if (this.fullscreenBtn) { const active = !!document.fullscreenElement const iconEl = this.fullscreenBtn.querySelector('.menu-icon') const textEl = this.fullscreenBtn.querySelector('.menu-text') if (iconEl) iconEl.innerHTML = icon(active ? 'minimize' : 'maximize', 18) if (textEl) textEl.textContent = active ? '退出全屏' : '全屏' } } private waitForStage(): Promise { if (this.host.stage.clientWidth > 0 && this.host.stage.clientHeight > 0) return Promise.resolve() return new Promise((resolve) => { const ro = new ResizeObserver(() => { if (this.host.stage.clientWidth > 0 && this.host.stage.clientHeight > 0) { ro.disconnect() resolve() } }) ro.observe(this.host.stage) }) } private async unlockIfNeeded(): Promise { let attempts = 0 while (this.doc.needsPassword()) { if (attempts >= 4) throw new Error('密码尝试次数过多') attempts++ const password = await this.promptPassword(attempts > 1) if (password == null) throw new Error('文档已加密,需要密码才能打开') try { this.doc.authenticatePassword(password) } catch { /* ignore */ } if (this.doc.needsPassword()) toast('密码不正确,请重试', 'error') } } private promptPassword(retry: boolean): Promise { return new Promise((resolve) => { let done = false const finish = (value: string | null) => { if (done) return done = true resolve(value) modal.close() } const input = h('input', { class: 'text-input', type: 'password', placeholder: '请输入文档密码', autocomplete: 'current-password' }) const form = h( 'form', { class: 'password-form' }, h('p', { class: 'modal-text', text: '此文档受密码保护,请输入密码继续。' }), retry ? h('p', { class: 'form-error', text: '密码不正确,请重试。' }) : null, input ) form.addEventListener('submit', (ev) => { ev.preventDefault() finish(input.value) }) const cancel = h('button', { class: 'btn btn-ghost', type: 'button', text: '取消' }) cancel.addEventListener('click', () => finish(null)) const unlock = h('button', { class: 'btn btn-primary', type: 'submit', text: '解锁' }) unlock.addEventListener('click', (ev) => { ev.preventDefault() finish(input.value) }) const modal = openModal({ title: '加密文档', subtitle: this.fileName, content: form, actions: [cancel, unlock], size: 'sm', onClose: () => { if (!done) { done = true resolve(null) } } }) window.setTimeout(() => input.focus({ preventScroll: true }), 80) }) } private layoutReflow(): void { const width = (this.scroller.clientWidth - 32) * (72 / 96) const height = Math.max(400, (this.scroller.clientHeight - 90) * (72 / 96)) this.lastReflowWidth = this.scroller.clientWidth this.lastReflowHeight = this.scroller.clientHeight try { this.doc.layout(Math.max(200, width), Math.max(400, height), 12) } catch { /* ignore */ } } private measureFirstPage(): void { const page = this.doc.loadPage(0) try { const b = page.getBounds() const size = { w: Math.max(1, b[2] - b[0]), h: Math.max(1, b[3] - b[1]) } this.defaultSize = size this.pageSizes.set(0, size) } finally { page.destroy() } } private buildPages(): void { clear(this.pagesEl) this.slots = [] this.visiblePages.clear() this.pendingRenders.clear() for (let i = 0; i < this.pageCount; i++) { const canvas = h('canvas', { class: 'doc-page-canvas' }) const ctx = canvas.getContext('2d', { alpha: false }) if (!ctx) throw new Error('无法创建 Canvas 2D 上下文') const el = h( 'div', { class: 'doc-page', dataset: { page: String(i + 1) }, 'aria-label': `第 ${i + 1} 页` }, canvas ) const slot: PageSlot = { index: i, el, canvas, ctx, renderedKey: '' } this.slots.push(slot) this.pagesEl.appendChild(el) } this.epoch++ } private pageSize(index: number): Size { return this.pageSizes.get(index) ?? this.defaultSize } private displaySize(index: number): Size { const size = this.pageSize(index) return this.rotate % 180 === 0 ? size : { w: size.h, h: size.w } } private applySlotSize(slot: PageSlot): void { const size = this.displaySize(slot.index) const w = Math.max(24, size.w * this.zoom) const hgt = Math.max(24, size.h * this.zoom) slot.el.style.width = `${w}px` slot.el.style.height = `${hgt}px` } private layoutPageSizes(): void { for (const slot of this.slots) this.applySlotSize(slot) } private computeFitWidth(): number { const size = this.displaySize(this.currentPage) const available = Math.max(120, this.scroller.clientWidth - 32) return Math.max(0.03, Math.min(16, available / Math.max(1, size.w))) } private computeFitPage(): number { const size = this.displaySize(this.currentPage) const availableW = Math.max(120, this.scroller.clientWidth - 32) const availableH = Math.max(180, this.scroller.clientHeight - 110) return Math.max(0.03, Math.min(16, Math.min(availableW / size.w, availableH / size.h))) } private fitWidth(): void { this.fitMode = 'width' const z = this.computeFitWidth() this.zoomTo(z) this.scroller.scrollTop = Math.min( this.scroller.scrollTop, Math.max(0, this.scroller.scrollHeight - this.scroller.clientHeight) ) } private fitPage(): void { this.fitMode = 'page' this.zoomTo(this.computeFitPage()) } private zoomTo(nextZoom: number, anchorY?: number, final = true, anchorX?: number): void { const z = Math.max(0.02, Math.min(20, nextZoom)) if (Math.abs(z - this.zoom) / this.zoom < 1e-4) { this.applyZoomLabel() return } const old = this.zoom const localY = anchorY ?? this.scroller.clientHeight / 2 const localX = anchorX ?? this.scroller.clientWidth / 2 const contentY = this.scroller.scrollTop + localY const contentX = this.scroller.scrollLeft + localX this.zoom = z this.layoutPageSizes() const ratio = z / old const maxScroll = Math.max(0, this.scroller.scrollHeight - this.scroller.clientHeight) const maxScrollX = Math.max(0, this.scroller.scrollWidth - this.scroller.clientWidth) this.scroller.scrollTop = Math.max(0, Math.min(maxScroll, contentY * ratio - localY)) this.scroller.scrollLeft = Math.max(0, Math.min(maxScrollX, contentX * ratio - localX)) this.applyZoomLabel() if (final) { this.epoch++ this.renderVisible() } if (this.fitMode !== 'custom' && Math.abs(z - (this.fitMode === 'page' ? this.computeFitPage() : this.computeFitWidth())) > 0.001) { this.fitMode = 'custom' } this.updateCurrentPage() } private applyZoomLabel(): void { this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%` } private buildOutline(): void { clear(this.outlineList) let outline: OutlineItem[] | null = null try { outline = this.doc.loadOutline() as OutlineItem[] | null } catch { outline = null } if (!outline || outline.length === 0) { this.outlineList.appendChild( h('p', { class: 'outline-empty', text: '此文档没有书签或目录。' }) ) return } const addItems = (items: OutlineItem[], depth: number): void => { if (depth > 8) return for (const item of items) { const title = (item.title || '').trim() || '(无标题)' let page = typeof item.page === 'number' ? item.page : undefined if (page == null && item.uri) { try { const resolved = this.doc.resolveLink(item.uri) if (typeof resolved === 'number' && resolved >= 0) page = resolved } catch { /* ignore */ } } const row = h('button', { class: `outline-item ${page != null ? '' : 'no-page'}`, type: 'button' }) row.style.paddingLeft = `${14 + depth * 16}px` row.append(h('span', { class: 'outline-text', text: title })) if (page != null) { row.append(h('span', { class: 'outline-page', text: String(page + 1) })) } row.addEventListener('click', () => { if (page != null) { this.goToPage(page, false) this.closeOutline() } }) this.outlineList.appendChild(row) if (item.down && item.down.length) addItems(item.down, depth + 1) } } addItems(outline, 0) } private bindEvents(): void { this.scroller.addEventListener('scroll', this.onScroll, { passive: true }) this.scroller.addEventListener('dblclick', this.onDoubleClick) this.scroller.addEventListener('wheel', this.onWheel, { passive: false }) this.scroller.addEventListener('touchstart', this.onTouchStart, { passive: false }) this.scroller.addEventListener('touchmove', this.onTouchMove, { passive: false }) this.scroller.addEventListener('touchend', this.onTouchEnd, { passive: true }) this.scroller.addEventListener('touchcancel', this.onTouchEnd, { passive: true }) document.addEventListener('keydown', this.onKeyDown) document.addEventListener('fullscreenchange', this.onFullscreenChange) window.addEventListener('resize', this.onWindowResize) } private observe(): void { this.disconnectObservers() this.resizeObserver = new ResizeObserver(() => { if (this.destroyed) return if (this.resizeTimer) window.clearTimeout(this.resizeTimer) this.resizeTimer = window.setTimeout(() => this.handleResize(), 320) }) this.resizeObserver.observe(this.host.stage) this.intersectionObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { const index = Number((entry.target as HTMLElement).dataset.index) if (!Number.isFinite(index)) continue if (entry.isIntersecting) { this.visiblePages.add(index) this.queueRender(index) } else { this.visiblePages.delete(index) } } this.trimCanvases() }, { root: this.scroller, rootMargin: '900px 0px', threshold: 0 } ) for (const slot of this.slots) { slot.el.dataset.index = String(slot.index) this.intersectionObserver.observe(slot.el) } } private disconnectObservers(): void { this.intersectionObserver?.disconnect() this.intersectionObserver = null this.resizeObserver?.disconnect() this.resizeObserver = null } private handleResize(): void { if (this.destroyed) return let reflowChanged = false if (this.reflowable) { const width = this.scroller.clientWidth const height = this.scroller.clientHeight const changed = Math.abs(width - this.lastReflowWidth) > 2 || Math.abs(height - this.lastReflowHeight) > 2 reflowChanged = changed if (changed) { const page = this.currentPage this.layoutReflow() this.pageCount = this.doc.countPages() this.pageTotal.textContent = `/ ${this.pageCount}` this.pageInput.max = String(this.pageCount) this.pageSizes.clear() this.measureFirstPage() this.buildPages() this.buildOutline() this.observe() this.currentPage = Math.min(page, Math.max(0, this.pageCount - 1)) if (this.searchHits.length) { this.searchHits = [] this.currentHit = -1 this.searchCount.textContent = '' } } } if (this.fitMode === 'width') this.zoom = this.computeFitWidth() else if (this.fitMode === 'page') this.zoom = this.computeFitPage() this.applyZoomLabel() this.layoutPageSizes() this.epoch++ this.renderVisible() if (this.reflowable && reflowChanged && this.slots.length) this.goToPage(this.currentPage, false) } private onWindowResize = () => { if (this.resizeTimer) window.clearTimeout(this.resizeTimer) this.resizeTimer = window.setTimeout(() => this.handleResize(), 320) } private onScroll = () => { if (this.scrollRaf) return this.scrollRaf = requestAnimationFrame(() => { this.scrollRaf = 0 this.updateCurrentPage() this.renderNear() }) } private updateCurrentPage(): void { if (!this.slots.length) return const mid = this.scroller.scrollTop + this.scroller.clientHeight / 2 let best = this.currentPage let bestDist = Infinity for (const slot of this.slots) { const center = slot.el.offsetTop + slot.el.offsetHeight / 2 const dist = Math.abs(center - mid) if (dist < bestDist) { bestDist = dist best = slot.index } } if (best !== this.currentPage) this.currentPage = best if (document.activeElement !== this.pageInput) { this.pageInput.value = String(this.currentPage + 1) } this.applySlotSize(this.slots[this.currentPage]) } private goToPage(index: number, smooth = true): void { const target = Math.max(0, Math.min(this.pageCount - 1, index)) this.currentPage = target const slot = this.slots[target] if (!slot) return const top = Math.max(0, slot.el.offsetTop - 10) this.scroller.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' }) this.pageInput.value = String(target + 1) this.queueRender(target) this.renderNear() } private renderNear(): void { const top = this.scroller.scrollTop - 900 const bottom = this.scroller.scrollTop + this.scroller.clientHeight + 900 for (const slot of this.slots) { const slotTop = slot.el.offsetTop const slotBottom = slotTop + slot.el.offsetHeight if (slotBottom >= top && slotTop <= bottom) this.queueRender(slot.index) } } private renderVisible(): void { this.renderNear() } private pageKey(): string { return `${this.epoch}:${this.zoom.toFixed(3)}:${this.rotate}:${this.invert ? 1 : 0}` } private queueRender(index: number): void { const slot = this.slots[index] if (!slot || this.destroyed) return const key = this.pageKey() if (slot.renderedKey === key) return if (this.pendingRenders.has(index)) return this.pendingRenders.add(index) this.renderQueue = this.renderQueue .then(async () => { if (this.destroyed) return this.pendingRenders.delete(index) await this.renderPage(index) }) .catch((error: unknown) => { this.pendingRenders.delete(index) console.warn('page render failed', error) if (!this.firstRenderDone) { this.firstRenderDone = true this.host.hideLoader() toast('页面渲染失败,文档可能不受支持', 'error') } }) } private async renderPage(index: number): Promise { const slot = this.slots[index] if (!slot || this.destroyed) return const key = this.pageKey() if (slot.renderedKey === key) return let page: any = null let pixmap: any = null try { page = this.doc.loadPage(index) const bounds = page.getBounds() const bw = Math.max(1, bounds[2] - bounds[0]) const bh = Math.max(1, bounds[3] - bounds[1]) const size = { w: bw, h: bh } const known = this.pageSizes.get(index) if (!known || Math.abs(known.w - bw) > 0.5 || Math.abs(known.h - bh) > 0.5) { this.pageSizes.set(index, size) this.applySlotSize(slot) } const dpr = Math.min(window.devicePixelRatio || 1, 3) let z = this.zoom * dpr let pixels = bw * z * bh * z const MAX_PIXELS = 6_000_000 if (pixels > MAX_PIXELS) { z *= Math.sqrt(MAX_PIXELS / pixels) pixels = bw * z * bh * z } let matrix = this.mupdf.Matrix.scale(z, z) if (this.rotate) matrix = this.mupdf.Matrix.concat(matrix, this.mupdf.Matrix.rotate(this.rotate)) pixmap = page.toPixmap(matrix, this.mupdf.ColorSpace.DeviceRGB, false, true) if (this.invert) pixmap.invert() const pw = pixmap.getWidth() const ph = pixmap.getHeight() if (!pw || !ph) throw new Error('页面渲染为空') slot.canvas.width = pw slot.canvas.height = ph const imageData = this.pixmapToImageData(pixmap, pw, ph) slot.ctx.setTransform(1, 0, 0, 1, 0, 0) slot.ctx.putImageData(imageData, 0, 0) this.drawSearchHighlights(slot.ctx, matrix, pixmap, index) slot.el.classList.add('rendered') slot.renderedKey = key if (!this.firstRenderDone) { this.firstRenderDone = true this.host.hideLoader() } this.trimCanvases() } finally { try { pixmap?.destroy?.() } catch { /* ignore */ } try { page?.destroy?.() } catch { /* ignore */ } } } private pixmapToImageData(pixmap: any, width: number, height: number): ImageData { const components = pixmap.getNumberOfComponents() const stride = pixmap.getStride() const src: Uint8ClampedArray = pixmap.getPixels() const image = new ImageData(width, height) const dst = image.data if (components === 4) { for (let y = 0; y < height; y++) { const start = y * stride dst.set(src.subarray(start, start + width * 4), y * width * 4) } } else if (components === 3) { for (let y = 0; y < height; y++) { const start = y * stride let d = y * width * 4 for (let x = 0; x < width; x++) { const s = start + x * 3 dst[d++] = src[s] dst[d++] = src[s + 1] dst[d++] = src[s + 2] dst[d++] = 255 } } } else if (components === 1) { for (let y = 0; y < height; y++) { const start = y * stride let d = y * width * 4 for (let x = 0; x < width; x++) { const v = src[start + x] dst[d++] = v dst[d++] = v dst[d++] = v dst[d++] = 255 } } } else { throw new Error(`不支持的像素格式(${components} 通道)`) } return image } private drawSearchHighlights( ctx: CanvasRenderingContext2D, matrix: number[], pixmap: any, pageIndex: number ): void { if (!this.searchHits.length) return const hits = this.searchHits.filter((hit) => hit.page === pageIndex) if (!hits.length) return const originX = pixmap.getX?.() ?? 0 const originY = pixmap.getY?.() ?? 0 ctx.save() for (const hit of hits) { const active = hit.hitIndex === this.currentHit ctx.fillStyle = active ? 'rgba(255,140,0,0.46)' : 'rgba(255,210,40,0.28)' ctx.strokeStyle = active ? 'rgba(255,110,0,0.95)' : 'rgba(230,170,0,0.5)' ctx.lineWidth = active ? 2 : 1 for (const quad of hit.quads) { let minX = Infinity let minY = Infinity let maxX = -Infinity let maxY = -Infinity for (let i = 0; i + 1 < quad.length; i += 2) { const x = quad[i] const y = quad[i + 1] const tx = matrix[0] * x + matrix[2] * y + matrix[4] - originX const ty = matrix[1] * x + matrix[3] * y + matrix[5] - originY if (tx < minX) minX = tx if (ty < minY) minY = ty if (tx > maxX) maxX = tx if (ty > maxY) maxY = ty } if (!Number.isFinite(minX)) continue const w = Math.max(2, maxX - minX) const hgt = Math.max(2, maxY - minY) ctx.fillRect(minX, minY, w, hgt) ctx.strokeRect(minX + 0.5, minY + 0.5, w - 1, hgt - 1) } } ctx.restore() } private trimCanvases(): void { const keep = new Set(this.visiblePages) keep.add(this.currentPage) const rendered = this.slots.filter((slot) => slot.renderedKey) if (rendered.length <= 6) return const removable = rendered .filter((slot) => !keep.has(slot.index)) .sort((a, b) => Math.abs(b.index - this.currentPage) - Math.abs(a.index - this.currentPage)) while (rendered.length > 6 && removable.length) { const slot = removable.shift()! slot.canvas.width = 0 slot.canvas.height = 0 slot.renderedKey = '' slot.el.classList.remove('rendered') rendered.splice(rendered.indexOf(slot), 1) } } private toggleSearch(): void { if (this.searchVisible) { this.closeSearch() } else { this.searchVisible = true this.searchBar.classList.add('open') window.setTimeout(() => this.searchInput.focus({ preventScroll: true }), 100) this.searchInput.select() } } private closeSearch(): void { this.searchVisible = false this.searchInput.blur() this.searchBar.classList.remove('open') this.host.stage.scrollTop = 0 this.host.stage.scrollLeft = 0 this.searchToken++ this.searchQuery = '' this.searchHits = [] this.currentHit = -1 this.searchCount.textContent = '' this.epoch++ this.renderVisible() } private async runSearch(query: string): Promise { const token = ++this.searchToken this.searchVisible = true this.searchBar.classList.add('open') this.searchQuery = query this.searchHits = [] this.currentHit = -1 this.searchCount.textContent = '搜索中…' const startPage = this.currentPage let total = 0 for (let pass = 0; pass < 2; pass++) { const begin = pass === 0 ? startPage : 0 const end = pass === 0 ? this.pageCount : startPage for (let i = begin; i < end; i++) { if (token !== this.searchToken || this.destroyed) return let page: any = null try { page = this.doc.loadPage(i) const results = page.search(query, {}) as number[][][] let pageHit = 0 for (const quads of results) { if (!Array.isArray(quads) || !quads.length) continue const flat: number[][] = [] for (const quad of quads) { if (Array.isArray(quad) && quad.length >= 4) flat.push(quad as number[]) } if (!flat.length) continue this.searchHits.push({ hitIndex: total, page: i, quads: flat }) total++ pageHit++ if (total >= 4000) break } if (pageHit && i % 5 === 0) { this.searchCount.textContent = `已找到 ${total} 处…` } } catch { /* skip page */ } finally { try { page?.destroy?.() } catch { /* ignore */ } } if (total >= 4000) break if (i % 4 === 3) await new Promise((resolve) => setTimeout(resolve, 0)) } if (total >= 4000) break if (this.searchHits.length) break } if (token !== this.searchToken) return if (!this.searchHits.length) { this.searchCount.textContent = '无结果' this.epoch++ this.renderVisible() return } this.currentHit = 0 this.updateSearchCount() this.epoch++ this.gotoHit(0) } private updateSearchCount(): void { if (!this.searchHits.length) { this.searchCount.textContent = this.searchQuery ? '无结果' : '' return } this.searchCount.textContent = `${this.currentHit + 1} / ${this.searchHits.length}` } private gotoHit(index: number): void { if (!this.searchHits.length) return const clamped = ((index % this.searchHits.length) + this.searchHits.length) % this.searchHits.length this.currentHit = clamped const hit = this.searchHits[clamped] this.updateSearchCount() this.epoch++ const target = this.slots[hit.page] if (target) { const top = Math.max(0, target.el.offsetTop - 10) this.scroller.scrollTo({ top, behavior: 'auto' }) this.currentPage = hit.page this.pageInput.value = String(hit.page + 1) } this.renderVisible() } private nextHit(): void { if (!this.searchHits.length) { if (this.searchInput.value.trim()) void this.runSearch(this.searchInput.value.trim()) return } this.gotoHit(this.currentHit + 1) } private prevHit(): void { if (!this.searchHits.length) return this.gotoHit(this.currentHit - 1) } private openOutline(): void { this.closeMenu() this.outlineBackdrop.classList.add('open') } private closeOutline(): void { this.outlineBackdrop.classList.remove('open') } private toggleMenu(): void { this.menu.classList.toggle('open') } private closeMenu(): void { this.menu.classList.remove('open') } private rotatePage(): void { this.rotate = (this.rotate + 90) % 360 if (this.fitMode === 'width') this.zoom = this.computeFitWidth() else if (this.fitMode === 'page') this.zoom = this.computeFitPage() this.applyZoomLabel() this.layoutPageSizes() this.epoch++ this.renderVisible() toast('已旋转 90°', 'info', 1500) } private toggleInvert(): void { this.invert = !this.invert try { localStorage.setItem(INVERT_KEY, this.invert ? '1' : '0') } catch { /* ignore */ } this.pagesEl.classList.toggle('inverted', this.invert) this.applyInvertUi() this.epoch++ this.renderVisible() } private applyInvertUi(): void { this.updateMenuUi() } private async toggleFullscreen(): Promise { try { if (!document.fullscreenElement) await this.host.root.requestFullscreen?.() else await document.exitFullscreen?.() } catch { toast('当前浏览器不支持全屏', 'error') } } private onFullscreenChange = () => { this.updateMenuUi() window.setTimeout(() => this.handleResize(), 100) } private onDoubleClick = (ev: MouseEvent) => { const rect = this.scroller.getBoundingClientRect() const y = ev.clientY - rect.top const x = ev.clientX - rect.left const fit = this.computeFitWidth() if (this.zoom < fit * 1.8) this.zoomTo(fit * 2, y, true, x) else this.fitWidth() } private onWheel = (ev: WheelEvent) => { if (!ev.ctrlKey && !ev.metaKey) return ev.preventDefault() const rect = this.scroller.getBoundingClientRect() const localY = ev.clientY - rect.top const localX = ev.clientX - rect.left const factor = Math.exp(-ev.deltaY * 0.002) this.zoomTo(this.zoom * factor, localY, true, localX) } private onTouchStart = (ev: TouchEvent) => { if (ev.touches.length !== 2) return ev.preventDefault() const [a, b] = [ev.touches[0], ev.touches[1]] const rect = this.scroller.getBoundingClientRect() const midY = (a.clientY + b.clientY) / 2 - rect.top const midX = (a.clientX + b.clientX) / 2 - rect.left this.pinch = { startDist: Math.max(1, Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY)), startZoom: this.zoom, startScrollTop: this.scroller.scrollTop, startScrollLeft: this.scroller.scrollLeft, midX, midY } } private onTouchMove = (ev: TouchEvent) => { if (!this.pinch || ev.touches.length !== 2) return ev.preventDefault() const [a, b] = [ev.touches[0], ev.touches[1]] const rect = this.scroller.getBoundingClientRect() const dist = Math.max(1, Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY)) const midY = (a.clientY + b.clientY) / 2 - rect.top const midX = (a.clientX + b.clientX) / 2 - rect.left const next = Math.max(0.02, Math.min(20, this.pinch.startZoom * (dist / this.pinch.startDist))) const ratio = next / this.pinch.startZoom const contentY = this.pinch.startScrollTop + this.pinch.midY const contentX = this.pinch.startScrollLeft + this.pinch.midX this.zoom = next this.layoutPageSizes() const maxScroll = Math.max(0, this.scroller.scrollHeight - this.scroller.clientHeight) const maxScrollX = Math.max(0, this.scroller.scrollWidth - this.scroller.clientWidth) this.scroller.scrollTop = Math.max(0, Math.min(maxScroll, contentY * ratio - midY)) this.scroller.scrollLeft = Math.max(0, Math.min(maxScrollX, contentX * ratio - midX)) this.applyZoomLabel() this.fitMode = 'custom' } private onTouchEnd = (ev: TouchEvent) => { if (!this.pinch) return if (ev.touches.length < 2) { this.pinch = null this.epoch++ this.renderVisible() this.updateCurrentPage() } } private onKeyDown = (ev: KeyboardEvent) => { const active = document.activeElement if (active && /^(input|textarea|select)$/i.test(active.tagName)) { if (ev.key === 'Escape') (active as HTMLElement).blur() return } switch (ev.key) { case '+': case '=': ev.preventDefault() this.zoomTo(this.zoom * 1.2) break case '-': case '_': ev.preventDefault() this.zoomTo(this.zoom / 1.2) break case '0': ev.preventDefault() this.fitWidth() break case '/': if (ev.ctrlKey || ev.metaKey) { ev.preventDefault() this.toggleSearch() } break case 'f': case 'F': if (ev.ctrlKey || ev.metaKey) { ev.preventDefault() this.toggleSearch() } break case 'Escape': if (this.outlineBackdrop.classList.contains('open')) this.closeOutline() else if (this.menu.classList.contains('open')) this.closeMenu() else if (this.searchVisible) this.closeSearch() break default: break } } destroy(): void { if (this.destroyed) return this.destroyed = true this.searchToken++ this.disconnectObservers() if (this.resizeTimer) window.clearTimeout(this.resizeTimer) if (this.scrollRaf) cancelAnimationFrame(this.scrollRaf) this.scroller.removeEventListener('scroll', this.onScroll) this.scroller.removeEventListener('dblclick', this.onDoubleClick) this.scroller.removeEventListener('wheel', this.onWheel) this.scroller.removeEventListener('touchstart', this.onTouchStart) this.scroller.removeEventListener('touchmove', this.onTouchMove) this.scroller.removeEventListener('touchend', this.onTouchEnd) this.scroller.removeEventListener('touchcancel', this.onTouchEnd) document.removeEventListener('keydown', this.onKeyDown) document.removeEventListener('fullscreenchange', this.onFullscreenChange) window.removeEventListener('resize', this.onWindowResize) if (this.onDocumentPointerDown) document.removeEventListener('pointerdown', this.onDocumentPointerDown) for (const slot of this.slots) { slot.canvas.width = 0 slot.canvas.height = 0 } try { this.doc?.destroy?.() } catch { /* ignore */ } this.doc = null this.host.destroy() } }