[Editor] Improve curve smoothing for Ink tool (bug 1789443)

- Remove the dependency on fit-curve;
- Improve the way to draw the current line in using a Path2D and
  in clearing only the last part of the curve instead of clearing
  all the canvas;
- Smooth the curve when drawing to avoid to have some changes after
  the drawing ends;
- Make the smoothing a bit less agressive.
This commit is contained in:
Calixte Denizet 2023-02-09 11:16:10 +01:00
parent 094fb3c783
commit d2b4ed3cea
10 changed files with 225 additions and 216 deletions

View File

@ -34,7 +34,7 @@
// Plugins
"import/extensions": ["error", "always", { "ignorePackages": true, }],
"import/no-unresolved": ["error", {
"ignore": ["pdfjs", "pdfjs-lib", "pdfjs-web", "pdfjs-fitCurve", "web"]
"ignore": ["pdfjs", "pdfjs-lib", "pdfjs-web", "web"]
}],
"mozilla/avoid-removeChild": "error",
"mozilla/use-includes-instead-of-indexOf": "error",

View File

@ -232,7 +232,6 @@ function createWebpackConfig(
pdfjs: "src",
"pdfjs-web": "web",
"pdfjs-lib": "web/pdfjs",
"pdfjs-fitCurve": "src/display/editor/fit_curve",
};
const viewerAlias = {
"web-annotation_editor_params": "web/annotation_editor_params.js",
@ -571,26 +570,6 @@ function createImageDecodersBundle(defines) {
.pipe(replaceJSRootName(imageDecodersAMDName, "pdfjsImageDecoders"));
}
function createFitCurveBundle(defines) {
const fitCurveOutputName = "fit_curve.js";
const fitCurveFileConfig = createWebpackConfig(
defines,
{
filename: fitCurveOutputName,
library: {
type: "module",
},
},
{
disableVersionInfo: true,
}
);
return gulp
.src("src/display/editor/fit_curve.js")
.pipe(webpack2Stream(fitCurveFileConfig));
}
function createCMapBundle() {
return gulp.src(["external/bcmaps/*.bcmap", "external/bcmaps/LICENSE"], {
base: "external/bcmaps",
@ -1551,7 +1530,6 @@ function buildLibHelper(bundleDefines, inputStream, outputDir) {
defines: bundleDefines,
map: {
"pdfjs-lib": "../pdf",
"pdfjs-fitCurve": "./fit_curve",
},
};
const licenseHeaderLibre = fs
@ -1690,90 +1668,54 @@ function setTestEnv(done) {
done();
}
gulp.task("dev-fitCurve", function createDevFitCurve() {
console.log();
console.log("### Building development fitCurve");
const defines = builder.merge(DEFINES, { GENERIC: true, TESTING: true });
const fitCurveDir = BUILD_DIR + "dev-fitCurve/";
rimraf.sync(fitCurveDir);
return createFitCurveBundle(defines).pipe(gulp.dest(fitCurveDir));
});
gulp.task(
"test",
gulp.series(
setTestEnv,
"generic",
"components",
"dev-fitCurve",
function runTest() {
return streamqueue(
{ objectMode: true },
createTestSource("unit"),
createTestSource("browser"),
createTestSource("integration")
);
}
)
gulp.series(setTestEnv, "generic", "components", function runTest() {
return streamqueue(
{ objectMode: true },
createTestSource("unit"),
createTestSource("browser"),
createTestSource("integration")
);
})
);
gulp.task(
"bottest",
gulp.series(
setTestEnv,
"generic",
"components",
"dev-fitCurve",
function runBotTest() {
return streamqueue(
{ objectMode: true },
createTestSource("unit", { bot: true }),
createTestSource("font", { bot: true }),
createTestSource("browser", { bot: true }),
createTestSource("integration")
);
}
)
gulp.series(setTestEnv, "generic", "components", function runBotTest() {
return streamqueue(
{ objectMode: true },
createTestSource("unit", { bot: true }),
createTestSource("font", { bot: true }),
createTestSource("browser", { bot: true }),
createTestSource("integration")
);
})
);
gulp.task(
"xfatest",
gulp.series(
setTestEnv,
"generic",
"components",
"dev-fitCurve",
function runXfaTest() {
return streamqueue(
{ objectMode: true },
createTestSource("unit"),
createTestSource("browser", { xfaOnly: true }),
createTestSource("integration")
);
}
)
gulp.series(setTestEnv, "generic", "components", function runXfaTest() {
return streamqueue(
{ objectMode: true },
createTestSource("unit"),
createTestSource("browser", { xfaOnly: true }),
createTestSource("integration")
);
})
);
gulp.task(
"botxfatest",
gulp.series(
setTestEnv,
"generic",
"components",
"dev-fitCurve",
function runBotXfaTest() {
return streamqueue(
{ objectMode: true },
createTestSource("unit", { bot: true }),
createTestSource("font", { bot: true }),
createTestSource("browser", { bot: true, xfaOnly: true }),
createTestSource("integration")
);
}
)
gulp.series(setTestEnv, "generic", "components", function runBotXfaTest() {
return streamqueue(
{ objectMode: true },
createTestSource("unit", { bot: true }),
createTestSource("font", { bot: true }),
createTestSource("browser", { bot: true, xfaOnly: true }),
createTestSource("integration")
);
})
);
gulp.task(
@ -1800,7 +1742,7 @@ gulp.task(
gulp.task(
"unittest",
gulp.series(setTestEnv, "generic", "dev-fitCurve", function runUnitTest() {
gulp.series(setTestEnv, "generic", function runUnitTest() {
return createTestSource("unit");
})
);
@ -2028,13 +1970,6 @@ gulp.task(
gulp.task(
"server",
gulp.parallel(
function watchDevFitCurve() {
gulp.watch(
["src/display/editor/*"],
{ ignoreInitial: false },
gulp.series("dev-fitCurve")
);
},
function watchDevSandbox() {
gulp.watch(
[

13
package-lock.json generated
View File

@ -32,7 +32,6 @@
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-sort-exports": "^0.8.0",
"eslint-plugin-unicorn": "^47.0.0",
"fit-curve": "^0.2.0",
"globals": "^13.20.0",
"gulp": "^4.0.2",
"gulp-postcss": "^9.0.1",
@ -7065,12 +7064,6 @@
"node": ">= 0.10"
}
},
"node_modules/fit-curve": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/fit-curve/-/fit-curve-0.2.0.tgz",
"integrity": "sha512-op7ofeL13getbqL5J5ACeNxTlLWzusn4/jjEjSVA1sS7PfXWumdtNITvLuTjSobis+jZzMil2rsJ3Vhw7OxQyQ==",
"dev": true
},
"node_modules/flagged-respawn": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz",
@ -12732,6 +12725,7 @@
},
"node_modules/npm/node_modules/lodash._baseindexof": {
"version": "3.1.0",
"dev": true,
"inBundle": true,
"license": "MIT"
},
@ -12747,16 +12741,19 @@
},
"node_modules/npm/node_modules/lodash._bindcallback": {
"version": "3.0.1",
"dev": true,
"inBundle": true,
"license": "MIT"
},
"node_modules/npm/node_modules/lodash._cacheindexof": {
"version": "3.0.2",
"dev": true,
"inBundle": true,
"license": "MIT"
},
"node_modules/npm/node_modules/lodash._createcache": {
"version": "3.1.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"dependencies": {
@ -12771,6 +12768,7 @@
},
"node_modules/npm/node_modules/lodash._getnative": {
"version": "3.9.1",
"dev": true,
"inBundle": true,
"license": "MIT"
},
@ -12788,6 +12786,7 @@
},
"node_modules/npm/node_modules/lodash.restparam": {
"version": "3.6.1",
"dev": true,
"inBundle": true,
"license": "MIT"
},

View File

@ -25,7 +25,6 @@
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-sort-exports": "^0.8.0",
"eslint-plugin-unicorn": "^47.0.0",
"fit-curve": "^0.2.0",
"globals": "^13.20.0",
"gulp": "^4.0.2",
"gulp-postcss": "^9.0.1",

View File

@ -1,20 +0,0 @@
/* Copyright 2022 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const fitCurve = require(PDFJSDev.test("LIB")
? "fit-curve"
: "fit-curve/src/fit-curve.js");
export { fitCurve };

View File

@ -19,7 +19,6 @@ import {
Util,
} from "../../shared/util.js";
import { AnnotationEditor } from "./editor.js";
import { fitCurve } from "pdfjs-fitCurve";
import { opacityToHex } from "./tools.js";
// The dimensions of the resizer is 15x15:
@ -37,6 +36,8 @@ class InkEditor extends AnnotationEditor {
#baseWidth = 0;
#boundCanvasContextMenu = this.canvasContextMenu.bind(this);
#boundCanvasPointermove = this.canvasPointermove.bind(this);
#boundCanvasPointerleave = this.canvasPointerleave.bind(this);
@ -45,11 +46,13 @@ class InkEditor extends AnnotationEditor {
#boundCanvasPointerdown = this.canvasPointerdown.bind(this);
#currentPath2D = new Path2D();
#disableEditing = false;
#isCanvasInitialized = false;
#hasSomethingToDraw = false;
#lastPoint = null;
#isCanvasInitialized = false;
#observer = null;
@ -76,6 +79,7 @@ class InkEditor extends AnnotationEditor {
this.opacity = params.opacity || null;
this.paths = [];
this.bezierPath2D = [];
this.allRawPaths = [];
this.currentPath = [];
this.scaleFactor = 1;
this.translationX = this.translationY = 0;
@ -294,7 +298,6 @@ class InkEditor extends AnnotationEditor {
super.enableEditMode();
this.div.draggable = false;
this.canvas.addEventListener("pointerdown", this.#boundCanvasPointerdown);
this.canvas.addEventListener("pointerup", this.#boundCanvasPointerup);
}
/** @inheritdoc */
@ -311,7 +314,6 @@ class InkEditor extends AnnotationEditor {
"pointerdown",
this.#boundCanvasPointerdown
);
this.canvas.removeEventListener("pointerup", this.#boundCanvasPointerup);
}
/** @inheritdoc */
@ -362,6 +364,15 @@ class InkEditor extends AnnotationEditor {
* @param {number} y
*/
#startDrawing(x, y) {
this.canvas.addEventListener("contextmenu", this.#boundCanvasContextMenu);
this.canvas.addEventListener("pointerleave", this.#boundCanvasPointerleave);
this.canvas.addEventListener("pointermove", this.#boundCanvasPointermove);
this.canvas.addEventListener("pointerup", this.#boundCanvasPointerup);
this.canvas.removeEventListener(
"pointerdown",
this.#boundCanvasPointerdown
);
this.isEditing = true;
if (!this.#isCanvasInitialized) {
this.#isCanvasInitialized = true;
@ -372,30 +383,14 @@ class InkEditor extends AnnotationEditor {
this.opacity ??= InkEditor._defaultOpacity;
}
this.currentPath.push([x, y]);
this.#lastPoint = null;
this.#hasSomethingToDraw = false;
this.#setStroke();
this.ctx.beginPath();
this.ctx.moveTo(x, y);
this.#requestFrameCallback = () => {
if (!this.#requestFrameCallback) {
return;
this.#drawPoints();
if (this.#requestFrameCallback) {
window.requestAnimationFrame(this.#requestFrameCallback);
}
if (this.#lastPoint) {
if (this.isEmpty()) {
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
} else {
this.#redraw();
}
this.ctx.lineTo(...this.#lastPoint);
this.#lastPoint = null;
this.ctx.stroke();
}
window.requestAnimationFrame(this.#requestFrameCallback);
};
window.requestAnimationFrame(this.#requestFrameCallback);
}
@ -407,11 +402,40 @@ class InkEditor extends AnnotationEditor {
*/
#draw(x, y) {
const [lastX, lastY] = this.currentPath.at(-1);
if (x === lastX && y === lastY) {
if (this.currentPath.length > 1 && x === lastX && y === lastY) {
return;
}
this.currentPath.push([x, y]);
this.#lastPoint = [x, y];
const currentPath = this.currentPath;
let path2D = this.#currentPath2D;
currentPath.push([x, y]);
this.#hasSomethingToDraw = true;
if (currentPath.length <= 2) {
path2D.moveTo(...currentPath[0]);
path2D.lineTo(x, y);
return;
}
if (currentPath.length === 3) {
this.#currentPath2D = path2D = new Path2D();
path2D.moveTo(...currentPath[0]);
}
this.#makeBezierCurve(
path2D,
...currentPath.at(-3),
...currentPath.at(-2),
x,
y
);
}
#endPath() {
if (this.currentPath.length === 0) {
return;
}
const lastPoint = this.currentPath.at(-1);
this.#currentPath2D.lineTo(...lastPoint);
}
/**
@ -420,38 +444,39 @@ class InkEditor extends AnnotationEditor {
* @param {number} y
*/
#stopDrawing(x, y) {
this.ctx.closePath();
this.#requestFrameCallback = null;
x = Math.min(Math.max(x, 0), this.canvas.width);
y = Math.min(Math.max(y, 0), this.canvas.height);
const [lastX, lastY] = this.currentPath.at(-1);
if (x !== lastX || y !== lastY) {
this.currentPath.push([x, y]);
}
this.#draw(x, y);
this.#endPath();
// Interpolate the path entered by the user with some
// Bezier's curves in order to have a smoother path and
// to reduce the data size used to draw it in the PDF.
let bezier;
if (this.currentPath.length !== 1) {
bezier = fitCurve(this.currentPath, 30, null);
bezier = this.#generateBezierPoints();
} else {
// We have only one point finally.
const xy = [x, y];
bezier = [[xy, xy.slice(), xy.slice(), xy]];
}
const path2D = InkEditor.#buildPath2D(bezier);
this.currentPath.length = 0;
const path2D = this.#currentPath2D;
const currentPath = this.currentPath;
this.currentPath = [];
this.#currentPath2D = new Path2D();
const cmd = () => {
this.allRawPaths.push(currentPath);
this.paths.push(bezier);
this.bezierPath2D.push(path2D);
this.rebuild();
};
const undo = () => {
this.allRawPaths.pop();
this.paths.pop();
this.bezierPath2D.pop();
if (this.paths.length === 0) {
@ -468,6 +493,95 @@ class InkEditor extends AnnotationEditor {
this.addCommands({ cmd, undo, mustExec: true });
}
#drawPoints() {
if (!this.#hasSomethingToDraw) {
return;
}
this.#hasSomethingToDraw = false;
const thickness = Math.ceil(this.thickness * this.parentScale);
const lastPoints = this.currentPath.slice(-3);
const x = lastPoints.map(xy => xy[0]);
const y = lastPoints.map(xy => xy[1]);
const xMin = Math.min(...x) - thickness;
const xMax = Math.max(...x) + thickness;
const yMin = Math.min(...y) - thickness;
const yMax = Math.max(...y) + thickness;
const { ctx } = this;
ctx.save();
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
// In Chrome, the clip() method doesn't work as expected.
ctx.clearRect(xMin, yMin, xMax - xMin, yMax - yMin);
ctx.beginPath();
ctx.rect(xMin, yMin, xMax - xMin, yMax - yMin);
ctx.clip();
} else {
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
for (const path of this.bezierPath2D) {
ctx.stroke(path);
}
ctx.stroke(this.#currentPath2D);
ctx.restore();
}
#makeBezierCurve(path2D, x0, y0, x1, y1, x2, y2) {
const prevX = (x0 + x1) / 2;
const prevY = (y0 + y1) / 2;
const x3 = (x1 + x2) / 2;
const y3 = (y1 + y2) / 2;
path2D.bezierCurveTo(
prevX + (2 * (x1 - prevX)) / 3,
prevY + (2 * (y1 - prevY)) / 3,
x3 + (2 * (x1 - x3)) / 3,
y3 + (2 * (y1 - y3)) / 3,
x3,
y3
);
}
#generateBezierPoints() {
const path = this.currentPath;
if (path.length <= 2) {
return [[path[0], path[0], path.at(-1), path.at(-1)]];
}
const bezierPoints = [];
let i;
let [x0, y0] = path[0];
for (i = 1; i < path.length - 2; i++) {
const [x1, y1] = path[i];
const [x2, y2] = path[i + 1];
const x3 = (x1 + x2) / 2;
const y3 = (y1 + y2) / 2;
// The quadratic is: [[x0, y0], [x1, y1], [x3, y3]].
// Convert the quadratic to a cubic
// (see https://fontforge.org/docs/techref/bezier.html#converting-truetype-to-postscript)
const control1 = [x0 + (2 * (x1 - x0)) / 3, y0 + (2 * (y1 - y0)) / 3];
const control2 = [x3 + (2 * (x1 - x3)) / 3, y3 + (2 * (y1 - y3)) / 3];
bezierPoints.push([[x0, y0], control1, control2, [x3, y3]]);
[x0, y0] = [x3, y3];
}
const [x1, y1] = path[i];
const [x2, y2] = path[i + 1];
// The quadratic is: [[x0, y0], [x1, y1], [x2, y2]].
const control1 = [x0 + (2 * (x1 - x0)) / 3, y0 + (2 * (y1 - y0)) / 3];
const control2 = [x2 + (2 * (x1 - x2)) / 3, y2 + (2 * (y1 - y2)) / 3];
bezierPoints.push([[x0, y0], control1, control2, [x2, y2]]);
return bezierPoints;
}
/**
* Redraw all the paths.
*/
@ -482,6 +596,7 @@ class InkEditor extends AnnotationEditor {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.#updateTransform();
for (const path of this.bezierPath2D) {
ctx.stroke(path);
}
@ -537,24 +652,29 @@ class InkEditor extends AnnotationEditor {
// Since it's the last child, there's no need to give it a higher z-index.
this.setInForeground();
event.preventDefault();
if (event.type !== "mouse") {
this.div.focus();
}
event.stopPropagation();
this.canvas.addEventListener("pointerleave", this.#boundCanvasPointerleave);
this.canvas.addEventListener("pointermove", this.#boundCanvasPointermove);
this.#startDrawing(event.offsetX, event.offsetY);
}
/**
* oncontextmenu callback for the canvas we're drawing on.
* @param {PointerEvent} event
*/
canvasContextMenu(event) {
event.preventDefault();
}
/**
* onpointermove callback for the canvas we're drawing on.
* @param {PointerEvent} event
*/
canvasPointermove(event) {
event.stopPropagation();
event.preventDefault();
this.#draw(event.offsetX, event.offsetY);
}
@ -563,17 +683,8 @@ class InkEditor extends AnnotationEditor {
* @param {PointerEvent} event
*/
canvasPointerup(event) {
if (event.button !== 0) {
return;
}
if (this.isInEditMode() && this.currentPath.length !== 0) {
event.stopPropagation();
this.#endDrawing(event);
// Since the ink editor covers all of the page and we want to be able
// to select another editor, we just put this one in the background.
this.setInBackground();
}
event.preventDefault();
this.#endDrawing(event);
}
/**
@ -582,7 +693,6 @@ class InkEditor extends AnnotationEditor {
*/
canvasPointerleave(event) {
this.#endDrawing(event);
this.setInBackground();
}
/**
@ -590,8 +700,6 @@ class InkEditor extends AnnotationEditor {
* @param {PointerEvent} event
*/
#endDrawing(event) {
this.#stopDrawing(event.offsetX, event.offsetY);
this.canvas.removeEventListener(
"pointerleave",
this.#boundCanvasPointerleave
@ -600,8 +708,25 @@ class InkEditor extends AnnotationEditor {
"pointermove",
this.#boundCanvasPointermove
);
this.canvas.removeEventListener("pointerup", this.#boundCanvasPointerup);
this.canvas.addEventListener("pointerdown", this.#boundCanvasPointerdown);
// Slight delay to avoid the context menu to appear (it can happen on a long
// tap with a pen).
setTimeout(() => {
this.canvas.removeEventListener(
"contextmenu",
this.#boundCanvasContextMenu
);
}, 10);
this.#stopDrawing(event.offsetX, event.offsetY);
this.addToAnnotationStorage();
// Since the ink editor covers all of the page and we want to be able
// to select another editor, we just put this one in the background.
this.setInBackground();
}
/**
@ -762,7 +887,7 @@ class InkEditor extends AnnotationEditor {
}
/**
* Convert the output of fitCurve in some Path2D.
* Convert into a Path2D.
* @param {Arra<Array<number>} bezier
* @returns {Path2D}
*/
@ -1099,4 +1224,4 @@ class InkEditor extends AnnotationEditor {
}
}
export { fitCurve, InkEditor };
export { InkEditor };

View File

@ -14,7 +14,6 @@
*/
import { CommandManager } from "../../src/display/editor/tools.js";
import { fitCurve } from "../../src/display/editor/ink.js";
describe("editor", function () {
describe("Command Manager", function () {
@ -91,29 +90,4 @@ describe("editor", function () {
manager.add({ ...makeDoUndo(5), mustExec: true });
expect(x).toEqual(11);
});
describe("fitCurve", function () {
it("should return a function", function () {
expect(typeof fitCurve).toEqual("function");
});
it("should compute an Array of bezier curves", function () {
const bezier = fitCurve(
[
[1, 2],
[4, 5],
],
30,
null
);
expect(bezier).toEqual([
[
[1, 2],
[2, 3],
[3, 4],
[4, 5],
],
]);
});
});
});

View File

@ -15,7 +15,6 @@
"pdfjs/": "../../src/",
"pdfjs-lib": "../../src/pdf.js",
"pdfjs-web/": "../../web/",
"pdfjs-fitCurve": "../../build/dev-fitCurve/fit_curve.js",
"pdfjs-test/": "../",
"web-annotation_editor_params": "../../web/annotation_editor_params.js",

View File

@ -48,7 +48,6 @@ See https://github.com/adobe-type-tools/cmap-resources
"pdfjs/": "../src/",
"pdfjs-lib": "../src/pdf.js",
"pdfjs-web/": "./",
"pdfjs-fitCurve": "../build/dev-fitCurve/fit_curve.js",
"web-annotation_editor_params": "./stubs-geckoview.js",
"web-com": "./genericcom.js",

View File

@ -59,7 +59,6 @@ See https://github.com/adobe-type-tools/cmap-resources
"pdfjs/": "../src/",
"pdfjs-lib": "../src/pdf.js",
"pdfjs-web/": "./",
"pdfjs-fitCurve": "../build/dev-fitCurve/fit_curve.js",
"web-annotation_editor_params": "./annotation_editor_params.js",
"web-com": "./genericcom.js",