createViewer.js

import { BookViewer } from "./BookViewer.js";
import { WebGPUSpreadRenderer } from "./rendering/WebGPUSpreadRenderer.js";
import { SpreadRenderer } from "./rendering/SpreadRenderer.js";
import { PdfTextLayerController } from "./controllers/PdfTextLayerController.js";
import { getPaperPresetOptions } from "./model/paper.js";

function pickRendererClass(option) {
  if (option === "2d") return SpreadRenderer;
  if (option === "webgpu") return WebGPUSpreadRenderer;
  if (option && typeof option === "function") return option;
  return "gpu" in navigator ? WebGPUSpreadRenderer : SpreadRenderer;
}

/**
 * Options for {@link createViewer}.
 *
 * @typedef {Object} RiffleOptions
 * @property {"auto"|"webgpu"|"2d"|Function} [renderer="auto"] Renderer selection. `auto` uses WebGPU when available.
 * @property {PageSource|null} [source=null] Initial page source.
 * @property {Partial<Layout>|null} [layout=null] Initial layout overrides.
 * @property {Partial<Display>|null} [display=null] Initial display overrides.
 * @property {"natural"|"ivory"|"bright-white"} [paperPreset] Named paper preset.
 * @property {string} [contentBlendMode="multiply"] Blend mode for page content.
 * @property {number} [paperThickness] Paper edge and turn-lighting strength from 0 to 1.
 * @property {number} [showThrough=0.1] Strength of the show-through translucency (adjacent page bleeding through) from 0 (off) to 1.
 * @property {boolean} [showPageBorder=true] Whether to render the page edge treatment.
 * @property {number} [maxHighResPages=8] High-resolution page bitmap LRU capacity.
 * @property {HTMLElement|null} [viewport=null] Element used for zoom measurement and scroll preservation.
 * @property {boolean} [selectablePdfText=true] Whether to overlay selectable PDF text on settled spreads.
 * @property {number} [renderScale=1] Pixel supersampling multiplier for the rendered spread canvas.
 * @property {number} [pdfRenderScale=1.5] Baseline PDF rasterization scale before DPR/zoom.
 * @property {number} [pdfRenderScaleHeadroom=1.1] Extra PDF rasterization margin above visible size.
 * @property {number} [pdfMaxRenderScale=0] Maximum PDF rasterization scale; set 0 to disable.
 * @property {number} [pdfPreviewSourceScale=0.5] Fallback PDF preview rasterization scale when page dimensions are unavailable.
 */

/**
 * A Riffle viewer.
 *
 * A viewer *is* a DOM element (a `<canvas>`) with the viewer API mixed in.
 * Create it with {@link createViewer}, append it to the document, then call
 * these methods directly on it — there is no separate controller object or
 * mounting step. Riffle imposes no wrapper element or layout styling; you
 * decide how the element is positioned, scrolled, and decorated.
 *
 * @interface RiffleViewer
 */

/**
 * Number of pages in the current document.
 * @member {number} pageCount
 * @memberof RiffleViewer
 * @instance
 * @readonly
 */

/**
 * Total number of two-page spreads in the current document.
 * @member {number} numSpreads
 * @memberof RiffleViewer
 * @instance
 * @readonly
 */

/**
 * Index of the settled spread currently on screen.
 * @member {number} currentSpread
 * @memberof RiffleViewer
 * @instance
 * @readonly
 */

/**
 * The target spread, including any in-flight page turn. Use this for
 * "where are we heading" reads (e.g. a page counter that updates the moment a
 * turn starts rather than when it settles).
 * @member {number} effectiveSpread
 * @memberof RiffleViewer
 * @instance
 * @readonly
 */

/**
 * Whether a page turn is currently animating.
 * @member {boolean} isAnimating
 * @memberof RiffleViewer
 * @instance
 * @readonly
 */

