Merge pull request #14989 from calixteman/ink1
[editor] Add an Ink editor
This commit is contained in:
commit
61a65344a3
@ -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…
|
||||
|
@ -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;
|
||||
}
|
||||
|
652
src/display/editor/fit_curve/fit_curve.js
Normal file
652
src/display/editor/fit_curve/fit_curve.js
Normal 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 };
|
@ -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
720
src/display/editor/ink.js
Normal 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 };
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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 = {
|
||||
|
9
web/images/toolbarButton-editorInk.svg
Normal file
9
web/images/toolbarButton-editorInk.svg
Normal 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 |
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
|
@ -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">
|
||||
|
@ -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"),
|
||||
|
Loading…
Reference in New Issue
Block a user