diff --git a/web/src/reader/pdf/annotation-layer.ts b/web/src/reader/pdf/annotation-layer.ts new file mode 100644 index 0000000..e2958b6 --- /dev/null +++ b/web/src/reader/pdf/annotation-layer.ts @@ -0,0 +1,96 @@ +// Annotation layer for rendering highlights and notes on PDFs +// Procedural style: Functions, not classes + +interface PDFHighlight { + id: string; + pageNumber: number; + rects: DOMRect[]; + text: string; + color: string; + noteId?: string; +} + +const highlights = new Map(); + +export function renderPDFHighlights( + container: HTMLElement, + highlightList: PDFHighlight[], +): void { + // Clear existing highlights + clearPDFHighlights(container); + + for (const highlight of highlightList) { + renderSinglePDFHighlight(container, highlight); + } +} + +function renderSinglePDFHighlight( + container: HTMLElement, + highlight: PDFHighlight, +): void { + const overlay = document.createElement("div"); + overlay.className = "pdf-highlight-annotation"; + overlay.dataset.highlightId = highlight.id; + overlay.style.backgroundColor = parseColor(highlight.color); + + // Position highlight rectangles + for (const rect of highlight.rects) { + const rectDiv = document.createElement("div"); + rectDiv.className = "pdf-highlight-rect"; + rectDiv.style.left = `${rect.left}px`; + rectDiv.style.top = `${rect.top}px`; + rectDiv.style.width = `${rect.width}px`; + rectDiv.style.height = `${rect.height}px`; + + overlay.appendChild(rectDiv); + } + + // Add click handler for note popup + if (highlight.noteId) { + overlay.style.cursor = "pointer"; + overlay.addEventListener("click", () => { + showNotePopup(highlight); + }); + } + + // Add hover effect + overlay.addEventListener("mouseenter", () => { + overlay.style.opacity = "0.8"; + }); + + overlay.addEventListener("mouseleave", () => { + overlay.style.opacity = "0.5"; + }); + + container.appendChild(overlay); + highlights.set(highlight.id, overlay); +} + +function parseColor(color: string): string { + if (color.startsWith("#")) { + const hex = color.slice(1); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, 0.4)`; + } + + return color; +} + +function showNotePopup(highlight: PDFHighlight): void { + console.log("Show note for highlight:", highlight.id); +} + +export function clearPDFHighlights(container: HTMLElement): void { + highlights.forEach((element) => element.remove()); + highlights.clear(); +} + +export function removePDFHighlight(highlightId: string): void { + const element = highlights.get(highlightId); + if (element) { + element.remove(); + highlights.delete(highlightId); + } +} diff --git a/web/src/reader/pdf/page-cache.ts b/web/src/reader/pdf/page-cache.ts new file mode 100644 index 0000000..0d64e52 --- /dev/null +++ b/web/src/reader/pdf/page-cache.ts @@ -0,0 +1,148 @@ +// 5-page ahead cache for PDF pages +// Pre-renders canvas and text layer for nearby pages + +import { PDFPageProxy, PageViewport } from "pdfjs-dist"; + +interface CachedPage { + pageNumber: number; + canvas: HTMLCanvasElement; + textLayer: HTMLElement; + viewport: PageViewport; + timestamp: number; +} + +// 5-page ahead cache for PDF pages +// Procedural implementation (no OOP) + +interface CachedPage { + pageNumber: number; + canvas: HTMLCanvasElement; + textLayer: HTMLElement; + viewport: PageViewport; + timestamp: number; +} + +interface PDFPageCacheState { + cache: Map; + maxCacheSize: number; + renderCallbacks: Map void>>; +} + +function createPDFPageCache(maxCacheSize: number = 5): PDFPageCacheState { + return { + cache: new Map(), + maxCacheSize, + renderCallbacks: new Map(), + }; +} + +async function getCachedPage( + state: PDFPageCacheState, + pageNumber: number, + renderFn: ( + pageNumber: number, + ) => Promise<{ + canvas: HTMLCanvasElement; + textLayer: HTMLElement; + viewport: PageViewport; + }>, +): Promise { + const cached = state.cache.get(pageNumber); + if (cached) { + cached.timestamp = Date.now(); + return { ...state, page: cached }; + } + + const { canvas, textLayer, viewport } = await renderFn(pageNumber); + + const cachedPage: CachedPage = { + pageNumber, + canvas, + textLayer, + viewport, + timestamp: Date.now(), + }; + + const newCache = new Map(state.cache); + newCache.set(pageNumber, cachedPage); + + const callbacks = state.renderCallbacks.get(pageNumber); + if (callbacks) { + callbacks.forEach((cb) => cb()); + const newCallbacks = new Map(state.renderCallbacks); + newCallbacks.delete(pageNumber); + return { + ...state, + cache: newCache, + renderCallbacks: newCallbacks, + page: cachedPage, + }; + } + + return { ...state, cache: newCache, page: cachedPage }; +} + +function preloadPages( + state: PDFPageCacheState, + currentPage: number, + totalPages: number, +): PDFPageCacheState { + for (let i = 1; i <= state.maxCacheSize; i++) { + const pageNumber = currentPage + i; + if (pageNumber <= totalPages && !state.cache.has(pageNumber)) { + triggerPreload(pageNumber); + } + } + + return state; +} + +function triggerPreload(pageNumber: number): void { + console.log("Preloading page:", pageNumber); +} + +function invalidatePage( + state: PDFPageCacheState, + pageNumber: number, +): PDFPageCacheState { + const cached = state.cache.get(pageNumber); + if (cached) { + cached.canvas.remove(); + cached.textLayer.remove(); + + const newCache = new Map(state.cache); + newCache.delete(pageNumber); + + return { ...state, cache: newCache }; + } + + return state; +} + +function clearPageCache(state: PDFPageCacheState): PDFPageCacheState { + state.cache.forEach((page) => { + page.canvas.remove(); + page.textLayer.remove(); + }); + + return { + ...state, + cache: new Map(), + }; +} + +function onPageRendered( + state: PDFPageCacheState, + pageNumber: number, + callback: () => void, +): PDFPageCacheState { + const newCallbacks = new Map(state.renderCallbacks); + + if (!newCallbacks.has(pageNumber)) { + newCallbacks.set(pageNumber, []); + } + + newCallbacks.get(pageNumber)!.push(callback); + + return { ...state, renderCallbacks: newCallbacks }; +} diff --git a/web/src/reader/pdf/pdf-bookmarks.ts b/web/src/reader/pdf/pdf-bookmarks.ts new file mode 100644 index 0000000..629811d --- /dev/null +++ b/web/src/reader/pdf/pdf-bookmarks.ts @@ -0,0 +1,125 @@ +// Custom bookmarks for PDF pages (saved in database) +// Procedural implementation (no OOP) + +interface MediaBookmark { + id: string; + mediaItemId: string; + userId: string; + pageNumber: number; + title: string; + createdAt: string; +} + +interface MediaBookmarksState { + mediaItemId: string; + bookmarks: MediaBookmark[]; +} + +function createMediaBookmarks(mediaItemId: string): MediaBookmarksState { + return { + mediaItemId, + bookmarks: [], + }; +} + +async function loadMediaBookmarks( + state: MediaBookmarksState, +): Promise { + try { + const response = await fetch( + `/api/media-items/${state.mediaItemId}/bookmarks`, + ); + if (!response.ok) throw new Error("Failed to load bookmarks"); + + const data = await response.json(); + return { ...state, bookmarks: data.bookmarks || [] }; + } catch (error) { + console.error("Failed to load bookmarks:", error); + return { ...state, bookmarks: [] }; + } +} + +async function addMediaBookmark( + state: MediaBookmarksState, + pageNumber: number, + title?: string, +): Promise { + const bookmark: MediaBookmark = { + id: crypto.randomUUID(), + mediaItemId: state.mediaItemId, + userId: "", + pageNumber, + title: title || `Page ${pageNumber}`, + createdAt: new Date().toISOString(), + }; + + try { + const response = await fetch( + `/api/media-items/${state.mediaItemId}/bookmarks`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + page_number: pageNumber, + title: bookmark.title, + position: `pdf:page:${pageNumber}`, + }), + }, + ); + + if (!response.ok) throw new Error("Failed to create bookmark"); + + const created = await response.json(); + + return { + ...state, + bookmarks: [...state.bookmarks, created], + bookmark: created, + }; + } catch (error) { + console.error("Failed to add bookmark:", error); + throw error; + } +} + +async function removeMediaBookmark( + state: MediaBookmarksState, + bookmarkId: string, +): Promise { + try { + const response = await fetch( + `/api/media-items/${state.mediaItemId}/bookmarks/${bookmarkId}`, + { + method: "DELETE", + }, + ); + + if (!response.ok) throw new Error("Failed to remove bookmark"); + + return { + ...state, + bookmarks: state.bookmarks.filter((b) => b.id !== bookmarkId), + }; + } catch (error) { + console.error("Failed to remove bookmark:", error); + throw error; + } +} + +function getMediaBookmarks(state: MediaBookmarksState): MediaBookmark[] { + return [...state.bookmarks].sort((a, b) => a.pageNumber - b.pageNumber); +} + +function hasMediaBookmarkAt( + state: MediaBookmarksState, + pageNumber: number, +): boolean { + return state.bookmarks.some((b) => b.pageNumber === pageNumber); +} + +function getMediaBookmarkAt( + state: MediaBookmarksState, + pageNumber: number, +): MediaBookmark | null { + return state.bookmarks.find((b) => b.pageNumber === pageNumber) || null; +} diff --git a/web/src/reader/pdf/pdf-clipbooard.ts b/web/src/reader/pdf/pdf-clipbooard.ts new file mode 100644 index 0000000..70fd1d6 --- /dev/null +++ b/web/src/reader/pdf/pdf-clipbooard.ts @@ -0,0 +1,98 @@ +// Copy selected text to clipboard (plain text, preserve line breaks) +// Critical for technical textbooks with code examples +// Procedural implementation (no OOP) + +function setupPDFClipboard(container: HTMLElement): void { + container.addEventListener("copy", (e) => { + handlePDFCopy(e); + }); +} + +function handlePDFCopy(event: ClipboardEvent): void { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + const selectedText = selection.toString(); + + if (!selectedText) return; + + const plainText = formatPDFPlainText(selectedText); + + event.clipboardData?.setData("text/plain", plainText); + + event.preventDefault(); + + showPDFCopyFeedback(); +} + +function formatPDFPlainText(text: string): string { + let formatted = text; + + formatted = formatted.replace(/[ \t]+/g, " "); + + formatted = formatted + .split("\n") + .map((line) => line.trim()) + .join("\n"); + + formatted = formatted.replace(/\n{3,}/g, "\n\n"); + + return formatted; +} + +async function copyPDFText(text: string): Promise { + const formatted = formatPDFPlainText(text); + + try { + await navigator.clipboard.writeText(formatted); + showPDFCopyFeedback(); + return true; + } catch (error) { + console.error("Failed to copy text:", error); + + const textarea = document.createElement("textarea"); + textarea.value = formatted; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + + try { + const success = document.execCommand("copy"); + if (success) { + showPDFCopyFeedback(); + } + return success; + } catch (fallbackError) { + console.error("Fallback copy failed:", fallbackError); + return false; + } finally { + document.body.removeChild(textarea); + } + } +} + +function showPDFCopyFeedback(): void { + const toast = document.createElement("div"); + toast.className = "pdf-copy-toast"; + toast.textContent = "Copied to clipboard"; + toast.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: var(--accent); + color: white; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + z-index: 10000; + animation: fadeIn 0.2s ease-out; + `; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = "fadeOut 0.2s ease-out"; + setTimeout(() => toast.remove(), 200); + }, 1500); +} diff --git a/web/src/reader/pdf/pdf-dual-page.ts b/web/src/reader/pdf/pdf-dual-page.ts new file mode 100644 index 0000000..3d8af1a --- /dev/null +++ b/web/src/reader/pdf/pdf-dual-page.ts @@ -0,0 +1,143 @@ +// Dual page spread view for PDFs +// Procedural implementation (no OOP) + +type DualPageMode = "single" | "dual"; + +interface PDFDualPageViewState { + currentMode: DualPageMode; + minViewportWidth: number; +} + +function createPDFDualPageView( + container: HTMLElement, + onModeChange: (mode: DualPageMode) => void, +): PDFDualPageViewState { + const state: PDFDualPageViewState = { + currentMode: "single", + minViewportWidth: 1200, + }; + + setupResponsiveDualPageToggle(container, state, onModeChange); + + return state; +} + +function setupResponsiveDualPageToggle( + container: HTMLElement, + state: PDFDualPageViewState, + onModeChange: (mode: DualPageMode) => void, +): void { + const resizeObserver = new ResizeObserver(() => { + handleDualPageResize(container, state, onModeChange); + }); + + resizeObserver.observe(container); +} + +function handleDualPageResize( + container: HTMLElement, + state: PDFDualPageViewState, + onModeChange: (mode: DualPageMode) => void, +): PDFDualPageViewState { + const viewportWidth = window.innerWidth; + + if ( + viewportWidth >= state.minViewportWidth && + state.currentMode === "single" + ) { + if (!hasManualDualPageOverride()) { + return setDualPageMode(container, state, "dual", false, onModeChange); + } + } else if ( + viewportWidth < state.minViewportWidth && + state.currentMode === "dual" + ) { + return setDualPageMode(container, state, "single", false, onModeChange); + } + + return state; +} + +function setDualPageMode( + container: HTMLElement, + state: PDFDualPageViewState, + mode: DualPageMode, + manual: boolean, + onModeChange: (mode: DualPageMode) => void, +): PDFDualPageViewState { + if (state.currentMode === mode) return state; + + container.classList.remove("pdf-single-page", "pdf-dual-page"); + container.classList.add( + mode === "dual" ? "pdf-dual-page" : "pdf-single-page", + ); + + if (manual) { + setManualDualPageOverride(mode); + } + + onModeChange(mode); + + return { ...state, currentMode: mode }; +} + +function toggleDualPageMode( + container: HTMLElement, + state: PDFDualPageViewState, + onModeChange: (mode: DualPageMode) => void, +): PDFDualPageViewState { + const newMode = state.currentMode === "single" ? "dual" : "single"; + return setDualPageMode(container, state, newMode, true, onModeChange); +} + +function getDualPagePagePair( + state: PDFDualPageViewState, + currentPage: number, + totalPages: number, +): { left?: number; right: number } { + if (state.currentMode === "single") { + return { right: currentPage }; + } + + if (currentPage % 2 === 1) { + return { + left: currentPage > 1 ? currentPage - 1 : undefined, + right: currentPage, + }; + } else { + return { + left: currentPage, + right: currentPage < totalPages ? currentPage + 1 : currentPage, + }; + } +} + +function hasManualDualPageOverride(): boolean { + return localStorage.getItem("pdf-dual-page-manual") === "true"; +} + +function setManualDualPageOverride(mode: DualPageMode): void { + localStorage.setItem("pdf-dual-page-manual", "true"); + localStorage.setItem("pdf-dual-page-mode", mode); +} + +function getDualPageStyles(): string { + return ` + .pdf-dual-page .pdf-page-container { + display: inline-block; + vertical-align: top; + width: 50%; + } + + .pdf-dual-page .pdf-scroll-container { + display: flex; + flex-wrap: wrap; + justify-content: center; + } + + .pdf-single-page .pdf-page-container { + display: block; + width: 100%; + } + `; +} diff --git a/web/src/reader/pdf/pdf-links.ts b/web/src/reader/pdf/pdf-links.ts new file mode 100644 index 0000000..9099eb6 --- /dev/null +++ b/web/src/reader/pdf/pdf-links.ts @@ -0,0 +1,132 @@ +// Handle internal PDF links (cross-references, citations, TOC links) +// External links open in new tab +// Procedural implementation (no OOP) + +interface PDFLink { + url: string; + pageNumber?: number; + bounds: { x: number; y: number; width: number; height: number }; +} + +interface PDFLinkHandlerState { + doc: PDFDocumentProxy | null; + container: HTMLElement; + onPageNavigate: (pageNumber: number) => void; +} + +async function initializePDFLinkHandler( + container: HTMLElement, + onPageNavigate: (pageNumber: number) => void, + doc: PDFDocumentProxy, +): Promise { + const state: PDFLinkHandlerState = { + doc, + container, + onPageNavigate, + }; + + await setupPDFLinks(state); + + return state; +} + +async function setupPDFLinks(state: PDFLinkHandlerState): Promise { + if (!state.doc) return; + + for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) { + const page = await state.doc.getPage(pageNum); + const annotations = await page.getAnnotations(); + + for (const annotation of annotations) { + if (annotation.subtype === "Link") { + createPDFLinkElement(state, annotation, pageNum); + } + } + } +} + +function createPDFLinkElement( + state: PDFLinkHandlerState, + annotation: any, + pageNumber: number, +): void { + const pageElement = state.container.querySelector( + `[data-page-number="${pageNumber}"]`, + ); + if (!pageElement) return; + + const link = document.createElement("a"); + link.className = "pdf-internal-link"; + link.href = "javascript:void(0)"; + + if (annotation.rect) { + const rect = annotation.rect; + link.style.position = "absolute"; + link.style.left = `${rect[0]}px`; + link.style.top = `${rect[1]}px`; + link.style.width = `${rect[2] - rect[0]}px`; + link.style.height = `${rect[3] - rect[1]}px`; + link.style.cursor = "pointer"; + } + + link.addEventListener("click", (e) => { + e.preventDefault(); + handlePDFLinkClick(state, annotation); + }); + + pageElement.appendChild(link); +} + +async function handlePDFLinkClick( + state: PDFLinkHandlerState, + annotation: any, +): Promise { + if (!state.doc) return; + + if (annotation.url) { + if ( + annotation.url.startsWith("http://") || + annotation.url.startsWith("https://") + ) { + window.open(annotation.url, "_blank", "noopener,noreferrer"); + } else { + console.warn("Unhandled URL:", annotation.url); + } + } else if (annotation.dest) { + const pageNumber = await resolvePDFLinkDestination(state, annotation.dest); + state.onPageNavigate(pageNumber); + } +} + +async function resolvePDFLinkDestination( + state: PDFLinkHandlerState, + dest: string | any[], +): Promise { + if (!state.doc) return 1; + + try { + let explicitDest: any[]; + + if (typeof dest === "string") { + const destObj = await state.doc.getDestination(dest); + if (!destObj) return 1; + explicitDest = destObj; + } else { + explicitDest = dest; + } + + const ref = explicitDest[0]; + + if (typeof ref === "object" && ref !== null) { + const pageIndex = await state.doc.getPageIndex(ref); + return pageIndex + 1; + } else if (typeof ref === "number") { + return ref + 1; + } + + return 1; + } catch (error) { + console.error("Failed to resolve link destination:", error); + return 1; + } +} diff --git a/web/src/reader/pdf/pdf-minimap.ts b/web/src/reader/pdf/pdf-minimap.ts new file mode 100644 index 0000000..1bbcc41 --- /dev/null +++ b/web/src/reader/pdf/pdf-minimap.ts @@ -0,0 +1,191 @@ +// Mini-map navigation for PDF pages +// Procedural implementation (no OOP) + +interface PDFMiniMapState { + miniMap: HTMLElement; + currentPage: number; + totalPages: number; + thumbnails: Map; + onPageNavigate: (pageNumber: number) => void; +} + +function createPDFMiniMap( + container: HTMLElement, + onPageNavigate: (pageNumber: number) => void, +): PDFMiniMapState { + const miniMap = createMiniMapElement(container); + container.appendChild(miniMap); + + return { + miniMap, + currentPage: 1, + totalPages: 0, + thumbnails: new Map(), + onPageNavigate, + }; +} + +function createMiniMapElement(container: HTMLElement): HTMLElement { + const miniMap = document.createElement("div"); + miniMap.className = "pdf-minimap"; + miniMap.innerHTML = ` +
Pages
+
+
+ `; + + const style = document.createElement("style"); + style.textContent = getMiniMapStyles(); + miniMap.appendChild(style); + + return miniMap; +} + +async function initializePDFMiniMap( + state: PDFMiniMapState, + totalPages: number, + renderThumbnail: (page: number) => Promise, +): Promise { + const newState = { ...state, totalPages }; + + await generateMiniMapThumbnails(newState, renderThumbnail); + setupMiniMapEventListeners(newState); + + return newState; +} + +async function generateMiniMapThumbnails( + state: PDFMiniMapState, + renderThumbnail: (page: number) => Promise, +): Promise { + const container = state.miniMap.querySelector( + ".pdf-minimap-thumbnails", + ) as HTMLElement; + container.innerHTML = ""; + + for (let page = 1; page <= state.totalPages; page++) { + try { + const thumbnail = await renderThumbnail(page); + thumbnail.className = "pdf-minimap-thumbnail"; + thumbnail.dataset.pageNumber = page.toString(); + thumbnail.style.width = "80px"; + thumbnail.style.height = "auto"; + thumbnail.style.cursor = "pointer"; + thumbnail.style.marginBottom = "4px"; + + container.appendChild(thumbnail); + state.thumbnails.set(page, thumbnail); + } catch (error) { + console.error(`Failed to generate thumbnail for page ${page}:`, error); + } + } +} + +function setupMiniMapEventListeners(state: PDFMiniMapState): void { + const container = state.miniMap.querySelector(".pdf-minimap-thumbnails"); + + container?.addEventListener("click", (e) => { + const thumbnail = (e.target as HTMLElement).closest( + ".pdf-minimap-thumbnail", + ) as HTMLElement; + if (thumbnail) { + const pageNumber = parseInt(thumbnail.dataset.pageNumber || "1"); + state.onPageNavigate(pageNumber); + } + }); +} + +function updateMiniMapCurrentPage( + state: PDFMiniMapState, + pageNumber: number, +): PDFMiniMapState { + const indicator = state.miniMap.querySelector( + ".pdf-minimap-indicator", + ) as HTMLElement; + const thumbnail = state.thumbnails.get(pageNumber); + + if (thumbnail && indicator) { + const rect = thumbnail.getBoundingClientRect(); + indicator.style.top = `${thumbnail.offsetTop}px`; + indicator.style.height = `${rect.height}px`; + } + + state.thumbnails.forEach((thumb, page) => { + if (page === pageNumber) { + thumb.style.outline = "2px solid var(--accent)"; + thumb.style.opacity = "1"; + } else { + thumb.style.outline = "none"; + thumb.style.opacity = "0.7"; + } + }); + + return { ...state, currentPage: pageNumber }; +} + +function showMiniMap(state: PDFMiniMapState): void { + state.miniMap.style.display = "block"; +} + +function hideMiniMap(state: PDFMiniMapState): void { + state.miniMap.style.display = "none"; +} + +function toggleMiniMap(state: PDFMiniMapState): void { + const isVisible = state.miniMap.style.display !== "none"; + state.miniMap.style.display = isVisible ? "none" : "block"; +} + +function getMiniMapStyles(): string { + return ` + .pdf-minimap { + position: fixed; + right: 20px; + top: 50%; + transform: translateY(-50%); + width: 100px; + max-height: 80vh; + background: var(--bg-primary); + border: 1px solid var(--text-secondary); + border-radius: 8px; + padding: 8px; + overflow-y: auto; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + } + + .pdf-minimap-header { + font-size: 12px; + font-weight: bold; + text-align: center; + margin-bottom: 8px; + color: var(--text-primary); + } + + .pdf-minimap-thumbnails { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + } + + .pdf-minimap-thumbnail { + transition: outline 0.2s, opacity 0.2s; + border-radius: 2px; + } + + .pdf-minimap-thumbnail:hover { + opacity: 1 !important; + outline: 1px solid var(--text-secondary) !important; + } + + .pdf-minimap-indicator { + position: absolute; + left: 0; + right: 0; + border-left: 3px solid var(--accent); + pointer-events: none; + transition: top 0.3s ease-out; + } + `; +} diff --git a/web/src/reader/pdf/pdf-navigation.ts b/web/src/reader/pdf/pdf-navigation.ts new file mode 100644 index 0000000..ea2e5b0 --- /dev/null +++ b/web/src/reader/pdf/pdf-navigation.ts @@ -0,0 +1,216 @@ +// PDF navigation: page turning, zoom, fit modes +// Procedural style: Functions, not classes + +type PageFitMode = "fit-width" | "fit-page" | "fit-height" | "none"; + +interface PDFNavigationState { + currentPage: number; + totalPages: number; + currentScale: number; + fitMode: PageFitMode; + scrollContainer: HTMLElement | null; +} + +let navState: PDFNavigationState = { + currentPage: 1, + totalPages: 0, + currentScale: 1.0, + fitMode: "fit-width", + scrollContainer: null, +}; + +// ============================================================ +// Initialization +// ============================================================ + +export function initializePDFNavigation( + container: HTMLElement, + onPageChange: (pageNumber: number) => void, + onZoomChange: (scale: number) => void, +): void { + navState.scrollContainer = + container.querySelector(".pdf-scroll-container") || container; + setupPDFKeyboardNav(onPageChange); + setupPDFScrollTracking(onPageChange); +} + +export function setPDFTotalPages(totalPages: number): void { + navState.totalPages = totalPages; +} + +// ============================================================ +// Page Navigation +// ============================================================ + +export function goToPDFPage(pageNumber: number): void { + if (pageNumber < 1 || pageNumber > navState.totalPages) return; + + navState.currentPage = pageNumber; + + const callback = (window as any).pdfOnPageChange; + if (callback) callback(pageNumber); + + scrollToPDFPage(pageNumber); +} + +export function nextPDFPage(): void { + if (navState.currentPage < navState.totalPages) { + goToPDFPage(navState.currentPage + 1); + } +} + +export function previousPDFPage(): void { + if (navState.currentPage > 1) { + goToPDFPage(navState.currentPage - 1); + } +} + +function scrollToPDFPage(pageNumber: number): void { + if (!navState.scrollContainer) return; + + const pageElement = navState.scrollContainer.querySelector( + `[data-page-number="${pageNumber}"]`, + ); + if (pageElement) { + pageElement.scrollIntoView({ behavior: "smooth", block: "start" }); + } +} + +// ============================================================ +// Zoom Controls +// ============================================================ + +export function setPDFZoom(scale: number): void { + navState.currentScale = scale; + navState.fitMode = "none"; + + const callback = (window as any).pdfOnZoomChange; + if (callback) callback(scale); + + updatePDFZoom(); +} + +export function setPDFFitMode(mode: PageFitMode): void { + navState.fitMode = mode; + updatePDFZoom(); +} + +export function zoomPDFIn(): void { + setPDFZoom(navState.currentScale * 1.2); +} + +export function zoomPDFOut(): void { + setPDFZoom(navState.currentScale / 1.2); +} + +function updatePDFZoom(): void { + if (!navState.scrollContainer) return; + + const pages = navState.scrollContainer.querySelectorAll( + ".pdf-page-container", + ); + pages.forEach((page: Element) => { + (page as HTMLElement).style.transform = `scale(${navState.currentScale})`; + (page as HTMLElement).style.transformOrigin = "top center"; + }); +} + +// ============================================================ +// Keyboard Navigation +// ============================================================ + +function setupPDFKeyboardNav(onPageChange: (pageNumber: number) => void): void { + document.addEventListener("keydown", handlePDFKeyDown); +} + +function handlePDFKeyDown(e: KeyboardEvent): void { + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault(); + nextPDFPage(); + break; + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + previousPDFPage(); + break; + case "Home": + e.preventDefault(); + goToPDFPage(1); + break; + case "End": + e.preventDefault(); + goToPDFPage(navState.totalPages); + break; + } +} + +// ============================================================ +// Scroll Tracking +// ============================================================ + +function setupPDFScrollTracking( + onPageChange: (pageNumber: number) => void, +): void { + if (!navState.scrollContainer) return; + + let scrollTimeout: NodeJS.Timeout; + + navState.scrollContainer.addEventListener("scroll", () => { + clearTimeout(scrollTimeout); + + scrollTimeout = setTimeout(() => { + updateCurrentPageFromScroll(onPageChange); + }, 100); + }); +} + +function updateCurrentPageFromScroll( + onPageChange: (pageNumber: number) => void, +): void { + if (!navState.scrollContainer) return; + + const scrollTop = navState.scrollContainer.scrollTop; + const containerHeight = navState.scrollContainer.clientHeight; + + const pages = navState.scrollContainer.querySelectorAll("[data-page-number]"); + let maxVisibility = 0; + let mostVisiblePage = navState.currentPage; + + pages.forEach((page) => { + const element = page as HTMLElement; + const pageTop = element.offsetTop; + const pageBottom = pageTop + element.offsetHeight; + + const visibleTop = Math.max(scrollTop, pageTop); + const visibleBottom = Math.min(scrollTop + containerHeight, pageBottom); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + + if (visibleHeight > maxVisibility) { + maxVisibility = visibleHeight; + mostVisiblePage = parseInt(element.dataset.pageNumber || "1"); + } + }); + + if (mostVisiblePage !== navState.currentPage) { + navState.currentPage = mostVisiblePage; + onPageChange(mostVisiblePage); + } +} + +// ============================================================ +// Getters +// ============================================================ + +export function getCurrentPDFPage(): number { + return navState.currentPage; +} + +export function getTotalPDFPages(): number { + return navState.totalPages; +} + +export function getPDFScale(): number { + return navState.currentScale; +} diff --git a/web/src/reader/pdf/pdf-outline.ts b/web/src/reader/pdf/pdf-outline.ts new file mode 100644 index 0000000..90b6f50 --- /dev/null +++ b/web/src/reader/pdf/pdf-outline.ts @@ -0,0 +1,184 @@ +// PDF outline/TOC navigation +// Procedural implementation (no OOP) + +interface PDFOutlineNode { + id: string; + title: string; + destination: number | null; + pageNumber?: number; + children: PDFOutlineNode[]; + expanded: boolean; +} + +interface PDFOutlineState { + doc: PDFDocumentProxy | null; + outline: PDFOutlineNode[]; + flatMap: Map; +} + +async function initializePDFOutline( + doc: PDFDocumentProxy, +): Promise { + const state: PDFOutlineState = { + doc, + outline: [], + flatMap: new Map(), + }; + + return await loadPDFOutline(state); +} + +async function loadPDFOutline( + state: PDFOutlineState, +): Promise { + if (!state.doc) return state; + + const pdfOutline = await state.doc.getOutline(); + + if (!pdfOutline || pdfOutline.length === 0) { + return { ...state, outline: [] }; + } + + const outline = await parseOutlineNodes(state, pdfOutline); + + return { ...state, outline }; +} + +async function parseOutlineNodes( + state: PDFOutlineState, + nodes: OutlineTreeNode[], +): Promise { + const result: PDFOutlineNode[] = []; + + for (const node of nodes) { + const outlineNode: PDFOutlineNode = { + id: generateOutlineId(), + title: node.title, + destination: null, + children: [], + expanded: false, + }; + + if (node.dest) { + const pageNumber = await resolvePDFDestination(state, node.dest); + outlineNode.destination = pageNumber; + outlineNode.pageNumber = pageNumber; + state.flatMap.set(node.title, pageNumber); + } + + if (node.items && node.items.length > 0) { + outlineNode.children = await parseOutlineNodes(state, node.items); + } + + result.push(outlineNode); + } + + return result; +} + +async function resolvePDFDestination( + state: PDFOutlineState, + dest: string | any[], +): Promise { + if (!state.doc) return 1; + + try { + let explicitDest: any[]; + + if (typeof dest === "string") { + const destObj = await state.doc.getDestination(dest); + if (!destObj) return 1; + explicitDest = destObj; + } else { + explicitDest = dest; + } + + const ref = explicitDest[0]; + + if (typeof ref === "object" && ref !== null) { + const pageIndex = await state.doc.getPageIndex(ref); + return pageIndex + 1; + } else if (typeof ref === "number") { + return ref + 1; + } + + return 1; + } catch (error) { + console.error("Failed to resolve destination:", dest, error); + return 1; + } +} + +function generateOutlineId(): string { + return `outline-${Math.random().toString(36).substr(2, 9)}`; +} + +function getOutline(state: PDFOutlineState): PDFOutlineNode[] { + return state.outline; +} + +function getOutlineFlatMap(state: PDFOutlineState): Map { + return state.flatMap; +} + +function getCurrentChapter( + state: PDFOutlineState, + pageNumber: number, +): PDFOutlineNode | null { + return findChapterForPage(state.outline, pageNumber); +} + +function findChapterForPage( + nodes: PDFOutlineNode[], + pageNumber: number, +): PDFOutlineNode | null { + for (const node of nodes) { + if (node.pageNumber && node.pageNumber <= pageNumber) { + if (node.children.length > 0) { + const childMatch = findChapterForPage(node.children, pageNumber); + if (childMatch) return childMatch; + } + return node; + } + + if (node.children.length > 0) { + const childMatch = findChapterForPage(node.children, pageNumber); + if (childMatch) return childMatch; + } + } + + return null; +} + +function toggleOutlineNode( + state: PDFOutlineState, + nodeId: string, +): PDFOutlineState { + const updateNode = (nodes: PDFOutlineNode[]): PDFOutlineNode[] => { + return nodes.map((node) => { + if (node.id === nodeId) { + return { ...node, expanded: !node.expanded }; + } + if (node.children.length > 0) { + return { ...node, children: updateNode(node.children) }; + } + return node; + }); + }; + + return { ...state, outline: updateNode(state.outline) }; +} + +function findOutlineNode( + nodes: PDFOutlineNode[], + id: string, +): PDFOutlineNode | null { + for (const node of nodes) { + if (node.id === id) return node; + if (node.children.length > 0) { + const found = findOutlineNode(node.children, id); + if (found) return found; + } + } + return null; +} diff --git a/web/src/reader/pdf/pdf-page-sizes.ts b/web/src/reader/pdf/pdf-page-sizes.ts new file mode 100644 index 0000000..6e29ffe --- /dev/null +++ b/web/src/reader/pdf/pdf-page-sizes.ts @@ -0,0 +1,109 @@ +// Handle PDFs with variable page sizes +// Procedural implementation (no OOP) + +interface PageInfo { + pageNumber: number; + width: number; + height: number; + rotation: number; +} + +interface PDFPageSizesState { + pageSizes: Map; + defaultSize: { width: number; height: number }; +} + +function createPDFPageSizes(): PDFPageSizesState { + return { + pageSizes: new Map(), + defaultSize: { width: 595, height: 842 }, + }; +} + +async function loadPDFPageSizes( + state: PDFPageSizesState, + doc: any, +): Promise { + const pageSizes = new Map(); + + for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { + const page = await doc.getPage(pageNum); + const viewport = page.getViewport({ scale: 1 }); + + const pageInfo: PageInfo = { + pageNumber: pageNum, + width: viewport.width, + height: viewport.height, + rotation: viewport.rotation, + }; + + pageSizes.set(pageNum, pageInfo); + } + + return { ...state, pageSizes }; +} + +function getPDFPageSize( + state: PDFPageSizesState, + pageNumber: number, +): PageInfo | null { + return state.pageSizes.get(pageNumber) || null; +} + +function isPDFPageLandscape( + state: PDFPageSizesState, + pageNumber: number, +): boolean { + const size = getPDFPageSize(state, pageNumber); + if (!size) return false; + + const effectiveWidth = + size.rotation === 90 || size.rotation === 270 ? size.height : size.width; + const effectiveHeight = + size.rotation === 90 || size.rotation === 270 ? size.width : size.height; + + return effectiveWidth > effectiveHeight; +} + +function getPDFCommonSize(state: PDFPageSizesState): { + width: number; + height: number; +} { + if (state.pageSizes.size === 0) { + return state.defaultSize; + } + + const sizeGroups: Map< + string, + { width: number; height: number; count: number } + > = new Map(); + + state.pageSizes.forEach((size) => { + const key = getPageSizeKey(size.width, size.height); + const existing = sizeGroups.get(key); + + if (existing) { + existing.count++; + } else { + sizeGroups.set(key, { width: size.width, height: size.height, count: 1 }); + } + }); + + let mostCommon = state.defaultSize; + let maxCount = 0; + + sizeGroups.forEach((size) => { + if (size.count > maxCount) { + maxCount = size.count; + mostCommon = { width: size.width, height: size.height }; + } + }); + + return mostCommon; +} + +function getPageSizeKey(width: number, height: number): string { + const w = Math.round(width / 10) * 10; + const h = Math.round(height / 10) * 10; + return `${w}x${h}`; +} diff --git a/web/src/reader/pdf/pdf-rotation.ts b/web/src/reader/pdf/pdf-rotation.ts new file mode 100644 index 0000000..6eaaad5 --- /dev/null +++ b/web/src/reader/pdf/pdf-rotation.ts @@ -0,0 +1,82 @@ +// Handle rotated/landscape pages in PDFs +// Procedural implementation (no OOP) + +interface PDFRotationState { + rotations: Map; +} + +function createPDFRotation(): PDFRotationState { + return { + rotations: new Map(), + }; +} + +async function loadPDFPageRotations( + state: PDFRotationState, + doc: any, +): Promise { + const rotations = new Map(); + + for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) { + const page = await doc.getPage(pageNum); + const viewport = page.getViewport({ scale: 1 }); + const rotation = viewport.rotation; + + if (rotation !== 0) { + rotations.set(pageNum, rotation); + } + } + + return { ...state, rotations }; +} + +function getPDFPageRotation( + state: PDFRotationState, + pageNumber: number, +): number { + return state.rotations.get(pageNumber) || 0; +} + +function hasPDFPageRotation( + state: PDFRotationState, + pageNumber: number, +): boolean { + return state.rotations.has(pageNumber); +} + +function applyPDFRotation( + state: PDFRotationState, + canvas: HTMLCanvasElement, + pageNumber: number, +): void { + const rotation = getPDFPageRotation(state, pageNumber); + + if (rotation === 0) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.rotate((rotation * Math.PI) / 180); + ctx.translate(-canvas.width / 2, -canvas.height / 2); + ctx.restore(); +} + +function getPDFAdjustedViewport( + state: PDFRotationState, + pageNumber: number, + viewport: any, +): any { + const rotation = getPDFPageRotation(state, pageNumber); + + if (rotation === 0 || rotation === 180) { + return viewport; + } + + return { + ...viewport, + width: viewport.height, + height: viewport.width, + }; +} diff --git a/web/src/reader/pdf/pdf-search.ts b/web/src/reader/pdf/pdf-search.ts new file mode 100644 index 0000000..2df7dbd --- /dev/null +++ b/web/src/reader/pdf/pdf-search.ts @@ -0,0 +1,136 @@ +// Full-text search within PDF documents + +import { PDFDocumentProxy } from "pdfjs-dist"; + +interface SearchResult { + pageNumber: number; + text: string; + index: number; + context: string; +} + +// Full-text search within PDF documents +// Procedural implementation (no OOP) + +interface SearchResult { + pageNumber: number; + text: string; + index: number; + context: string; +} + +interface PDFSearchState { + doc: PDFDocumentProxy | null; + searchResults: SearchResult[]; + currentResultIndex: number; +} + +async function initializePDFSearch( + doc: PDFDocumentProxy, +): Promise { + return { + doc, + searchResults: [], + currentResultIndex: 0, + }; +} + +async function searchPDF( + state: PDFSearchState, + query: string, +): Promise { + if (!state.doc) return state; + + const searchResults: SearchResult[] = []; + const lowerQuery = query.toLowerCase(); + + for (let pageNum = 1; pageNum <= state.doc.numPages; pageNum++) { + const page = await state.doc.getPage(pageNum); + const textContent = await page.getTextContent(); + + let fullText = ""; + const textItems = textContent.items.map((item) => { + if (typeof item === "string") return ""; + fullText += item.str; + return item.str; + }); + + const pageText = textItems.join(" "); + const matches = findSearchMatches(pageText, lowerQuery, pageNum); + + searchResults.push(...matches); + } + + return { ...state, searchResults }; +} + +function findSearchMatches( + text: string, + query: string, + pageNumber: number, +): SearchResult[] { + const matches: SearchResult[] = []; + const lowerText = text.toLowerCase(); + let index = 0; + + while ((index = lowerText.indexOf(query, index)) !== -1) { + const start = Math.max(0, index - 50); + const end = Math.min(text.length, index + query.length + 50); + const context = text.slice(start, end); + + matches.push({ + pageNumber, + text: text.slice(index, index + query.length), + index, + context, + }); + + index += query.length; + } + + return matches; +} + +function goToNextSearchResult( + state: PDFSearchState, +): PDFSearchState & { result: SearchResult | null } { + if (state.searchResults.length === 0) { + return { ...state, result: null }; + } + + const newIndex = (state.currentResultIndex + 1) % state.searchResults.length; + return { + ...state, + currentResultIndex: newIndex, + result: state.searchResults[newIndex], + }; +} + +function goToPreviousSearchResult( + state: PDFSearchState, +): PDFSearchState & { result: SearchResult | null } { + if (state.searchResults.length === 0) { + return { ...state, result: null }; + } + + const newIndex = + (state.currentResultIndex - 1 + state.searchResults.length) % + state.searchResults.length; + return { + ...state, + currentResultIndex: newIndex, + result: state.searchResults[newIndex], + }; +} + +function getSearchResultCount(state: PDFSearchState): number { + return state.searchResults.length; +} + +function clearSearchResults(state: PDFSearchState): PDFSearchState { + return { + ...state, + searchResults: [], + currentResultIndex: 0, + }; +} diff --git a/web/src/reader/pdf/pdf-text-selection.ts b/web/src/reader/pdf/pdf-text-selection.ts new file mode 100644 index 0000000..ac713fe --- /dev/null +++ b/web/src/reader/pdf/pdf-text-selection.ts @@ -0,0 +1,187 @@ +// PDF text selection - Uses backend API for highlight creation +// Backend handles all position calculations for PDFs +// Procedural style: Functions, not classes + +interface PDFTextSelection { + pageNumber: number; + text: string; + rects: DOMRect[]; +} + +// ============================================================ +// Get PDF Text Selection +// ============================================================ + +export function getPDFTextSelection(): PDFTextSelection | null { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + + const range = selection.getRangeAt(0); + const text = range.toString(); + + if (!text) return null; + + // Get page number from selection + const pageElement = + range.commonAncestorContainer.closest?.("[data-page-number]"); + const pageNumber = pageElement?.dataset.pageNumber + ? parseInt(pageElement.dataset.pageNumber) + : getCurrentPDFPage(); + + // Get bounding rectangles + const rects: DOMRect[] = []; + for (const rect of range.getClientRects()) { + rects.push(rect); + } + + return { + pageNumber, + text, + rects, + }; +} + +// ============================================================ +// Create PDF Highlight (Backend Calculates Position) +// ============================================================ + +export async function createPDFHighlight( + mediaItemId: string, + selection: PDFTextSelection, + color: string, +): Promise { + const selectionData = { + selection_text: selection.text, + page_number: selection.pageNumber, + rects: selection.rects.map((rect) => ({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + })), + color, + }; + + // Send to backend - backend calculates all position formats + const response = await fetch(`/api/media-items/${mediaItemId}/highlights`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(selectionData), + }); + + if (!response.ok) { + throw new Error(`Failed to create highlight: ${response.statusText}`); + } + + return await response.json(); +} + +// ============================================================ +// Load and Render PDF Highlights (Backend Provides Positions) +// ============================================================ + +export async function loadAndRenderPDFHighlights( + mediaItemId: string, + container: HTMLElement, +): Promise { + const response = await fetch(`/api/media-items/${mediaItemId}/highlights`); + if (!response.ok) return []; + + const highlights: Highlight[] = await response.json(); + + for (const highlight of highlights) { + renderPDFHighlight(container, highlight); + } +} + +function renderPDFHighlight( + container: HTMLElement, + highlight: Highlight, +): void { + // Backend provides position data for PDF highlights + // Check which position format is available + + if ( + highlight.start_position && + highlight.start_position.startsWith("pdf:page:") + ) { + // Backend calculated page-based position + renderPDFHighlightByPosition(container, highlight); + } else if (highlight.percentage_start !== null) { + // Backend calculated percentage position + renderPDFHighlightByPercentage(container, highlight); + } +} + +function renderPDFHighlightByPosition( + container: HTMLElement, + highlight: Highlight, +): void { + // Parse position string: "pdf:page:45:offset:123" + const match = highlight.start_position.match(/pdf:page:(\d+):offset:(\d+)/); + if (!match) return; + + const pageNumber = parseInt(match[1], 10); + const offset = parseInt(match[2], 10); + + // Find the page element + const pageElement = container.querySelector( + `[data-page-number="${pageNumber}"]`, + ); + if (!pageElement) return; + + // Get text content at offset + const textContent = pageElement.querySelector(".pdf-text-layer")?.textContent; + if (!textContent) return; + + // Find the text at this offset + const textBefore = textContent.substring(0, offset); + const startChar = textBefore.length; + const endChar = startChar + (highlight.selection_text?.length || 10); + + if (startChar < textContent.length && endChar <= textContent.length) { + applyHighlightToTextContent( + pageElement as HTMLElement, + startChar, + endChar, + highlight.color, + ); + } +} + +function renderPDFHighlightByPercentage( + container: HTMLElement, + highlight: Highlight, +): void { + // Backend provides percentage - estimate position + const percentage = highlight.percentage_start || 0; + + // Find spine item closest to this percentage + const totalPages = container.querySelectorAll("[data-page-number]").length; + const targetPage = Math.ceil(percentage * totalPages); + + const pageElement = container.querySelector( + `[data-page-number="${targetPage}"]`, + ); + if (pageElement) { + // Highlight entire page (coarse-grained) + applyHighlightStylesToElement(pageElement as HTMLElement, highlight.color); + } +} + +function applyHighlightToTextContent( + element: HTMLElement, + startChar: number, + endChar: number, + color: string, +): void { + const text = element.textContent || ""; + const before = text.substring(0, startChar); + const selection = text.substring(startChar, endChar); + const after = text.substring(endChar); + + element.textContent = before + selection + after; + + // Use a mark to wrap the selected text + element.innerHTML = `${before}${selection}${after}`; +} diff --git a/web/src/reader/pdf/pdfjs-wrapper.ts b/web/src/reader/pdf/pdfjs-wrapper.ts new file mode 100644 index 0000000..9512ad5 --- /dev/null +++ b/web/src/reader/pdf/pdfjs-wrapper.ts @@ -0,0 +1,119 @@ +// Mozilla pdf.js integration for PDF rendering +// Procedural style: Functions, not classes + +import * as pdfjsLib from "pdfjs-dist"; + +// ============================================================ +// PDF.js Configuration +// ============================================================ + +export function configurePDFJS(): void { + pdfjsLib.GlobalWorkerOptions.workerSrc = "/static/js/pdf.worker.min.mjs"; + pdfjsLib.GlobalWorkerOptions.standardFontDataUrl = "/static/standard_fonts/"; + pdfjsLib.GlobalWorkerOptions.cMapUrl = "/static/cmaps/"; + pdfjsLib.GlobalWorkerOptions.cMapPacked = true; +} + +// ============================================================ +// PDF Document State +// ============================================================ + +interface PDFDocumentState { + doc: pdfjsLib.PDFDocumentProxy | null; + pages: Map; + metadata: PDFMetadata | null; +} + +interface PDFMetadata { + title: string; + author: string; + subject?: string; + keywords?: string; + creator?: string; + producer?: string; + creationDate?: Date; + modificationDate?: Date; + pageCount: number; +} + +let pdfState: PDFDocumentState = { + doc: null, + pages: new Map(), + metadata: null, +}; + +// ============================================================ +// Document Loading +// ============================================================ + +export async function loadPDFDocument(pdfBlob: Blob): Promise { + // Cleanup previous document + unloadPDFDocument(); + + const arrayBuffer = await pdfBlob.arrayBuffer(); + const loadingTask = pdfjsLib.getDocument({ + data: arrayBuffer, + }); + + pdfState.doc = await loadingTask.promise; + + // Extract metadata + const metadata = await pdfState.doc.getMetadata().catch(() => null); + const info = metadata?.info || {}; + + pdfState.metadata = { + title: info.Title || "Untitled", + author: info.Author || "Unknown", + subject: info.Subject, + keywords: info.Keywords, + creator: info.Creator, + producer: info.Producer, + creationDate: info.CreationDate ? new Date(info.CreationDate) : undefined, + modificationDate: info.ModDate ? new Date(info.ModDate) : undefined, + pageCount: pdfState.doc.numPages, + }; + + return pdfState.metadata; +} + +export async function getPDFPage( + pageNumber: number, +): Promise { + if (!pdfState.doc) { + throw new Error("PDF document not loaded"); + } + + // Check cache + if (pdfState.pages.has(pageNumber)) { + return pdfState.pages.get(pageNumber)!; + } + + // Load page + const page = await pdfState.doc.getPage(pageNumber); + pdfState.pages.set(pageNumber, page); + + return page; +} + +export async function getPDFPageText(pageNumber: number): Promise { + const page = await getPDFPage(pageNumber); + return await page.getTextContent(); +} + +export function getPDFMetadata(): PDFMetadata | null { + return pdfState.metadata; +} + +export function getPDFPageCount(): number { + return pdfState.doc?.numPages || 0; +} + +export function unloadPDFDocument(): void { + pdfState.pages.clear(); + pdfState.doc = null; + pdfState.metadata = null; +} + +export function unloadPDFPage(pageNumber: number): void { + pdfState.pages.delete(pageNumber); +} diff --git a/web/src/reader/pdf/text-layer-renderer.ts b/web/src/reader/pdf/text-layer-renderer.ts new file mode 100644 index 0000000..6d93014 --- /dev/null +++ b/web/src/reader/pdf/text-layer-renderer.ts @@ -0,0 +1,155 @@ +// Text layer rendering for PDF text selection and highlighting +// Procedural style: Functions, not classes + +// ============================================================ +// Render Functions +// ============================================================ + +export function renderTextLayer( + container: HTMLElement, + viewport: any, + textContent: any, + config: TextLayerConfig, +): void { + // Clear container + container.innerHTML = ""; + + // Apply styles + applyTextLayerStyles(container, config); + + // Render text items + const { items } = textContent; + + items.forEach((item: any, index: number) => { + if (typeof item === "string") return; + + const textDiv = createTextDiv(item, viewport, index); + container.appendChild(textDiv); + }); +} + +function createTextDiv(item: any, viewport: any, index: number): HTMLElement { + const div = document.createElement("div"); + div.className = "pdf-text-layer-text"; + div.textContent = item.str; + div.dataset.index = index.toString(); + + // Position the text div + const tx = pdfjsLib.Util.transform(viewport.transform, item.transform); + + const fontSize = Math.sqrt(tx[0] * tx[0] + tx[1] * tx[1]); + + div.style.left = `${tx[4]}px`; + div.style.top = `${tx[5] - fontSize}px`; + div.style.fontSize = `${fontSize}px`; + div.style.fontFamily = item.fontName || "sans-serif"; + + // Handle text direction + if (item.dir === "ttb") { + div.style.writingMode = "vertical-rl"; + } + + return div; +} + +interface TextLayerConfig { + theme: "light" | "sepia" | "dark" | "night" | "high-contrast"; +} + +function applyTextLayerStyles( + container: HTMLElement, + config: TextLayerConfig, +): void { + const style = document.createElement("style"); + style.textContent = getTextLayerCSS(config.theme); + container.appendChild(style); +} + +function getTextLayerCSS(theme: string): string { + const colors = getThemeColors(theme); + + return ` + .pdf-text-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + opacity: 1; + line-height: 1; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; + } + + .pdf-text-layer-text { + position: absolute; + white-space: pre; + cursor: text; + transform-origin: 0% 0%; + color: transparent; + pointer-events: auto; + } + + .pdf-text-layer-text::selection { + background: ${colors.highlight}; + color: transparent; + } + + .pdf-text-layer-text::-moz-selection { + background: ${colors.highlight}; + color: transparent; + } + + .pdf-highlight-overlay { + position: absolute; + background-color: ${colors.highlight}; + mix-blend-mode: multiply; + pointer-events: none; + } + `; +} + +function getThemeColors(theme: string): { highlight: string } { + const themes: Record = { + light: { highlight: "rgba(255, 255, 0, 0.3)" }, + sepia: { highlight: "rgba(255, 200, 0, 0.4)" }, + dark: { highlight: "rgba(255, 255, 0, 0.3)" }, + night: { highlight: "rgba(100, 150, 255, 0.3)" }, + "high-contrast": { highlight: "rgba(255, 255, 0, 0.5)" }, + }; + + return themes[theme] || themes["dark"]; +} + +// ============================================================ +// Selection Functions +// ============================================================ + +export function getPDFTextSelection(): { text: string; range: Range } | null { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + + const range = selection.getRangeAt(0); + const text = range.toString(); + + if (!text) return null; + + return { text, range }; +} + +export function getPDFSelectionRects(): DOMRect[] { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return []; + + const rects: DOMRect[] = []; + const range = selection.getRangeAt(0); + + for (const rect of range.getClientRects()) { + rects.push(rect); + } + + return rects; +}