/**
 * Current visual zoom factor (`1` = fit to viewport).
 * @member {number} contentZoom
 * @memberof RiffleViewer
 * @instance
 * @readonly
 */

/**
 * Loads a PDF and displays its first spread. To load from a URL, `fetch()` it
 * first and pass the resulting `ArrayBuffer`.
 *
 * @function openPdf
 * @memberof RiffleViewer
 * @instance
 * @param {File|Blob|ArrayBuffer} source The PDF to display.
 * @returns {Promise<void>} Resolves once the first spread is on screen.
 */

/**
 * Displays a list of image files, one page per image. Fits the layout to the
 * first image. Non-image files in the list are ignored.
 *
 * @function openImages
 * @memberof RiffleViewer
 * @instance
 * @param {File[]|FileList} files The images to display.
 * @returns {Promise<void>} Resolves once the first spread is on screen.
 */

/**
 * Displays an arbitrary page source and fits the layout to its first page.
 * Most callers use {@link RiffleViewer#openPdf} or
 * {@link RiffleViewer#openImages} instead; reach for this to display a custom
 * {@link PageSource}.
 *
 * @function setSource
 * @memberof RiffleViewer
 * @instance
 * @param {PageSource} source The source to display.
 * @returns {void}
 */

/**
 * Navigates to a spread by index, animating a page turn.
 *
 * @function navigateTo
 * @memberof RiffleViewer
 * @instance
 * @param {number} spreadIndex Destination spread index.
 * @returns {void}
 */

/**
 * Navigates by a relative number of spreads, animating a page turn. Respects a
 * turn already in flight (it moves from the effective target, not the settled
 * spread).
 *
 * @function navigateBy
 * @memberof RiffleViewer
 * @instance
 * @param {number} delta Spreads to move, e.g. `+1` (next) or `-1` (previous).
 * @returns {void}
 */

/**
 * Navigates so a given page is visible, focusing it within its spread. A no-op
 * if the page is out of range.
 *
 * @function goToPage
 * @memberof RiffleViewer
 * @instance
 * @param {number} page 1-based page number (`1` is the first page).
 * @returns {void}
 */

/**
 * The 1-based page number(s) visible in a spread — e.g. `[3, 4]`, or `[1]` for
 * a single-page spread. Empty when the spread has no source pages. Handy for a
 * "page N of M" readout: `viewer.pagesInSpread(viewer.effectiveSpread)`.
 *
 * @function pagesInSpread
 * @memberof RiffleViewer
 * @instance
 * @param {number} spreadIndex Spread to inspect.
 * @returns {number[]} 1-based page numbers on that spread.
 */

/**
 * Zooms in or out by one step.
 *
 * @function adjustZoom
 * @memberof RiffleViewer
 * @instance
 * @param {number} direction Positive to zoom in, negative to zoom out.
 * @returns {void}
 */

/**
 * Sets the book zoom, clamped to 0.5–6. An optional point in browser client
 * coordinates keeps that point on the book beneath a pinch gesture.
 *
 * @function setZoom
 * @memberof RiffleViewer
 * @instance
 * @param {number} zoom Absolute zoom (`1` fits the spread).
 * @param {{clientX: number, clientY: number}} [anchor] Zoom focal point.
 * @returns {void}
 */

/**
 * Restores the fit-to-viewport zoom.
 *
 * @function resetZoom
 * @memberof RiffleViewer
 * @instance
 * @returns {void}
 */

/**
 * Sets the element used for zoom measurement and scroll preservation. Defaults
 * to the viewer's parent; set this when the viewer isn't a direct child of the
 * element you scroll.
 *
 * @function setViewport
 * @memberof RiffleViewer
 * @instance
 * @param {HTMLElement} element The scroll/viewport element.
 * @returns {void}
 */

/**
 * Subscribes to a viewer event. See the {@tutorial 06-events} guide for the
 * event names and their payloads.
 *
 * @function on
 * @memberof RiffleViewer
 * @instance
 * @param {string} event Event name, e.g. `"spreadchange"`.
 * @param {Function} handler Called with the event payload.
 * @returns {void}
 */

