Merge pull request #14989 from calixteman/ink1

[editor] Add an Ink editor
This commit is contained in:
calixteman 2022-06-09 21:13:06 +02:00 committed by GitHub
commit 61a65344a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1461 additions and 13 deletions

View File

@ -255,5 +255,7 @@ editor_none.title=Disable Annotation Editing
editor_none_label=Disable Editing
editor_free_text.title=Add FreeText Annotation
editor_free_text_label=FreeText Annotation
editor_ink.title=Add Ink Annotation
editor_ink_label=Ink Annotation
freetext_default_content=Enter some text…

View File

@ -23,6 +23,7 @@
import { AnnotationEditorType, Util } from "../../shared/util.js";
import { bindEvents, KeyboardManager } from "./tools.js";
import { FreeTextEditor } from "./freetext.js";
import { InkEditor } from "./ink.js";
import { PixelsPerInch } from "../display_utils.js";
/**
@ -298,6 +299,8 @@ class AnnotationEditorLayer {
switch (this.#uiManager.getMode()) {
case AnnotationEditorType.FREETEXT:
return new FreeTextEditor(params);
case AnnotationEditorType.INK:
return new InkEditor(params);
}
return null;
}

View File

@ -0,0 +1,652 @@
/**
* @preserve JavaScript implementation of
* Algorithm for Automatically Fitting Digitized Curves
* by Philip J. Schneider
* "Graphics Gems", Academic Press, 1990
*
* The MIT License (MIT)
*
* https://github.com/soswow/fit-curves
*/
/**
* Fit one or more Bezier curves to a set of points.
*
* @param {Array<Array<Number>>} points - Array of digitized points,
* e.g. [[5,5],[5,50],[110,140],[210,160],[320,110]]
* @param {Number} maxError - Tolerance, squared error between points and
* fitted curve
* @returns {Array<Array<Array<Number>>>} Array of Bezier curves, where each
* element is
* [first-point, control-point-1, control-point-2, second-point]
* and points are [x, y]
*/
function fitCurve(points, maxError, progressCallback) {
if (!Array.isArray(points)) {
throw new TypeError("First argument should be an array");
}
points.forEach(point => {
if (
!Array.isArray(point) ||
point.some(item => typeof item !== "number") ||
point.length !== points[0].length
) {
throw Error(
"Each point should be an array of numbers. Each point should have the same amount of numbers."
);
}
});
// Remove duplicate points
points = points.filter(
(point, i) => i === 0 || !point.every((val, j) => val === points[i - 1][j])
);
if (points.length < 2) {
return [];
}
const len = points.length;
const leftTangent = createTangent(points[1], points[0]);
const rightTangent = createTangent(points[len - 2], points[len - 1]);
return fitCubic(
points,
leftTangent,
rightTangent,
maxError,
progressCallback
);
}
/**
* Fit a Bezier curve to a (sub)set of digitized points.
* Your code should not call this function directly.
* Use {@link fitCurve} instead.
*
* @param {Array<Array<Number>>} points - Array of digitized points,
* e.g. [[5,5],[5,50],[110,140],[210,160],[320,110]]
* @param {Array<Number>} leftTangent - Unit tangent vector at start point
* @param {Array<Number>} rightTangent - Unit tangent vector at end point
* @param {Number} error - Tolerance, squared error between points and
* fitted curve
* @returns {Array<Array<Array<Number>>>} Array of Bezier curves, where
* each element is
* [first-point, control-point-1, control-point-2, second-point]
* and points are [x, y]
*/
function fitCubic(points, leftTangent, rightTangent, error, progressCallback) {
const MaxIterations = 20; // Max times to try iterating (to find an acceptable curve)
let bezCurve, // Control points of fitted Bezier curve
uPrime, // Improved parameter values
maxError,
prevErr, // Maximum fitting error
splitPoint,
prevSplit, // Point to split point set at if we need more than one curve
centerVector,
beziers, // Array of fitted Bezier curves if we need more than one curve
dist,
i;
// Use heuristic if region only has two points in it
if (points.length === 2) {
dist = maths.vectorLen(maths.subtract(points[0], points[1])) / 3.0;
bezCurve = [
points[0],
maths.addArrays(points[0], maths.mulItems(leftTangent, dist)),
maths.addArrays(points[1], maths.mulItems(rightTangent, dist)),
points[1],
];
return [bezCurve];
}
// Parameterize points, and attempt to fit curve
// Parameter values for point
const u = chordLengthParameterize(points);
[bezCurve, maxError, splitPoint] = generateAndReport(
points,
u,
u,
leftTangent,
rightTangent,
progressCallback
);
if (maxError === 0 || maxError < error) {
return [bezCurve];
}
// If error not too large, try some reparameterization and iteration
if (maxError < error * error) {
uPrime = u;
prevErr = maxError;
prevSplit = splitPoint;
for (i = 0; i < MaxIterations; i++) {
uPrime = reparameterize(bezCurve, points, uPrime);
[bezCurve, maxError, splitPoint] = generateAndReport(
points,
u,
uPrime,
leftTangent,
rightTangent,
progressCallback
);
if (maxError < error) {
return [bezCurve];
}
// If the development of the fitted curve grinds to a halt,
// we abort this attempt (and try a shorter curve):
else if (splitPoint === prevSplit) {
const errChange = maxError / prevErr;
if (errChange > 0.9999 && errChange < 1.0001) {
break;
}
}
prevErr = maxError;
prevSplit = splitPoint;
}
}
// Fitting failed -- split at max error point and fit recursively
beziers = [];
// To create a smooth transition from one curve segment to the next, we
// calculate the line between the points directly before and after the
// center, and use that as the tangent both to and from the center point.
centerVector = maths.subtract(points[splitPoint - 1], points[splitPoint + 1]);
// However, this won't work if they're the same point, because the line we
// want to use as a tangent would be 0. Instead, we calculate the line from
// that "double-point" to the center point, and use its tangent.
if (centerVector.every(val => val === 0)) {
// [x,y] -> [-y,x]: http://stackoverflow.com/a/4780141/1869660
centerVector = maths.subtract(points[splitPoint - 1], points[splitPoint]);
[centerVector[0], centerVector[1]] = [-centerVector[1], centerVector[0]];
}
const toCenterTangent = maths.normalize(centerVector);
// To and from need to point in opposite directions:
// Unit tangent vector(s) at splitPoint
const fromCenterTangent = maths.mulItems(toCenterTangent, -1);
/*
Note:
An alternative to this "divide and conquer" recursion could be to always
let new curve segments start by trying to go all the way to the end,
instead of only to the end of the current subdivided polyline.
That might let many segments fit a few points more, reducing the number of
total segments.
However, a few tests have shown that the segment reduction is insignificant
(240 pts, 100 err: 25 curves vs 27 curves. 140 pts, 100 err: 17 curves
on both), and the results take twice as many steps and milliseconds to
finish, without looking any better than what we already have.
*/
beziers = beziers.concat(
fitCubic(
points.slice(0, splitPoint + 1),
leftTangent,
toCenterTangent,
error,
progressCallback
)
);
beziers = beziers.concat(
fitCubic(
points.slice(splitPoint),
fromCenterTangent,
rightTangent,
error,
progressCallback
)
);
return beziers;
}
function generateAndReport(
points,
paramsOrig,
paramsPrime,
leftTangent,
rightTangent,
progressCallback
) {
const bezCurve = generateBezier(
points,
paramsPrime,
leftTangent,
rightTangent
);
// Find max deviation of points to fitted curve.
// Here we always use the original parameters (from
// chordLengthParameterize()), because we need to compare the current
// curve to the actual source polyline, and not the currently iterated
// parameters which reparameterize() & generateBezier() use, as those
// have probably drifted far away and may no longer be in ascending order.
const [maxError, splitPoint] = computeMaxError(points, bezCurve, paramsOrig);
if (progressCallback) {
progressCallback({
bez: bezCurve,
points,
params: paramsOrig,
maxErr: maxError,
maxPoint: splitPoint,
});
}
return [bezCurve, maxError, splitPoint];
}
/**
* Use least-squares method to find Bezier control points for region.
*
* @param {Array<Array<Number>>} points - Array of digitized points
* @param {Array<Number>} parameters - Parameter values for region
* @param {Array<Number>} leftTangent - Unit tangent vector at start point
* @param {Array<Number>} rightTangent - Unit tangent vector at end point
* @returns {Array<Array<Number>>} Approximated Bezier curve:
* [first-point, control-point-1, control-point-2, second-point]
* where points are [x, y]
*/
function generateBezier(points, parameters, leftTangent, rightTangent) {
let a, // Precomputed rhs for eqn
tmp,
u,
ux;
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
// Bezier curve ctl pts
const bezCurve = [firstPoint, null, null, lastPoint];
// Compute the A's
const A = maths.zeros_Xx2x2(parameters.length);
for (let i = 0, len = parameters.length; i < len; i++) {
u = parameters[i];
ux = 1 - u;
a = A[i];
a[0] = maths.mulItems(leftTangent, 3 * u * (ux * ux));
a[1] = maths.mulItems(rightTangent, 3 * ux * (u * u));
}
// Create the C and X matrices
const C = [
[0, 0],
[0, 0],
];
const X = [0, 0];
for (let i = 0, len = points.length; i < len; i++) {
u = parameters[i];
a = A[i];
C[0][0] += maths.dot(a[0], a[0]);
C[0][1] += maths.dot(a[0], a[1]);
C[1][0] += maths.dot(a[0], a[1]);
C[1][1] += maths.dot(a[1], a[1]);
tmp = maths.subtract(
points[i],
bezier.q([firstPoint, firstPoint, lastPoint, lastPoint], u)
);
X[0] += maths.dot(a[0], tmp);
X[1] += maths.dot(a[1], tmp);
}
// Compute the determinants of C and X
const det_C0_C1 = C[0][0] * C[1][1] - C[1][0] * C[0][1];
const det_C0_X = C[0][0] * X[1] - C[1][0] * X[0];
const det_X_C1 = X[0] * C[1][1] - X[1] * C[0][1];
// Finally, derive alpha values
const alpha_l = det_C0_C1 === 0 ? 0 : det_X_C1 / det_C0_C1;
const alpha_r = det_C0_C1 === 0 ? 0 : det_C0_X / det_C0_C1;
// If alpha negative, use the Wu/Barsky heuristic (see text).
// If alpha is 0, you get coincident control points that lead to
// divide by zero in any subsequent NewtonRaphsonRootFind() call.
const segLength = maths.vectorLen(maths.subtract(firstPoint, lastPoint));
const epsilon = 1.0e-6 * segLength;
if (alpha_l < epsilon || alpha_r < epsilon) {
// Fall back on standard (probably inaccurate) formula, and subdivide
// further if needed.
bezCurve[1] = maths.addArrays(
firstPoint,
maths.mulItems(leftTangent, segLength / 3.0)
);
bezCurve[2] = maths.addArrays(
lastPoint,
maths.mulItems(rightTangent, segLength / 3.0)
);
} else {
// First and last control points of the Bezier curve are
// positioned exactly at the first and last data points
// Control points 1 and 2 are positioned an alpha distance out
// on the tangent vectors, left and right, respectively
bezCurve[1] = maths.addArrays(
firstPoint,
maths.mulItems(leftTangent, alpha_l)
);
bezCurve[2] = maths.addArrays(
lastPoint,
maths.mulItems(rightTangent, alpha_r)
);
}
return bezCurve;
}
/**
* Given set of points and their parameterization, try to find a better
* parameterization.
*
* @param {Array<Array<Number>>} bezier - Current fitted curve
* @param {Array<Array<Number>>} points - Array of digitized points
* @param {Array<Number>} parameters - Current parameter values
* @returns {Array<Number>} New parameter values
*/
function reparameterize(bezier, points, parameters) {
return parameters.map((p, i) => newtonRaphsonRootFind(bezier, points[i], p));
}
/**
* Use Newton-Raphson iteration to find better root.
*
* @param {Array<Array<Number>>} bez - Current fitted curve
* @param {Array<Number>} point - Digitized point
* @param {Number} u - Parameter value for "P"
* @returns {Number} New u
*/
function newtonRaphsonRootFind(bez, point, u) {
/*
Newton's root finding algorithm calculates f(x)=0 by reiterating
x_n+1 = x_n - f(x_n)/f'(x_n)
We are trying to find curve parameter u for some point p that minimizes
the distance from that point to the curve. Distance point to curve
is d=q(u)-p.
At minimum distance the point is perpendicular to the curve.
We are solving
f = q(u)-p * q'(u) = 0
with
f' = q'(u) * q'(u) + q(u)-p * q''(u)
gives
u_n+1 = u_n - |q(u_n)-p * q'(u_n)| / |q'(u_n)**2 + q(u_n)-p * q''(u_n)|
*/
const d = maths.subtract(bezier.q(bez, u), point),
qprime = bezier.qprime(bez, u),
numerator = maths.mulMatrix(d, qprime),
denominator =
maths.sum(maths.squareItems(qprime)) +
2 * maths.mulMatrix(d, bezier.qprimeprime(bez, u));
if (denominator === 0) {
return u;
}
return u - numerator / denominator;
}
/**
* Assign parameter values to digitized points using relative distances
* between points.
*
* @param {Array<Array<Number>>} points - Array of digitized points
* @returns {Array<Number>} Parameter values
*/
function chordLengthParameterize(points) {
let u = [],
currU,
prevU,
prevP;
points.forEach((p, i) => {
currU = i ? prevU + maths.vectorLen(maths.subtract(p, prevP)) : 0;
u.push(currU);
prevU = currU;
prevP = p;
});
u = u.map(x => x / prevU);
return u;
}
/**
* Find the maximum squared distance of digitized points to fitted curve.
*
* @param {Array<Array<Number>>} points - Array of digitized points
* @param {Array<Array<Number>>} bez - Fitted curve
* @param {Array<Number>} parameters - Parameterization of points
* @returns {Array<Number>} Maximum error (squared) and point of max error
*/
function computeMaxError(points, bez, parameters) {
let dist, // Current error
maxDist, // Maximum error
splitPoint, // Point of maximum error
v, // Vector from point to curve
i,
count,
point,
t;
maxDist = 0;
splitPoint = Math.floor(points.length / 2);
const t_distMap = mapTtoRelativeDistances(bez, 10);
for (i = 0, count = points.length; i < count; i++) {
point = points[i];
// Find 't' for a point on the bez curve that's as close to 'point'
// as possible:
t = find_t(bez, parameters[i], t_distMap, 10);
v = maths.subtract(bezier.q(bez, t), point);
dist = v[0] * v[0] + v[1] * v[1];
if (dist > maxDist) {
maxDist = dist;
splitPoint = i;
}
}
return [maxDist, splitPoint];
}
// Sample 't's and map them to relative distances along the curve:
function mapTtoRelativeDistances(bez, B_parts) {
let B_t_curr;
let B_t_dist = [0];
let B_t_prev = bez[0];
let sumLen = 0;
for (let i = 1; i <= B_parts; i++) {
B_t_curr = bezier.q(bez, i / B_parts);
sumLen += maths.vectorLen(maths.subtract(B_t_curr, B_t_prev));
B_t_dist.push(sumLen);
B_t_prev = B_t_curr;
}
// Normalize B_length to the same interval as the parameter distances; 0 to 1:
B_t_dist = B_t_dist.map(x => x / sumLen);
return B_t_dist;
}
function find_t(bez, param, t_distMap, B_parts) {
if (param < 0) {
return 0;
}
if (param > 1) {
return 1;
}
/*
'param' is a value between 0 and 1 telling us the relative position
of a point on the source polyline (linearly from the start (0) to the
end (1)).
To see if a given curve - 'bez' - is a close approximation of the polyline,
we compare such a poly-point to the point on the curve that's the same
relative distance along the curve's length.
But finding that curve-point takes a little work:
There is a function "B(t)" to find points along a curve from the parametric
parameter 't' (also relative from 0 to 1: http://stackoverflow.com/a/32841764/1869660
http://pomax.github.io/bezierinfo/#explanation),
but 't' isn't linear by length (http://gamedev.stackexchange.com/questions/105230).
So, we sample some points along the curve using a handful of values for 't'.
Then, we calculate the length between those samples via plain euclidean
distance; B(t) concentrates the points around sharp turns, so this should
give us a good-enough outline of the curve. Thus, for a given relative
distance ('param'), we can now find an upper and lower value for the
corresponding 't' by searching through those sampled distances. Finally, we
just use linear interpolation to find a better value for the exact 't'.
More info:
http://gamedev.stackexchange.com/questions/105230/points-evenly-spaced-along-a-bezier-curve
http://stackoverflow.com/questions/29438398/cheap-way-of-calculating-cubic-bezier-length
http://steve.hollasch.net/cgindex/curves/cbezarclen.html
https://github.com/retuxx/tinyspline
*/
let lenMax, lenMin, tMax, tMin, t;
// Find the two t-s that the current param distance lies between,
// and then interpolate a somewhat accurate value for the exact t:
for (let i = 1; i <= B_parts; i++) {
if (param <= t_distMap[i]) {
tMin = (i - 1) / B_parts;
tMax = i / B_parts;
lenMin = t_distMap[i - 1];
lenMax = t_distMap[i];
t = ((param - lenMin) / (lenMax - lenMin)) * (tMax - tMin) + tMin;
break;
}
}
return t;
}
/**
* Creates a vector of length 1 which shows the direction from B to A
*/
function createTangent(pointA, pointB) {
return maths.normalize(maths.subtract(pointA, pointB));
}
/*
Simplified versions of what we need from math.js
Optimized for our input, which is only numbers and 1x2 arrays
(i.e. [x, y] coordinates).
*/
class maths {
static zeros_Xx2x2(x) {
const zs = [];
while (x--) {
zs.push([0, 0]);
}
return zs;
}
static mulItems(items, multiplier) {
return items.map(x => x * multiplier);
}
static mulMatrix(m1, m2) {
// https://en.wikipedia.org/wiki/Matrix_multiplication#Matrix_product_.28two_matrices.29
// Simplified to only handle 1-dimensional matrices (i.e. arrays)
// of equal length:
return m1.reduce((sum, x1, i) => sum + x1 * m2[i], 0);
}
// Only used to subract to points (or at least arrays):
static subtract(arr1, arr2) {
return arr1.map((x1, i) => x1 - arr2[i]);
}
static addArrays(arr1, arr2) {
return arr1.map((x1, i) => x1 + arr2[i]);
}
static addItems(items, addition) {
return items.map(x => x + addition);
}
static sum(items) {
return items.reduce((sum, x) => sum + x);
}
// Only used on two arrays. The dot product is equal to the matrix product
// in this case:
static dot(m1, m2) {
return maths.mulMatrix(m1, m2);
}
// https://en.wikipedia.org/wiki/Norm_(mathematics)#Euclidean_norm
// var norm = logAndRun(math.norm);
static vectorLen(v) {
return Math.hypot(...v);
}
static divItems(items, divisor) {
return items.map(x => x / divisor);
}
static squareItems(items) {
return items.map(x => x * x);
}
static normalize(v) {
return this.divItems(v, this.vectorLen(v));
}
}
class bezier {
// Evaluates cubic bezier at t, return point
static q(ctrlPoly, t) {
const tx = 1.0 - t;
const pA = maths.mulItems(ctrlPoly[0], tx * tx * tx),
pB = maths.mulItems(ctrlPoly[1], 3 * tx * tx * t),
pC = maths.mulItems(ctrlPoly[2], 3 * tx * t * t),
pD = maths.mulItems(ctrlPoly[3], t * t * t);
return maths.addArrays(maths.addArrays(pA, pB), maths.addArrays(pC, pD));
}
// Evaluates cubic bezier first derivative at t, return point
static qprime(ctrlPoly, t) {
const tx = 1.0 - t;
const pA = maths.mulItems(
maths.subtract(ctrlPoly[1], ctrlPoly[0]),
3 * tx * tx
),
pB = maths.mulItems(maths.subtract(ctrlPoly[2], ctrlPoly[1]), 6 * tx * t),
pC = maths.mulItems(maths.subtract(ctrlPoly[3], ctrlPoly[2]), 3 * t * t);
return maths.addArrays(maths.addArrays(pA, pB), pC);
}
// Evaluates cubic bezier second derivative at t, return point
static qprimeprime(ctrlPoly, t) {
return maths.addArrays(
maths.mulItems(
maths.addArrays(
maths.subtract(ctrlPoly[2], maths.mulItems(ctrlPoly[1], 2)),
ctrlPoly[0]
),
6 * (1.0 - t)
),
maths.mulItems(
maths.addArrays(
maths.subtract(ctrlPoly[3], maths.mulItems(ctrlPoly[2], 2)),
ctrlPoly[1]
),
6 * t
)
);
}
}
export { fitCurve };

