diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index 94c36b032..d13a8d83d 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -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… diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 78f79f72d..da5931676 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -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; } diff --git a/src/display/editor/fit_curve/fit_curve.js b/src/display/editor/fit_curve/fit_curve.js new file mode 100644 index 000000000..d9562bda4 --- /dev/null +++ b/src/display/editor/fit_curve/fit_curve.js @@ -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>} 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 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>} points - Array of digitized points, + * e.g. [[5,5],[5,50],[110,140],[210,160],[320,110]] + * @param {Array} leftTangent - Unit tangent vector at start point + * @param {Array} rightTangent - Unit tangent vector at end point + * @param {Number} error - Tolerance, squared error between points and + * fitted curve + * @returns {Array>>} 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>} points - Array of digitized points + * @param {Array} parameters - Parameter values for region + * @param {Array} leftTangent - Unit tangent vector at start point + * @param {Array} rightTangent - Unit tangent vector at end point + * @returns {Array>} 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>} bezier - Current fitted curve + * @param {Array>} points - Array of digitized points + * @param {Array} parameters - Current parameter values + * @returns {Array} 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>} bez - Current fitted curve + * @param {Array} 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>} points - Array of digitized points + * @returns {Array} 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>} points - Array of digitized points + * @param {Array>} bez - Fitted curve + * @param {Array} parameters - Parameterization of points + * @returns {Array} 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 }; diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index bed3db1e3..1b04c7066 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -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; diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js new file mode 100644 index 000000000..431ead2c6 --- /dev/null +++ b/src/display/editor/ink.js @@ -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} 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} 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} + */ + #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 }; diff --git a/src/shared/util.js b/src/shared/util.js index b6a635c27..de3685413 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -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. diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index 6abc16659..7fe9b43d6 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -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; diff --git a/web/app.js b/web/app.js index 90cc8626d..5b13358e5 100644 --- a/web/app.js +++ b/web/app.js @@ -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(); diff --git a/web/app_options.js b/web/app_options.js index 243aa9e11..d75277c56 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -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 = { diff --git a/web/images/toolbarButton-editorInk.svg b/web/images/toolbarButton-editorInk.svg new file mode 100644 index 000000000..fb9e13d5b --- /dev/null +++ b/web/images/toolbarButton-editorInk.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/web/toolbar.js b/web/toolbar.js index 1fa31209e..5da90c8f8 100644 --- a/web/toolbar.js +++ b/web/toolbar.js @@ -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; } /** diff --git a/web/viewer.css b/web/viewer.css index 6bd7edf95..f5bc4176f 100644 --- a/web/viewer.css +++ b/web/viewer.css @@ -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); diff --git a/web/viewer.html b/web/viewer.html index 1d5446f00..86e979455 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -270,6 +270,9 @@ See https://github.com/adobe-type-tools/cmap-resources +