/**
 * Removes a previously registered event listener.
 *
 * @function off
 * @memberof RiffleViewer
 * @instance
 * @param {string} event Event name.
 * @param {Function} handler The same handler reference passed to `on`.
 * @returns {void}
 */

/**
 * Attaches an hOCR text layer to the current source for selectable and
 * searchable text *(advanced)*. Works with pages loaded from a PDF
 * ({@link RiffleViewer#openPdf}) or from images ({@link RiffleViewer#openImages}).
 *
 * @function openHocr
 * @memberof RiffleViewer
 * @instance
 * @param {File|Blob|string} fileOrText hOCR markup, or a file/blob containing it.
 * @param {Object} [options={}] Text-attachment options.
 * @param {number} [options.pageOffset=0] Index of the first document page the hOCR pages map to.
 * @returns {Promise<{pages: Object[], attached: number}>} Resolves with the parsed `pages` and the `attached` count (number of document pages updated).
 */

/**
 * Creates a Riffle viewer.
 *
 * The returned value is a `<canvas>` element with the viewer API mixed in.
 * Append it to the document and call methods on it directly; Riffle imposes no
 * DOM wrapper or layout styling.
 *
 * @param {RiffleOptions} [options={}] Viewer options.
 * @returns {RiffleViewer} A canvas element with the public viewer API.
 */
