1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-19 22:58:07 +02:00

[api-minor] Use a Path2D when doing a path operation in the canvas (bug 1946953)

With this patch, all the paths components are collected in the worker until a path
operation is met (i.e., stroke, fill, ...).
Then in the canvas a Path2D is created and will replace the path data transfered from the worker,
this way when rescaling, the Path2D can be reused.
In term of performances, using Path2D is very slightly improving speed when scaling the canvas.
This commit is contained in:
Calixte Denizet 2025-03-19 20:39:02 +01:00
parent a229914b46
commit be1f5671bb
10 changed files with 367 additions and 374 deletions

View file

@ -16,6 +16,7 @@
import {
AbortException,
assert,
DrawOPS,
FONT_IDENTITY_MATRIX,
FormatError,
IDENTITY_MATRIX,
@ -925,7 +926,7 @@ class PartialEvaluator {
smaskOptions,
operatorList,
task,
stateManager.state.clone(),
stateManager.state.clone({ newPath: true }),
localColorSpaceCache
);
}
@ -1383,80 +1384,112 @@ class PartialEvaluator {
return promise;
}
buildPath(operatorList, fn, args, parsingText = false) {
const lastIndex = operatorList.length - 1;
if (!args) {
args = [];
}
if (
lastIndex < 0 ||
operatorList.fnArray[lastIndex] !== OPS.constructPath
) {
// Handle corrupt PDF documents that contains path operators inside of
// text objects, which may shift subsequent text, by enclosing the path
// operator in save/restore operators (fixes issue10542_reduced.pdf).
//
// Note that this will effectively disable the optimization in the
// `else` branch below, but given that this type of corruption is
// *extremely* rare that shouldn't really matter much in practice.
if (parsingText) {
warn(`Encountered path operator "${fn}" inside of a text object.`);
operatorList.addOp(OPS.save, null);
buildPath(fn, args, state) {
const { pathMinMax: minMax, pathBuffer } = state;
switch (fn | 0) {
case OPS.rectangle: {
const x = (state.currentPointX = args[0]);
const y = (state.currentPointY = args[1]);
const width = args[2];
const height = args[3];
const xw = x + width;
const yh = y + height;
if (width === 0 || height === 0) {
pathBuffer.push(
DrawOPS.moveTo,
x,
y,
DrawOPS.lineTo,
xw,
yh,
DrawOPS.closePath
);
} else {
pathBuffer.push(
DrawOPS.moveTo,
x,
y,
DrawOPS.lineTo,
xw,
y,
DrawOPS.lineTo,
xw,
yh,
DrawOPS.lineTo,
x,
yh,
DrawOPS.closePath
);
}
minMax[0] = Math.min(minMax[0], x, xw);
minMax[1] = Math.min(minMax[1], y, yh);
minMax[2] = Math.max(minMax[2], x, xw);
minMax[3] = Math.max(minMax[3], y, yh);
break;
}
let minMax;
switch (fn) {
case OPS.rectangle:
const x = args[0] + args[2];
const y = args[1] + args[3];
minMax = [
Math.min(args[0], x),
Math.min(args[1], y),
Math.max(args[0], x),
Math.max(args[1], y),
];
break;
case OPS.moveTo:
case OPS.lineTo:
minMax = [args[0], args[1], args[0], args[1]];
break;
default:
minMax = [Infinity, Infinity, -Infinity, -Infinity];
break;
case OPS.moveTo: {
const x = (state.currentPointX = args[0]);
const y = (state.currentPointY = args[1]);
pathBuffer.push(DrawOPS.moveTo, x, y);
minMax[0] = Math.min(minMax[0], x);
minMax[1] = Math.min(minMax[1], y);
minMax[2] = Math.max(minMax[2], x);
minMax[3] = Math.max(minMax[3], y);
break;
}
operatorList.addOp(OPS.constructPath, [[fn], args, minMax]);
if (parsingText) {
operatorList.addOp(OPS.restore, null);
case OPS.lineTo: {
const x = (state.currentPointX = args[0]);
const y = (state.currentPointY = args[1]);
pathBuffer.push(DrawOPS.lineTo, x, y);
minMax[0] = Math.min(minMax[0], x);
minMax[1] = Math.min(minMax[1], y);
minMax[2] = Math.max(minMax[2], x);
minMax[3] = Math.max(minMax[3], y);
break;
}
} else {
const opArgs = operatorList.argsArray[lastIndex];
opArgs[0].push(fn);
opArgs[1].push(...args);
const minMax = opArgs[2];
// Compute min/max in the worker instead of the main thread.
// If the current matrix (when drawing) is a scaling one
// then min/max can be easily computed in using those values.
// Only rectangle, lineTo and moveTo are handled here since
// Bezier stuff requires to have the starting point.
switch (fn) {
case OPS.rectangle:
const x = args[0] + args[2];
const y = args[1] + args[3];
minMax[0] = Math.min(minMax[0], args[0], x);
minMax[1] = Math.min(minMax[1], args[1], y);
minMax[2] = Math.max(minMax[2], args[0], x);
minMax[3] = Math.max(minMax[3], args[1], y);
break;
case OPS.moveTo:
case OPS.lineTo:
minMax[0] = Math.min(minMax[0], args[0]);
minMax[1] = Math.min(minMax[1], args[1]);
minMax[2] = Math.max(minMax[2], args[0]);
minMax[3] = Math.max(minMax[3], args[1]);
break;
case OPS.curveTo: {
const startX = state.currentPointX;
const startY = state.currentPointY;
const [x1, y1, x2, y2, x, y] = args;
state.currentPointX = x;
state.currentPointY = y;
pathBuffer.push(DrawOPS.curveTo, x1, y1, x2, y2, x, y);
Util.bezierBoundingBox(startX, startY, x1, y1, x2, y2, x, y, minMax);
break;
}
case OPS.curveTo2: {
const startX = state.currentPointX;
const startY = state.currentPointY;
const [x1, y1, x, y] = args;
state.currentPointX = x;
state.currentPointY = y;
pathBuffer.push(DrawOPS.curveTo, startX, startY, x1, y1, x, y);
Util.bezierBoundingBox(
startX,
startY,
startX,
startY,
x1,
y1,
x,
y,
minMax
);
break;
}
case OPS.curveTo3: {
const startX = state.currentPointX;
const startY = state.currentPointY;
const [x1, y1, x, y] = args;
state.currentPointX = x;
state.currentPointY = y;
pathBuffer.push(DrawOPS.curveTo, x1, y1, x, y, x, y);
Util.bezierBoundingBox(startX, startY, x1, y1, x, y, x, y, minMax);
break;
}
case OPS.closePath:
pathBuffer.push(DrawOPS.closePath);
break;
}
}
@ -1731,7 +1764,6 @@ class PartialEvaluator {
const self = this;
const xref = this.xref;
let parsingText = false;
const localImageCache = new LocalImageCache();
const localColorSpaceCache = new LocalColorSpaceCache();
const localGStateCache = new LocalGStateCache();
@ -1847,7 +1879,7 @@ class PartialEvaluator {
null,
operatorList,
task,
stateManager.state.clone(),
stateManager.state.clone({ newPath: true }),
localColorSpaceCache
)
.then(function () {
@ -1909,12 +1941,6 @@ class PartialEvaluator {
})
);
return;
case OPS.beginText:
parsingText = true;
break;
case OPS.endText:
parsingText = false;
break;
case OPS.endInlineImage:
const cacheKey = args[0].cacheKey;
if (cacheKey) {
@ -2237,8 +2263,40 @@ class PartialEvaluator {
case OPS.curveTo3:
case OPS.closePath:
case OPS.rectangle:
self.buildPath(operatorList, fn, args, parsingText);
self.buildPath(fn, args, stateManager.state);
continue;
case OPS.stroke:
case OPS.closeStroke:
case OPS.fill:
case OPS.eoFill:
case OPS.fillStroke:
case OPS.eoFillStroke:
case OPS.closeFillStroke:
case OPS.closeEOFillStroke:
case OPS.endPath: {
const {
state: { pathBuffer, pathMinMax },
} = stateManager;
if (
fn === OPS.closeStroke ||
fn === OPS.closeFillStroke ||
fn === OPS.closeEOFillStroke
) {
pathBuffer.push(DrawOPS.closePath);
}
if (pathBuffer.length === 0) {
operatorList.addOp(OPS.constructPath, [fn, [null], null]);
} else {
operatorList.addOp(OPS.constructPath, [
fn,
[new Float32Array(pathBuffer)],
pathMinMax.slice(),
]);
pathBuffer.length = 0;
pathMinMax.set([Infinity, Infinity, -Infinity, -Infinity], 0);
}
continue;
}
case OPS.markPoint:
case OPS.markPointProps:
case OPS.beginCompat:
@ -4935,6 +4993,16 @@ class EvalState {
this._fillColorSpace = this._strokeColorSpace = ColorSpaceUtils.gray;
this.patternFillColorSpace = null;
this.patternStrokeColorSpace = null;
// Path stuff.
this.currentPointX = this.currentPointY = 0;
this.pathMinMax = new Float32Array([
Infinity,
Infinity,
-Infinity,
-Infinity,
]);
this.pathBuffer = [];
}
get fillColorSpace() {
@ -4953,8 +5021,18 @@ class EvalState {
this._strokeColorSpace = this.patternStrokeColorSpace = colorSpace;
}
clone() {
return Object.create(this);
clone({ newPath = false } = {}) {
const clone = Object.create(this);
if (newPath) {
clone.pathBuffer = [];
clone.pathMinMax = new Float32Array([
Infinity,
Infinity,
-Infinity,
-Infinity,
]);
}
return clone;
}
}

View file

@ -703,6 +703,12 @@ class OperatorList {
transfers.push(arg.data.buffer);
}
break;
case OPS.constructPath:
const [, [data], minMax] = argsArray[i];
if (data) {
transfers.push(data.buffer, minMax.buffer);
}
break;
}
}
return transfers;

View file

@ -14,6 +14,7 @@
*/
import {
DrawOPS,
FeatureTest,
FONT_IDENTITY_MATRIX,
IDENTITY_MATRIX,
@ -58,6 +59,10 @@ const MAX_SIZE_TO_COMPILE = 1000;
const FULL_CHUNK_HEIGHT = 16;
// Only used in rescaleAndStroke. The goal is to avoid
// creating a new DOMMatrix object each time we need it.
const SCALE_MATRIX = new DOMMatrix();
/**
* Overrides certain methods on a 2d ctx so that when they are called they
* will also call the same method on the destCtx. The methods that are
@ -502,19 +507,6 @@ class CanvasExtraState {
return clone;
}
setCurrentPoint(x, y) {
this.x = x;
this.y = y;
}
updatePathMinMax(transform, x, y) {
[x, y] = Util.applyTransform([x, y], transform);
this.minX = Math.min(this.minX, x);
this.minY = Math.min(this.minY, y);
this.maxX = Math.max(this.maxX, x);
this.maxY = Math.max(this.maxY, y);
}
updateRectMinMax(transform, rect) {
const p1 = Util.applyTransform(rect, transform);
const p2 = Util.applyTransform(rect.slice(2), transform);
@ -527,22 +519,6 @@ class CanvasExtraState {
this.maxY = Math.max(this.maxY, p1[1], p2[1], p3[1], p4[1]);
}
updateScalingPathMinMax(transform, minMax) {
Util.scaleMinMax(transform, minMax);
this.minX = Math.min(this.minX, minMax[0]);
this.minY = Math.min(this.minY, minMax[1]);
this.maxX = Math.max(this.maxX, minMax[2]);
this.maxY = Math.max(this.maxY, minMax[3]);
}
updateCurvePathMinMax(transform, x0, y0, x1, y1, x2, y2, x3, y3, minMax) {
const box = Util.bezierBoundingBox(x0, y0, x1, y1, x2, y2, x3, y3, minMax);
if (minMax) {
return;
}
this.updateRectMinMax(transform, box);
}
getPathBoundingBox(pathType = PathType.FILL, transform = null) {
const box = [this.minX, this.minY, this.maxX, this.maxY];
if (pathType === PathType.STROKE) {
@ -1612,156 +1588,54 @@ class CanvasGraphics {
}
// Path
constructPath(ops, args, minMax) {
const ctx = this.ctx;
const current = this.current;
let x = current.x,
y = current.y;
let startX, startY;
const currentTransform = getCurrentTransform(ctx);
// Most of the time the current transform is a scaling matrix
// so we don't need to transform points before computing min/max:
// we can compute min/max first and then smartly "apply" the
// transform (see Util.scaleMinMax).
// For rectangle, moveTo and lineTo, min/max are computed in the
// worker (see evaluator.js).
const isScalingMatrix =
(currentTransform[0] === 0 && currentTransform[3] === 0) ||
(currentTransform[1] === 0 && currentTransform[2] === 0);
const minMaxForBezier = isScalingMatrix ? minMax.slice(0) : null;
for (let i = 0, j = 0, ii = ops.length; i < ii; i++) {
switch (ops[i] | 0) {
case OPS.rectangle:
x = args[j++];
y = args[j++];
const width = args[j++];
const height = args[j++];
const xw = x + width;
const yh = y + height;
ctx.moveTo(x, y);
if (width === 0 || height === 0) {
ctx.lineTo(xw, yh);
} else {
ctx.lineTo(xw, y);
ctx.lineTo(xw, yh);
ctx.lineTo(x, yh);
}
if (!isScalingMatrix) {
current.updateRectMinMax(currentTransform, [x, y, xw, yh]);
}
ctx.closePath();
break;
case OPS.moveTo:
x = args[j++];
y = args[j++];
ctx.moveTo(x, y);
if (!isScalingMatrix) {
current.updatePathMinMax(currentTransform, x, y);
}
break;
case OPS.lineTo:
x = args[j++];
y = args[j++];
ctx.lineTo(x, y);
if (!isScalingMatrix) {
current.updatePathMinMax(currentTransform, x, y);
}
break;
case OPS.curveTo:
startX = x;
startY = y;
x = args[j + 4];
y = args[j + 5];
ctx.bezierCurveTo(
args[j],
args[j + 1],
args[j + 2],
args[j + 3],
x,
y
);
current.updateCurvePathMinMax(
currentTransform,
startX,
startY,
args[j],
args[j + 1],
args[j + 2],
args[j + 3],
x,
y,
minMaxForBezier
);
j += 6;
break;
case OPS.curveTo2:
startX = x;
startY = y;
ctx.bezierCurveTo(
x,
y,
args[j],
args[j + 1],
args[j + 2],
args[j + 3]
);
current.updateCurvePathMinMax(
currentTransform,
startX,
startY,
x,
y,
args[j],
args[j + 1],
args[j + 2],
args[j + 3],
minMaxForBezier
);
x = args[j + 2];
y = args[j + 3];
j += 4;
break;
case OPS.curveTo3:
startX = x;
startY = y;
x = args[j + 2];
y = args[j + 3];
ctx.bezierCurveTo(args[j], args[j + 1], x, y, x, y);
current.updateCurvePathMinMax(
currentTransform,
startX,
startY,
args[j],
args[j + 1],
x,
y,
x,
y,
minMaxForBezier
);
j += 4;
break;
case OPS.closePath:
ctx.closePath();
break;
constructPath(op, data, minMax) {
let [path] = data;
if (!minMax) {
// The path is empty, so no need to update the current minMax.
path ||= data[0] = new Path2D();
this[op](path);
return;
}
if (!(path instanceof Path2D)) {
// Using a SVG string is slightly slower than using the following loop.
const path2d = (data[0] = new Path2D());
for (let i = 0, ii = path.length; i < ii; ) {
switch (path[i++]) {
case DrawOPS.moveTo:
path2d.moveTo(path[i++], path[i++]);
break;
case DrawOPS.lineTo:
path2d.lineTo(path[i++], path[i++]);
break;
case DrawOPS.curveTo:
path2d.bezierCurveTo(
path[i++],
path[i++],
path[i++],
path[i++],
path[i++],
path[i++]
);
break;
case DrawOPS.closePath:
path2d.closePath();
break;
default:
warn(`Unrecognized drawing path operator: ${path[i - 1]}`);
break;
}
}
path = path2d;
}
if (isScalingMatrix) {
current.updateScalingPathMinMax(currentTransform, minMaxForBezier);
}
current.setCurrentPoint(x, y);
this.current.updateRectMinMax(getCurrentTransform(this.ctx), minMax);
this[op](path);
}
closePath() {
this.ctx.closePath();
}
stroke(consumePath = true) {
stroke(path, consumePath = true) {
const ctx = this.ctx;
const strokeColor = this.current.strokeColor;
// For stroke we want to temporarily change the global alpha to the
@ -1769,6 +1643,9 @@ class CanvasGraphics {
ctx.globalAlpha = this.current.strokeAlpha;
if (this.contentVisible) {
if (typeof strokeColor === "object" && strokeColor?.getPattern) {
const baseTransform = strokeColor.isModifyingCurrentTransform()
? ctx.getTransform()
: null;
ctx.save();
ctx.strokeStyle = strokeColor.getPattern(
ctx,
@ -1776,31 +1653,41 @@ class CanvasGraphics {
getCurrentTransformInverse(ctx),
PathType.STROKE
);
this.rescaleAndStroke(/* saveRestore */ false);
if (baseTransform) {
const newPath = new Path2D();
newPath.addPath(
path,
ctx.getTransform().invertSelf().multiplySelf(baseTransform)
);
path = newPath;
}
this.rescaleAndStroke(path, /* saveRestore */ false);
ctx.restore();
} else {
this.rescaleAndStroke(/* saveRestore */ true);
this.rescaleAndStroke(path, /* saveRestore */ true);
}
}
if (consumePath) {
this.consumePath(this.current.getClippedPathBoundingBox());
this.consumePath(path, this.current.getClippedPathBoundingBox());
}
// Restore the global alpha to the fill alpha
ctx.globalAlpha = this.current.fillAlpha;
}
closeStroke() {
this.closePath();
this.stroke();
closeStroke(path) {
this.stroke(path);
}
fill(consumePath = true) {
fill(path, consumePath = true) {
const ctx = this.ctx;
const fillColor = this.current.fillColor;
const isPatternFill = this.current.patternFill;
let needRestore = false;
if (isPatternFill) {
const baseTransform = fillColor.isModifyingCurrentTransform()
? ctx.getTransform()
: null;
ctx.save();
ctx.fillStyle = fillColor.getPattern(
ctx,
@ -1808,16 +1695,24 @@ class CanvasGraphics {
getCurrentTransformInverse(ctx),
PathType.FILL
);
if (baseTransform) {
const newPath = new Path2D();
newPath.addPath(
path,
ctx.getTransform().invertSelf().multiplySelf(baseTransform)
);
path = newPath;
}
needRestore = true;
}
const intersect = this.current.getClippedPathBoundingBox();
if (this.contentVisible && intersect !== null) {
if (this.pendingEOFill) {
ctx.fill("evenodd");
ctx.fill(path, "evenodd");
this.pendingEOFill = false;
} else {
ctx.fill();
ctx.fill(path);
}
}
@ -1825,40 +1720,38 @@ class CanvasGraphics {
ctx.restore();
}
if (consumePath) {
this.consumePath(intersect);
this.consumePath(path, intersect);
}
}
eoFill() {
eoFill(path) {
this.pendingEOFill = true;
this.fill();
this.fill(path);
}
fillStroke() {
this.fill(false);
this.stroke(false);
fillStroke(path) {
this.fill(path, false);
this.stroke(path, false);
this.consumePath();
this.consumePath(path);
}
eoFillStroke() {
eoFillStroke(path) {
this.pendingEOFill = true;
this.fillStroke();
this.fillStroke(path);
}
closeFillStroke() {
this.closePath();
this.fillStroke();
closeFillStroke(path) {
this.fillStroke(path);
}
closeEOFillStroke() {
closeEOFillStroke(path) {
this.pendingEOFill = true;
this.closePath();
this.fillStroke();
this.fillStroke(path);
}
endPath() {
this.consumePath();
endPath(path) {
this.consumePath(path);
}
// Clipping
@ -3168,7 +3061,7 @@ class CanvasGraphics {
// Helper functions
consumePath(clipBox) {
consumePath(path, clipBox) {
const isEmpty = this.current.isEmptyClip();
if (this.pendingClip) {
this.current.updateClipFromPath();
@ -3180,9 +3073,9 @@ class CanvasGraphics {
if (this.pendingClip) {
if (!isEmpty) {
if (this.pendingClip === EO_CLIP) {
ctx.clip("evenodd");
ctx.clip(path, "evenodd");
} else {
ctx.clip();
ctx.clip(path);
}
}
this.pendingClip = null;
@ -3267,15 +3160,16 @@ class CanvasGraphics {
// Rescale before stroking in order to have a final lineWidth
// with both thicknesses greater or equal to 1.
rescaleAndStroke(saveRestore) {
const { ctx } = this;
const { lineWidth } = this.current;
rescaleAndStroke(path, saveRestore) {
const {
ctx,
current: { lineWidth },
} = this;
const [scaleX, scaleY] = this.getScaleForStroking();
ctx.lineWidth = lineWidth || 1;
if (scaleX === 1 && scaleY === 1) {
ctx.stroke();
if (scaleX === scaleY) {
ctx.lineWidth = (lineWidth || 1) * scaleX;
ctx.stroke(path);
return;
}
@ -3285,6 +3179,10 @@ class CanvasGraphics {
}
ctx.scale(scaleX, scaleY);
SCALE_MATRIX.a = 1 / scaleX;
SCALE_MATRIX.d = 1 / scaleY;
const newPath = new Path2D();
newPath.addPath(path, SCALE_MATRIX);
// How the dashed line is rendered depends on the current transform...
// so we added a rescale to handle too thin lines and consequently
@ -3299,7 +3197,8 @@ class CanvasGraphics {
ctx.lineDashOffset /= scale;
}
ctx.stroke();
ctx.lineWidth = lineWidth || 1;
ctx.stroke(newPath);
if (saveRestore) {
ctx.restore();

View file

@ -43,6 +43,10 @@ class BaseShadingPattern {
}
}
isModifyingCurrentTransform() {
return false;
}
getPattern() {
unreachable("Abstract method `getPattern` called.");
}
@ -388,6 +392,10 @@ class MeshShadingPattern extends BaseShadingPattern {
};
}
isModifyingCurrentTransform() {
return true;
}
getPattern(ctx, owner, inverse, pathType) {
applyBoundingBox(ctx, this._bbox);
let scale;
@ -704,6 +712,10 @@ class TilingPattern {
}
}
isModifyingCurrentTransform() {
return false;
}
getPattern(ctx, owner, inverse, pathType) {
// PDF spec 8.7.2 NOTE 1: pattern's matrix is relative to initial matrix.
let matrix = inverse;

View file

@ -341,6 +341,15 @@ const OPS = {
setFillTransparent: 93,
};
// In order to have a switch statement that is fast (i.e. which use a jump
// table), we need to have the OPS in a contiguous range.
const DrawOPS = {
moveTo: 0,
lineTo: 1,
curveTo: 2,
closePath: 3,
};
const PasswordResponses = {
NEED_PASSWORD: 1,
INCORRECT_PASSWORD: 2,
@ -667,57 +676,6 @@ class Util {
return `#${hexNumbers[r]}${hexNumbers[g]}${hexNumbers[b]}`;
}
// Apply a scaling matrix to some min/max values.
// If a scaling factor is negative then min and max must be
// swapped.
static scaleMinMax(transform, minMax) {
let temp;
if (transform[0]) {
if (transform[0] < 0) {
temp = minMax[0];
minMax[0] = minMax[2];
minMax[2] = temp;
}
minMax[0] *= transform[0];
minMax[2] *= transform[0];
if (transform[3] < 0) {
temp = minMax[1];
minMax[1] = minMax[3];
minMax[3] = temp;
}
minMax[1] *= transform[3];
minMax[3] *= transform[3];
} else {
temp = minMax[0];
minMax[0] = minMax[1];
minMax[1] = temp;
temp = minMax[2];
minMax[2] = minMax[3];
minMax[3] = temp;
if (transform[1] < 0) {
temp = minMax[1];
minMax[1] = minMax[3];
minMax[3] = temp;
}
minMax[1] *= transform[1];
minMax[3] *= transform[1];
if (transform[2] < 0) {
temp = minMax[0];
minMax[0] = minMax[2];
minMax[2] = temp;
}
minMax[0] *= transform[2];
minMax[2] *= transform[2];
}
minMax[0] += transform[4];
minMax[1] += transform[5];
minMax[2] += transform[4];
minMax[3] += transform[5];
}
// Concatenates two transformation matrices together and returns the result.
static transform(m1, m2) {
return [
@ -1192,6 +1150,7 @@ export {
bytesToString,
createValidAbsoluteUrl,
DocumentActionEventType,
DrawOPS,
FeatureTest,
FONT_IDENTITY_MATRIX,
FormatError,