View File

@ -107,9 +107,8 @@ class FreeTextEditor extends AnnotationEditor {
}
if (!this.isAttachedToDOM) {
// At some point this editor has been removed and
// we're rebuilting it, hence we must add it to its
// parent.
// At some point this editor was removed and we're rebuilting it,
// hence we must add it to its parent.
this.parent.add(this);
}
}
@ -131,7 +130,7 @@ class FreeTextEditor extends AnnotationEditor {
/** @inheritdoc */
onceAdded() {
if (this.width) {
// The editor has been created in using ctrl+c.
// The editor was created in using ctrl+c.
this.div.focus();
return;
}
@ -226,7 +225,7 @@ class FreeTextEditor extends AnnotationEditor {
bindEvents(this, this.div, ["dblclick"]);
if (this.width) {
// This editor has been created in using copy (ctrl+c).
// This editor was created in using copy (ctrl+c).
this.setAt(this.x + this.width, this.y + this.height);
// eslint-disable-next-line no-unsanitized/property
this.editorDiv.innerHTML = this.#contentHTML;

720
src/display/editor/ink.js Normal file
View File

@ -0,0 +1,720 @@
/* 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.
*/
import { AnnotationEditorType, Util } from "../../shared/util.js";
import { AnnotationEditor } from "./editor.js";
import { fitCurve } from "./fit_curve/fit_curve.js";
/**
* Basic draw editor in order to generate an Ink annotation.
*/
class InkEditor extends AnnotationEditor {
#aspectRatio;
#baseHeight;
#baseWidth;
#boundCanvasMousemove;
#boundCanvasMouseleave;
#boundCanvasMouseup;
#boundCanvasMousedown;
#disableEditing;
#observer;
constructor(params) {
super({ ...params, name: "inkEditor" });
this.color = params.color || "CanvasText";
this.thickness = params.thickness || 1;
this.paths = [];
this.bezierPath2D = [];
this.currentPath = [];
this.scaleFactor = 1;
this.translationX = this.translationY = 0;
this.#baseWidth = this.#baseHeight = 0;
this.#aspectRatio = 0;
this.#disableEditing = false;
this.#observer = null;
this.x = 0;
this.y = 0;
this.#boundCanvasMousemove = this.canvasMousemove.bind(this);
this.#boundCanvasMouseleave = this.canvasMouseleave.bind(this);
this.#boundCanvasMouseup = this.canvasMouseup.bind(this);
this.#boundCanvasMousedown = this.canvasMousedown.bind(this);
}
/** @inheritdoc */
copy() {
const editor = new InkEditor({
parent: this.parent,
id: this.parent.getNextId(),
});
editor.x = this.x;
editor.y = this.y;
editor.width = this.width;
editor.height = this.height;
editor.color = this.color;
editor.thickness = this.thickness;
editor.paths = this.paths.slice();
editor.bezierPath2D = this.bezierPath2D.slice();
editor.scaleFactor = this.scaleFactor;
editor.translationX = this.translationX;
editor.translationY = this.translationY;
editor.#aspectRatio = this.#aspectRatio;
editor.#baseWidth = this.#baseWidth;
editor.#baseHeight = this.#baseHeight;
editor.#disableEditing = this.#disableEditing;
return editor;
}
/** @inheritdoc */
rebuild() {
if (this.div === null) {
return;
}
if (!this.canvas) {
this.#createCanvas();
this.#createObserver();
}
if (!this.isAttachedToDOM) {
// At some point this editor was removed and we're rebuilding it,
// hence we must add it to its parent.
this.parent.add(this);
this.#setCanvasDims();
}
this.#fitToContent();
}
/** @inheritdoc */
remove() {
if (this.canvas === null) {
return;
}
super.remove();
// Destroy the canvas.
this.canvas.width = this.canvas.heigth = 0;
this.canvas.remove();
this.canvas = null;
this.#observer.disconnect();
this.#observer = null;
}
/** @inheritdoc */
enableEditMode() {
if (this.#disableEditing) {
return;
}
super.enableEditMode();
this.canvas.style.cursor = "pointer";
this.div.draggable = false;
this.canvas.addEventListener("mousedown", this.#boundCanvasMousedown);
this.canvas.addEventListener("mouseup", this.#boundCanvasMouseup);
}
/** @inheritdoc */
disableEditMode() {
if (!this.isInEditMode()) {
return;
}
super.disableEditMode();
this.canvas.style.cursor = "auto";
this.div.draggable = true;
this.div.classList.remove("editing");
this.canvas.removeEventListener("mousedown", this.#boundCanvasMousedown);
this.canvas.removeEventListener("mouseup", this.#boundCanvasMouseup);
}
/** @inheritdoc */
onceAdded() {
this.div.focus();
}
/** @inheritdoc */
isEmpty() {
return this.paths.length === 0;
}
/**
* Set line styles.
*/
#setStroke() {
this.ctx.lineWidth =
(this.thickness * this.parent.scaleFactor) / this.scaleFactor;
this.ctx.lineCap = "round";
this.ctx.lineJoin = "miter";
this.ctx.miterLimit = 10;
this.ctx.strokeStyle = this.color;
}
/**
* Start to draw on the canvas.
* @param {number} x
* @param {number} y
*/
#startDrawing(x, y) {
this.currentPath.push([x, y]);
this.#setStroke();
this.ctx.beginPath();
this.ctx.moveTo(x, y);
}
/**
* Draw on the canvas.
* @param {number} x
* @param {number} y
*/
#draw(x, y) {
this.currentPath.push([x, y]);
this.ctx.lineTo(x, y);
this.ctx.stroke();
}
/**
* Stop to draw on the canvas.
* @param {number} x
* @param {number} y
*/
#stopDrawing(x, y) {
x = Math.min(Math.max(x, 0), this.canvas.width);
y = Math.min(Math.max(y, 0), this.canvas.height);
this.currentPath.push([x, y]);
// 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 !== 2 ||
this.currentPath[0][0] !== x ||
this.currentPath[0][1] !== y
) {
bezier = fitCurve(this.currentPath, 30, null);
} else {
// We have only one point finally.
const xy = [x, y];
bezier = [[xy, xy.slice(), xy.slice(), xy]];
}
const path2D = this.#buildPath2D(bezier);
this.currentPath.length = 0;
const cmd = () => {
this.paths.push(bezier);
this.bezierPath2D.push(path2D);
this.rebuild();
};
const undo = () => {
this.paths.pop();
this.bezierPath2D.pop();
if (this.paths.length === 0) {
this.remove();
} else {
this.#fitToContent();
}
};
this.parent.addCommands(cmd, undo);
}
/**
* Redraw all the paths.
*/
#redraw() {
this.#setStroke();
if (this.isEmpty()) {
this.#updateTransform();
return;
}
const { ctx, height, width } = this;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, width, height);
this.#updateTransform();
for (const path of this.bezierPath2D) {
ctx.stroke(path);
}
}
/**
* Commit the curves we have in this editor.
* @returns {undefined}
*/
commit() {
if (this.#disableEditing) {
return;
}
this.disableEditMode();
this.#disableEditing = true;
this.div.classList.add("disabled");
this.#fitToContent();
}
/** @inheritdoc */
focusin(/* event */) {
super.focusin();
this.enableEditMode();
}
/**
* onmousedown callback for the canvas we're drawing on.
* @param {MouseEvent} event
* @returns {undefined}
*/
canvasMousedown(event) {
if (!this.isInEditMode() || this.#disableEditing) {
return;
}
event.stopPropagation();
this.canvas.addEventListener("mouseleave", this.#boundCanvasMouseleave);
this.canvas.addEventListener("mousemove", this.#boundCanvasMousemove);
this.#startDrawing(event.offsetX, event.offsetY);
}
/**
* onmousemove callback for the canvas we're drawing on.
* @param {MouseEvent} event
* @returns {undefined}
*/
canvasMousemove(event) {
event.stopPropagation();
this.#draw(event.offsetX, event.offsetY);
}
/**
* onmouseup callback for the canvas we're drawing on.
* @param {MouseEvent} event
* @returns {undefined}
*/
canvasMouseup(event) {
if (this.isInEditMode() && this.currentPath.length !== 0) {
event.stopPropagation();
this.#endDrawing(event);
}
}
/**
* onmouseleave callback for the canvas we're drawing on.
* @param {MouseEvent} event
* @returns {undefined}
*/
canvasMouseleave(event) {
this.#endDrawing(event);
}
/**
* End the drawing.
* @param {MouseEvent} event
*/
#endDrawing(event) {
this.#stopDrawing(event.offsetX, event.offsetY);
this.canvas.removeEventListener("mouseleave", this.#boundCanvasMouseleave);
this.canvas.removeEventListener("mousemove", this.#boundCanvasMousemove);
}
/**
* Create the canvas element.
*/
#createCanvas() {
this.canvas = document.createElement("canvas");
this.canvas.className = "inkEditorCanvas";
this.div.appendChild(this.canvas);
this.ctx = this.canvas.getContext("2d");
}
/**
* Create the resize observer.
*/
#createObserver() {
this.#observer = new ResizeObserver(entries => {
const rect = entries[0].contentRect;
if (rect.width && rect.height) {
this.setDimensions(rect.width, rect.height);
}
});
this.#observer.observe(this.div);
}
/** @inheritdoc */
render() {
if (this.div) {
return this.div;
}
super.render();
this.#createCanvas();
this.div.classList.add("editing");
if (this.width) {
// This editor was created in using copy (ctrl+c).
this.setAt(this.x + this.width, this.y + this.height);
this.setDims(this.width, this.height);
this.#setCanvasDims();
this.#redraw();
this.div.classList.add("disabled");
}
this.#createObserver();
return this.div;
}
#setCanvasDims() {
this.canvas.width = this.width;
this.canvas.height = this.height;
this.#updateTransform();
}
/**
* When the dimensions of the div change the inner canvas must
* renew its dimensions, hence it must redraw its own contents.
* @param {number} width - the new width of the div
* @param {number} height - the new height of the div
* @returns
*/
setDimensions(width, height) {
if (this.width === width && this.height === height) {
return;
}
this.canvas.style.visibility = "hidden";
if (this.#aspectRatio) {
height = Math.ceil(width / this.#aspectRatio);
this.div.style.height = `${height}px`;
}
this.width = width;
this.height = height;
if (this.#disableEditing) {
const padding = this.#getPadding();
const scaleFactorW = (width - padding) / this.#baseWidth;
const scaleFactorH = (height - padding) / this.#baseHeight;
this.scaleFactor = Math.min(scaleFactorW, scaleFactorH);
}
this.#setCanvasDims();
this.#redraw();
this.canvas.style.visibility = "visible";
}
/**
* Update the canvas transform.
*/
#updateTransform() {
const padding = this.#getPadding() / 2;
this.ctx.setTransform(
this.scaleFactor,
0,
0,
this.scaleFactor,
this.translationX * this.scaleFactor + padding,
this.translationY * this.scaleFactor + padding
);
}
/**
* Convert the output of fitCurve in some Path2D.
* @param {Arra<Array<number>} bezier
* @returns {Path2D}
*/
#buildPath2D(bezier) {
const path2D = new Path2D();
for (let i = 0, ii = bezier.length; i < ii; i++) {
const [first, control1, control2, second] = bezier[i];
if (i === 0) {
path2D.moveTo(...first);
}
path2D.bezierCurveTo(
control1[0],
control1[1],
control2[0],
control2[1],
second[0],
second[1]
);
}
return path2D;
}
/**
* Transform and serialize the paths.
* @param {number} s - scale factor
* @param {number} tx - abscissa of the translation
* @param {number} ty - ordinate of the translation
* @param {number} h - height of the bounding box
*/
#serializePaths(s, tx, ty, h) {
const NUMBER_OF_POINTS_ON_BEZIER_CURVE = 4;
const paths = [];
const padding = this.thickness / 2;
let buffer, points;
for (const bezier of this.paths) {
buffer = [];
points = [];
for (let i = 0, ii = bezier.length; i < ii; i++) {
const [first, control1, control2, second] = bezier[i];
const p10 = s * (first[0] + tx) + padding;
const p11 = h - s * (first[1] + ty) - padding;
const p20 = s * (control1[0] + tx) + padding;
const p21 = h - s * (control1[1] + ty) - padding;
const p30 = s * (control2[0] + tx) + padding;
const p31 = h - s * (control2[1] + ty) - padding;
const p40 = s * (second[0] + tx) + padding;
const p41 = h - s * (second[1] + ty) - padding;
if (i === 0) {
buffer.push(p10, p11);
points.push(p10, p11);
}
buffer.push(p20, p21, p30, p31, p40, p41);
this.#extractPointsOnBezier(
p10,
p11,
p20,
p21,
p30,
p31,
p40,
p41,
NUMBER_OF_POINTS_ON_BEZIER_CURVE,
points
);
}
paths.push({ bezier: buffer, points });
}
return paths;
}
/**
* Extract n-1 points from the cubic Bezier curve.
* @param {number} p10
* @param {number} p11
* @param {number} p20
* @param {number} p21
* @param {number} p30
* @param {number} p31
* @param {number} p40
* @param {number} p41
* @param {number} n
* @param {Array<number>} points
* @returns {undefined}
*/
#extractPointsOnBezier(p10, p11, p20, p21, p30, p31, p40, p41, n, points) {
// If we can save few points thanks to the flatness we must do it.
if (this.#isAlmostFlat(p10, p11, p20, p21, p30, p31, p40, p41)) {
points.push(p40, p41);
return;
}
// Apply the de Casteljau's algorithm in order to get n points belonging
// to the Bezier's curve:
// https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm
// The first point is the last point of the previous Bezier curve
// so no need to push the first point.
for (let i = 1; i < n - 1; i++) {
const t = i / n;
const mt = 1 - t;
let q10 = t * p10 + mt * p20;
let q11 = t * p11 + mt * p21;
let q20 = t * p20 + mt * p30;
let q21 = t * p21 + mt * p31;
const q30 = t * p30 + mt * p40;
const q31 = t * p31 + mt * p41;
q10 = t * q10 + mt * q20;
q11 = t * q11 + mt * q21;
q20 = t * q20 + mt * q30;
q21 = t * q21 + mt * q31;
q10 = t * q10 + mt * q20;
q11 = t * q11 + mt * q21;
points.push(q10, q11);
}
points.push(p40, p41);
}
/**
* Check if a cubic Bezier curve is almost flat.
* @param {number} p10
* @param {number} p11
* @param {number} p20
* @param {number} p21
* @param {number} p30
* @param {number} p31
* @param {number} p40
* @param {number} p41
* @returns {boolean}
*/
#isAlmostFlat(p10, p11, p20, p21, p30, p31, p40, p41) {
// For reference:
// https://jeremykun.com/tag/bezier-curves/
const tol = 10;
const ax = (3 * p20 - 2 * p10 - p40) ** 2;
const ay = (3 * p21 - 2 * p11 - p41) ** 2;
const bx = (3 * p30 - p10 - 2 * p40) ** 2;
const by = (3 * p31 - p11 - 2 * p41) ** 2;
return Math.max(ax, bx) + Math.max(ay, by) <= tol;
}
/**
* Get the bounding box containing all the paths.
* @returns {Array<number>}
*/
#getBbox() {
let xMin = Infinity;
let xMax = -Infinity;
let yMin = Infinity;
let yMax = -Infinity;
for (const path of this.paths) {
for (const [first, control1, control2, second] of path) {
const bbox = Util.bezierBoundingBox(
...first,
...control1,
...control2,
...second
);
xMin = Math.min(xMin, bbox[0]);
yMin = Math.min(yMin, bbox[1]);
xMax = Math.max(xMax, bbox[2]);
yMax = Math.max(yMax, bbox[3]);
}
}
return [xMin, yMin, xMax, yMax];
}
/**
* The bounding box is computed with null thickness, so we must take
* it into account for the display.
* It corresponds to the total padding, hence it should be divided by 2
* in order to have left/right paddings.
* @returns {number}
*/
#getPadding() {
return Math.ceil(this.thickness * this.parent.scaleFactor);
}
/**
* Set the div position and dimensions in order to fit to
* the bounding box of the contents.
* @returns {undefined}
*/
#fitToContent() {
if (this.isEmpty()) {
return;
}
if (!this.#disableEditing) {
this.#redraw();
return;
}
const bbox = this.#getBbox();
const padding = this.#getPadding();
this.#baseWidth = bbox[2] - bbox[0];
this.#baseHeight = bbox[3] - bbox[1];
const width = Math.ceil(padding + this.#baseWidth * this.scaleFactor);
const height = Math.ceil(padding + this.#baseHeight * this.scaleFactor);
this.width = width;
this.height = height;
this.#aspectRatio = width / height;
const prevTranslationX = this.translationX;
const prevTranslationY = this.translationY;
this.translationX = -bbox[0];
this.translationY = -bbox[1];
this.#setCanvasDims();
this.#redraw();
this.setDims(width, height);
this.translate(
prevTranslationX - this.translationX,
prevTranslationY - this.translationY
);
}
/** @inheritdoc */
serialize() {
const rect = this.div.getBoundingClientRect();
const [x1, y1] = Util.applyTransform(
[this.x, this.y + rect.height],
this.parent.inverseViewportTransform
);
const [x2, y2] = Util.applyTransform(
[this.x + rect.width, this.y],
this.parent.inverseViewportTransform
);
return {
annotationType: AnnotationEditorType.INK,
color: [0, 0, 0],
thickness: this.thickness,
paths: this.#serializePaths(
this.scaleFactor / this.parent.scaleFactor,
this.translationX,
this.translationY,
y2 - y1
),
pageIndex: this.parent.pageIndex,
rect: [x1, y1, x2, y2],
};
}
}
export { InkEditor };