export function createViewer({
  renderer = "auto",
  source = null,
  layout = null,
  display = null,
  paperPreset,
  contentBlendMode = "multiply",
  paperThickness,
  showThrough,
  showPageBorder = true,
  maxHighResPages = 8,
  viewport = null,
  selectablePdfText = true,
  renderScale = 1,
  pdfRenderScale = 1.5,
  pdfRenderScaleHeadroom = 1.1,
  pdfMaxRenderScale = 0,
  pdfPreviewSourceScale = 0.5,
} = {}) {
  const spreadCanvas = document.createElement("canvas");
  spreadCanvas.width = 0;
  spreadCanvas.height = 0;
  spreadCanvas.style.display = "block";
  spreadCanvas.style.width = "100%";
  spreadCanvas.style.height = "100%";
  spreadCanvas.style.objectFit = "contain";

  const rendererClass = pickRendererClass(renderer);
  const bookViewer = new BookViewer({
    spreadCanvas,
    viewport,          // BookViewer falls back to spreadCanvas.parentElement
    rendererClass,
    source,
    layout,
    display,
    paperPreset,
    contentBlendMode,
    paperThickness,
    showThrough,
    showPageBorder,
    maxHighResPages,
    renderScale,
    pdfRenderScale,
    pdfRenderScaleHeadroom,
    pdfMaxRenderScale,
    pdfPreviewSourceScale,
  });
  const pdfTextLayer = selectablePdfText ? new PdfTextLayerController(bookViewer) : null;

  // Display a source, deriving the spread layout from its first page's aspect
  // ratio. openPdf and setSource both route through here so the page geometry
  // is correct no matter how content was loaded — callers never set layout.
  const applySource = (src) => {
    bookViewer.setSource(src);
    const firstAspect = src?.getPageMetadata?.(0)?.aspectRatio ?? 0.647;
    bookViewer.setLayout({
      pw: bookViewer.layout.ph * firstAspect,
      ratio: firstAspect * 0.999,
    });
  };

  const api = {
    // --- Content ---
    get pageCount() { return bookViewer.book.sourcePageCount(); },
    openPdf: async (file) => {
      const { PdfPageSource } = await import("./sources/PdfPageSource.js");
      const src = new PdfPageSource();
      await src.openPdf(file);
      applySource(src);
    },
    openImages: async (files) => {
      const { createImageSourceFromFiles } = await import("./sources/imageFilesSource.js");
      const src = await createImageSourceFromFiles(files);
      if (src) applySource(src);
    },
    setSource: (s) => applySource(s),

    // --- Navigation ---
    get numSpreads() { return bookViewer.numSpreads; },
    get currentSpread() { return bookViewer.currentSpread; },
    // The currently-targeted spread including any in-flight animation. Use
    // this for "where are we heading" reads (e.g., relative navigation).
    get effectiveSpread() { return bookViewer.navigationController.getEffectiveSpread(); },
    get isAnimating() { return bookViewer.isAnimating; },
    navigateTo: (s, p) => bookViewer.navigateTo(s, p),
    navigateBy: (delta) => bookViewer.navigateTo(bookViewer.navigationController.getEffectiveSpread() + delta),
    // Navigate so 1-based page `page` is visible, focusing it within its
    // spread. No-op if the page is out of range.
    goToPage: (page) => {
      const sourcePageIndex = page - 1;
      const spread = bookViewer.book.spreadIndexForSourcePage(sourcePageIndex);
      if (spread < 0) return;
      bookViewer.navigateTo(spread, bookViewer.book.sourcePageIndexToPageIndex(sourcePageIndex));
    },
    // The 1-based page number(s) visible in a spread, e.g. [3, 4] (or [1] for a
    // single-page spread). Empty when the spread has no source pages.
    pagesInSpread: (spreadIndex) => {
      const book = bookViewer.book;
      const entries = book.spreadPageEntries?.(spreadIndex);
      if (!entries) return [];
      const pages = [];
      for (const side of [entries.left, entries.right]) {
        const sourcePageIndex = book.pageIndexToSourcePageIndex(side?.pageIndex ?? -1);
        if (sourcePageIndex >= 0) pages.push(sourcePageIndex + 1);
      }
      return pages;
    },

    // --- Appearance ---
    // A copy: mutating the viewer's own display object would change what is
    // drawn without redrawing. Go through setDisplay / setPaperPreset.
    get display() { return { ...bookViewer.display }; },
    get paperPresets() { return getPaperPresetOptions(); },
    setDisplay: (fields) => bookViewer.setDisplay(fields),
    setPaperPreset: (presetId) => bookViewer.setPaperPreset(presetId),
    setShowPageBorder: (show) => bookViewer.setShowPageBorder(show),

    // --- Zoom ---
    get contentZoom() { return bookViewer.contentZoom; },
    adjustZoom: (d) => bookViewer.adjustZoom(d),
    setZoom: (zoom, anchor) => bookViewer.setZoom(zoom, anchor),
    resetZoom: () => bookViewer.resetZoom(),
    setViewport: (el) => bookViewer.setViewport(el),

    // --- Events ---
    on: (event, fn) => bookViewer.on(event, fn),
    off: (event, fn) => bookViewer.off(event, fn),

    // --- Text layer (advanced) ---
    openHocr: async (fileOrText, options = {}) => {
      const { loadHocr } = await import("./loading/hocr.js");
      const pages = await loadHocr(fileOrText);
      const attach = bookViewer.source?.attachTextContent;
      if (typeof attach !== "function") {
        throw new Error("Current Riffle source does not support external text content");
      }
      const attached = attach.call(bookViewer.source, pages, options);
      pdfTextLayer?.update();
      return { pages, attached };
    },
  };
  // Use defineProperties so getters stay live — Object.assign would
  // invoke each getter once at copy time and stamp the resulting value,
  // freezing `numSpreads`/`currentSpread`/etc. at construction-time
  // values (back when the book was empty).
  Object.defineProperties(spreadCanvas, Object.getOwnPropertyDescriptors(api));

  // Internal engine handle used by createPageStrip to bind to the same book.
  // Non-enumerable and not part of the public API — its shape can change
  // between releases; use the documented methods above instead.
  Object.defineProperty(spreadCanvas, "bookViewer", { value: bookViewer, enumerable: false });
  return spreadCanvas;
}