Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions PROJECT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,25 @@ All formats are single-file bundles. `preserveModules` was considered for ESM bu

---

## Performance Optimizations

### Dirty-flag dimension caching (Layer 1)
`IframeView.expand()` now caches `textWidth()`/`textHeight()` results and only re-measures when content actually changes (RESIZE/EXPAND events). This eliminates redundant synchronous reflows during page navigation, font size changes, and layout recalculations. The RESIZE event handler pre-populates the cache from `resizeCheck()` measurements, cutting the resize chain from 4 reflows to 2.

### Canvas-based text measurement (Layers 2–3)
`TextMeasurer` (`src/utils/text-measurer.ts`) measures text widths via `CanvasRenderingContext2D.measureText()` instead of DOM Range + `getBoundingClientRect()`. `Mapping.findTextStartRange()` and `findTextEndRange()` use binary search on pre-measured cumulative widths, reducing per-word reflow loops from O(N) to O(1) for text-heavy content. Falls back to DOM measurement for content with exotic CSS (`letter-spacing`, `word-spacing`, `text-indent`).

### Browser requirements for optimizations
- `OffscreenCanvas`: Chrome 69+, Firefox 105+, Safari 16.4+ (fallback: `HTMLCanvasElement`)
- `Intl.Segmenter`: Chrome 87+, Firefox 125+, Safari 15.4+ (fallback: space/CJK splitting)
- Older browsers get the same behavior as before β€” all optimizations are transparent fallbacks

---

## Next Steps

- **Annotation rendering** β€” `highlight()`, `underline()`, `mark()` in `annotations.ts` have TODO stubs needing View/Contents integration
- **3 remaining TODOs** β€” CFI range validation (`epubcfi.ts`), CFI validity check and page list fallback (`pagelist.ts`)
- **Logger abstraction** β€” 9 `eslint-disable no-console` suppressions could be replaced with a pluggable logger
- **Canvas page estimation (Layer 4)** β€” Optional: estimate page counts from text metrics for instant progress display before full `Locations.generate()` completes