View File

@ -57,6 +57,7 @@ const AnnotationEditorPrefix = "pdfjs_internal_editor_";
const AnnotationEditorType = {
NONE: 0,
FREETEXT: 3,
INK: 15,
};
// Permission flags from Table 22, Section 7.6.3.2 of the PDF specification.

View File

@ -78,10 +78,45 @@
outline: var(--focus-outline);
}
.annotationEditorLayer .freeTextEditor:hover:not(:focus-within) {
.annotationEditorLayer .inkEditor:not(:focus) {
resize: none;
}
.annotationEditorLayer .freeTextEditor:hover:not(:focus-within),
.annotationEditorLayer .inkEditor:hover:not(:focus) {
outline: var(--hover-outline);
}
.annotationEditorLayer .inkEditor.disabled:focus {
resize: horizontal;
}
.annotationEditorLayer .inkEditor {
position: absolute;
background: transparent;
border-radius: 3px;
overflow: auto;
width: 100%;
height: 100%;
}
.annotationEditorLayer .inkEditor:focus {
outline: var(--focus-outline);
resize: both;
}
.annotationEditorLayer .inkEditor.editing {
resize: none;
}
.annotationEditorLayer .inkEditor .inkEditorCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.annotationEditorLayer .selectedEditor {
outline: var(--focus-outline);
resize: none;

View File

@ -2474,11 +2474,7 @@ function webViewerPresentationMode() {
PDFViewerApplication.requestPresentationMode();
}
function webViewerSwitchAnnotationEditorMode(evt) {
if (evt.toggle) {
PDFViewerApplication.pdfViewer.annotionEditorEnabled = true;
} else {
PDFViewerApplication.pdfViewer.annotationEditorMode = evt.mode;
}
PDFViewerApplication.pdfViewer.annotationEditorMode = evt.mode;
}
function webViewerPrint() {
PDFViewerApplication.triggerPrinting();

View File

@ -38,6 +38,12 @@ if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
compatibilityParams.maxCanvasPixels = 5242880;
}
})();
(function checkResizeObserver() {
if (typeof ResizeObserver === "undefined") {
compatibilityParams.annotationEditorEnabled = false;
}
})();
}
const OptionKind = {
@ -309,6 +315,9 @@ if (
kind: OptionKind.VIEWER,
};
defaultOptions.annotationEditorEnabled.compatibility =
compatibilityParams.annotationEditorEnabled;
defaultOptions.renderer.kind += OptionKind.PREFERENCE;
} else if (PDFJSDev.test("CHROME")) {
defaultOptions.disableTelemetry = {

View File

@ -0,0 +1,9 @@
<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" xmlns:xlink="http://www.w3.org/1999/xlink" enable-background="new 0 0 16 16">
<g>
<g transform="scale(0.03125)">
<path d="m455.1,137.9l-32.4,32.4-81-81.1 32.4-32.4c6.6-6.6 18.1-6.6 24.7,0l56.3,56.4c6.8,6.8 6.8,17.9 0,24.7zm-270.7,271l-81-81.1 209.4-209.7 81,81.1-209.4,209.7zm-99.7-42l60.6,60.7-84.4,23.8 23.8-84.5zm399.3-282.6l-56.3-56.4c-11-11-50.7-31.8-82.4,0l-285.3,285.5c-2.5,2.5-4.3,5.5-5.2,8.9l-43,153.1c-2,7.1 0.1,14.7 5.2,20 5.2,5.3 15.6,6.2 20,5.2l153-43.1c3.4-0.9 6.4-2.7 8.9-5.2l285.1-285.5c22.7-22.7 22.7-59.7 0-82.5z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 781 B

View File

@ -84,6 +84,11 @@ class Toolbar {
eventName: "switchannotationeditormode",
eventDetails: { mode: AnnotationEditorType.FREETEXT },
},
{
element: options.editorInkButton,
eventName: "switchannotationeditormode",
eventDetails: { mode: AnnotationEditorType.INK },
},
];
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
this.buttons.push({ element: options.openFile, eventName: "openfile" });
@ -99,6 +104,7 @@ class Toolbar {
zoomOut: options.zoomOut,
editorNoneButton: options.editorNoneButton,
editorFreeTextButton: options.editorFreeTextButton,
editorInkButton: options.editorInkButton,
};
this._wasLocalized = false;
@ -201,11 +207,16 @@ class Toolbar {
this.#bindEditorToolsListener(options);
}
#bindEditorToolsListener({ editorNoneButton, editorFreeTextButton }) {
#bindEditorToolsListener({
editorNoneButton,
editorFreeTextButton,
editorInkButton,
}) {
this.eventBus._on("annotationeditormodechanged", evt => {
const editorButtons = [
[AnnotationEditorType.NONE, editorNoneButton],
[AnnotationEditorType.FREETEXT, editorFreeTextButton],
[AnnotationEditorType.INK, editorInkButton],
];
for (const [mode, button] of editorButtons) {
@ -276,10 +287,12 @@ class Toolbar {
}
updateEditorModeButtonsState(disabled = false) {
const { editorNoneButton, editorFreeTextButton } = this.items;
const { editorNoneButton, editorFreeTextButton, editorInkButton } =
this.items;
editorNoneButton.disabled = disabled;
editorFreeTextButton.disabled = disabled;
editorInkButton.disabled = disabled;
}
/**

View File

@ -73,6 +73,7 @@
--treeitem-collapsed-icon: url(images/treeitem-collapsed.svg);
--toolbarButton-editorNone-icon: url(images/toolbarButton-editorNone.svg);
--toolbarButton-editorFreeText-icon: url(images/toolbarButton-editorFreeText.svg);
--toolbarButton-editorInk-icon: url(images/toolbarButton-editorInk.svg);
--toolbarButton-menuArrow-icon: url(images/toolbarButton-menuArrow.svg);
--toolbarButton-sidebarToggle-icon: url(images/toolbarButton-sidebarToggle.svg);
--toolbarButton-secondaryToolbarToggle-icon: url(images/toolbarButton-secondaryToolbarToggle.svg);
@ -834,6 +835,10 @@ select {
mask-image: var(--toolbarButton-editorFreeText-icon);
}
#editorInk::before {
mask-image: var(--toolbarButton-editorInk-icon);
}
#print::before,
#secondaryPrint::before {
mask-image: var(--toolbarButton-print-icon);

View File

@ -270,6 +270,9 @@ See https://github.com/adobe-type-tools/cmap-resources
<button id="editorFreeText" class="toolbarButton" title="Add FreeText Annotation" role="radio" aria-checked="false" tabindex="32" data-l10n-id="editor_free_text">
<span data-l10n-id="editor_free_text_label">FreeText Annotation</span>
</button>
<button id="editorInk" class="toolbarButton" title="Add Ink Annotation" role="radio" aria-checked="false" tabindex="33" data-l10n-id="editor_ink">
<span data-l10n-id="editor_ink_label">Ink Annotation</span>
</button>
</div>
<button id="presentationMode" class="toolbarButton hiddenLargeView" title="Switch to Presentation Mode" tabindex="43" data-l10n-id="presentation_mode">

View File

@ -96,6 +96,7 @@ function getViewerConfiguration() {
editorModeButtons: document.getElementById("editorModeButtons"),
editorNoneButton: document.getElementById("editorNone"),
editorFreeTextButton: document.getElementById("editorFreeText"),
editorInkButton: document.getElementById("editorInk"),
presentationModeButton: document.getElementById("presentationMode"),
download: document.getElementById("download"),
viewBookmark: document.getElementById("viewBookmark"),