diff --git a/web/src/reader/formats/comic/background-color.ts b/web/src/reader/formats/comic/background-color.ts new file mode 100644 index 0000000..c2306f1 --- /dev/null +++ b/web/src/reader/formats/comic/background-color.ts @@ -0,0 +1,135 @@ +// Background color options for manga/comics +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + const state = createBackgroundColorState(); + applyBackgroundColor(state.current); + + context.events.on("background-color:set", (detail: { color: BackgroundColor; customColor?: string }) => { + setBackgroundColor(state, detail.color, detail.customColor); + }); + + context.events.on("background-color:toggle", () => { + toggleBackgroundColor(state); + }); + + context.events.on("ui:show-settings", (detail: { container: HTMLElement }) => { + renderBackgroundColorPicker(detail.container, state); + }); + + context.events.on("reader:unload", () => { + const picker = document.querySelector(".background-color-picker"); + picker?.remove(); + }); +} + +type BackgroundColor = "black" | "white" | "gray" | "sepia" | "custom"; + +interface BackgroundColorState { + current: BackgroundColor; + customColor: string; +} + +const backgroundColors: Record = { + black: "#000000", + white: "#ffffff", + gray: "#333333", + sepia: "#f4ecd8", + custom: "", +}; + +function createBackgroundColorState( + initial: BackgroundColor = "black", +): BackgroundColorState { + const saved = localStorage.getItem("reader-background-color") as BackgroundColor; + return { + current: saved || initial, + customColor: "#000000", + }; +} + +function applyBackgroundColor(color: BackgroundColor): void { + const bgColor = backgroundColors[color]; + document.documentElement.style.setProperty("--reader-bg-color", bgColor); + const viewer = document.querySelector(".reader-content") as HTMLElement; + if (viewer) { + viewer.style.backgroundColor = bgColor; + } +} + +function setBackgroundColor( + state: BackgroundColorState, + color: BackgroundColor, + customColor?: string, +): BackgroundColorState { + state.current = color; + state.customColor = customColor || state.customColor; + + const bgColor = color === "custom" ? state.customColor : backgroundColors[color]; + document.documentElement.style.setProperty("--reader-bg-color", bgColor); + + const viewer = document.querySelector(".reader-content") as HTMLElement; + if (viewer) { + viewer.style.backgroundColor = bgColor; + } + + localStorage.setItem("reader-background-color", color); + + const picker = document.querySelector(".background-color-picker"); + if (picker) { + updateBackgroundColorUI(picker as HTMLElement, state); + } + + return state; +} + +function toggleBackgroundColor(state: BackgroundColorState): BackgroundColorState { + const order: BackgroundColor[] = ["black", "white", "gray", "sepia"]; + const currentIndex = order.indexOf(state.current); + const nextIndex = (currentIndex + 1) % order.length; + return setBackgroundColor(state, order[nextIndex]); +} + +function renderBackgroundColorPicker( + container: HTMLElement, + state: BackgroundColorState, +): void { + const existing = container.querySelector(".background-color-picker"); + existing?.remove(); + + const picker = document.createElement("div"); + picker.className = + "background-color-picker fixed bottom-24 left-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex gap-2 z-40"; + + const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"]; + + colors.forEach((color) => { + const btn = document.createElement("button"); + btn.className = `w-8 h-8 rounded-full border-2 ${ + state.current === color ? "border-blue-500" : "border-transparent" + }`; + btn.style.backgroundColor = backgroundColors[color]; + btn.title = color.charAt(0).toUpperCase() + color.slice(1); + btn.addEventListener("click", () => { + setBackgroundColor(state, color); + updateBackgroundColorUI(picker, state); + }); + picker.appendChild(btn); + }); + + container.appendChild(picker); +} + +function updateBackgroundColorUI( + container: HTMLElement, + state: BackgroundColorState, +): void { + const buttons = container.querySelectorAll("button"); + const colors: BackgroundColor[] = ["black", "white", "gray", "sepia"]; + + buttons.forEach((btn, index) => { + btn.classList.toggle("border-blue-500", colors[index] === state.current); + }); +} \ No newline at end of file diff --git a/web/src/reader/formats/comic/chapter-markers.ts b/web/src/reader/formats/comic/chapter-markers.ts new file mode 100644 index 0000000..1132a7f --- /dev/null +++ b/web/src/reader/formats/comic/chapter-markers.ts @@ -0,0 +1,148 @@ +// Chapter markers for manga/comics +// Visual indicators for chapter boundaries +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let state: ChapterMarkerState | null = null; + + context.events.on("reader:loaded", (detail: { chapters: ChapterInfo[]; currentPage: number }) => { + state = createChapterMarkerState(detail.chapters, detail.currentPage); + renderChapterMarkers(context.elements.readerContent, state); + }); + + context.events.on("page-changed", (detail: { page: number }) => { + if (state) { + updateCurrentChapter(state, detail.page); + } + }); + + context.events.on("chapter-markers:toggle", () => { + if (state) { + toggleChapterMarkers(state); + } + }); + + context.events.on("chapter-markers:navigate", (detail: { chapterNumber: number }) => { + if (state) { + scrollToChapter(state, detail.chapterNumber); + } + }); + + context.events.on("reader:unload", () => { + const markers = document.querySelector(".chapter-markers"); + markers?.remove(); + }); +} + +interface ChapterInfo { + chapterNumber: number; + pageStart: number; + pageEnd: number; + title?: string; +} + +interface ChapterMarkerState { + chapters: ChapterInfo[]; + currentChapter: number; + showMarkers: boolean; +} + +function createChapterMarkerState( + chapters: ChapterInfo[], + currentPage: number, +): ChapterMarkerState { + const currentChapter = + chapters.find((c) => currentPage >= c.pageStart && currentPage <= c.pageEnd) + ?.chapterNumber || 1; + + return { + chapters, + currentChapter, + showMarkers: true, + }; +} + +function renderChapterMarkers( + container: HTMLElement, + state: ChapterMarkerState, +): void { + if (!state.showMarkers) return; + + const markersContainer = document.createElement("div"); + markersContainer.className = + "chapter-markers absolute left-0 right-0 pointer-events-none z-10"; + + state.chapters.forEach((chapter) => { + const marker = document.createElement("div"); + marker.className = + "chapter-marker flex items-center gap-2 text-sm text-gray-400"; + + const isCurrentChapter = chapter.chapterNumber === state.currentChapter; + + marker.style.position = "absolute"; + marker.style.top = `${((chapter.pageStart - 1) / 100) * 100}%`; + marker.style.left = "10px"; + + marker.innerHTML = ` + + ${chapter.title || `Chapter ${chapter.chapterNumber}`} + + p.${chapter.pageStart} + ${isCurrentChapter ? '' : ""} + `; + + markersContainer.appendChild(marker); + }); + + const existing = container.querySelector(".chapter-markers"); + existing?.remove(); + container.appendChild(markersContainer); +} + +function updateCurrentChapter( + state: ChapterMarkerState, + currentPage: number, +): ChapterMarkerState { + const currentChapter = + state.chapters.find( + (c) => currentPage >= c.pageStart && currentPage <= c.pageEnd, + )?.chapterNumber || state.currentChapter; + + if (currentChapter !== state.currentChapter) { + state.currentChapter = currentChapter; + + const markers = document.querySelector(".chapter-markers"); + if (markers) { + renderChapterMarkers(markers.parentElement!, state); + } + } + + return state; +} + +function toggleChapterMarkers(state: ChapterMarkerState): ChapterMarkerState { + state.showMarkers = !state.showMarkers; + + const markers = document.querySelector(".chapter-markers"); + if (markers) { + markers.classList.toggle("hidden", !state.showMarkers); + } + + return state; +} + +function scrollToChapter( + state: ChapterMarkerState, + chapterNumber: number, +): void { + const chapter = state.chapters.find((c) => c.chapterNumber === chapterNumber); + if (chapter) { + window.dispatchEvent( + new CustomEvent("navigate-to-page", { + detail: { page: chapter.pageStart }, + }), + ); + } +} \ No newline at end of file diff --git a/web/src/reader/formats/comic/image-parser.ts b/web/src/reader/formats/comic/image-parser.ts new file mode 100644 index 0000000..c87e867 --- /dev/null +++ b/web/src/reader/formats/comic/image-parser.ts @@ -0,0 +1,81 @@ +// Comic/Manga Reader - Image-based pages +// Handles CBZ, comic archives, image directories +interface ReaderMetadata { + media_item_id: string; + title: string; + author: string; + cover_image_path: string; + library_type: "ebook" | "comic" | "manga" | "pdf"; + mime_type: string; + file_path: string; + total_pages?: number; +} +interface ComicReader { + type: "comic"; + images: Blob[]; + currentPage: number; +} +interface MangaReader { + type: "manga"; + images: Blob[]; + currentPage: number; + readingDirection: "rtl" | "vertical"; +} +// ============================================================ +// Comic Reader Initialization +// ============================================================ +export async function initializeComicReader( + metadata: ReaderMetadata, +): Promise { + const response = await fetch(metadata.file_path); + const archiveBlob = await response.blob(); + // Parse comic archive (CBZ) or image directory + const images = await parseComicArchive(archiveBlob); + return { + type: "comic", + images, + currentPage: 1, + }; +} +// ============================================================ +// Manga Reader Initialization +// ============================================================ +export async function initializeMangaReader( + metadata: ReaderMetadata, +): Promise { + const response = await fetch(metadata.file_path); + const archiveBlob = await response.blob(); + const images = await parseComicArchive(archiveBlob); + return { + type: "manga", + images, + currentPage: 1, + readingDirection: "rtl", // Default for manga + }; +} +// ============================================================ +// Comic Archive Parser +// ============================================================ +async function parseComicArchive(archiveBlob: Blob): Promise { + const JSZip = (await import("jszip")).default; + const zip = await JSZip.loadAsync(archiveBlob); + const images: Blob[] = []; + // Get all image files from archive + const files = Object.keys(zip.files).filter((filename) => + filename.match(/\.(jpg|jpeg|png|gif|webp)$/i), + ); + // Sort files naturally (page-01.jpg, page-02.jpg, etc.) + files.sort((a, b) => { + const aName = a.split("/").pop() || a; + const bName = b.split("/").pop() || b; + return aName.localeCompare(bName, undefined, { numeric: true }); + }); + // Extract images + for (const file of files) { + const fileData = await zip.file(file)?.async("blob"); + if (fileData) { + images.push(fileData); + } + } + return images; +} diff --git a/web/src/reader/formats/comic/page-cache.ts b/web/src/reader/formats/comic/page-cache.ts new file mode 100644 index 0000000..1dece20 --- /dev/null +++ b/web/src/reader/formats/comic/page-cache.ts @@ -0,0 +1,188 @@ +// Lazy-loading page cache with 5-page ahead prefetch +// Shared by both comic and manga readers +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; +import { detectPanels } from "./panel-detection.service"; + +export function init(context: ReaderContext): void { + let state: PageCacheState | null = null; + + context.events.on("reader:loaded", (detail: { mediaItemId: string }) => { + state = createPageCache(detail.mediaItemId); + }); + + context.events.on("page-cache:get", async (detail: { pageNumber: number }) => { + if (state) { + const result = await getCachedPage(state, detail.pageNumber); + context.events.emit("page-cache:loaded", { + page: detail.pageNumber, + image: result.page, + }); + } + }); + + context.events.on("page-cache:prefetch", (detail: { startPage: number }) => { + if (state) { + prefetchPages(state, detail.startPage); + } + }); + + context.events.on("page-cache:cleanup", (detail: { currentPage: number }) => { + if (state) { + cleanupPageCache(state, detail.currentPage); + } + }); + + context.events.on("page-cache:detected-panels", async (detail: { pageNumber: number }) => { + if (state) { + const panels = await detectPagePanels(state, detail.pageNumber); + context.events.emit("page-cache:panels-ready", { + pageNumber: detail.pageNumber, + panels, + }); + } + }); + + context.events.on("reader:unload", () => { + if (state) { + state.cache.clear(); + state.loading.clear(); + state.panelData.clear(); + } + }); +} + +export interface PageCacheState { + cache: Map; + loading: Set; + maxAhead: number; + mediaItemId: string; + panelData: Map; +} + +export function createPageCache(mediaItemId: string): PageCacheState { + return { + cache: new Map(), + loading: new Set(), + maxAhead: 5, + mediaItemId, + panelData: new Map(), + }; +} + +export async function getCachedPage( + state: PageCacheState, + pageNumber: number, +): Promise { + if (state.cache.has(pageNumber)) { + return { ...state, page: state.cache.get(pageNumber)! }; + } + + if (state.loading.has(pageNumber)) { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (state.cache.has(pageNumber)) { + clearInterval(checkInterval); + resolve({ ...state, page: state.cache.get(pageNumber)! }); + } + }, 100); + }) as Promise; + } + + state.loading.add(pageNumber); + + const img = await loadComicPage(state, pageNumber); + + state.cache.set(pageNumber, img); + state.loading.delete(pageNumber); + + prefetchPages(state, pageNumber + 1); + cleanupPageCache(state, pageNumber); + + return { ...state, page: img }; +} + +export async function loadComicPage( + state: PageCacheState, + pageNumber: number, +): Promise { + const token = localStorage.getItem("token"); + const response = await fetch( + `/readers/${state.mediaItemId}/pages/${pageNumber}`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to load page ${pageNumber}`); + } + + const blob = await response.blob(); + const img = new Image(); + img.src = URL.createObjectURL(blob); + await new Promise((resolve) => { + img.onload = resolve; + }); + return img; +} + +export function prefetchPages(state: PageCacheState, startPage: number): void { + for (let i = startPage; i < startPage + state.maxAhead; i++) { + if (!state.cache.has(i) && !state.loading.has(i)) { + loadComicPage(state, i).then((img) => { + state.cache.set(i, img); + }); + } + } +} + +export function cleanupPageCache( + state: PageCacheState, + currentPage: number, +): PageCacheState { + const keepPages = 10; + const newCache = new Map(state.cache); + + for (const [page] of state.cache) { + if (page < currentPage - keepPages) { + newCache.delete(page); + } + } + + state.cache = newCache; + return state; +} + +export async function detectPagePanels( + state: PageCacheState, + pageNumber: number, +): Promise { + if (state.panelData?.has(pageNumber)) { + return state.panelData.get(pageNumber)!.panels; + } + + let image: HTMLImageElement; + if (state.cache.has(pageNumber)) { + image = state.cache.get(pageNumber)!; + } else { + image = await loadComicPage(state, pageNumber); + } + + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + const result = await detectPanels(imageData, true); + + if (!state.panelData) { + state.panelData = new Map(); + } + state.panelData.set(pageNumber, result); + + return result.panels; +} \ No newline at end of file diff --git a/web/src/reader/formats/comic/page-order.ts b/web/src/reader/formats/comic/page-order.ts new file mode 100644 index 0000000..340637b --- /dev/null +++ b/web/src/reader/formats/comic/page-order.ts @@ -0,0 +1,142 @@ +// Page order presets for manga/comics +// Auto-detect Japanese vs Western reading order +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let state: PageOrderState | null = null; + + context.events.on("reader:loaded", (detail: { totalPages: number; pageNames: string[] }) => { + state = createPageOrderState(detail.totalPages, detail.pageNames); + }); + + context.events.on("page-order:set", (detail: { mode: PageOrderMode }) => { + if (state) { + setPageOrderMode(state, detail.mode, context); + } + }); + + context.events.on("page-order:get", () => { + if (state) { + const order = getPageOrder(state); + context.events.emit("page-order:current", { order }); + } + }); + + context.events.on("page-order:reorder", (detail: { pageNumbers: number[] }) => { + if (state) { + const reordered = reorderPages(state, detail.pageNumbers); + context.events.emit("page-order:reordered", { pages: reordered }); + } + }); + + context.events.on("page-order:display-number", (detail: { actualPage: number }) => { + if (state) { + const displayPage = getDisplayPageNumber(state, detail.actualPage); + context.events.emit("page-order:display-page", { displayPage }); + } + }); +} + +type PageOrderMode = "auto" | "japanese" | "western"; + +interface PageOrderConfig { + mode: PageOrderMode; + detectedOrder: PageOrderMode; + userOverride: boolean; +} + +interface PageOrderState { + config: PageOrderConfig; + totalPages: number; +} + +function detectPageOrder(pageNames: string[]): PageOrderMode { + if (pageNames.length < 2) return "western"; + + const firstPage = pageNames[0].toLowerCase(); + const lastPage = pageNames[pageNames.length - 1].toLowerCase(); + + const hasFrontCover = /cover|front|001/.test(firstPage); + const hasBackCover = /back|end|最后的/.test(lastPage); + + if (hasFrontCover && !hasBackCover) { + return "western"; + } + if (hasBackCover && !hasFrontCover) { + return "japanese"; + } + + const chapterMatches = pageNames.filter((n) => /ch-\d+|chapter/i.test(n)); + if (chapterMatches.length > 0) { + const firstChapter = chapterMatches[0]; + const pageNum = parseInt(firstChapter.match(/\d+/)?.[0] || "0"); + return pageNum > 0 ? "western" : "japanese"; + } + + return "western"; +} + +function createPageOrderState( + totalPages: number, + pageNames: string[], +): PageOrderState { + const detectedOrder = detectPageOrder(pageNames); + + return { + config: { + mode: "auto", + detectedOrder, + userOverride: false, + }, + totalPages, + }; +} + +function setPageOrderMode( + state: PageOrderState, + mode: PageOrderMode, + context: ReaderContext, +): PageOrderState { + state.config = { + ...state.config, + mode, + userOverride: mode !== "auto", + }; + + const order = getPageOrder(state); + context.events.emit("page-order:changed", { mode, order }); + + return state; +} + +function getPageOrder(state: PageOrderState): PageOrderMode { + if (state.config.mode === "auto") { + return state.config.detectedOrder; + } + return state.config.mode; +} + +function reorderPages(state: PageOrderState, pageNumbers: number[]): number[] { + const order = getPageOrder(state); + + if (order === "japanese") { + return [...pageNumbers].reverse(); + } + + return pageNumbers; +} + +function getDisplayPageNumber( + state: PageOrderState, + actualPage: number, +): number { + const order = getPageOrder(state); + + if (order === "japanese") { + return state.totalPages - actualPage + 1; + } + + return actualPage; +} \ No newline at end of file diff --git a/web/src/reader/formats/comic/page-scrubber.ts b/web/src/reader/formats/comic/page-scrubber.ts new file mode 100644 index 0000000..1ee94d2 --- /dev/null +++ b/web/src/reader/formats/comic/page-scrubber.ts @@ -0,0 +1,120 @@ +// Page slider/scrubber for quick navigation +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let state: PageScrubberState | null = null; + let scrubberElement: HTMLElement | null = null; + + context.events.on("reader:loaded", (detail: { currentPage: number; totalPages: number; container: HTMLElement }) => { + state = createPageScrubber(detail.container, detail.currentPage, detail.totalPages); + }); + + context.events.on("page-scrubber:show", () => { + if (state) { + showPageScrubber(state); + } + }); + + context.events.on("page-scrubber:hide", () => { + if (state) { + hidePageScrubber(state); + } + }); + + context.events.on("page-changed", (detail: { page: number }) => { + if (state) { + updatePageScrubber(state, detail.page); + } + }); + + context.events.on("reader:unload", () => { + scrubberElement?.remove(); + scrubberElement = null; + }); +} + +interface PageScrubberState { + currentPage: number; + totalPages: number; + container: HTMLElement; +} + +function createPageScrubber( + container: HTMLElement, + currentPage: number, + totalPages: number, +): PageScrubberState { + const state: PageScrubberState = { + currentPage, + totalPages, + container, + }; + + renderPageScrubber(state); + return state; +} + +function renderPageScrubber(state: PageScrubberState): void { + const existing = state.container.querySelector(".page-scrubber"); + existing?.remove(); + + const scrubber = document.createElement("div"); + scrubber.className = + "page-scrubber fixed bottom-20 left-1/2 transform -translate-x-1/2 bg-gray-900 bg-opacity-90 rounded-full px-4 py-2 flex items-center gap-4 z-40"; + scrubber.innerHTML = ` + ${state.currentPage} + + ${state.totalPages} + `; + + const slider = scrubber.querySelector(".page-slider") as HTMLInputElement; + slider.addEventListener("input", (e) => { + const targetPage = parseInt((e.target as HTMLInputElement).value); + updatePageScrubber(state, targetPage); + }); + + slider.addEventListener("change", () => { + const targetPage = parseInt(slider.value); + dispatchPageNavigationEvent(targetPage); + }); + + state.container.appendChild(scrubber); +} + +function updatePageScrubber( + state: PageScrubberState, + currentPage: number, +): PageScrubberState { + state.currentPage = currentPage; + + const label = state.container.querySelector(".page-label"); + if (label) { + label.textContent = String(currentPage); + } + + return state; +} + +function showPageScrubber(state: PageScrubberState): void { + const scrubber = state.container.querySelector(".page-scrubber"); + scrubber?.classList.remove("hidden"); +} + +function hidePageScrubber(state: PageScrubberState): void { + const scrubber = state.container.querySelector(".page-scrubber"); + scrubber?.classList.add("hidden"); +} + +function dispatchPageNavigationEvent(page: number): void { + window.dispatchEvent( + new CustomEvent("navigate-to-page", { detail: { page } }), + ); +} \ No newline at end of file diff --git a/web/src/reader/formats/comic/panel-detection.ml.ts b/web/src/reader/formats/comic/panel-detection.ml.ts new file mode 100644 index 0000000..9d1a26b --- /dev/null +++ b/web/src/reader/formats/comic/panel-detection.ml.ts @@ -0,0 +1,93 @@ +// ML-based panel detection using COCO-SSD pre-trained model + +interface Panel { + id: string; + x: number; + y: number; + width: number; + height: number; + reading_order: number; +} + +let model: any = null; +let tfLoaded = false; + +async function loadTF(): Promise { + if (tfLoaded) return; + + // Load TensorFlow.js + await import("@tensorflow/tfjs"); + tfLoaded = true; +} + +async function loadModel(): Promise { + if (model) return; + + await loadTF(); + + // Load COCO-SSD model (pre-trained on millions of images) + const cocoSsd = await import("@tensorflow-models/coco-ssd"); + model = await cocoSsd.load({ + base: "lite_mobilenet_v2", // Smaller, faster model + }); +} + +async function detectPanelsML(imageData: ImageData): Promise { + await loadModel(); + + // Create HTMLCanvasElement to run model inference + const canvas = document.createElement("canvas"); + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext("2d")!; + ctx.putImageData(imageData, 0, 0); + + // Run COCO-SSD model + const predictions = await model.detect(canvas); + + // Filter predictions to find rectangular regions (panels) + // COCO-SSD detects common objects, we look for rectangular ones + const panels: Panel[] = []; + const imgWidth = imageData.width; + const imgHeight = imageData.height; + + for (let i = 0; i < predictions.length; i++) { + const pred = predictions[i]; + + // COCO-SSD detects "book" and similar objects + // We filter for reasonable panel-like detections + const [x, y, w, h] = pred.bbox; + const aspectRatio = w / h; + + const isRectangular = + aspectRatio > 0.3 && // Not too tall/thin + aspectRatio < 5 && // Not too wide + w > imgWidth * 0.05 && // Not too small + h > imgHeight * 0.05; + + if (isRectangular) { + panels.push({ + id: `ml-panel-${i}`, + x: (x / imgWidth) * 100, + y: (y / imgHeight) * 100, + width: (w / imgWidth) * 100, + height: (h / imgHeight) * 100, + reading_order: i, + }); + } + } + + // Sort panels by reading order + panels.sort((a, b) => { + const rowA = Math.floor(a.y / 25); + const rowB = Math.floor(b.y / 25); + if (rowA !== rowB) return rowA - rowB; + return a.x - b.x; + }); + + panels.forEach((p, i) => (p.reading_order = i)); + + return panels; +} + +export { detectPanelsML, loadModel }; diff --git a/web/src/reader/formats/comic/panel-detection.opencv.ts b/web/src/reader/formats/comic/panel-detection.opencv.ts new file mode 100644 index 0000000..fa4c9c7 --- /dev/null +++ b/web/src/reader/formats/comic/panel-detection.opencv.ts @@ -0,0 +1,113 @@ +// OpenCV.js-based edge detection for panel boundaries + +interface Panel { + id: string; + x: number; + y: number; + width: number; + height: number; + reading_order: number; +} + +let openCVLoaded = false; + +async function loadOpenCV(): Promise { + if (openCVLoaded) return; + + // OpenCV.js loads asynchronously and registers globally + await import("@techstark/opencv-js"); + + // Wait for OpenCV to be ready + return new Promise((resolve) => { + const check = () => { + if ((window as any).cv && (window as any).cv.Mat) { + openCVLoaded = true; + resolve(); + } else { + setTimeout(check, 50); + } + }; + check(); + }); +} + +async function detectPanelsOpenCV(imageData: ImageData): Promise { + await loadOpenCV(); + + const cv = (window as any).cv; + + // Create matrices from ImageData + const src = cv.matFromImageData(imageData); + const gray = new cv.Mat(); + const blurred = new cv.Mat(); + const edges = new cv.Mat(); + const contours = new cv.Mat(); + const hierarchy = new cv.Mat(); + + try { + // Convert to grayscale + cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0); + + // Apply Gaussian blur to reduce noise + cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT); + + // Detect edges using Canny + cv.Canny(blurred, edges, 50, 150, 3, false); + + // Find contours + cv.findContours( + edges, + contours, + hierarchy, + cv.RETR_EXTERNAL, + cv.CHAIN_APPROX_SIMPLE, + ); + + // Convert contours to panels + const panels: Panel[] = []; + const imgWidth = imageData.width; + const imgHeight = imageData.height; + + for (let i = 0; i < contours.size(); i++) { + const rect = cv.boundingRect(contours.get(i)); + const aspectRatio = rect.width / rect.height; + + // Filter: reject very small or very thin contours + const minSize = Math.min(imgWidth, imgHeight) * 0.05; + if (rect.width < minSize || rect.height < minSize) continue; + if (aspectRatio < 0.1 || aspectRatio > 10) continue; + + panels.push({ + id: `opencv-panel-${i}`, + x: (rect.x / imgWidth) * 100, + y: (rect.y / imgHeight) * 100, + width: (rect.width / imgWidth) * 100, + height: (rect.height / imgHeight) * 100, + reading_order: i, + }); + } + + // Sort panels by reading order (top-left to bottom-right) + panels.sort((a, b) => { + const rowA = Math.floor(a.y / 25); + const rowB = Math.floor(b.y / 25); + if (rowA !== rowB) return rowA - rowB; + return a.x - b.x; + }); + + // Reassign reading order after sorting + panels.forEach((p, i) => (p.reading_order = i)); + + return panels; + } finally { + // Clean up OpenCV matrices + src.delete(); + gray.delete(); + blurred.delete(); + edges.delete(); + contours.delete(); + hierarchy.delete(); + } +} + +export { detectPanelsOpenCV, loadOpenCV }; diff --git a/web/src/reader/formats/comic/panel-detection.service.ts b/web/src/reader/formats/comic/panel-detection.service.ts new file mode 100644 index 0000000..516d5bd --- /dev/null +++ b/web/src/reader/formats/comic/panel-detection.service.ts @@ -0,0 +1,77 @@ +// Main panel detection service with fallback chain +// Priority: OpenCV → ML → Grid → Manual Editor +import { detectPanelsOpenCV } from "./panel-detection.opencv"; +import { detectPanelsML } from "./panel-detection.ml"; +import { detectPanelsGrid } from "./panel-detector"; + +interface DetectionResult { + panels: Panel[]; + method: "opencv" | "ml" | "grid" | "manual"; + confidence: number; +} + +interface Panel { + id: string; + x: number; + y: number; + width: number; + height: number; + reading_order: number; +} + +async function detectPanels( + imageData: ImageData, + allowManual: boolean = true, +): Promise { + // Tier 1: OpenCV Edge Detection + try { + const panels = await detectPanelsOpenCV(imageData); + if (validatePanels(panels, imageData)) { + return { panels, method: "opencv", confidence: 0.85 }; + } + } catch (e) { + console.warn("OpenCV detection failed:", e); + } + + // Tier 2: ML Detection (COCO-SSD) + try { + const panels = await detectPanelsML(imageData); + if (validatePanels(panels, imageData)) { + return { panels, method: "ml", confidence: 0.9 }; + } + } catch (e) { + console.warn("ML detection failed:", e); + } + + // Tier 3: Grid Detection (baseline) + const panels = detectPanelsGrid(imageData); + if (allowManual && panels.length === 0) { + return { + panels: [], + method: "manual" as const, + confidence: 0, + }; + } + return { panels, method: "grid", confidence: 0.5 }; +} + +function validatePanels(panels: Panel[], imageData: ImageData): boolean { + // Must have at least 1 panel + if (panels.length === 0) return false; + // Should not have too many panels (probably noise) + if (panels.length > 30) return false; + // Panels should cover reasonable area (not all empty space) + let totalArea = panels.reduce((sum, p) => sum + p.width * p.height, 0); + if (totalArea < 10 || totalArea > 100) return false; + // Check panel sizes are reasonable relative to image dimensions + const minPanelSize = Math.min(imageData.width, imageData.height) * 0.02; + const tooSmall = panels.some( + (p) => + (p.width / 100) * imageData.width < minPanelSize || + (p.height / 100) * imageData.height < minPanelSize, + ); + if (tooSmall) return false; + return true; +} + +export { detectPanels, DetectionResult, Panel }; diff --git a/web/src/reader/formats/comic/panel-detector.ts b/web/src/reader/formats/comic/panel-detector.ts new file mode 100644 index 0000000..7172749 --- /dev/null +++ b/web/src/reader/formats/comic/panel-detector.ts @@ -0,0 +1,172 @@ +// Grid-based panel detection (fast, lightweight) +// Keep as final fallback + +export interface Panel { + id: string; + x: number; + y: number; + width: number; + height: number; + reading_order: number; +} + +interface GridConfig { + rows: number; + cols: number; +} + +function detectPanelsGrid( + imageData: ImageData, + config: GridConfig = { rows: 3, cols: 3 }, +): Panel[] { + const panels: Panel[] = []; + const cellWidth = imageData.width / config.cols; + const cellHeight = imageData.height / config.rows; + + for (let y = 0; y < config.rows; y++) { + for (let x = 0; x < config.cols; x++) { + const cell = extractCell(imageData, x, y, cellWidth, cellHeight); + + if (!isEmpty(cell)) { + panels.push({ + id: `panel-${panels.length}`, + x: (x / config.cols) * 100, + y: (y / config.rows) * 100, + width: (1 / config.cols) * 100, + height: (1 / config.rows) * 100, + reading_order: panels.length, + }); + } + } + } + + return mergeAdjacentPanels(panels); +} + +function isEmpty(cellData: ImageData): boolean { + // Simple edge detection to find empty space + // Count white/transparent pixels + let emptyPixels = 0; + const totalPixels = cellData.width * cellData.height; + const threshold = 0.95; // 95% empty = empty cell + + for (let i = 0; i < cellData.data.length; i += 4) { + const r = cellData.data[i]; + const g = cellData.data[i + 1]; + const b = cellData.data[i + 2]; + const a = cellData.data[i + 3]; + + // Consider white or transparent as empty + if (a < 10 || (r > 250 && g > 250 && b > 250)) { + emptyPixels++; + } + } + + return emptyPixels / totalPixels > threshold; +} + +function mergeAdjacentPanels(panels: Panel[]): Panel[] { + // Merge panels that are next to each other + // Simplified algorithm - can be enhanced + const merged: Panel[] = []; + const used = new Set(); + + for (let i = 0; i < panels.length; i++) { + if (used.has(i)) continue; + + let current = { ...panels[i] }; + used.add(i); + + // Look for adjacent panels + for (let j = i + 1; j < panels.length; j++) { + if (used.has(j)) continue; + if (isAdjacent(current, panels[j])) { + current = mergePanels(current, panels[j]); + used.add(j); + } + } + + merged.push(current); + } + + return merged; +} + +function extractCell( + imageData: ImageData, + gridX: number, + gridY: number, + cellWidth: number, + cellHeight: number, +): ImageData { + const startX = Math.floor(gridX * cellWidth); + const startY = Math.floor(gridY * cellHeight); + const width = Math.floor(cellWidth); + const height = Math.floor(cellHeight); + + const cellData = new Uint8ClampedArray(width * height * 4); + // Copy pixels for the cell region + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4; + const destIdx = (y * width + x) * 4; + cellData[destIdx] = imageData.data[srcIdx]; + cellData[destIdx + 1] = imageData.data[srcIdx + 1]; + cellData[destIdx + 2] = imageData.data[srcIdx + 2]; + cellData[destIdx + 3] = imageData.data[srcIdx + 3]; + } + } + + return new ImageData(cellData, width, height); +} + +function isAdjacent(p1: Panel, p2: Panel): boolean { + const tolerance = 5; // 5% tolerance for alignment + // Check horizontal adjacency + if ( + Math.abs(p1.y - p2.y) < tolerance && + Math.abs(p1.height - p2.height) < tolerance + ) { + return ( + Math.abs(p1.x + p1.width - p2.x) < tolerance || + Math.abs(p2.x + p2.width - p1.x) < tolerance + ); + } + // Check vertical adjacency + if ( + Math.abs(p1.x - p2.x) < tolerance && + Math.abs(p1.width - p2.width) < tolerance + ) { + return ( + Math.abs(p1.y + p1.height - p2.y) < tolerance || + Math.abs(p2.y + p2.height - p1.y) < tolerance + ); + } + return false; +} + +function mergePanels(p1: Panel, p2: Panel): Panel { + const minX = Math.min(p1.x, p2.x); + const minY = Math.min(p1.y, p2.y); + const maxX = Math.max(p1.x + p1.width, p2.x + p2.width); + const maxY = Math.max(p1.y + p1.height, p2.y + p2.height); + + return { + id: p1.id, + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + reading_order: Math.min(p1.reading_order, p2.reading_order), + }; +} + +// ADD THIS EXPORT AT THE END OF THE FILE +export { + detectPanelsGrid, + isEmpty, + mergeAdjacentPanels, + extractCell, + isAdjacent, + mergePanels, +}; diff --git a/web/src/reader/formats/comic/panel-editor.ts b/web/src/reader/formats/comic/panel-editor.ts new file mode 100644 index 0000000..d6509e7 --- /dev/null +++ b/web/src/reader/formats/comic/panel-editor.ts @@ -0,0 +1,168 @@ +// Manual panel editor for admins/power users + +import { Alpine } from "../../alpine"; +import { apiPut } from "../../api"; +import { Panel } from "./panel-detector"; +import { detectPanels } from "./panel-detection.service"; + +async function loadImageForPage(pageNumber: number): Promise { + const mediaItemId = document.body.dataset.mediaItemId; + if (!mediaItemId) { + throw new Error("No mediaItemId found"); + } + + const token = localStorage.getItem("token"); + const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to load page ${pageNumber}`); + } + + const blob = await response.blob(); + const img = new Image(); + img.src = URL.createObjectURL(blob); + + await new Promise((resolve) => { + img.onload = () => resolve(); + }); + + return img; +} + +function getCurrentPageNumber(): number { + // Try Alpine first + const Alpine = (window as any).Alpine; + if (Alpine) { + const readerEl = document.querySelector('[x-data="readerShell"]'); + if (readerEl) { + const readerShell = Alpine.$data(readerEl); + if (readerShell?.currentPage) { + return readerShell.currentPage; + } + } + } + + // Fallback: check for dataset attribute on reader content + const content = document.getElementById("reader-content"); + const pageFromDataset = content?.dataset.currentPage; + if (pageFromDataset) { + return parseInt(pageFromDataset, 10); + } + + // Final fallback + return 1; +} +function loadPage(pageNumber: number): void { + // Dispatch event for reader to handle navigation + window.dispatchEvent( + new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }), + ); +} + +function openPanelEditor(pageNumber: number): void { + const modal = document.getElementById("panel-editor-modal"); + modal?.classList.remove("hidden"); + + // Load page image + const canvas = document.getElementById( + "panel-editor-canvas", + ) as HTMLCanvasElement; + const ctx = canvas?.getContext("2d"); + + // Load image and draw to canvas + loadImageForPage(pageNumber).then((image) => { + canvas!.width = image.width; + canvas!.height = image.height; + ctx?.drawImage(image, 0, 0); + + // Allow user to draw panels + enablePanelDrawing(canvas!); + }); +} + +function enablePanelDrawing(canvas: HTMLCanvasElement): void { + let isDrawing = false; + let startX = 0; + let startY = 0; + + canvas.addEventListener("mousedown", (e) => { + isDrawing = true; + startX = e.offsetX; + startY = e.offsetY; + }); + + canvas.addEventListener("mousemove", (e) => { + if (!isDrawing) return; + + // Draw selection rectangle + const ctx = canvas.getContext("2d"); + ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY); + }); + + canvas.addEventListener("mouseup", (e) => { + if (!isDrawing) return; + isDrawing = false; + + // Save panel + const panel: Panel = { + id: `manual-${Date.now()}`, + x: (startX / canvas.width) * 100, + y: (startY / canvas.height) * 100, + width: ((e.offsetX - startX) / canvas.width) * 100, + height: ((e.offsetY - startY) / canvas.height) * 100, + reading_order: 0, // Will be set by server + }; + + saveManualPanel(panel); + }); +} + +async function saveManualPanel(panel: Panel): Promise { + const mediaItemId = document.body.dataset.mediaItemId; + const pageNumber = getCurrentPageNumber(); + + await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, { + detection_method: "manual", + panels: [panel], + }); + + // Reload with new panels + loadPage(pageNumber); +} + +// Re-detect panels using detection service +async function reDetectPanels(pageNumber: number): Promise { + const image = await loadImageForPage(pageNumber); + + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(image, 0, 0); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const result = await detectPanels(imageData, true); + + return result.panels; +} + +// Alpine component +Alpine.data("panelEditor", () => ({ + get isComicOrManga(): boolean { + const libraryType = document.body.dataset.mediaType; + return libraryType === "comic" || libraryType === "manga"; + }, + + openPanelEditor(pageNumber: number) { + openPanelEditor(pageNumber); + }, + + async reDetectPanels(pageNumber: number) { + const panels = await reDetectPanels(pageNumber); + return panels; + }, +})); + +export { openPanelEditor, reDetectPanels }; diff --git a/web/src/reader/formats/comic/panel-gap.ts b/web/src/reader/formats/comic/panel-gap.ts new file mode 100644 index 0000000..3e84e24 --- /dev/null +++ b/web/src/reader/formats/comic/panel-gap.ts @@ -0,0 +1,150 @@ +// Adjustable panel gap controls +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + const state = createPanelGapState(); + applyPanelGap(state.gapSize, state.showBorders); + + context.events.on("panel-gap:set", (detail: { gap: number }) => { + setPanelGap(state, detail.gap); + }); + + context.events.on("panel-gap:increase", (detail?: { amount: number }) => { + increasePanelGap(state, detail?.amount); + }); + + context.events.on("panel-gap:decrease", (detail?: { amount: number }) => { + decreasePanelGap(state, detail?.amount); + }); + + context.events.on("panel-gap:borders:toggle", () => { + togglePanelBorders(state); + }); + + context.events.on("ui:show-settings", (detail: { container: HTMLElement }) => { + renderPanelGapControls(detail.container, state); + }); + + context.events.on("reader:unload", () => { + const controls = document.querySelector(".panel-gap-controls"); + controls?.remove(); + }); +} + +interface PanelGapState { + gapSize: number; + showBorders: boolean; +} + +function createPanelGapState(initialGap: number = 4): PanelGapState { + const saved = localStorage.getItem("reader-panel-gap"); + return { + gapSize: saved ? parseInt(saved) : initialGap, + showBorders: false, + }; +} + +function applyPanelGap(gap: number, showBorders: boolean): void { + document.documentElement.style.setProperty("--panel-gap", `${gap}px`); + document.documentElement.style.setProperty( + "--panel-border-width", + showBorders ? "1px" : "0px", + ); + localStorage.setItem("reader-panel-gap", String(gap)); +} + +function setPanelGap(state: PanelGapState, gap: number): PanelGapState { + const clampedGap = Math.max(0, Math.min(20, gap)); + state.gapSize = clampedGap; + + document.documentElement.style.setProperty("--panel-gap", `${clampedGap}px`); + localStorage.setItem("reader-panel-gap", String(clampedGap)); + + const controls = document.querySelector(".panel-gap-controls"); + if (controls) { + updatePanelGapUI(controls as HTMLElement, state); + } + + return state; +} + +function increasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { + return setPanelGap(state, state.gapSize + amount); +} + +function decreasePanelGap(state: PanelGapState, amount: number = 2): PanelGapState { + return setPanelGap(state, state.gapSize - amount); +} + +function togglePanelBorders(state: PanelGapState): PanelGapState { + state.showBorders = !state.showBorders; + + document.documentElement.style.setProperty( + "--panel-border-width", + state.showBorders ? "1px" : "0px", + ); + + const controls = document.querySelector(".panel-gap-controls"); + if (controls) { + updatePanelGapUI(controls as HTMLElement, state); + } + + return state; +} + +function renderPanelGapControls( + container: HTMLElement, + state: PanelGapState, +): void { + const existing = container.querySelector(".panel-gap-controls"); + existing?.remove(); + + const controls = document.createElement("div"); + controls.className = + "panel-gap-controls fixed bottom-24 right-4 bg-gray-900 bg-opacity-90 rounded-lg p-2 flex flex-col gap-2 z-40"; + controls.innerHTML = ` + + ${state.gapSize}px + + + `; + + controls + .querySelector(".panel-gap-increase") + ?.addEventListener("click", () => { + increasePanelGap(state); + updatePanelGapUI(controls, state); + }); + + controls + .querySelector(".panel-gap-decrease") + ?.addEventListener("click", () => { + decreasePanelGap(state); + updatePanelGapUI(controls, state); + }); + + controls + .querySelector(".panel-gap-borders") + ?.addEventListener("click", () => { + togglePanelBorders(state); + updatePanelGapUI(controls, state); + }); + + container.appendChild(controls); +} + +function updatePanelGapUI(container: HTMLElement, state: PanelGapState): void { + const gapLabel = container.querySelector("span"); + if (gapLabel) { + gapLabel.textContent = `${state.gapSize}px`; + } + + const bordersBtn = container.querySelector(".panel-gap-borders"); + if (bordersBtn) { + bordersBtn.textContent = state.showBorders ? "▦" : "▢"; + } +} \ No newline at end of file diff --git a/web/src/reader/formats/manga/reading-direction.ts b/web/src/reader/formats/manga/reading-direction.ts new file mode 100644 index 0000000..6aaea6b --- /dev/null +++ b/web/src/reader/formats/manga/reading-direction.ts @@ -0,0 +1,129 @@ +// Detect reading direction from metadata or user preference +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let state: ReadingDirectionState | null = null; + + context.events.on("reader:loaded", async (detail: { metadata: any }) => { + state = await detectReadingDirection(detail.metadata); + const effectiveDirection = getEffectiveDirection(state); + context.events.emit("reading-direction:detected", { direction: effectiveDirection }); + }); + + context.events.on("reading-direction:set", (detail: { direction: "auto" | "ltr" | "rtl" | "vertical" }) => { + if (state) { + state.direction = detail.direction; + const effectiveDirection = getEffectiveDirection(state); + context.events.emit("reading-direction:changed", { direction: effectiveDirection }); + } + }); + + context.events.on("reading-direction:get", () => { + if (state) { + const effectiveDirection = getEffectiveDirection(state); + context.events.emit("reading-direction:current", { direction: effectiveDirection }); + } + }); + + context.events.on("reading-direction:is-rtl", () => { + if (state) { + const isRTL = shouldUseRTL(state); + context.events.emit("reading-direction:is-rtl-result", { isRTL }); + } + }); + + context.events.on("reading-direction:is-vertical", () => { + if (state) { + const isVertical = shouldUseVerticalScroll(state); + context.events.emit("reading-direction:is-vertical-result", { isVertical }); + } + }); +} + +type ReadingDirection = "auto" | "ltr" | "rtl" | "vertical"; + +interface ReadingDirectionState { + direction: ReadingDirection; + detectedDirection: "ltr" | "rtl" | "vertical"; + userPreference: ReadingDirection | null; +} + +async function detectReadingDirection( + metadata: any, +): Promise { + const userPreference = await getUserReadingDirectionPreference(); + if (userPreference && userPreference !== "auto") { + return { + direction: userPreference, + detectedDirection: "ltr", + userPreference, + }; + } + + const detectedDirection = detectFromMetadata(metadata); + + return { + direction: "auto", + detectedDirection, + userPreference: null, + }; +} + +function detectFromMetadata( + metadata: any, +): "ltr" | "rtl" | "vertical" { + const mangaType = (metadata as any).manga_type; + if (mangaType === "yes_and_right_to_left" || mangaType === "yes") { + return "rtl"; + } + + const readingDirection = (metadata as any).reading_direction; + if (readingDirection === "rtl" || readingDirection === "vertical") { + return readingDirection; + } + + const filename = metadata.filePath.toLowerCase(); + if ( + filename.includes("manga") || + filename.includes("manhwa") || + filename.includes("webtoon") + ) { + return "vertical"; + } + + return "ltr"; +} + +async function getUserReadingDirectionPreference(): Promise { + const userId = localStorage.getItem("userId"); + if (!userId) return null; + + try { + const response = await fetch(`/api/users/${userId}/settings`); + if (!response.ok) return null; + + const settings = await response.json(); + return settings.reading_direction || null; + } catch { + return null; + } +} + +function getEffectiveDirection( + state: ReadingDirectionState, +): "ltr" | "rtl" | "vertical" { + if (state.direction !== "auto") { + return state.direction as "ltr" | "rtl" | "vertical"; + } + return state.detectedDirection; +} + +function shouldUseRTL(state: ReadingDirectionState): boolean { + return getEffectiveDirection(state) === "rtl"; +} + +function shouldUseVerticalScroll(state: ReadingDirectionState): boolean { + return getEffectiveDirection(state) === "vertical"; +} \ No newline at end of file diff --git a/web/src/reader/formats/manga/rtl-navigator.ts b/web/src/reader/formats/manga/rtl-navigator.ts new file mode 100644 index 0000000..9da25bd --- /dev/null +++ b/web/src/reader/formats/manga/rtl-navigator.ts @@ -0,0 +1,96 @@ +// Right-to-left navigation for manga +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let state: RTLNavigatorState | null = null; + + context.events.on("reader:loaded", (detail: { totalPages: number; currentPage?: number }) => { + state = createRTLNavigator(detail.totalPages); + if (detail.currentPage) { + state.currentPage = detail.currentPage; + } + }); + + context.events.on("navigation:next-page", () => { + if (state) { + const nextPage = getNextPage(state); + state.currentPage = nextPage; + context.events.emit("navigation:to-page", { page: nextPage }); + } + }); + + context.events.on("navigation:previous-page", () => { + if (state) { + const previousPage = getPreviousPage(state); + state.currentPage = previousPage; + context.events.emit("navigation:to-page", { page: previousPage }); + } + }); + + context.events.on("navigation:to-page", (detail: { page: number }) => { + if (state) { + state = navigateToPage(state, detail.page); + const progress = getReadingProgressPercentage(state); + context.events.emit("navigation:progress", { progress }); + } + }); + + context.events.on("navigation:get-progress", () => { + if (state) { + const progress = getProgress(state); + context.events.emit("navigation:progress-current", progress); + } + }); +} + +interface RTLNavigatorState { + currentPage: number; + totalPages: number; + readingDirection: "rtl" | "ltr"; +} + +function createRTLNavigator(totalPages: number): RTLNavigatorState { + return { + currentPage: 1, + totalPages, + readingDirection: "rtl", + }; +} + +function getNextPage(state: RTLNavigatorState): number { + if (state.readingDirection === "rtl") { + return Math.max(1, state.currentPage - 1); + } + return Math.min(state.totalPages, state.currentPage + 1); +} + +function getPreviousPage(state: RTLNavigatorState): number { + if (state.readingDirection === "rtl") { + return Math.min(state.totalPages, state.currentPage + 1); + } + return Math.max(1, state.currentPage - 1); +} + +function navigateToPage( + state: RTLNavigatorState, + pageNumber: number, +): RTLNavigatorState { + state.currentPage = Math.max(1, Math.min(state.totalPages, pageNumber)); + return state; +} + +function getProgress(state: RTLNavigatorState): { + current: number; + total: number; +} { + return { + current: state.currentPage, + total: state.totalPages, + }; +} + +function getReadingProgressPercentage(state: RTLNavigatorState): number { + return (state.currentPage / state.totalPages) * 100; +} \ No newline at end of file diff --git a/web/src/reader/formats/manga/settings.ts b/web/src/reader/formats/manga/settings.ts new file mode 100644 index 0000000..cbbd3b5 --- /dev/null +++ b/web/src/reader/formats/manga/settings.ts @@ -0,0 +1,99 @@ +// Manga-specific settings integration +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let currentSettings: MangaSettings | null = null; + + context.events.on("reader:loaded", async () => { + currentSettings = await getMangaSettings(); + applyMangaSettings(currentSettings); + context.events.emit("manga-settings:loaded", currentSettings); + }); + + context.events.on("manga-settings:update", async (detail: { settings: Partial }) => { + if (currentSettings) { + currentSettings = { ...currentSettings, ...detail.settings }; + await updateMangaSettings(detail.settings); + applyMangaSettings(currentSettings); + context.events.emit("manga-settings:changed", currentSettings); + } + }); + + context.events.on("manga-settings:get", () => { + if (currentSettings) { + context.events.emit("manga-settings:current", currentSettings); + } + }); +} + +interface MangaSettings { + readingDirection: "auto" | "ltr" | "rtl" | "vertical"; + verticalScrollSpeed: "slow" | "normal" | "fast"; + rtlPageTransition: "slide" | "fade" | "none"; + webtoonMode: boolean; +} + +async function getMangaSettings(): Promise { + const defaultSettings: MangaSettings = { + readingDirection: "auto", + verticalScrollSpeed: "normal", + rtlPageTransition: "slide", + webtoonMode: false, + }; + + try { + const userId = localStorage.getItem("userId"); + const response = await fetch(`/api/users/${userId}/settings`); + + if (response.ok) { + const settings = await response.json(); + return { ...defaultSettings, ...settings }; + } + } catch (error) { + console.error("Failed to load manga settings:", error); + } + + return defaultSettings; +} + +async function updateMangaSettings( + settings: Partial, +): Promise { + const userId = localStorage.getItem("userId"); + + try { + const response = await fetch(`/api/users/${userId}/settings`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + body: JSON.stringify(settings), + }); + + if (!response.ok) { + throw new Error("Failed to update manga settings"); + } + } catch (error) { + console.error("Failed to save manga settings:", error); + } +} + +function applyMangaSettings(settings: MangaSettings): void { + document.documentElement.dataset.readingDirection = settings.readingDirection; + + if (settings.verticalScrollSpeed === "slow") { + document.documentElement.style.scrollBehavior = "smooth"; + } else if (settings.verticalScrollSpeed === "fast") { + document.documentElement.style.scrollBehavior = "auto"; + } + + if (settings.rtlPageTransition !== "none") { + document.documentElement.dataset.pageTransition = + settings.rtlPageTransition; + } + + document.documentElement.dataset.webtoonMode = String(settings.webtoonMode); +} \ No newline at end of file diff --git a/web/src/reader/formats/manga/vertical-scroll-mode.ts b/web/src/reader/formats/manga/vertical-scroll-mode.ts new file mode 100644 index 0000000..d78632f --- /dev/null +++ b/web/src/reader/formats/manga/vertical-scroll-mode.ts @@ -0,0 +1,183 @@ +// Vertical scroll mode for webtoons/manhwa +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let state: VerticalScrollState | null = null; + + context.events.on("reader:loaded", (detail: { container: HTMLElement; mediaItemId: string; totalPages: number }) => { + state = createVerticalScroll(detail.container, detail.mediaItemId, detail.totalPages); + }); + + context.events.on("vertical-scroll:load-page", async (detail: { pageNumber: number }) => { + if (state) { + await loadPage(state, detail.pageNumber); + } + }); + + context.events.on("vertical-scroll:get-current", () => { + if (state) { + const currentPage = getCurrentPageFromScroll(state); + context.events.emit("vertical-scroll:current-page", { page: currentPage }); + } + }); + + context.events.on("reader:unload", () => { + if (state) { + destroyVerticalScroll(state); + state = null; + } + }); +} + +interface VerticalScrollState { + container: HTMLElement; + loadedPages: Set; + loadingPages: Set; + currentPage: number; + totalPages: number; + threshold: number; + mediaItemId: string; +} + +function createVerticalScroll( + container: HTMLElement, + mediaItemId: string, + totalPages: number, +): VerticalScrollState { + const state: VerticalScrollState = { + container, + loadedPages: new Set(), + loadingPages: new Set(), + currentPage: 1, + totalPages, + threshold: 500, + mediaItemId, + }; + + loadPage(state, 1); + setupScrollListener(state); + + return state; +} + +async function loadPage( + state: VerticalScrollState, + pageNumber: number, +): Promise { + if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) { + return; + } + + state.loadingPages.add(pageNumber); + + try { + const token = localStorage.getItem("token"); + const response = await fetch( + `/readers/${state.mediaItemId}/pages/${pageNumber}`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to load page ${pageNumber}`); + } + + const blob = await response.blob(); + const imgUrl = URL.createObjectURL(blob); + + const pageContainer = document.createElement("div"); + pageContainer.className = "vertical-page"; + pageContainer.dataset.pageNumber = pageNumber.toString(); + + const img = document.createElement("img"); + img.src = imgUrl; + img.alt = `Page ${pageNumber}`; + img.loading = "lazy"; + + pageContainer.appendChild(img); + state.container.appendChild(pageContainer); + + state.loadedPages.add(pageNumber); + state.loadingPages.delete(pageNumber); + + if (pageNumber < state.totalPages) { + loadPage(state, pageNumber + 1); + if (pageNumber + 1 < state.totalPages) { + loadPage(state, pageNumber + 2); + } + } + } catch (error) { + console.error(`Failed to load page ${pageNumber}:`, error); + state.loadingPages.delete(pageNumber); + } +} + +function setupScrollListener(state: VerticalScrollState): void { + let scrollTimeout: number | undefined; + + state.container.addEventListener("scroll", () => { + clearTimeout(scrollTimeout); + scrollTimeout = window.setTimeout(() => { + checkScrollPosition(state); + }, 100); + }); +} + +function checkScrollPosition(state: VerticalScrollState): void { + const scrollBottom = + state.container.scrollHeight - + state.container.scrollTop - + state.container.clientHeight; + + if (scrollBottom < state.threshold) { + const lastPage = Math.max(...state.loadedPages); + if (lastPage < state.totalPages) { + loadPage(state, lastPage + 1); + } + } + + const currentPage = getCurrentPageFromScroll(state); + if (currentPage !== state.currentPage) { + state.currentPage = currentPage; + window.dispatchEvent( + new CustomEvent("page-change", { + detail: { page: currentPage }, + }), + ); + } +} + +function getCurrentPageFromScroll(state: VerticalScrollState): number { + const pages = Array.from(state.container.querySelectorAll(".vertical-page")); + + for (const page of pages) { + const rect = page.getBoundingClientRect(); + const containerRect = state.container.getBoundingClientRect(); + + const pageMiddle = rect.top + rect.height / 2; + const viewportMiddle = containerRect.top + containerRect.height / 2; + + if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) { + return parseInt((page as HTMLElement).dataset.pageNumber || "1"); + } + } + + return state.currentPage; +} + +function destroyVerticalScroll(state: VerticalScrollState): void { + const images = Array.from(state.container.querySelectorAll("img")); + images.forEach((img) => { + const url = img.src; + if (url.startsWith("blob:")) { + URL.revokeObjectURL(url); + } + }); + + state.container.innerHTML = ""; + state.loadedPages.clear(); + state.loadingPages.clear(); +} \ No newline at end of file diff --git a/web/src/reader/formats/pdf/annotation-layer.ts b/web/src/reader/formats/pdf/annotation-layer.ts new file mode 100644 index 0000000..859f9ee --- /dev/null +++ b/web/src/reader/formats/pdf/annotation-layer.ts @@ -0,0 +1,108 @@ +// Annotation layer for rendering highlights and notes on PDFs +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + const highlights = new Map(); + + context.events.on("pdf:highlights:render", (detail: { container: HTMLElement; highlights: any[] }) => { + clearPDFHighlights(detail.container); + for (const highlight of detail.highlights) { + renderSinglePDFHighlight(detail.container, highlight, highlights); + } + }); + + context.events.on("pdf:highlights:clear", (detail: { container: HTMLElement }) => { + clearPDFHighlights(detail.container); + }); + + context.events.on("pdf:highlight:remove", (detail: { highlightId: string }) => { + removePDFHighlight(detail.highlightId, highlights); + }); + + context.events.on("reader:unload", () => { + highlights.forEach((element) => element.remove()); + highlights.clear(); + }); +} + +interface PDFHighlight { + id: string; + pageNumber: number; + rects: DOMRect[]; + text: string; + color: string; + noteId?: string; +} + +function renderSinglePDFHighlight( + container: HTMLElement, + highlight: PDFHighlight, + highlights: Map, +): void { + const overlay = document.createElement("div"); + overlay.className = "pdf-highlight-annotation"; + overlay.dataset.highlightId = highlight.id; + overlay.style.backgroundColor = parseColor(highlight.color); + + 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); + } + + if (highlight.noteId) { + overlay.style.cursor = "pointer"; + overlay.addEventListener("click", () => { + showNotePopup(highlight); + }); + } + + 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); + const event = new CustomEvent("pdf:note-show", { detail: { highlightId: highlight.id } }); + window.dispatchEvent(event); +} + +export function clearPDFHighlights(container: HTMLElement): void { + const highlights = container.querySelectorAll(".pdf-highlight-annotation"); + Array.from(highlights).forEach((element) => element.remove()); +} + +export function removePDFHighlight(highlightId: string, highlights: Map): void { + const element = highlights.get(highlightId); + if (element) { + element.remove(); + highlights.delete(highlightId); + } +} \ No newline at end of file diff --git a/web/src/reader/formats/pdf/page-cache.ts b/web/src/reader/formats/pdf/page-cache.ts new file mode 100644 index 0000000..0d64e52 --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-bookmarks.ts b/web/src/reader/formats/pdf/pdf-bookmarks.ts new file mode 100644 index 0000000..629811d --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-clipbooard.ts b/web/src/reader/formats/pdf/pdf-clipbooard.ts new file mode 100644 index 0000000..70fd1d6 --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-dual-page.ts b/web/src/reader/formats/pdf/pdf-dual-page.ts new file mode 100644 index 0000000..3d8af1a --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-links.ts b/web/src/reader/formats/pdf/pdf-links.ts new file mode 100644 index 0000000..9099eb6 --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-minimap.ts b/web/src/reader/formats/pdf/pdf-minimap.ts new file mode 100644 index 0000000..1bbcc41 --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-navigation.ts b/web/src/reader/formats/pdf/pdf-navigation.ts new file mode 100644 index 0000000..84b5b87 --- /dev/null +++ b/web/src/reader/formats/pdf/pdf-navigation.ts @@ -0,0 +1,185 @@ +// PDF navigation: page turning, zoom, fit modes +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let navState: PDFNavigationState | null = null; + + context.events.on("reader:loaded", (detail: { container: HTMLElement; totalPages: number }) => { + navState = { + currentPage: 1, + totalPages: detail.totalPages, + currentScale: 1.0, + fitMode: "fit-width", + scrollContainer: detail.container.querySelector(".pdf-scroll-container") || detail.container, + }; + setupPDFKeyboardNav(context, navState); + setupPDFScrollTracking(context, navState); + }); + + context.events.on("pdf:navigate:to-page", (detail: { page: number }) => { + if (navState) { + goToPDFPage(navState, detail.page, context); + } + }); + + context.events.on("pdf:navigate:next", () => { + if (navState) { + nextPDFPage(navState, context); + } + }); + + context.events.on("pdf:navigate:previous", () => { + if (navState) { + previousPDFPage(navState, context); + } + }); + + context.events.on("pdf:zoom:set", (detail: { scale: number }) => { + if (navState) { + setPDFZoom(navState, detail.scale, context); + } + }); + + context.events.on("pdf:zoom:in", () => { + if (navState) { + zoomPDFIn(navState, context); + } + }); + + context.events.on("pdf:zoom:out", () => { + if (navState) { + zoomPDFOut(navState, context); + } + }); + + context.events.on("pdf:fit:set", (detail: { mode: "fit-width" | "fit-page" | "fit-height" | "none" }) => { + if (navState) { + setPDFFitMode(navState, detail.mode, context); + } + }); + + context.events.on("reader:unload", () => { + navState = null; + }); +} + +type PageFitMode = "fit-width" | "fit-page" | "fit-height" | "none"; + +interface PDFNavigationState { + currentPage: number; + totalPages: number; + currentScale: number; + fitMode: PageFitMode; + scrollContainer: HTMLElement | null; +} + +function goToPDFPage(state: PDFNavigationState, pageNumber: number, context: ReaderContext): void { + if (pageNumber < 1 || pageNumber > state.totalPages) return; + + state.currentPage = pageNumber; + scrollToPDFPage(state, pageNumber); + context.events.emit("pdf:page-changed", { page: pageNumber }); +} + +function nextPDFPage(state: PDFNavigationState, context: ReaderContext): void { + if (state.currentPage < state.totalPages) { + goToPDFPage(state, state.currentPage + 1, context); + } +} + +function previousPDFPage(state: PDFNavigationState, context: ReaderContext): void { + if (state.currentPage > 1) { + goToPDFPage(state, state.currentPage - 1, context); + } +} + +function scrollToPDFPage(state: PDFNavigationState, pageNumber: number): void { + if (!state.scrollContainer) return; + + const pageElement = state.scrollContainer.querySelector( + `[data-page-number="${pageNumber}"]`, + ); + if (pageElement) { + pageElement.scrollIntoView({ behavior: "smooth", block: "start" }); + } +} + +function setPDFZoom(state: PDFNavigationState, scale: number, context: ReaderContext): void { + state.currentScale = scale; + state.fitMode = "none"; + updatePDFZoom(state); + context.events.emit("pdf:zoom-changed", { scale }); +} + +function setPDFFitMode(state: PDFNavigationState, mode: PageFitMode, context: ReaderContext): void { + state.fitMode = mode; + updatePDFZoom(state); + context.events.emit("pdf:fit-changed", { mode }); +} + +function zoomPDFIn(state: PDFNavigationState, context: ReaderContext): void { + setPDFZoom(state, state.currentScale * 1.2, context); +} + +function zoomPDFOut(state: PDFNavigationState, context: ReaderContext): void { + setPDFZoom(state, state.currentScale / 1.2, context); +} + +function updatePDFZoom(state: PDFNavigationState): void { + const event = new CustomEvent("pdf-update-zoom", { + detail: { + scale: state.currentScale, + fitMode: state.fitMode, + }, + }); + window.dispatchEvent(event); +} + +function setupPDFKeyboardNav(context: ReaderContext, state: PDFNavigationState): void { + document.addEventListener("keydown", (e) => { + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + nextPDFPage(state, context); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + previousPDFPage(state, context); + } else if (e.key === "+" || e.key === "=") { + zoomPDFIn(state, context); + } else if (e.key === "-" || e.key === "_") { + zoomPDFOut(state, context); + } + }); +} + +function setupPDFScrollTracking(context: ReaderContext, state: PDFNavigationState): void { + if (!state.scrollContainer) return; + + state.scrollContainer.addEventListener("scroll", () => { + const currentPage = getCurrentPDFPage(state); + if (currentPage !== state.currentPage) { + state.currentPage = currentPage; + context.events.emit("pdf:page-changed", { page: currentPage }); + } + }); +} + +function getCurrentPDFPage(state: PDFNavigationState): number { + if (!state.scrollContainer) return state.currentPage; + + const containerRect = state.scrollContainer.getBoundingClientRect(); + const viewportMiddle = containerRect.top + containerRect.height / 2; + + for (let i = 1; i <= state.totalPages; i++) { + const pageElement = state.scrollContainer.querySelector( + `[data-page-number="${i}"]`, + ); + if (pageElement) { + const rect = pageElement.getBoundingClientRect(); + if (rect.top <= viewportMiddle && rect.bottom >= viewportMiddle) { + return i; + } + } + } + + return state.currentPage; +} \ No newline at end of file diff --git a/web/src/reader/formats/pdf/pdf-outline.ts b/web/src/reader/formats/pdf/pdf-outline.ts new file mode 100644 index 0000000..90b6f50 --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-page-sizes.ts b/web/src/reader/formats/pdf/pdf-page-sizes.ts new file mode 100644 index 0000000..6e29ffe --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-rotation.ts b/web/src/reader/formats/pdf/pdf-rotation.ts new file mode 100644 index 0000000..6eaaad5 --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-search.ts b/web/src/reader/formats/pdf/pdf-search.ts new file mode 100644 index 0000000..2df7dbd --- /dev/null +++ b/web/src/reader/formats/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/formats/pdf/pdf-text-selection.ts b/web/src/reader/formats/pdf/pdf-text-selection.ts new file mode 100644 index 0000000..21f4337 --- /dev/null +++ b/web/src/reader/formats/pdf/pdf-text-selection.ts @@ -0,0 +1,154 @@ +// PDF text selection - Uses backend API for highlight creation +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let currentMediaItemId: string | null = null; + + context.events.on("reader:loaded", (detail: { mediaItemId: string }) => { + currentMediaItemId = detail.mediaItemId; + }); + + context.events.on("pdf:selection:get", () => { + const selection = getPDFTextSelection(); + context.events.emit("pdf:selection-current", selection); + }); + + context.events.on("pdf:highlight:create", async (detail: { selection: PDFTextSelection; color: string }) => { + if (currentMediaItemId) { + try { + const highlight = await createPDFHighlight(currentMediaItemId, detail.selection, detail.color); + context.events.emit("pdf:highlight-created", highlight); + } catch (error) { + console.error("Failed to create highlight:", error); + } + } + }); + + context.events.on("pdf:highlights:load", async (detail: { container: HTMLElement }) => { + if (currentMediaItemId) { + await loadAndRenderPDFHighlights(currentMediaItemId, detail.container); + } + }); + + context.events.on("reader:unload", () => { + currentMediaItemId = null; + }); +} + +interface PDFTextSelection { + pageNumber: number; + text: string; + rects: DOMRect[]; +} + +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; + + const pageElement = + range.commonAncestorContainer.parentElement?.closest?.("[data-page-number]") as HTMLElement; + const pageNumber = pageElement?.dataset.pageNumber + ? parseInt(pageElement.dataset.pageNumber) + : getCurrentPDFPage(); + + const rects: DOMRect[] = []; + for (const rect of Array.from(range.getClientRects())) { + rects.push(rect); + } + + return { + pageNumber, + text, + rects, + }; +} + +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, + }; + + 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(); +} + +export async function loadAndRenderPDFHighlights( + mediaItemId: string, + container: HTMLElement, +): Promise { + const response = await fetch(`/api/media-items/${mediaItemId}/highlights`); + if (!response.ok) return; + + const highlights: any[] = await response.json(); + + for (const highlight of highlights) { + renderPDFHighlight(container, highlight); + } +} + +function renderPDFHighlight( + container: HTMLElement, + highlight: any, +): void { + const overlay = document.createElement("div"); + overlay.className = "pdf-highlight-annotation"; + overlay.dataset.highlightId = highlight.id; + overlay.style.backgroundColor = parseColor(highlight.color || "#ffff00"); + + for (const rect of highlight.rects || []) { + const rectDiv = document.createElement("div"); + rectDiv.className = "pdf-highlight-rect"; + rectDiv.style.left = `${rect.x}px`; + rectDiv.style.top = `${rect.y}px`; + rectDiv.style.width = `${rect.width}px`; + rectDiv.style.height = `${rect.height}px`; + overlay.appendChild(rectDiv); + } + + container.appendChild(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 getCurrentPDFPage(): number { + const pageElement = document.querySelector("[data-page-number]"); + return pageElement ? parseInt(pageElement.getAttribute("data-page-number") || "1") : 1; +} \ No newline at end of file diff --git a/web/src/reader/formats/pdf/pdfjs-wrapper.ts b/web/src/reader/formats/pdf/pdfjs-wrapper.ts new file mode 100644 index 0000000..4da7202 --- /dev/null +++ b/web/src/reader/formats/pdf/pdfjs-wrapper.ts @@ -0,0 +1,151 @@ +// 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; +} + +// ============================================================ +// Reader Initialization +// ============================================================ +interface ReaderMetadata { + media_item_id: string; + title: string; + author: string; + cover_image_path: string; + library_type: "ebook" | "comic" | "manga" | "pdf"; + mime_type: string; + file_path: string; + total_pages?: number; +} +interface PDFReader { + type: "pdf"; + doc: any; + currentPage: number; +} +export async function initializePDFReader( + metadata: ReaderMetadata, +): Promise { + configurePDFJS(); + const response = await fetch(metadata.file_path); + const pdfBlob = await response.blob(); + const pdfMetadata = await loadPDFDocument(pdfBlob); + return { + type: "pdf", + doc: pdfState.doc, + currentPage: 1, + }; +} + +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/formats/pdf/text-layer-renderer.ts b/web/src/reader/formats/pdf/text-layer-renderer.ts new file mode 100644 index 0000000..6d93014 --- /dev/null +++ b/web/src/reader/formats/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; +} diff --git a/web/src/reader/formats/reflowable/content-renderer.ts b/web/src/reader/formats/reflowable/content-renderer.ts new file mode 100644 index 0000000..2b51733 --- /dev/null +++ b/web/src/reader/formats/reflowable/content-renderer.ts @@ -0,0 +1,101 @@ +// Render a page's content to the DOM +export function renderPage(container: HTMLElement, content: string): void { + container.innerHTML = ""; + + const wrapper = document.createElement("div"); + wrapper.className = "reflowable-page"; + wrapper.style.height = "calc(100vh - 120px)"; + wrapper.style.overflow = "hidden"; + wrapper.style.position = "relative"; + wrapper.style.display = "flex"; + wrapper.style.flexDirection = "column"; + + // Parse the HTML content (which is already sliced by getPageContent) + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = content; + const pageContent = tempDiv.querySelector(".page-content-wrapper"); + + if (!pageContent) { + // Fallback if wrapper not found + const contentDiv = document.createElement("div"); + contentDiv.className = "page-content"; + contentDiv.innerHTML = content; + contentDiv.style.height = "100%"; + contentDiv.style.overflow = "hidden"; + contentDiv.style.flex = "1"; + contentDiv.style.overflowY = "auto"; + wrapper.appendChild(contentDiv); + } else { + // Transfer the sliced content to our wrapper + const contentDiv = document.createElement("div"); + contentDiv.className = "page-content"; + contentDiv.style.height = "100%"; + contentDiv.style.overflow = "hidden"; + contentDiv.style.flex = "1"; + contentDiv.style.padding = "20px"; + + while (pageContent.firstChild) { + contentDiv.appendChild(pageContent.firstChild); + } + + wrapper.appendChild(contentDiv); + } + + container.appendChild(wrapper); +} + +// Update container styles for paginated mode +export function applyPaginatedStyles(): void { + const existing = document.getElementById("reflowable-styles"); + existing?.remove(); + + const style = document.createElement("style"); + style.id = "reflowable-styles"; + style.textContent = ` + .reflowable-page { + height: calc(100vh - 120px) !important; + overflow: hidden !important; + position: relative !important; + } + + .page-content { + height: 100% !important; + overflow: hidden !important; + -webkit-column-width: auto !important; + column-width: auto !important; + -webkit-column-count: 1 !important; + column-count: 1 !important; + -webkit-column-fill: auto !important; + column-fill: auto !important; + } + + .page-content img { + max-width: 100% !important; + height: auto !important; + display: block !important; + } + + .page-content p { + margin: 0.5em 0 !important; + text-align: justify !important; + } + + .page-content h1, + .page-content h2, + .page-content h3, + .page-content h4, + .page-content h5, + .page-content h6 { + margin: 1em 0 0.5em 0 !important; + page-break-after: avoid !important; + break-after: avoid !important; + } + `; + document.head.appendChild(style); +} + +// Clear all styles +export function clearPaginatedStyles(): void { + const existing = document.getElementById("reflowable-styles"); + existing?.remove(); +} diff --git a/web/src/reader/formats/reflowable/ebook/copy-handler.ts b/web/src/reader/formats/reflowable/ebook/copy-handler.ts new file mode 100644 index 0000000..c9e20a1 --- /dev/null +++ b/web/src/reader/formats/reflowable/ebook/copy-handler.ts @@ -0,0 +1,67 @@ +// Handle text copying with citation +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; +import { showToast } from "../../toast"; + +export function init(context: ReaderContext): void { + let mediaItem: MediaItemSummary | null = null; + + context.events.on("reader:loaded", (detail: { mediaItem: MediaItemSummary }) => { + mediaItem = detail.mediaItem; + enableContextMenuCopy(mediaItem); + }); + + context.events.on("copy:selection", async () => { + if (mediaItem) { + await copySelection(mediaItem); + } + }); + + context.events.on("reader:unload", () => { + mediaItem = null; + }); +} + +async function copySelection(mediaItem: MediaItemSummary): Promise { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return false; + + const selectedText = selection.toString(); + if (!selectedText.trim()) return false; + + const citation = createCitation(selectedText, mediaItem); + + try { + await navigator.clipboard.writeText(citation); + showToast("Copied to clipboard", "success"); + return true; + } catch (error) { + console.error("Failed to copy:", error); + showToast("Failed to copy to clipboard", "error"); + return false; + } +} + +function createCitation(text: string, mediaItem: MediaItemSummary): string { + let citation = `"${text}"\n`; + citation += `— ${mediaItem.title}`; + if (mediaItem.author) { + citation += ` by ${mediaItem.author}`; + } + citation += `\n(Source: Bookhoard)`; + + return citation; +} + +function enableContextMenuCopy(mediaItem: MediaItemSummary): void { + document.addEventListener("contextmenu", async (e) => { + const selection = window.getSelection(); + const selectedText = selection?.toString().trim(); + + if (selectedText) { + e.preventDefault(); + await copySelection(mediaItem); + } + }); +} \ No newline at end of file diff --git a/web/src/reader/formats/reflowable/ebook/dictionary-popup.ts b/web/src/reader/formats/reflowable/ebook/dictionary-popup.ts new file mode 100644 index 0000000..92c7944 --- /dev/null +++ b/web/src/reader/formats/reflowable/ebook/dictionary-popup.ts @@ -0,0 +1,83 @@ +// Dictionary lookup popup for ebooks +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + context.events.on("dictionary:lookup", (detail: { word: string; position: { x: number; y: number } }) => { + showDictionaryPopup(detail.word, detail.position); + }); + + context.events.on("reader:loaded", () => { + handleTextSelection(); + }); + + context.events.on("reader:unload", () => { + const popup = document.getElementById("dictionary-popup"); + popup?.remove(); + }); +} + +function showDictionaryPopup( + word: string, + position: { x: number; y: number }, +): void { + const existing = document.getElementById("dictionary-popup"); + existing?.remove(); + + const popup = document.createElement("div"); + popup.id = "dictionary-popup"; + popup.className = + "absolute bg-white text-black p-4 rounded-lg shadow-xl max-w-md z-50"; + popup.style.left = `${position.x}px`; + popup.style.top = `${position.y}px`; + + popup.innerHTML = '