19 changes: 17 additions & 2 deletions src/managers/default/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Mapping from "../../mapping";
import Queue from "../../utils/queue";
import Stage from "../helpers/stage";
import Views from "../helpers/views";
import TextMeasurer from "../../utils/text-measurer";
import { EVENTS } from "../../utils/constants";
import type Layout from "../../layout";
import type Section from "../../section";
Expand Down Expand Up @@ -40,6 +41,7 @@ class DefaultViewManager implements IEventEmitter<DefaultManagerEvents> {
overflow!: string;
layout!: Layout;
mapping!: Mapping;
_measurer!: TextMeasurer;
location!: ViewLocation[];
isPaginated!: boolean;
scrollLeft!: number;
Expand Down Expand Up @@ -96,6 +98,7 @@ class DefaultViewManager implements IEventEmitter<DefaultManagerEvents> {
allowPopups: this.settings.allowPopups
};

this._measurer = new TextMeasurer();
this.rendered = false;

}
Expand Down Expand Up @@ -208,6 +211,10 @@ class DefaultViewManager implements IEventEmitter<DefaultManagerEvents> {

this.stage.destroy();

if (this._measurer) {
this._measurer.destroy();
}

this.rendered = false;

this.__listeners = {};
Expand Down Expand Up @@ -750,6 +757,14 @@ class DefaultViewManager implements IEventEmitter<DefaultManagerEvents> {
// this.q.clear();

if (this.views) {
// Invalidate canvas measurement caches for views being removed
if (this._measurer) {
this.views.forEach((view: IframeView) => {
if (view?.document?.body) {
this._measurer.invalidate(view.document.body);
}
});
}
this.views.hide();
this.scrollTo(0,0, true);
this.views.clear();
Expand Down Expand Up @@ -1070,7 +1085,7 @@ class DefaultViewManager implements IEventEmitter<DefaultManagerEvents> {

this.viewSettings.layout = layout;

this.mapping = new Mapping(layout.props, this.settings.direction, this.settings.axis);
this.mapping = new Mapping(layout.props, this.settings.direction, this.settings.axis, false, this._measurer);

if(this.views) {

Expand Down Expand Up @@ -1101,7 +1116,7 @@ class DefaultViewManager implements IEventEmitter<DefaultManagerEvents> {
this.viewSettings.axis = axis;

if (this.mapping) {
this.mapping = new Mapping(this.layout.props, this.settings.direction, this.settings.axis);
this.mapping = new Mapping(this.layout.props, this.settings.direction, this.settings.axis, false, this._measurer);
}

if (this.layout) {
Expand Down
33 changes: 27 additions & 6 deletions src/managers/views/iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class IframeView implements IEventEmitter<IframeViewEvents> {
_textHeight: number | undefined;
_contentWidth: number | undefined;
_contentHeight: number | undefined;
_contentDirty!: boolean;
_needsReframe!: boolean;
_expanding!: boolean;
elementBounds!: { width: number; height: number };
Expand Down Expand Up @@ -283,6 +284,7 @@ class IframeView implements IEventEmitter<IframeViewEvents> {
this._textHeight = undefined;
this._contentHeight = undefined;
}
this._contentDirty = true;
this._needsReframe = true;
}

Expand Down Expand Up @@ -349,8 +351,6 @@ class IframeView implements IEventEmitter<IframeViewEvents> {
let height = this.lockedHeight;
let columns;

let _textWidth, _textHeight;

if(!this.iframe || this._expanding) return;

this._expanding = true;
Expand All @@ -361,8 +361,14 @@ class IframeView implements IEventEmitter<IframeViewEvents> {
}
// Expand Horizontally
else if(this.settings.axis === "horizontal") {
// Get the width of the text
width = this.contents!.textWidth();
// Use cached text width when content hasn't changed (avoids synchronous reflow)
if (!this._contentDirty && this._textWidth !== undefined) {
width = this._textWidth;
} else {
width = this.contents!.textWidth();
this._textWidth = width;
this._contentDirty = false;
}

if (width % this.layout.pageWidth > 0) {
width = Math.ceil(width / this.layout.pageWidth) * this.layout.pageWidth;
Expand All @@ -380,7 +386,15 @@ class IframeView implements IEventEmitter<IframeViewEvents> {

} // Expand Vertically
else if(this.settings.axis === "vertical") {
height = this.contents!.textHeight();
// Use cached text height when content hasn't changed (avoids synchronous reflow)
if (!this._contentDirty && this._textHeight !== undefined) {
height = this._textHeight;
} else {
height = this.contents!.textHeight();
this._textHeight = height;
this._contentDirty = false;
}

if (this.settings.flow === "paginated" &&
height % this.layout.height > 0) {
height = Math.ceil(height / this.layout.height) * this.layout.height;
Expand Down Expand Up @@ -505,15 +519,21 @@ class IframeView implements IEventEmitter<IframeViewEvents> {

this.contents.on(EVENTS.CONTENTS.EXPAND, () => {
if(this.displayed && this.iframe) {
this._contentDirty = true;
this.expand();
if (this.contents) {
this.layout.format(this.contents);
}
}
});

this.contents.on(EVENTS.CONTENTS.RESIZE, (_e: { width: number; height: number }) => {
this.contents.on(EVENTS.CONTENTS.RESIZE, (e: { width: number; height: number }) => {
if(this.displayed && this.iframe) {
// Pre-populate cache with values already measured by resizeCheck(),
// avoiding a redundant reflow when expand() runs next
this._textWidth = e.width;
this._textHeight = e.height;
this._contentDirty = false;
this.expand();
if (this.contents) {
this.layout.format(this.contents);
Expand All @@ -529,6 +549,7 @@ class IframeView implements IEventEmitter<IframeViewEvents> {

if (this.contents) {
this.layout.format(this.contents);
this._contentDirty = true;
this.expand();
}
}
Expand Down
118 changes: 107 additions & 11 deletions src/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { nodeBounds } from "./utils/core";
import type { EpubCFIPair, RangePair, LayoutProps } from "./types";
import type IframeView from "./managers/views/iframe";
import type Contents from "./contents";
import type TextMeasurer from "./utils/text-measurer";
import type { PreparedNode } from "./utils/text-measurer";

/**
* Map text locations to CFI ranges
Expand All @@ -17,12 +19,14 @@ class Mapping {
horizontal: boolean;
direction: string;
_dev: boolean;
_measurer: TextMeasurer | null;

constructor(layout: LayoutProps, direction?: string, axis?: string, dev: boolean = false) {
constructor(layout: LayoutProps, direction?: string, axis?: string, dev: boolean = false, measurer?: TextMeasurer) {
this.layout = layout;
this.horizontal = (axis === "horizontal") ? true : false;
this.direction = direction || "ltr";
this._dev = dev;
this._measurer = measurer || null;
}

/**
Expand Down Expand Up @@ -133,6 +137,7 @@ class Mapping {
let $el;
let found;
let $prev = root;
let lastElPos: DOMRect | undefined;

while (stack.length) {

Expand All @@ -143,6 +148,7 @@ class Mapping {


const elPos = nodeBounds(node);
lastElPos = elPos;

if (this.horizontal && this.direction === "ltr") {

Expand Down Expand Up @@ -193,13 +199,13 @@ class Mapping {
});

if(found) {
return this.findTextStartRange(found, start, end);
return this.findTextStartRange(found, start, end, lastElPos);
}

}

// Return last element
return this.findTextStartRange($prev, start, end);
return this.findTextStartRange($prev, start, end, lastElPos);
}

/**
Expand All @@ -215,6 +221,7 @@ class Mapping {
let $el;
let $prev = root;
let found;
let lastElPos: DOMRect | undefined;

while (stack.length) {

Expand All @@ -225,6 +232,7 @@ class Mapping {
let left, right, top, bottom;

const elPos = nodeBounds(node);
lastElPos = elPos;

if (this.horizontal && this.direction === "ltr") {

Expand Down Expand Up @@ -275,24 +283,103 @@ class Mapping {


if(found){
return this.findTextEndRange(found, start, end);
return this.findTextEndRange(found, start, end, lastElPos);
}

}

// end of chapter
return this.findTextEndRange($prev, start, end);
return this.findTextEndRange($prev, start, end, lastElPos);
}

/**
* Try to prepare a text node's root for canvas-based measurement.
* Returns the PreparedNode for this text node, or null if not available.
* @private
*/
private _canvasPrepare(node: Node): PreparedNode | null {
if (!this._measurer || node.nodeType !== Node.TEXT_NODE) return null;

const textNode = node as Text;

// O(1) lookup if already prepared
const indexed = this._measurer.getPreparedNode(textNode);
if (indexed) return indexed;

const root = textNode.parentElement;
if (!root) return null;

const win = root.ownerDocument?.defaultView;
if (!win) return null;

if (this._measurer.hasExoticCSS(textNode, win)) return null;

// Prepare the entire document body (populates _nodeIndex for all text nodes)
this._measurer.prepare(root.ownerDocument.body, win);
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

root.ownerDocument.body is typed as HTMLBodyElement | null in lib.dom, so this call will fail strictNullChecks (and can throw at runtime for documents without a <body>). Please guard for a missing body (or use a non-null assertion only if the <body> is guaranteed here).

Suggested change
this._measurer.prepare(root.ownerDocument.body, win);
const body = root.ownerDocument?.body;
if (!body) return null;
this._measurer.prepare(body, win);

Copilot uses AI. Check for mistakes.
return this._measurer.getPreparedNode(textNode);
}
Comment on lines +300 to +320
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_canvasPrepare() calls prepare(body) and then does an array .find() to locate the PreparedNode for the current Text node. That lookup is O(N) per call and can dominate for large documents, offsetting the intended savings. Consider having TextMeasurer keep an index (e.g., WeakMap<Text, PreparedNode>) or returning a Map keyed by Text for O(1) lookup.

Copilot uses AI. Check for mistakes.

/**
* Canvas fast path: use binary search on pre-measured cumulative widths
* to find a Range at the target position, then verify with one getBoundingClientRect.
* Returns the Range if verification passes, or null to fall through to DOM loop.
* @private
*/
private _canvasFindRange(
node: Node, nodePos: DOMRect, target: number, verifyFn: (pos: DOMRect) => boolean
): Range | null {
const prepared = this._canvasPrepare(node);
if (!prepared || prepared.segments.length === 0) return null;

const textNode = node as Text;
const nodeStart = this.horizontal
? (this.direction === "rtl" ? nodePos.right : nodePos.left)
: nodePos.top;
const relativeTarget = (this.horizontal && this.direction === "rtl")
? nodeStart - target
: target - nodeStart;

if (relativeTarget < 0) return null;

const segIdx = this._measurer!.findSegmentIndex(prepared.segments, relativeTarget);
const seg = prepared.segments[segIdx]!;
const nextSeg = prepared.segments[segIdx + 1];
const segEnd = nextSeg ? nextSeg.charOffset : textNode.data.length;

const doc = textNode.ownerDocument!;
const range = doc.createRange();
const safeStart = Math.min(seg.charOffset, textNode.data.length);
const safeEnd = Math.min(segEnd, textNode.data.length);
range.setStart(textNode, safeStart);
range.setEnd(textNode, safeEnd);

const pos = range.getBoundingClientRect();
return verifyFn(pos) ? range : null;
}
Comment on lines +356 to 358
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When canvas verification fails and you fall back to the DOM loop, the prepared measurement cache remains in place. If the failure is due to stale measurements (font load / theme change / reflow), subsequent calls may repeatedly miss the fast path. Consider invalidating the measurer's prepared cache for the document body on verification failure so the next attempt can re-prepare with current styles.

Copilot uses AI. Check for mistakes.

/**
* Find Text Start Range
* @private
* @param {Node} root root node
* @param {Node} node text node
* @param {number} start position to start at
* @param {number} end position to end at
* @param {DOMRect} [nodePos] pre-computed node bounds from findStart (avoids redundant reflow)
* @return {Range}
*/
findTextStartRange(node: Node, start: number, end: number): Range {
findTextStartRange(node: Node, start: number, end: number, nodePos?: DOMRect): Range {
// Canvas fast path: reuse nodePos from findStart to avoid a second reflow
if (nodePos) {
const canvasRange = this._canvasFindRange(node, nodePos, start, (pos) => {
const check = this.horizontal
? (this.direction === "rtl" ? pos.right : pos.left)
: pos.top;
if (this.horizontal && this.direction === "ltr") return check >= start;
if (this.horizontal && this.direction === "rtl") return check <= end;
return check >= start;
});
if (canvasRange) return canvasRange;
Comment on lines +372 to +380
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The canvas fast-path for findTextStartRange() binary-searches by segment end (cumWidth >= relativeTarget), but the start-range logic accepts the first range whose leading edge is >= start. When start falls mid-segment, the chosen segment will usually have pos.left < start, fail verification, and fall back to the DOM loopβ€”negating most of the optimization. Consider searching by segment start offsets (or, on verification failure for start-range only, retrying with the next segment before falling back).

Suggested change
const canvasRange = this._canvasFindRange(node, nodePos, start, (pos) => {
const check = this.horizontal
? (this.direction === "rtl" ? pos.right : pos.left)
: pos.top;
if (this.horizontal && this.direction === "ltr") return check >= start;
if (this.horizontal && this.direction === "rtl") return check <= end;
return check >= start;
});
if (canvasRange) return canvasRange;
const prepared = this._canvasPrepare(node);
if (prepared && prepared.segments.length > 0) {
const textNode = node as Text;
const nodeStart = this.horizontal
? (this.direction === "rtl" ? nodePos.right : nodePos.left)
: nodePos.top;
const relativeTarget = (this.horizontal && this.direction === "rtl")
? nodeStart - start
: start - nodeStart;
if (relativeTarget >= 0) {
const segments = prepared.segments;
const maxIdx = segments.length - 1;
const trySegment = (idx: number): Range | null => {
if (idx < 0 || idx > maxIdx) {
return null;
}
const seg = segments[idx]!;
const nextSeg = segments[idx + 1];
const segEnd = nextSeg ? nextSeg.charOffset : textNode.data.length;
const doc = textNode.ownerDocument!;
const range = doc.createRange();
const safeStart = Math.min(seg.charOffset, textNode.data.length);
const safeEnd = Math.min(segEnd, textNode.data.length);
range.setStart(textNode, safeStart);
range.setEnd(textNode, safeEnd);
const pos = range.getBoundingClientRect();
const check = this.horizontal
? (this.direction === "rtl" ? pos.right : pos.left)
: pos.top;
if (this.horizontal && this.direction === "ltr") {
return check >= start ? range : null;
}
if (this.horizontal && this.direction === "rtl") {
return check <= end ? range : null;
}
// Vertical
return check >= start ? range : null;
};
const segIdx = this._measurer!.findSegmentIndex(segments, relativeTarget);
let canvasRange = trySegment(segIdx);
if (!canvasRange) {
canvasRange = trySegment(segIdx + 1);
}
if (canvasRange) {
return canvasRange;
}
}
}

Copilot uses AI. Check for mistakes.
}

const ranges = this.splitTextNodeIntoRanges(node);
let range;
let pos;
Expand Down Expand Up @@ -326,8 +413,6 @@ class Mapping {

}

// prev = range;

}

return ranges[0]!;
Expand All @@ -336,12 +421,23 @@ class Mapping {
/**
* Find Text End Range
* @private
* @param {Node} root root node
* @param {Node} node text node
* @param {number} start position to start at
* @param {number} end position to end at
* @param {DOMRect} [nodePos] pre-computed node bounds from findEnd (avoids redundant reflow)
* @return {Range}
*/
findTextEndRange(node: Node, start: number, end: number): Range {
findTextEndRange(node: Node, start: number, end: number, nodePos?: DOMRect): Range {
// Canvas fast path: reuse nodePos from findEnd to avoid a second reflow
if (nodePos) {
const canvasRange = this._canvasFindRange(node, nodePos, end, (pos) => {
if (this.horizontal && this.direction === "ltr") return pos.left <= end && pos.right >= end;
if (this.horizontal && this.direction === "rtl") return pos.right >= start && pos.left <= start;
return pos.top <= end && pos.bottom >= end;
});
if (canvasRange) return canvasRange;
}
Comment on lines 369 to +439
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add/update tests for the new canvas fast path in findTextStartRange/findTextEndRange to ensure it produces the same (or intentionally updated) CFIs/ranges as the existing DOM Range loop across LTR/RTL and vertical/horizontal modes, and that it correctly falls back when verification fails. There are existing mapping tests, so this new behavior should be covered to prevent subtle pagination regressions.

Copilot generated this review using guidance from repository custom instructions.

const ranges = this.splitTextNodeIntoRanges(node);
let prev;
let range;
Expand Down
Loading
Loading