Merge pull request #15722 from calixteman/refactor_textlayer

[api-minor] Refactor the text layer code in order to avoid to recompute it on each draw
This commit is contained in:
calixteman 2022-12-01 18:56:02 +01:00 committed by GitHub
commit 6e4968225e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 362 additions and 240 deletions

View File

@ -16,6 +16,7 @@
import {
AbortException,
createPromiseCapability,
FeatureTest,
Util,
} from "../shared/util.js";
@ -27,16 +28,40 @@ import {
* render (the object is returned by the page's `getTextContent` method).
* @property {ReadableStream} [textContentStream] - Text content stream to
* render (the stream is returned by the page's `streamTextContent` method).
* @property {DocumentFragment | HTMLElement} container - The DOM node that
* will contain the text runs.
* @property {HTMLElement} container - The DOM node that will contain the text
* runs.
* @property {import("./display_utils").PageViewport} viewport - The target
* viewport to properly layout the text runs.
* @property {Array<HTMLElement>} [textDivs] - HTML elements that correspond to
* the text items of the textContent input.
* This is output and shall initially be set to an empty array.
* @property {WeakMap<HTMLElement,Object>} [textDivProperties] - Some properties
* weakly mapped to the HTML elements used to render the text.
* @property {Array<string>} [textContentItemsStr] - Strings that correspond to
* the `str` property of the text items of the textContent input.
* This is output and shall initially be set to an empty array.
* @property {boolean} [isOffscreenCanvasSupported] true if we can use
* OffscreenCanvas to measure string widths.
*/
/**
* Text layer update parameters.
*
* @typedef {Object} TextLayerUpdateParameters
* @property {HTMLElement} container - The DOM node that will contain the text
* runs.
* @property {import("./display_utils").PageViewport} viewport - The target
* viewport to properly layout the text runs.
* @property {Array<HTMLElement>} [textDivs] - HTML elements that correspond to
* the text items of the textContent input.
* This is output and shall initially be set to an empty array.
* @property {WeakMap<HTMLElement,Object>} [textDivProperties] - Some properties
* weakly mapped to the HTML elements used to render the text.
* @property {boolean} [isOffscreenCanvasSupported] true if we can use
* OffscreenCanvas to measure string widths.
* @property {boolean} [mustRotate] true if the text layer must be rotated.
* @property {boolean} [mustRescale] true if the text layer contents must be
* rescaled.
*/
const MAX_TEXT_DIVS_TO_RENDER = 100000;
@ -44,13 +69,27 @@ const DEFAULT_FONT_SIZE = 30;
const DEFAULT_FONT_ASCENT = 0.8;
const ascentCache = new Map();
function getAscent(fontFamily, ctx) {
function getCtx(size, isOffscreenCanvasSupported) {
let ctx;
if (isOffscreenCanvasSupported && FeatureTest.isOffscreenCanvasSupported) {
ctx = new OffscreenCanvas(size, size).getContext("2d", { alpha: false });
} else {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
ctx = canvas.getContext("2d", { alpha: false });
}
return ctx;
}
function getAscent(fontFamily, isOffscreenCanvasSupported) {
const cachedAscent = ascentCache.get(fontFamily);
if (cachedAscent) {
return cachedAscent;
}
ctx.save();
const ctx = getCtx(DEFAULT_FONT_SIZE, isOffscreenCanvasSupported);
ctx.font = `${DEFAULT_FONT_SIZE}px ${fontFamily}`;
const metrics = ctx.measureText("");
@ -58,9 +97,10 @@ function getAscent(fontFamily, ctx) {
let ascent = metrics.fontBoundingBoxAscent;
let descent = Math.abs(metrics.fontBoundingBoxDescent);
if (ascent) {
ctx.restore();
const ratio = ascent / (ascent + descent);
ascentCache.set(fontFamily, ratio);
ctx.canvas.width = ctx.canvas.height = 0;
return ratio;
}
@ -99,7 +139,7 @@ function getAscent(fontFamily, ctx) {
}
}
ctx.restore();
ctx.canvas.width = ctx.canvas.height = 0;
if (ascent) {
const ratio = ascent / (ascent + descent);
@ -111,7 +151,7 @@ function getAscent(fontFamily, ctx) {
return DEFAULT_FONT_ASCENT;
}
function appendText(task, geom, styles, ctx) {
function appendText(task, geom, styles) {
// Initialize all used properties to keep the caches monomorphic.
const textDiv = document.createElement("span");
const textDivProperties = {
@ -123,14 +163,15 @@ function appendText(task, geom, styles, ctx) {
};
task._textDivs.push(textDiv);
const tx = Util.transform(task._viewport.transform, geom.transform);
const tx = Util.transform(task._transform, geom.transform);
let angle = Math.atan2(tx[1], tx[0]);
const style = styles[geom.fontName];
if (style.vertical) {
angle += Math.PI / 2;
}
const fontHeight = Math.hypot(tx[2], tx[3]);
const fontAscent = fontHeight * getAscent(style.fontFamily, ctx);
const fontAscent =
fontHeight * getAscent(style.fontFamily, task._isOffscreenCanvasSupported);
let left, top;
if (angle === 0) {
@ -140,12 +181,21 @@ function appendText(task, geom, styles, ctx) {
left = tx[4] + fontAscent * Math.sin(angle);
top = tx[5] - fontAscent * Math.cos(angle);
}
const scaleFactorStr = "calc(var(--scale-factor)*";
const divStyle = textDiv.style;
// Setting the style properties individually, rather than all at once,
// should be OK since the `textDiv` isn't appended to the document yet.
textDiv.style.left = `${left}px`;
textDiv.style.top = `${top}px`;
textDiv.style.fontSize = `${fontHeight}px`;
textDiv.style.fontFamily = style.fontFamily;
if (task._container === task._rootContainer) {
divStyle.left = `${((100 * left) / task._pageWidth).toFixed(2)}%`;
divStyle.top = `${((100 * top) / task._pageHeight).toFixed(2)}%`;
} else {
// We're in a marked content span, hence we can't use percents.
divStyle.left = `${scaleFactorStr}${left.toFixed(2)}px)`;
divStyle.top = `${scaleFactorStr}${top.toFixed(2)}px)`;
}
divStyle.fontSize = `${scaleFactorStr}${fontHeight.toFixed(2)}px)`;
divStyle.fontFamily = style.fontFamily;
textDivProperties.fontSize = fontHeight;
@ -183,11 +233,7 @@ function appendText(task, geom, styles, ctx) {
}
}
if (shouldScaleText) {
if (style.vertical) {
textDivProperties.canvasWidth = geom.height * task._viewport.scale;
} else {
textDivProperties.canvasWidth = geom.width * task._viewport.scale;
}
textDivProperties.canvasWidth = style.vertical ? geom.height : geom.width;
}
task._textDivProperties.set(textDiv, textDivProperties);
if (task._textContentStream) {
@ -195,6 +241,35 @@ function appendText(task, geom, styles, ctx) {
}
}
function layout(params) {
const { div, scale, properties, ctx, prevFontSize, prevFontFamily } = params;
const { style } = div;
let transform = "";
if (properties.canvasWidth !== 0 && properties.hasText) {
const { fontFamily } = style;
const { canvasWidth, fontSize } = properties;
if (prevFontSize !== fontSize || prevFontFamily !== fontFamily) {
ctx.font = `${fontSize * scale}px ${fontFamily}`;
params.prevFontSize = fontSize;
params.prevFontFamily = fontFamily;
}
// Only measure the width for multi-char text divs, see `appendText`.
const { width } = ctx.measureText(div.textContent);
if (width > 0) {
transform = `scaleX(${(canvasWidth * scale) / width})`;
}
}
if (properties.angle !== 0) {
transform = `rotate(${properties.angle}deg) ${transform}`;
}
if (transform.length > 0) {
style.transform = transform;
}
}
function render(task) {
if (task._canceled) {
return;
@ -228,40 +303,41 @@ class TextLayerRenderTask {
container,
viewport,
textDivs,
textDivProperties,
textContentItemsStr,
isOffscreenCanvasSupported,
}) {
this._textContent = textContent;
this._textContentStream = textContentStream;
this._container = container;
this._document = container.ownerDocument;
this._viewport = viewport;
this._container = this._rootContainer = container;
this._textDivs = textDivs || [];
this._textContentItemsStr = textContentItemsStr || [];
this._fontInspectorEnabled = !!globalThis.FontInspector?.enabled;
this._reader = null;
this._layoutTextLastFontSize = null;
this._layoutTextLastFontFamily = null;
this._layoutTextCtx = null;
this._textDivProperties = new WeakMap();
this._textDivProperties = textDivProperties || new WeakMap();
this._renderingDone = false;
this._canceled = false;
this._capability = createPromiseCapability();
this._devicePixelRatio = globalThis.devicePixelRatio || 1;
this._layoutTextParams = {
prevFontSize: null,
prevFontFamily: null,
div: null,
scale: viewport.scale * (globalThis.devicePixelRatio || 1),
properties: null,
ctx: getCtx(0, isOffscreenCanvasSupported),
};
const [pageLLx, pageLLy, pageURx, pageURy] = viewport.viewBox;
this._transform = [1, 0, 0, -1, -pageLLx, pageURy];
this._pageWidth = pageURx - pageLLx;
this._pageHeight = pageURy - pageLLy;
setTextLayerDimensions(container, viewport);
// Always clean-up the temporary canvas once rendering is no longer pending.
this._capability.promise
.finally(() => {
// The `textDiv` properties are no longer needed.
this._textDivProperties = null;
if (this._layoutTextCtx) {
// Zeroing the width and height cause Firefox to release graphics
// resources immediately, which can greatly reduce memory consumption.
this._layoutTextCtx.canvas.width = 0;
this._layoutTextCtx.canvas.height = 0;
this._layoutTextCtx = null;
}
this._layoutTextParams = null;
})
.catch(() => {
// Avoid "Uncaught promise" messages in the console.
@ -289,7 +365,7 @@ class TextLayerRenderTask {
});
this._reader = null;
}
this._capability.reject(new Error("TextLayer task cancelled."));
this._capability.reject(new AbortException("TextLayer task cancelled."));
}
/**
@ -315,7 +391,7 @@ class TextLayerRenderTask {
continue;
}
this._textContentItemsStr.push(item.str);
appendText(this, item, styleCache, this._layoutTextCtx);
appendText(this, item, styleCache);
}
}
@ -323,39 +399,10 @@ class TextLayerRenderTask {
* @private
*/
_layoutText(textDiv) {
const textDivProperties = this._textDivProperties.get(textDiv);
let transform = "";
if (textDivProperties.canvasWidth !== 0 && textDivProperties.hasText) {
const { fontFamily } = textDiv.style;
const { fontSize } = textDivProperties;
// Only build font string and set to context if different from last.
if (
fontSize !== this._layoutTextLastFontSize ||
fontFamily !== this._layoutTextLastFontFamily
) {
this._layoutTextCtx.font = `${
fontSize * this._devicePixelRatio
}px ${fontFamily}`;
this._layoutTextLastFontSize = fontSize;
this._layoutTextLastFontFamily = fontFamily;
}
// Only measure the width for multi-char text divs, see `appendText`.
const { width } = this._layoutTextCtx.measureText(textDiv.textContent);
if (width > 0) {
transform = `scaleX(${
(this._devicePixelRatio * textDivProperties.canvasWidth) / width
})`;
}
}
if (textDivProperties.angle !== 0) {
transform = `rotate(${textDivProperties.angle}deg) ${transform}`;
}
if (transform.length > 0) {
textDiv.style.transform = transform;
}
const textDivProperties = (this._layoutTextParams.properties =
this._textDivProperties.get(textDiv));
this._layoutTextParams.div = textDiv;
layout(this._layoutTextParams);
if (textDivProperties.hasText) {
this._container.append(textDiv);
@ -375,10 +422,6 @@ class TextLayerRenderTask {
let styleCache = Object.create(null);
// The temporary canvas is used to measure text length in the DOM.
const canvas = this._document.createElement("canvas");
canvas.height = canvas.width = DEFAULT_FONT_SIZE;
this._layoutTextCtx = canvas.getContext("2d", { alpha: false });
if (this._textContent) {
const textItems = this._textContent.items;
@ -426,9 +469,67 @@ function renderTextLayer(renderParameters) {
viewport: renderParameters.viewport,
textDivs: renderParameters.textDivs,
textContentItemsStr: renderParameters.textContentItemsStr,
textDivProperties: renderParameters.textDivProperties,
isOffscreenCanvasSupported: renderParameters.isOffscreenCanvasSupported,
});
task._render();
return task;
}
export { renderTextLayer, TextLayerRenderTask };
/**
* @param {TextLayerUpdateParameters} renderParameters
* @returns {TextLayerRenderTask}
*/
function updateTextLayer({
container,
viewport,
textDivs,
textDivProperties,
isOffscreenCanvasSupported,
mustRotate = true,
mustRescale = true,
}) {
if (mustRotate) {
setTextLayerDimensions(container, { rotation: viewport.rotation });
}
if (mustRescale) {
const ctx = getCtx(0, isOffscreenCanvasSupported);
const scale = viewport.scale * (globalThis.devicePixelRatio || 1);
const params = {
prevFontSize: null,
prevFontFamily: null,
div: null,
scale,
properties: null,
ctx,
};
for (const div of textDivs) {
params.properties = textDivProperties.get(div);
params.div = div;
layout(params);
}
}
}
/**
* @param {HTMLDivElement} div
* @param {import("./display_utils").PageViewport} viewport
*/
function setTextLayerDimensions(div, viewport) {
if (!viewport.viewBox) {
div.setAttribute("data-main-rotation", viewport.rotation);
return;
}
const [pageLLx, pageLLy, pageURx, pageURy] = viewport.viewBox;
const pageWidth = pageURx - pageLLx;
const pageHeight = pageURy - pageLLy;
const { style } = div;
style.width = `calc(var(--scale-factor) * ${pageWidth}px)`;
style.height = `calc(var(--scale-factor) * ${pageHeight}px)`;
div.setAttribute("data-main-rotation", viewport.rotation);
}
export { renderTextLayer, TextLayerRenderTask, updateTextLayer };

View File

@ -23,6 +23,7 @@
/** @typedef {import("./display/text_layer").TextLayerRenderTask} TextLayerRenderTask */
import {
AbortException,
AnnotationEditorParamsType,
AnnotationEditorType,
AnnotationMode,
@ -60,12 +61,12 @@ import {
PixelsPerInch,
RenderingCancelledException,
} from "./display/display_utils.js";
import { renderTextLayer, updateTextLayer } from "./display/text_layer.js";
import { AnnotationEditorLayer } from "./display/editor/annotation_editor_layer.js";
import { AnnotationEditorUIManager } from "./display/editor/tools.js";
import { AnnotationLayer } from "./display/annotation_layer.js";
import { GlobalWorkerOptions } from "./display/worker_options.js";
import { isNodeJS } from "./shared/is_node.js";
import { renderTextLayer } from "./display/text_layer.js";
import { SVGGraphics } from "./display/svg.js";
import { XfaLayer } from "./display/xfa_layer.js";
@ -110,6 +111,7 @@ if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("PRODUCTION")) {
}
export {
AbortException,
AnnotationEditorLayer,
AnnotationEditorParamsType,
AnnotationEditorType,
@ -143,6 +145,7 @@ export {
SVGGraphics,
UnexpectedResponseException,
UNSUPPORTED_FEATURES,
updateTextLayer,
Util,
VerbosityLevel,
version,

View File

@ -168,7 +168,7 @@ class Rasterize {
}
static get textStylePromise() {
const styles = ["./text_layer_test.css"];
const styles = [VIEWER_CSS, "./text_layer_test.css"];
return shadow(this, "textStylePromise", loadStyles(styles));
}
@ -256,8 +256,10 @@ class Rasterize {
// Items are transformed to have 1px font size.
svg.setAttribute("font-size", 1);
const [overrides] = await this.textStylePromise;
style.textContent = overrides;
const [common, overrides] = await this.textStylePromise;
style.textContent =
`${common}\n${overrides}\n` +
`:root { --scale-factor: ${viewport.scale} }`;
// Rendering text layer as HTML.
const task = renderTextLayer({
@ -265,6 +267,7 @@ class Rasterize {
container: div,
viewport,
});
await task.promise;
svg.append(foreignObject);

View File

@ -22,9 +22,11 @@
right: 0;
bottom: 0;
line-height: 1;
opacity: 1;
}
.textLayer span,
.textLayer br {
color: black;
position: absolute;
white-space: pre;
transform-origin: 0% 0%;

View File

@ -24,7 +24,7 @@ import { isNodeJS } from "../../src/shared/is_node.js";
describe("textLayer", function () {
it("creates textLayer from ReadableStream", async function () {
if (isNodeJS) {
pending("document.createDocumentFragment is not supported in Node.js.");
pending("document.createElement is not supported in Node.js.");
}
const loadingTask = getDocument(buildGetDocumentParams("basicapi.pdf"));
const pdfDocument = await loadingTask.promise;
@ -34,7 +34,7 @@ describe("textLayer", function () {
const textLayerRenderTask = renderTextLayer({
textContentStream: page.streamTextContent(),
container: document.createDocumentFragment(),
container: document.createElement("div"),
viewport: page.getViewport(),
textContentItemsStr,
});

View File

@ -501,6 +501,7 @@ const PDFViewerApplication = {
imageResourcesPath: AppOptions.get("imageResourcesPath"),
enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"),
useOnlyCssZoom: AppOptions.get("useOnlyCssZoom"),
isOffscreenCanvasSupported: AppOptions.get("isOffscreenCanvasSupported"),
maxCanvasPixels: AppOptions.get("maxCanvasPixels"),
enablePermissions: AppOptions.get("enablePermissions"),
pageColors,

View File

@ -165,12 +165,9 @@ class DefaultStructTreeLayerFactory {
class DefaultTextLayerFactory {
/**
* @typedef {Object} CreateTextLayerBuilderParameters
* @property {HTMLDivElement} textLayerDiv
* @property {number} pageIndex
* @property {PageViewport} viewport
* @property {EventBus} eventBus
* @property {TextHighlighter} highlighter
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {boolean} [isOffscreenCanvasSupported]
*/
/**
@ -178,20 +175,14 @@ class DefaultTextLayerFactory {
* @returns {TextLayerBuilder}
*/
createTextLayerBuilder({
textLayerDiv,
pageIndex,
viewport,
eventBus,
highlighter,
accessibilityManager = null,
isOffscreenCanvasSupported = true,
}) {
return new TextLayerBuilder({
textLayerDiv,
pageIndex,
viewport,
eventBus,
highlighter,
accessibilityManager,
isOffscreenCanvasSupported,
});
}
}

View File

@ -21,7 +21,6 @@
/** @typedef {import("./annotation_layer_builder").AnnotationLayerBuilder} AnnotationLayerBuilder */
// eslint-disable-next-line max-len
/** @typedef {import("./annotation_editor_layer_builder").AnnotationEditorLayerBuilder} AnnotationEditorLayerBuilder */
/** @typedef {import("./event_utils").EventBus} EventBus */
// eslint-disable-next-line max-len
/** @typedef {import("./struct_tree_builder").StructTreeLayerBuilder} StructTreeLayerBuilder */
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
@ -168,12 +167,9 @@ class IRenderableView {
class IPDFTextLayerFactory {
/**
* @typedef {Object} CreateTextLayerBuilderParameters
* @property {HTMLDivElement} textLayerDiv
* @property {number} pageIndex
* @property {PageViewport} viewport
* @property {EventBus} eventBus
* @property {TextHighlighter} highlighter
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {boolean} [isOffscreenCanvasSupported]
*/
/**
@ -181,12 +177,9 @@ class IPDFTextLayerFactory {
* @returns {TextLayerBuilder}
*/
createTextLayerBuilder({
textLayerDiv,
pageIndex,
viewport,
eventBus,
highlighter,
accessibilityManager,
isOffscreenCanvasSupported,
}) {}
}

View File

@ -33,6 +33,7 @@
/** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */
import {
AbortException,
AnnotationMode,
createPromiseCapability,
PixelsPerInch,
@ -82,6 +83,8 @@ import { TextAccessibilityManager } from "./text_accessibility.js";
* for annotation icons. Include trailing slash.
* @property {boolean} [useOnlyCssZoom] - Enables CSS only zooming. The default
* value is `false`.
* @property {boolean} [isOffscreenCanvasSupported] - Allows to use an
* OffscreenCanvas if needed.
* @property {number} [maxCanvasPixels] - The maximum supported canvas size in
* total pixels, i.e. width * height. Use -1 for no limit. The default value
* is 4096 * 4096 (16 mega-pixels).
@ -128,6 +131,8 @@ class PDFPageView {
options.annotationMode ?? AnnotationMode.ENABLE_FORMS;
this.imageResourcesPath = options.imageResourcesPath || "";
this.useOnlyCssZoom = options.useOnlyCssZoom || false;
this.isOffscreenCanvasSupported =
options.isOffscreenCanvasSupported ?? true;
this.maxCanvasPixels = options.maxCanvasPixels || MAX_CANVAS_PIXELS;
this.pageColors = options.pageColors || null;
@ -174,8 +179,8 @@ class PDFPageView {
const div = document.createElement("div");
div.className = "page";
div.style.width = Math.floor(this.viewport.width) + "px";
div.style.height = Math.floor(this.viewport.height) + "px";
div.style.width = Math.round(this.viewport.width) + "px";
div.style.height = Math.round(this.viewport.height) + "px";
div.setAttribute("data-page-number", this.id);
div.setAttribute("role", "region");
this.l10n.get("page_landmark", { page: this.id }).then(msg => {
@ -284,6 +289,37 @@ class PDFPageView {
}
}
async #renderTextLayer() {
const { pdfPage, textLayer, viewport } = this;
if (!textLayer) {
return;
}
let error = null;
try {
if (!textLayer.renderingDone) {
const readableStream = pdfPage.streamTextContent({
includeMarkedContent: true,
});
textLayer.setTextContentStream(readableStream);
}
await textLayer.render(viewport);
} catch (ex) {
if (ex instanceof AbortException) {
return;
}
console.error(`#renderTextLayer: "${ex}".`);
error = ex;
}
this.eventBus.dispatch("textlayerrendered", {
source: this,
pageNumber: this.id,
numTextDivs: textLayer.numTextDivs,
error,
});
}
async _buildXfaTextContentItems(textDivs) {
const text = await this.pdfPage.getTextContent();
const items = [];
@ -320,17 +356,19 @@ class PDFPageView {
keepAnnotationLayer = false,
keepAnnotationEditorLayer = false,
keepXfaLayer = false,
keepTextLayer = false,
} = {}) {
this.cancelRendering({
keepAnnotationLayer,
keepAnnotationEditorLayer,
keepXfaLayer,
keepTextLayer,
});
this.renderingState = RenderingStates.INITIAL;
const div = this.div;
div.style.width = Math.floor(this.viewport.width) + "px";
div.style.height = Math.floor(this.viewport.height) + "px";
div.style.width = Math.round(this.viewport.width) + "px";
div.style.height = Math.round(this.viewport.height) + "px";
const childNodes = div.childNodes,
zoomLayerNode = (keepZoomLayer && this.zoomLayer) || null,
@ -338,7 +376,8 @@ class PDFPageView {
(keepAnnotationLayer && this.annotationLayer?.div) || null,
annotationEditorLayerNode =
(keepAnnotationEditorLayer && this.annotationEditorLayer?.div) || null,
xfaLayerNode = (keepXfaLayer && this.xfaLayer?.div) || null;
xfaLayerNode = (keepXfaLayer && this.xfaLayer?.div) || null,
textLayerNode = (keepTextLayer && this.textLayer?.div) || null;
for (let i = childNodes.length - 1; i >= 0; i--) {
const node = childNodes[i];
switch (node) {
@ -346,6 +385,7 @@ class PDFPageView {
case annotationLayerNode:
case annotationEditorLayerNode:
case xfaLayerNode:
case textLayerNode:
continue;
}
node.remove();
@ -369,6 +409,10 @@ class PDFPageView {
this.xfaLayer.hide();
}
if (textLayerNode) {
this.textLayer.hide();
}
if (!zoomLayerNode) {
if (this.canvas) {
this.paintedViewportMap.delete(this.canvas);
@ -450,6 +494,7 @@ class PDFPageView {
redrawAnnotationLayer: true,
redrawAnnotationEditorLayer: true,
redrawXfaLayer: true,
redrawTextLayer: true,
});
this.eventBus.dispatch("pagerendered", {
@ -484,6 +529,7 @@ class PDFPageView {
redrawAnnotationLayer: true,
redrawAnnotationEditorLayer: true,
redrawXfaLayer: true,
redrawTextLayer: true,
});
this.eventBus.dispatch("pagerendered", {
@ -508,6 +554,7 @@ class PDFPageView {
keepAnnotationLayer: true,
keepAnnotationEditorLayer: true,
keepXfaLayer: true,
keepTextLayer: true,
});
}
@ -519,6 +566,7 @@ class PDFPageView {
keepAnnotationLayer = false,
keepAnnotationEditorLayer = false,
keepXfaLayer = false,
keepTextLayer = false,
} = {}) {
if (this.paintTask) {
this.paintTask.cancel();
@ -526,7 +574,7 @@ class PDFPageView {
}
this.resume = null;
if (this.textLayer) {
if (this.textLayer && (!keepTextLayer || !this.textLayer.div)) {
this.textLayer.cancel();
this.textLayer = null;
}
@ -561,6 +609,7 @@ class PDFPageView {
redrawAnnotationLayer = false,
redrawAnnotationEditorLayer = false,
redrawXfaLayer = false,
redrawTextLayer = false,
}) {
// Scale target (canvas or svg), its wrapper and page container.
const width = this.viewport.width;
@ -587,49 +636,6 @@ class PDFPageView {
}
target.style.transform = `rotate(${relativeRotation}deg) scale(${scaleX}, ${scaleY})`;
if (this.textLayer) {
// Rotating the text layer is more complicated since the divs inside the
// the text layer are rotated.
// TODO: This could probably be simplified by drawing the text layer in
// one orientation and then rotating overall.
const textLayerViewport = this.textLayer.viewport;
const textRelativeRotation =
this.viewport.rotation - textLayerViewport.rotation;
const textAbsRotation = Math.abs(textRelativeRotation);
let scale = width / textLayerViewport.width;
if (textAbsRotation === 90 || textAbsRotation === 270) {
scale = width / textLayerViewport.height;
}
const textLayerDiv = this.textLayer.textLayerDiv;
let transX, transY;
switch (textAbsRotation) {
case 0:
transX = transY = 0;
break;
case 90:
transX = 0;
transY = "-" + textLayerDiv.style.height;
break;
case 180:
transX = "-" + textLayerDiv.style.width;
transY = "-" + textLayerDiv.style.height;
break;
case 270:
transX = "-" + textLayerDiv.style.width;
transY = 0;
break;
default:
console.error("Bad rotation value.");
break;
}
textLayerDiv.style.transform =
`rotate(${textAbsRotation}deg) ` +
`scale(${scale}) ` +
`translate(${transX}, ${transY})`;
textLayerDiv.style.transformOrigin = "0% 0%";
}
if (redrawAnnotationLayer && this.annotationLayer) {
this._renderAnnotationLayer();
}
@ -639,6 +645,9 @@ class PDFPageView {
if (redrawXfaLayer && this.xfaLayer) {
this._renderXfaLayer();
}
if (redrawTextLayer && this.textLayer) {
this.#renderTextLayer();
}
}
get width() {
@ -686,40 +695,33 @@ class PDFPageView {
canvasWrapper.style.height = div.style.height;
canvasWrapper.classList.add("canvasWrapper");
const lastDivBeforeTextDiv =
this.annotationLayer?.div || this.annotationEditorLayer?.div;
if (lastDivBeforeTextDiv) {
// The annotation layer needs to stay on top.
lastDivBeforeTextDiv.before(canvasWrapper);
if (this.textLayer) {
this.textLayer.div.before(canvasWrapper);
} else {
div.append(canvasWrapper);
}
let textLayer = null;
if (this.textLayerMode !== TextLayerMode.DISABLE && this.textLayerFactory) {
this._accessibilityManager ||= new TextAccessibilityManager();
const textLayerDiv = document.createElement("div");
textLayerDiv.className = "textLayer";
textLayerDiv.style.width = canvasWrapper.style.width;
textLayerDiv.style.height = canvasWrapper.style.height;
const lastDivBeforeTextDiv =
this.annotationLayer?.div || this.annotationEditorLayer?.div;
if (lastDivBeforeTextDiv) {
// The annotation layer needs to stay on top.
lastDivBeforeTextDiv.before(textLayerDiv);
lastDivBeforeTextDiv.before(canvasWrapper);
} else {
div.append(textLayerDiv);
div.append(canvasWrapper);
}
}
textLayer = this.textLayerFactory.createTextLayerBuilder({
textLayerDiv,
pageIndex: this.id - 1,
viewport: this.viewport,
eventBus: this.eventBus,
if (
!this.textLayer &&
this.textLayerMode !== TextLayerMode.DISABLE &&
this.textLayerFactory
) {
this._accessibilityManager ||= new TextAccessibilityManager();
this.textLayer = this.textLayerFactory.createTextLayerBuilder({
highlighter: this.textHighlighter,
accessibilityManager: this._accessibilityManager,
isOffscreenCanvasSupported: this.isOffscreenCanvasSupported,
});
canvasWrapper.after(this.textLayer.div);
}
this.textLayer = textLayer;
if (
this.#annotationMode !== AnnotationMode.DISABLE &&
@ -809,13 +811,7 @@ class PDFPageView {
const resultPromise = paintTask.promise.then(
() => {
return finishPaintTask(null).then(() => {
if (textLayer) {
const readableStream = pdfPage.streamTextContent({
includeMarkedContent: true,
});
textLayer.setTextContentStream(readableStream);
textLayer.render();
}
this.#renderTextLayer();
if (this.annotationLayer) {
this._renderAnnotationLayer().then(() => {
@ -949,10 +945,12 @@ class PDFPageView {
const sfx = approximateFraction(outputScale.sx);
const sfy = approximateFraction(outputScale.sy);
canvas.width = roundToDivide(viewport.width * outputScale.sx, sfx[0]);
canvas.height = roundToDivide(viewport.height * outputScale.sy, sfy[0]);
canvas.style.width = roundToDivide(viewport.width, sfx[1]) + "px";
canvas.style.height = roundToDivide(viewport.height, sfy[1]) + "px";
const { style } = canvas;
style.width = roundToDivide(viewport.width, sfx[1]) + "px";
style.height = roundToDivide(viewport.height, sfy[1]) + "px";
// Add the viewport so it's known what it was originally drawn with.
this.paintedViewportMap.set(canvas, viewport);

View File

@ -128,6 +128,8 @@ function isValidAnnotationEditorMode(mode) {
* landscape pages upon printing. The default is `false`.
* @property {boolean} [useOnlyCssZoom] - Enables CSS only zooming. The default
* value is `false`.
* @property {boolean} [isOffscreenCanvasSupported] - Allows to use an
* OffscreenCanvas if needed.
* @property {number} [maxCanvasPixels] - The maximum supported canvas size in
* total pixels, i.e. width * height. Use -1 for no limit. The default value
* is 4096 * 4096 (16 mega-pixels).
@ -287,6 +289,8 @@ class PDFViewer {
this.renderer = options.renderer || RendererType.CANVAS;
}
this.useOnlyCssZoom = options.useOnlyCssZoom || false;
this.isOffscreenCanvasSupported =
options.isOffscreenCanvasSupported ?? true;
this.maxCanvasPixels = options.maxCanvasPixels;
this.l10n = options.l10n || NullL10n;
this.#enablePermissions = options.enablePermissions || false;
@ -775,6 +779,7 @@ class PDFViewer {
? this.renderer
: null,
useOnlyCssZoom: this.useOnlyCssZoom,
isOffscreenCanvasSupported: this.isOffscreenCanvasSupported,
maxCanvasPixels: this.maxCanvasPixels,
pageColors: this.pageColors,
l10n: this.l10n,
@ -1635,12 +1640,9 @@ class PDFViewer {
/**
* @typedef {Object} CreateTextLayerBuilderParameters
* @property {HTMLDivElement} textLayerDiv
* @property {number} pageIndex
* @property {PageViewport} viewport
* @property {EventBus} eventBus
* @property {TextHighlighter} highlighter
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {boolean} [isOffscreenCanvasSupported]
*/
/**
@ -1648,20 +1650,14 @@ class PDFViewer {
* @returns {TextLayerBuilder}
*/
createTextLayerBuilder({
textLayerDiv,
pageIndex,
viewport,
eventBus,
highlighter,
accessibilityManager = null,
isOffscreenCanvasSupported = true,
}) {
return new TextLayerBuilder({
textLayerDiv,
eventBus,
pageIndex,
viewport,
highlighter,
accessibilityManager,
isOffscreenCanvasSupported,
});
}

View File

@ -95,6 +95,7 @@ class TextHighlighter {
);
this._onUpdateTextLayerMatches = null;
}
this._updateMatches(/* reset = */ true);
}
_convertMatches(matches, matchesLength) {
@ -264,8 +265,8 @@ class TextHighlighter {
}
}
_updateMatches() {
if (!this.enabled) {
_updateMatches(reset = false) {
if (!this.enabled && !reset) {
return;
}
const { findController, matches, pageIdx } = this;
@ -283,7 +284,7 @@ class TextHighlighter {
clearedUntilDivIdx = match.end.divIdx + 1;
}
if (!findController?.highlightMatches) {
if (!findController?.highlightMatches || reset) {
return;
}
// Convert the matches on the `findController` into the match format

View File

@ -25,6 +25,7 @@
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
}
.textLayer span,

View File

@ -15,22 +15,19 @@
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
/** @typedef {import("./event_utils").EventBus} EventBus */
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
import { renderTextLayer } from "pdfjs-lib";
import { renderTextLayer, updateTextLayer } from "pdfjs-lib";
/**
* @typedef {Object} TextLayerBuilderOptions
* @property {HTMLDivElement} textLayerDiv - The text layer container.
* @property {EventBus} eventBus - The application event bus.
* @property {number} pageIndex - The page index.
* @property {PageViewport} viewport - The viewport of the text layer.
* @property {TextHighlighter} highlighter - Optional object that will handle
* highlighting text from the find controller.
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {boolean} [isOffscreenCanvasSupported] - Allows to use an
* OffscreenCanvas if needed.
*/
/**
@ -39,28 +36,28 @@ import { renderTextLayer } from "pdfjs-lib";
* contain text that matches the PDF text they are overlaying.
*/
class TextLayerBuilder {
#scale = 0;
#rotation = 0;
constructor({
textLayerDiv,
eventBus,
pageIndex,
viewport,
highlighter = null,
accessibilityManager = null,
isOffscreenCanvasSupported = true,
}) {
this.textLayerDiv = textLayerDiv;
this.eventBus = eventBus;
this.textContent = null;
this.textContentItemsStr = [];
this.textContentStream = null;
this.renderingDone = false;
this.pageNumber = pageIndex + 1;
this.viewport = viewport;
this.textDivs = [];
this.textDivProperties = new WeakMap();
this.textLayerRenderTask = null;
this.highlighter = highlighter;
this.accessibilityManager = accessibilityManager;
this.isOffscreenCanvasSupported = isOffscreenCanvasSupported;
this.#bindMouse();
this.div = document.createElement("div");
this.div.className = "textLayer";
}
#finishRendering() {
@ -68,48 +65,80 @@ class TextLayerBuilder {
const endOfContent = document.createElement("div");
endOfContent.className = "endOfContent";
this.textLayerDiv.append(endOfContent);
this.div.append(endOfContent);
this.eventBus.dispatch("textlayerrendered", {
source: this,
pageNumber: this.pageNumber,
numTextDivs: this.textDivs.length,
});
this.#bindMouse();
}
get numTextDivs() {
return this.textDivs.length;
}
/**
* Renders the text layer.
*/
render() {
if (!(this.textContent || this.textContentStream) || this.renderingDone) {
async render(viewport) {
if (!(this.textContent || this.textContentStream)) {
throw new Error(
`Neither "textContent" nor "textContentStream" specified.`
);
}
const scale = viewport.scale * (globalThis.devicePixelRatio || 1);
if (this.renderingDone) {
const { rotation } = viewport;
const mustRotate = rotation !== this.#rotation;
const mustRescale = scale !== this.#scale;
if (mustRotate || mustRescale) {
this.hide();
updateTextLayer({
container: this.div,
viewport,
textDivs: this.textDivs,
textDivProperties: this.textDivProperties,
isOffscreenCanvasSupported: this.isOffscreenCanvasSupported,
mustRescale,
mustRotate,
});
this.show();
this.#scale = scale;
this.#rotation = rotation;
}
return;
}
this.cancel();
this.textDivs.length = 0;
this.cancel();
this.highlighter?.setTextMapping(this.textDivs, this.textContentItemsStr);
this.accessibilityManager?.setTextMapping(this.textDivs);
const textLayerFrag = document.createDocumentFragment();
this.textLayerRenderTask = renderTextLayer({
textContent: this.textContent,
textContentStream: this.textContentStream,
container: textLayerFrag,
viewport: this.viewport,
container: this.div,
viewport,
textDivs: this.textDivs,
textDivProperties: this.textDivProperties,
textContentItemsStr: this.textContentItemsStr,
isOffscreenCanvasSupported: this.isOffscreenCanvasSupported,
});
this.textLayerRenderTask.promise.then(
() => {
this.textLayerDiv.append(textLayerFrag);
this.#finishRendering();
this.highlighter?.enable();
this.accessibilityManager?.enable();
},
function (reason) {
// Cancelled or failed to render text layer; skipping errors.
}
);
await this.textLayerRenderTask.promise;
this.#finishRendering();
this.#scale = scale;
this.accessibilityManager?.enable();
this.show();
}
hide() {
// We turn off the highlighter in order to avoid to scroll into view an
// element of the text layer which could be hidden.
this.highlighter?.disable();
this.div.hidden = true;
}
show() {
this.div.hidden = false;
this.highlighter?.enable();
}
/**
@ -122,6 +151,9 @@ class TextLayerBuilder {
}
this.highlighter?.disable();
this.accessibilityManager?.disable();
this.textContentItemsStr.length = 0;
this.textDivs.length = 0;
this.textDivProperties = new WeakMap();
}
setTextContentStream(readableStream) {
@ -140,7 +172,7 @@ class TextLayerBuilder {
* dragged up or down.
*/
#bindMouse() {
const div = this.textLayerDiv;
const { div } = this;
div.addEventListener("mousedown", evt => {
const end = div.querySelector(".endOfContent");