Loading...

'; + document.body.appendChild(popup); + + lookupWord(word) + .then((entry) => { + popup.innerHTML = ` +

${entry.word}

+

${entry.part_of_speech || ""}

+

${entry.definition}

+ ${entry.example ? `

"${entry.example}"

` : ""} + `; + }) + .catch(() => { + popup.innerHTML = `

Definition not found for "${word}"

`; + }); + + setTimeout(() => { + document.addEventListener("click", function closePopup(e: MouseEvent) { + if (!popup.contains(e.target as Node)) { + popup.remove(); + document.removeEventListener("click", closePopup); + } + }); + }, 100); +} + +function handleTextSelection(): void { + document.addEventListener("mouseup", () => { + const selection = window.getSelection(); + const selectedText = selection?.toString().trim(); + + if (selectedText && selectedText.split(" ").length === 1) { + const range = selection?.getRangeAt(0); + const rect = range?.getBoundingClientRect(); + + if (rect) { + showDictionaryPopup(selectedText, { x: rect.left, y: rect.bottom }); + } + } + }); +} + +async function lookupWord(word: string): Promise { + const response = await fetch(`/api/dictionary/${word}`); + if (!response.ok) { + throw new Error(`Failed to lookup word: ${word}`); + } + return await response.json(); +} \ No newline at end of file diff --git a/web/src/reader/formats/reflowable/ebook/font-loader.ts b/web/src/reader/formats/reflowable/ebook/font-loader.ts new file mode 100644 index 0000000..25691e1 --- /dev/null +++ b/web/src/reader/formats/reflowable/ebook/font-loader.ts @@ -0,0 +1,96 @@ +// Font loading with performance optimization +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + const userPreferredFont = localStorage.getItem("reader-font") || "literata"; + + context.events.on("reader:loaded", async () => { + await preloadFonts(userPreferredFont); + }); + + context.events.on("font:change", async (detail: { fontId: string }) => { + const stack = getFontStack(detail.fontId); + applyFontStack(stack); + await preloadFonts(detail.fontId); + }); + + context.events.on("font:get-stack", (detail: { fontId: string }) => { + const stack = getFontStack(detail.fontId); + context.events.emit("font:stack-ready", { stack }); + }); +} + +const READING_FONTS = [ + { + id: "literata", + name: "Literata", + stack: "Literata, serif", + description: "Designed for Google Play Books", + }, + { + id: "crimson", + name: "Crimson Text", + stack: "Crimson Text, serif", + description: "Optimized for screen reading", + }, + { + id: "source-serif", + name: "Source Serif 4", + stack: "Source Serif 4, serif", + description: "Professional Adobe quality", + }, + { + id: "eb-garamond", + name: "EB Garamond", + stack: "EB Garamond, serif", + description: "Classic elegance", + }, + { + id: "libertinus", + name: "Libertinus Serif", + stack: "Libertinus Serif, serif", + description: "Excellent for technical content", + }, + { + id: "noto-serif", + name: "Noto Serif", + stack: "Noto Serif, serif", + description: "Maximum language support", + }, + { + id: "charis-sil", + name: "Charis SIL", + stack: "Charis SIL, serif", + description: "Multilingual specialist", + }, + { + id: "ibm-plex", + name: "IBM Plex Serif", + stack: "IBM Plex Serif, serif", + description: "Modern & versatile", + }, +]; + +async function preloadFonts(userPreferredFont: string): Promise { + const fontsToPreload = new Set(["literata", userPreferredFont]); + + for (const fontId of fontsToPreload) { + const font = READING_FONTS.find((f) => f.id === fontId); + if (font) { + document.fonts.load(`16px "${font.stack}"`); + } + } +} + +function getFontStack(fontId: string): string { + const font = READING_FONTS.find((f) => f.id === fontId); + return font?.stack || "Literata, serif"; +} + +function applyFontStack(stack: string): void { + document.documentElement.style.setProperty("--reader-font-family", stack); +} + +export { READING_FONTS, preloadFonts, getFontStack }; \ No newline at end of file diff --git a/web/src/reader/formats/reflowable/ebook/search.ts b/web/src/reader/formats/reflowable/ebook/search.ts new file mode 100644 index 0000000..fe1077e --- /dev/null +++ b/web/src/reader/formats/reflowable/ebook/search.ts @@ -0,0 +1,142 @@ +// Search within ebook content +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let ebookData: any = null; + + context.events.on("reader:loaded", (detail: { ebookData: any }) => { + ebookData = detail.ebookData; + }); + + context.events.on("search:execute", async (detail: { query: string }) => { + if (ebookData) { + const results = await searchEbook(ebookData, detail.query); + context.events.emit("search:results", { results }); + } + }); + + context.events.on("reader:unload", () => { + ebookData = null; + }); +} + +interface SearchResult { + cfi: string; + snippet: string; + chapterTitle: string; +} + +export async function searchEbook( + ebookData: any, + query: string, +): Promise { + const results: SearchResult[] = []; + const lowerQuery = query.toLowerCase(); + + if (!ebookData.spine) return results; + + for (const spineItem of ebookData.spine) { + const doc = await getSpineItemDocument(ebookData, spineItem); + + if (!doc) continue; + + const chapterTitle = getChapterTitle(spineItem); + const textNodes = findTextNodes(doc.body); + + for (const node of textNodes) { + const text = node.textContent || ""; + const lowerText = text.toLowerCase(); + + let foundAt = 0; + while ((foundAt = lowerText.indexOf(lowerQuery, foundAt)) !== -1) { + const cfi = generateCFIForNode(node, foundAt); + const snippet = extractSnippet(text, foundAt, query.length); + + results.push({ + cfi, + snippet, + chapterTitle, + }); + + foundAt += lowerQuery.length; + } + } + } + + return results; +} + +async function getSpineItemDocument( + ebookData: any, + spineItem: any, +): Promise { + try { + const resources = ebookData.resources; + if (!resources) return null; + + const content = await resources.get(spineItem.href)?.text(); + if (!content) return null; + + const parser = new DOMParser(); + return parser.parseFromString(content, "text/html"); + } catch (error) { + console.error("Failed to load spine item:", spineItem.href, error); + return null; + } +} + +function getChapterTitle(spineItem: any): string { + return spineItem.id || `Section ${spineItem.index || ""}`; +} + +function findTextNodes(root: Node): Text[] { + const textNodes: Text[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => { + const parent = node.parentElement; + if (parent && ["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)) { + return NodeFilter.FILTER_REJECT; + } + + if (!node.textContent?.trim()) { + return NodeFilter.FILTER_REJECT; + } + + return NodeFilter.FILTER_ACCEPT; + }, + }); + + let node: Node | null; + while ((node = walker.nextNode())) { + textNodes.push(node as Text); + } + + return textNodes; +} + +function generateCFIForNode(node: Node, offset: number): string { + const path: number[] = []; + let current: Node | null = node; + + while (current && current.parentNode) { + const siblings = Array.from(current.parentNode.childNodes); + const index = siblings.indexOf(current as ChildNode); + path.unshift(index); + current = current.parentNode; + } + + return `/6/4${path.map((i) => `/${i + 2}`).join("")}:${offset}`; +} + +function extractSnippet(text: string, offset: number, length: number): string { + const start = Math.max(0, offset - 40); + const end = Math.min(text.length, offset + length + 40); + let snippet = text.substring(start, end); + + if (start > 0) snippet = "..." + snippet; + if (end < text.length) snippet = snippet + "..."; + + return snippet; +} \ No newline at end of file diff --git a/web/src/reader/formats/reflowable/ebook/typography-engine.ts b/web/src/reader/formats/reflowable/ebook/typography-engine.ts new file mode 100644 index 0000000..4b93120 --- /dev/null +++ b/web/src/reader/formats/reflowable/ebook/typography-engine.ts @@ -0,0 +1,162 @@ +// Typography engine for ebook rendering +// Feature Registration Pattern implementation + +import type { ReaderContext } from "../core/reader-context"; + +export function init(context: ReaderContext): void { + let currentConfig: TypographyConfig | null = null; + + context.events.on("reader:loaded", (detail: { container: HTMLElement; config?: Partial }) => { + currentConfig = { + readingFont: "literata", + fontSize: 18, + lineHeight: 1.6, + marginTop: 0, + marginBottom: 16, + marginLeft: 0, + marginRight: 0, + textAlign: "left", + textIndent: 0, + hyphenate: false, + ligatures: true, + fontSmoothing: "auto", + ...detail.config, + }; + applyTypography(detail.container, currentConfig); + }); + + context.events.on("typography:update", (detail: { container: HTMLElement; config: Partial }) => { + if (currentConfig) { + currentConfig = updateTypographyConfig(currentConfig, detail.config); + applyTypography(detail.container, currentConfig); + } + }); + + context.events.on("typography:measure", (detail: { container: HTMLElement }) => { + const time = measureReadingTime(detail.container); + context.events.emit("typography:reading-time", { minutes: time }); + }); +} + +interface TypographyConfig { + readingFont: + | "literata" + | "crimson" + | "source-serif" + | "eb-garamond" + | "libertinus" + | "noto-serif" + | "charis-sil" + | "ibm-plex"; + fontSize: number; + lineHeight: number; + marginTop: number; + marginBottom: number; + marginLeft: number; + marginRight: number; + textAlign: "left" | "right" | "center" | "justify"; + textIndent: number; + hyphenate: boolean; + ligatures: boolean; + fontSmoothing: "auto" | "antialiased" | "subpixel-antialiased"; +} + +function applyTypography( + container: HTMLElement, + config: TypographyConfig, +): void { + const content = container.querySelector(".ebook-content"); + if (!content) return; + + const fontStack = getFontStack(config.readingFont); + + content.setAttribute( + "style", + ` + font-family: ${fontStack}; + font-size: ${config.fontSize}px; + line-height: ${config.lineHeight}; + text-align: ${config.textAlign}; + margin-top: ${config.marginTop}px; + margin-bottom: ${config.marginBottom}px; + margin-left: ${config.marginLeft}px; + margin-right: ${config.marginRight}px; + text-indent: ${config.textIndent}px; + -webkit-font-smoothing: ${config.fontSmoothing}; + -moz-osx-font-smoothing: auto; + `, + ); + + if (config.hyphenate) { + enableHyphenation(container, content as HTMLElement); + } + + setLigatures(content as HTMLElement, config.ligatures); + + if (config.textAlign === "justify") { + enableJustification(content as HTMLElement); + } +} + +function getFontStack(fontId: string): string { + const fonts: Record = { + "literata": "Literata, serif", + "crimson": "Crimson Text, serif", + "source-serif": "Source Serif 4, serif", + "eb-garamond": "EB Garamond, serif", + "libertinus": "Libertinus Serif, serif", + "noto-serif": "Noto Serif, serif", + "charis-sil": "Charis SIL, serif", + "ibm-plex": "IBM Plex Serif, serif", + }; + return fonts[fontId] || "Literata, serif"; +} + +function enableHyphenation(container: HTMLElement, element: HTMLElement): void { + element.style.hyphens = "auto"; + element.style.hyphenateLimitChars = "6 3 3"; + + const lang = + container.closest("[data-language]")?.getAttribute("data-language") || "en"; + element.setAttribute("lang", lang); +} + +function setLigatures(element: HTMLElement, enabled: boolean): void { + if (enabled) { + element.style.fontVariantLigatures = "common-ligatures"; + element.style.fontFeatureSettings = '"liga", "dlig"'; + } else { + element.style.fontVariantLigatures = "no-common-ligatures"; + element.style.fontFeatureSettings = "normal"; + } +} + +function enableJustification(element: HTMLElement): void { + element.style.wordBreak = "normal"; + element.style.overflowWrap = "break-word"; + element.style.wordWrap = "break-word"; + element.style.letterSpacing = "0.01em"; +} + +function updateTypographyConfig( + currentConfig: TypographyConfig, + newConfig: Partial, +): TypographyConfig { + return { ...currentConfig, ...newConfig }; +} + +function measureReadingTime( + container: HTMLElement, + wordsPerMinute: number = 250, +): number { + const content = container.querySelector(".ebook-content"); + if (!content) return 0; + + const text = content.textContent || ""; + const words = text.split(/\s+/).length; + const minutes = words / wordsPerMinute; + + return Math.ceil(minutes); +} + +export { applyTypography, getFontStack }; \ No newline at end of file diff --git a/web/src/reader/formats/reflowable/navigation.ts b/web/src/reader/formats/reflowable/navigation.ts new file mode 100644 index 0000000..a808d16 --- /dev/null +++ b/web/src/reader/formats/reflowable/navigation.ts @@ -0,0 +1,106 @@ +// Import types +import type { ReadingPosition, ReflowableBook } from "./types"; +import { + getPageContent, + findPageByCFI, + createPositionFromPage, +} from "./page-calculator"; + +// Navigate to specific page +export function goToPage( + book: ReflowableBook, + targetPage: number, +): { + success: boolean; + position: ReadingPosition; + content: string; +} { + if (!book.pagination) { + return { success: false, position: createDefaultPosition(), content: "" }; + } + + const pageIndex = Math.max( + 0, + Math.min(targetPage - 1, book.pagination.totalPages - 1), + ); + const content = getPageContent(book.pagination, pageIndex); + const position = createPositionFromPage(book, pageIndex + 1); + + return { success: true, position, content }; +} + +// Navigate to next page +export function nextPage(book: ReflowableBook): { + success: boolean; + position: ReadingPosition; + content: string; +} { + const nextPageNum = book.position.currentPage + 1; + return goToPage(book, nextPageNum); +} + +// Navigate to previous page +export function previousPage(book: ReflowableBook): { + success: boolean; + position: ReadingPosition; + content: string; +} { + const prevPageNum = book.position.currentPage - 1; + return goToPage(book, prevPageNum); +} + +// Jump to specific CFI +export function goToCFI( + book: ReflowableBook, + cfi: string, +): { + success: boolean; + position: ReadingPosition; + content: string; +} { + if (!book.pagination) { + return { success: false, position: createDefaultPosition(), content: "" }; + } + + const pageNum = findPageByCFI(book.pagination, cfi); + return goToPage(book, pageNum); +} + +// Create default position +function createDefaultPosition(): ReadingPosition { + return { + currentPage: 1, + spineIndex: 0, + localPageIndex: 0, + cfi: "", + progress: 0, + }; +} + +// Check if navigation is possible +export function canGoNext(book: ReflowableBook): boolean { + return book.position.currentPage < (book.pagination?.totalPages || 1); +} + +// Check if previous navigation is possible +export function canGoPrevious(book: ReflowableBook): boolean { + return book.position.currentPage > 1; +} + +// Get progress percentage +export function getProgressPercentage(book: ReflowableBook): number { + return Math.round(book.position.progress * 100); +} + +// Update book position (after resize/recalculation) +export function updatePosition( + book: ReflowableBook, + newCFI?: string, +): ReadingPosition { + if (newCFI && book.pagination) { + const pageNum = findPageByCFI(book.pagination, newCFI); + return createPositionFromPage(book, pageNum); + } + + return book.position; +} diff --git a/web/src/reader/formats/reflowable/page-calculator.ts b/web/src/reader/formats/reflowable/page-calculator.ts new file mode 100644 index 0000000..ce69ab4 --- /dev/null +++ b/web/src/reader/formats/reflowable/page-calculator.ts @@ -0,0 +1,498 @@ +// Import types +import type { + SpineItem, + SpineInfo, + PageBoundary, + PaginationData, + PaginationSettings, + ReadingPosition, + ReflowableBook, +} from "./types"; + +// Constants for word count estimation (from Kavita) +const WORDS_PER_PAGE_BASE = 250; // At 16px font, 1.6 line height + +// Calculate words per page based on settings +function calculateWordsPerPage(settings: PaginationSettings): number { + const fontSizeFactor = 16 / settings.fontSize; + const lineHeightFactor = 1.6 / settings.lineHeight; + const areaFactor = + (settings.viewportWidth * settings.viewportHeight) / (800 * 600); + + return Math.round( + WORDS_PER_PAGE_BASE * fontSizeFactor * lineHeightFactor * areaFactor, + ); +} + +// Extract plain text from HTML +function extractTextFromHTML(html: string): string { + // Remove script and style tags + const withoutScripts = html.replace( + /)<[^<]*)*<\/script>/gi, + "", + ); + const withoutStyles = withoutScripts.replace( + /)<[^<]*)*<\/style>/gi, + "", + ); + + // Extract text content (simple version, no DOM) + return withoutStyles + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +// Count words in text +function countWords(text: string): number { + return text + .trim() + .split(/\s+/) + .filter((w) => w.length > 0).length; +} + +// Split text into word ranges for pages +function splitIntoWordRanges( + wordCount: number, + wordsPerPage: number, +): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + let start = 0; + + while (start < wordCount) { + const end = Math.min(start + wordsPerPage, wordCount); + ranges.push({ start, end }); + start = end; + } + + return ranges; +} + +// Escape special characters in CFI +function escapeCFIString(str: string): string { + return str + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)") + .replace(/,/g, "\\,") + .replace(/;/g, "\\;") + .replace(/=/g, "\\="); +} + +// Generate EPUB CFI for a position in spine +// Follows EPUB CFI spec: https://www.w3.org/TR/epub-cfi/ +// Format: epubcfi(/6/spine_index!/path/element/offset) +function generateCFI( + spineIndex: number, + charOffset: number, + totalChars: number, + spineItemId: string, +): string { + const escapedId = spineItemId ? `[${escapeCFIString(spineItemId)}]` : ""; + const offset = Math.min(charOffset, totalChars); + const spinePath = `/6/${spineIndex + 2}${escapedId}`; + + return `epubcfi(${spinePath}!/4/2/1:${offset})`; +} + +// Parse EPUB CFI to extract position +function parseCFI( + cfi: string, +): { spineIndex: number; charOffset: number } | null { + if (!cfi.startsWith("epubcfi(")) { + return null; + } + + // Remove epubcfi( wrapper + const inner = cfi.slice(8, -1); + if (!inner) return null; + + // Split on ! to separate spine path from content path + const parts = inner.split("!"); + if (parts.length < 2) return null; + + // Extract spine index from /6/4 or /6/4[id] format + const spineMatch = parts[0].match(/\/6\/(\d+)/); + if (!spineMatch) return null; + + const spineIndex = parseInt(spineMatch[1]) - 2; // Adjust for offset + if (spineIndex < 0) return null; + + // Extract character offset from :123 format + const offsetMatch = parts[1].match(/:(\d+)$/); + if (!offsetMatch) return null; + + const charOffset = parseInt(offsetMatch[1]); + + return { spineIndex, charOffset }; +} + +// Calculate pagination for entire book +export async function calculatePagination( + spineItems: SpineItem[], + contentMap: Map, + settings: PaginationSettings, +): Promise { + const wordsPerPage = calculateWordsPerPage(settings); + const spines: SpineInfo[] = []; + const pageMap = new Map(); + let globalPageIndex = 0; + + // Process each spine item + for (let i = 0; i < spineItems.length; i++) { + const spineItem = spineItems[i]; + + // Skip non-HTML items (cover pages, etc) + if (spineItem.type !== "html") { + spines.push({ + spineIndex: i, + spineItemId: spineItem.id, + content: "", + charCount: 0, + wordCount: 0, + cfiStart: "", + pages: [], + }); + continue; + } + + // Get content + const contentBlob = contentMap.get(spineItem.content); + if (!contentBlob) { + console.warn(`Content not found for spine ${spineItem.id}`); + continue; + } + + const contentHTML = await contentBlob.text(); + const plainText = extractTextFromHTML(contentHTML); + const wordCount = countWords(plainText); + const charCount = plainText.length; + + // Skip empty spines + if (wordCount === 0) { + spines.push({ + spineIndex: i, + spineItemId: spineItem.id, + content: contentHTML, + charCount, + wordCount, + cfiStart: generateCFI(i, 0, charCount, spineItem.id), + pages: [], + }); + continue; + } + + // Split into pages + const wordRanges = splitIntoWordRanges(wordCount, wordsPerPage); + const pages: PageBoundary[] = []; + + for (let j = 0; j < wordRanges.length; j++) { + const range = wordRanges[j]; + const page: PageBoundary = { + pageIndex: globalPageIndex, + localPageIndex: j, + charStart: Math.round((range.start / wordCount) * charCount), + charEnd: Math.round((range.end / wordCount) * charCount), + wordStart: range.start, + wordEnd: range.end, + cfi: generateCFI( + i, + Math.round((range.start / wordCount) * charCount), + charCount, + spineItem.id, + ), + }; + + pages.push(page); + pageMap.set(globalPageIndex, page); + globalPageIndex++; + } + + spines.push({ + spineIndex: i, + spineItemId: spineItem.id, + content: contentHTML, + charCount, + wordCount, + cfiStart: generateCFI(i, 0, charCount, spineItem.id), + pages, + }); + } + + // Build map + const spineMap = new Map(); + for (const spine of spines) { + spineMap.set(spine.spineIndex, spine); + } + + return { + totalPages: globalPageIndex, + spines, + spineMap, + pageMap, + calculatedAt: Date.now(), + settings: { ...settings, wordsPerPage }, + }; +} + +// Find which page contains a CFI +export function findPageByCFI( + pagination: PaginationData, + targetCFI: string, +): number { + const parsed = parseCFI(targetCFI); + if (!parsed) return 1; + + const { spineIndex, charOffset } = parsed; + const spine = pagination.spineMap.get(spineIndex); + + if (!spine || spine.pages.length === 0) return 1; + + // Find page containing this character offset + for (const page of spine.pages) { + if (charOffset >= page.charStart && charOffset < page.charEnd) { + return page.pageIndex + 1; // 1-indexed + } + } + + return 1; +} + +// Extract HTML slice between character offsets +function extractHTMLSlice( + html: string, + charStart: number, + charEnd: number, +): string { + if (charStart === 0 && charEnd >= html.length) { + return html; + } + + // Parse HTML and extract text nodes within the character range + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const body = doc.body; + + // Find all text nodes and their cumulative character counts + type TextNodeInfo = { node: Text; startChar: number; endChar: number }; + const textNodes: TextNodeInfo[] = []; + let cumulativeChars = 0; + + function traverse(node: Node) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ""; + const startChar = cumulativeChars; + cumulativeChars += text.length; + const endChar = cumulativeChars; + + textNodes.push({ node: node as Text, startChar, endChar }); + } else if (node.nodeType === Node.ELEMENT_NODE) { + // Skip script and style tags + if (node instanceof HTMLElement) { + const tagName = node.tagName.toLowerCase(); + if (tagName === "script" || tagName === "style") { + return; + } + } + + // Recursively traverse children + for (const child of Array.from(node.childNodes)) { + traverse(child); + } + } + } + + traverse(body); + + // Find which text nodes intersect with the requested range + const relevantNodes: { node: Text; before: string; after: string }[] = []; + + for (const { node, startChar, endChar } of textNodes) { + if (endChar <= charStart || startChar >= charEnd) { + // No overlap + continue; + } + const text = node.textContent || ""; + let resultText = text; + // Trim from left if node starts before page + if (startChar < charStart) { + resultText = text.substring(charStart - startChar); + } + // Trim from right if node extends past page end + if (endChar > charEnd) { + // Calculate where to cut within the (potentially already trimmed) text + const cutPosition = charEnd - startChar; + resultText = text.substring(0, cutPosition); + } + // Handle case where both trims are needed + if (startChar < charStart && endChar > charEnd) { + const leftTrim = charStart - startChar; + const rightTrim = endChar - charEnd; + resultText = text.substring(leftTrim, text.length - rightTrim); + } + relevantNodes.push({ node, before: "", after: resultText }); + } + + // Preserve original HTML structure for nodes in range + const startNode = textNodes.find((n) => n.endChar > charStart); + const endNode = textNodes.find((n) => n.startChar < charEnd); + + if (!startNode || !endNode) { + return html; + } + + // Find element boundaries + let startElement: Node | null = startNode.node; + while (startElement && startElement.parentNode !== body) { + startElement = startElement.parentNode; + } + + let endElement: Node | null = endNode.node; + while (endElement && endElement.parentNode !== body) { + endElement = endElement.parentNode; + } + + // Extract and modify the relevant portion + if (startElement && endElement) { + const fragment = document.createDocumentFragment(); + + let currentElement: Node | null = startElement; + let foundEnd = false; + + while (currentElement && !foundEnd) { + if (currentElement.nodeType === Node.ELEMENT_NODE) { + const clone = (currentElement as Element).cloneNode(false); + fragment.appendChild(clone); + + // Process children + for (const child of Array.from(currentElement.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const textNodeInfo = textNodes.find((n) => n.node === child); + if (textNodeInfo) { + const modified = document.createTextNode( + relevantNodes.find((n) => n.node === child)?.after || "", + ); + clone.appendChild(modified); + } + } else if (child.nodeType === Node.ELEMENT_NODE) { + // Recursively handle element children + const childClone = child.cloneNode(true); + clone.appendChild(childClone); + } + } + + if (currentElement === endElement) { + foundEnd = true; + } + } + + currentElement = currentElement.nextSibling; + } + + // Serialize fragment back to HTML + const tempDiv = document.createElement("div"); + tempDiv.appendChild(fragment); + return tempDiv.innerHTML; + } + + // Fallback: return original HTML if extraction fails + return html; +} + +// Get page content (HTML slice for a page) +export function getPageContent( + pagination: PaginationData, + pageIndex: number, +): string { + const page = pagination.pageMap.get(pageIndex); + if (!page) return ""; + + // Find the spine that contains this page + // Pages are stored in order, so we can find the spine by checking which pages it contains + let spine: SpineInfo | undefined; + for (const s of pagination.spines) { + if (s.pages.some((p) => p.pageIndex === pageIndex)) { + spine = s; + break; + } + } + + if (!spine) return ""; + + // Extract HTML content between page boundaries + const htmlSlice = extractHTMLSlice( + spine.content, + page.charStart, + page.charEnd, + ); + + // Wrap in a div to ensure valid HTML structure + return `
${htmlSlice}
`; +} + +// Recalculate pagination on viewport change +export function shouldRecalculate( + pagination: PaginationData | null, + newSettings: PaginationSettings, +): boolean { + if (!pagination) return true; + + const sizeChanged = + Math.abs(pagination.settings.viewportWidth - newSettings.viewportWidth) > + 50 || + Math.abs(pagination.settings.viewportHeight - newSettings.viewportHeight) > + 50; + + const fontChanged = pagination.settings.fontSize !== newSettings.fontSize; + const lineChanged = pagination.settings.lineHeight !== newSettings.lineHeight; + + return sizeChanged || fontChanged || lineChanged; +} + +// Create position object from page number +export function createPositionFromPage( + book: ReflowableBook, + page: number, +): ReadingPosition { + if (!book.pagination) { + return { + currentPage: 1, + spineIndex: 0, + localPageIndex: 0, + cfi: "", + progress: 0, + }; + } + + const pageIndex = page - 1; + const pageData = book.pagination.pageMap.get(pageIndex); + + if (!pageData) { + return { + currentPage: 1, + spineIndex: 0, + localPageIndex: 0, + cfi: "", + progress: 0, + }; + } + + // Find which spine this page belongs to + let spineIndex = 0; + for (const spine of book.pagination.spines) { + if (pageData.localPageIndex < spine.pages.length) { + spineIndex = spine.spineIndex; + break; + } + } + + return { + currentPage: page, + spineIndex, + localPageIndex: pageData.localPageIndex, + cfi: pageData.cfi, + progress: + book.pagination.totalPages > 0 ? page / book.pagination.totalPages : 0, + }; +} diff --git a/web/src/reader/formats/reflowable/parser.ts b/web/src/reader/formats/reflowable/parser.ts new file mode 100644 index 0000000..86a63a3 --- /dev/null +++ b/web/src/reader/formats/reflowable/parser.ts @@ -0,0 +1,62 @@ +// Import types and existing parsers +import type { ReflowableBook, SpineItem, TOCItem } from "./types"; +import { parseEPUB } from "../../parsers/epub-parsers"; +import { parseFB2 } from "../../parsers/fb2-parser"; +import { parseTXT } from "../../parsers/txt-parser"; +import { parseHTML } from "../../parsers/html-parser"; + +// Parse any reflowable format +export async function parseReflowable( + file: File, + format: "epub" | "fb2" | "txt" | "html", +): Promise { + switch (format) { + case "epub": + return await parseEPUB(file); + case "fb2": + return await parseFB2(file); + case "txt": + return await parseTXT(file); + case "html": + return await parseHTML(file); + default: + throw new Error(`Unsupported reflowable format: ${format}`); + } +} + +// Validate parsed book data +export function validateBook(book: ReflowableBook): boolean { + return book.spine.length > 0 && book.metadata.title !== ""; +} + +// Get book title +export function getBookTitle(book: ReflowableBook): string { + return book.metadata.title || "Untitled"; +} + +// Get book author +export function getBookAuthor(book: ReflowableBook): string { + return book.metadata.author || "Unknown"; +} + +// Get total spine count +export function getSpineCount(book: ReflowableBook): number { + return book.spine.length; +} + +// Get TOC as flat list +export function getFlatTOC(book: ReflowableBook): TOCItem[] { + const flat: TOCItem[] = []; + + function traverse(items: TOCItem[]) { + for (const item of items) { + flat.push(item); + if (item.children.length > 0) { + traverse(item.children); + } + } + } + + traverse(book.toc); + return flat; +} diff --git a/web/src/reader/formats/reflowable/progress-tracker.ts b/web/src/reader/formats/reflowable/progress-tracker.ts new file mode 100644 index 0000000..9a44f5f --- /dev/null +++ b/web/src/reader/formats/reflowable/progress-tracker.ts @@ -0,0 +1,82 @@ +// Import types +import type { ReflowableBook, ReadingPosition } from "./types"; +import { findPageByCFI, createPositionFromPage } from "./page-calculator"; + +// Update current position +export function updateCurrentPosition( + book: ReflowableBook, + position: ReadingPosition, +): ReflowableBook { + return { + ...book, + position, + }; +} + +// Extract CFI from position +export function getCurrentCFI(book: ReflowableBook): string { + return book.position.cfi; +} + +// Calculate progress for display +export function calculateProgress(book: ReflowableBook): { + currentPage: number; + totalPages: number; + percentage: number; +} { + const totalPages = book.pagination?.totalPages || 1; + const currentPage = book.position.currentPage; + const percentage = + totalPages > 0 ? Math.round((currentPage / totalPages) * 100) : 0; + + return { currentPage, totalPages, percentage }; +} + +// Get position for saving to database +export function getPositionForSave(book: ReflowableBook): { + cfi: string; + progress: number; + page: number; +} { + return { + cfi: book.position.cfi, + progress: book.position.progress, + page: book.position.currentPage, + }; +} + +// Restore position from database +export function restorePosition( + book: ReflowableBook, + savedCFI: string, + savedPage?: number, +): ReadingPosition { + if (!book.pagination) { + return book.position; + } + + // If we have saved CFI, try to find exact position + if (savedCFI) { + const pageNum = findPageByCFI(book.pagination, savedCFI); + return createPositionFromPage(book, pageNum); + } + + // Otherwise use saved page number + if (savedPage && savedPage > 0) { + return createPositionFromPage(book, savedPage); + } + + return book.position; +} + +// Check if position changed significantly +export function didPositionChange( + oldPos: ReadingPosition, + newPos: ReadingPosition, +): boolean { + return ( + oldPos.currentPage !== newPos.currentPage || + oldPos.cfi !== newPos.cfi || + Math.abs(oldPos.progress - newPos.progress) > 0.01 + ); +} diff --git a/web/src/reader/formats/reflowable/types.ts b/web/src/reader/formats/reflowable/types.ts new file mode 100644 index 0000000..b02f97e --- /dev/null +++ b/web/src/reader/formats/reflowable/types.ts @@ -0,0 +1,84 @@ +// Spine item structure from parsed EPUB/FB2/etc +export interface SpineItem { + id: string; + type: "html" | "image" | "other"; + content: string; // Blob URL or content ID + href?: string; // For CFI generation +} + +// Information about a single spine item +export interface SpineInfo { + spineIndex: number; + spineItemId: string; + content: string; // Full HTML content + charCount: number; // Total characters + wordCount: number; // Total words (for pagination) + cfiStart: string; // CFI at start of this spine + pages: PageBoundary[]; // Page boundaries within this spine +} + +// A single page boundary within a spine +export interface PageBoundary { + pageIndex: number; // Global page index + localPageIndex: number; // Page index within this spine + charStart: number; // Character offset from start of spine + charEnd: number; // Character offset at end of page + wordStart: number; // Word offset from start of spine + wordEnd: number; // Word offset at end of page + cfi: string; // CFI for this position +} + +// Complete pagination data +export interface PaginationData { + totalPages: number; + spines: SpineInfo[]; + spineMap: Map; + pageMap: Map; // pageIndex -> PageBoundary + calculatedAt: number; + settings: PaginationSettings; +} + +// Settings used for calculation +export interface PaginationSettings { + fontSize: number; + lineHeight: number; + viewportWidth: number; + viewportHeight: number; + wordsPerPage: number; // Calculated from above +} + +// Current reading position +export interface ReadingPosition { + currentPage: number; + spineIndex: number; + localPageIndex: number; + cfi: string; + progress: number; // 0-1 +} + +// Reflowable book data +export interface ReflowableBook { + type: "epub" | "fb2" | "txt" | "html"; + spine: SpineItem[]; + resources: Map; + toc: TOCItem[]; + metadata: BookMetadata; + pagination: PaginationData | null; + position: ReadingPosition; +} + +// Table of contents item +export interface TOCItem { + id: string; + title: string; + href: string; + children: TOCItem[]; +} + +// Book metadata +export interface BookMetadata { + title: string; + author: string; + identifier: string; + [key: string]: any; +}