1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-22 16:18:08 +02:00

[Editor] (WIP) Add a new tool in order to add an handwritten signature to a pdf (bug 1942343)

This patch is adding some code in order to extract a drawing as curves from an image.
The algorithm is basically the following:
 - reduce the dimensions
 - make it gray
 - apply a bilateral filter in order to add some blurryness while keeping the edges
 - compute the histogram
 - guess what's the background color which should contain a large majority of the pixels
 - make a binary image
 - extract the contours in using the Suzuki algorithm
 - apply the Douglas-Peucker algorithm in order to reduce the number of points

The algorithm is improvable but it should work pretty well if there's a clear difference between
the background and the drawing.
In a v2 we could use a ML model in order to improve the extraction.

There's few changes related to the UI in order to make the tool usable, but they're very basic
for the moment.
This commit is contained in:
Calixte Denizet 2025-01-17 18:04:34 +01:00
parent 711bf2bd12
commit 2f828c7bf4
18 changed files with 839 additions and 9 deletions

View file

@ -31,6 +31,7 @@ import { FreeTextEditor } from "./freetext.js";
import { HighlightEditor } from "./highlight.js";
import { InkEditor } from "./ink.js";
import { setLayerDimensions } from "../display_utils.js";
import { SignatureEditor } from "./signature.js";
import { StampEditor } from "./stamp.js";
/**
@ -89,10 +90,13 @@ class AnnotationEditorLayer {
static _initialized = false;
static #editorTypes = new Map(
[FreeTextEditor, InkEditor, StampEditor, HighlightEditor].map(type => [
type._editorType,
type,
])
[
FreeTextEditor,
InkEditor,
StampEditor,
HighlightEditor,
SignatureEditor,
].map(type => [type._editorType, type])
);
/**
@ -758,7 +762,11 @@ class AnnotationEditorLayer {
return;
}
if (this.#uiManager.getMode() === AnnotationEditorType.STAMP) {
const currentMode = this.#uiManager.getMode();
if (
currentMode === AnnotationEditorType.STAMP ||
currentMode === AnnotationEditorType.SIGNATURE
) {
this.#uiManager.unselectAll();
return;
}

View file

@ -91,6 +91,10 @@ class DrawingEditor extends AnnotationEditor {
super(params);
this.#mustBeCommitted = params.mustBeCommitted || false;
this._addOutlines(params);
}
_addOutlines(params) {
if (params.drawOutlines) {
this.#createDrawOutlines(params);
this.#addToDrawLayer();

View file

@ -0,0 +1,28 @@
/* Copyright 2024 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 { InkDrawOutline } from "./inkdraw.js";
class ContourDrawOutline extends InkDrawOutline {
toSVGPath() {
let path = super.toSVGPath();
if (!path.endsWith("Z")) {
path += "Z";
}
return path;
}
}
export { ContourDrawOutline };

View file

@ -0,0 +1,553 @@
/* 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 { ContourDrawOutline } from "./contour.js";
import { Outline } from "./outline.js";
/**
* Basic text editor in order to create a Signature annotation.
*/
class SignatureExtractor {
static #PARAMETERS = {
maxDim: 512,
sigmaSFactor: 0.02,
sigmaR: 25,
kernelSize: 16,
};
static #neighborIndexToId(i0, j0, i, j) {
/*
The idea is to map the neighbors of a pixel into a unique id.
3 2 1
4 X 0
5 6 7
*/
i -= i0;
j -= j0;
if (i === 0) {
return j > 0 ? 0 : 4;
}
if (i === 1) {
return j + 6;
}
return 2 - j;
}
static #neighborIdToIndex = new Int32Array([
0, 1, -1, 1, -1, 0, -1, -1, 0, -1, 1, -1, 1, 0, 1, 1,
]);
static #clockwiseNonZero(buf, width, i0, j0, i, j, offset) {
const id = this.#neighborIndexToId(i0, j0, i, j);
for (let k = 0; k < 8; k++) {
const kk = (-k + id - offset + 16) % 8;
const shiftI = this.#neighborIdToIndex[2 * kk];
const shiftJ = this.#neighborIdToIndex[2 * kk + 1];
if (buf[(i0 + shiftI) * width + (j0 + shiftJ)] !== 0) {
return kk;
}
}
return -1;
}
static #counterClockwiseNonZero(buf, width, i0, j0, i, j, offset) {
const id = this.#neighborIndexToId(i0, j0, i, j);
for (let k = 0; k < 8; k++) {
const kk = (k + id + offset + 16) % 8;
const shiftI = this.#neighborIdToIndex[2 * kk];
const shiftJ = this.#neighborIdToIndex[2 * kk + 1];
if (buf[(i0 + shiftI) * width + (j0 + shiftJ)] !== 0) {
return kk;
}
}
return -1;
}
static #findContours(buf, width, height, threshold) {
// Based on the Suzuki's algorithm:
// https://web.archive.org/web/20231213161741/https://www.nevis.columbia.edu/~vgenty/public/suzuki_et_al.pdf
const N = buf.length;
const types = new Int32Array(N);
for (let i = 0; i < N; i++) {
types[i] = buf[i] <= threshold ? 1 : 0;
}
for (let i = 1; i < height - 1; i++) {
types[i * width] = types[i * width + width - 1] = 0;
}
for (let i = 0; i < width; i++) {
types[i] = types[width * height - 1 - i] = 0;
}
let nbd = 1;
let lnbd;
const contours = [];
for (let i = 1; i < height - 1; i++) {
lnbd = 1;
for (let j = 1; j < width - 1; j++) {
const ij = i * width + j;
const pix = types[ij];
if (pix === 0) {
continue;
}
let i2 = i;
let j2 = j;
if (pix === 1 && types[ij - 1] === 0) {
// Outer border.
nbd += 1;
j2 -= 1;
} else if (pix >= 1 && types[ij + 1] === 0) {
// Hole border.
nbd += 1;
j2 += 1;
if (pix > 1) {
lnbd = pix;
}
} else {
if (pix !== 1) {
lnbd = Math.abs(pix);
}
continue;
}
const points = [j, i];
const isHole = j2 === j + 1;
const contour = {
isHole,
points,
id: nbd,
parent: 0,
};
contours.push(contour);
let contour0;
for (const c of contours) {
if (c.id === lnbd) {
contour0 = c;
break;
}
}
if (!contour0) {
contour.parent = isHole ? lnbd : 0;
} else if (contour0.isHole) {
contour.parent = isHole ? contour0.parent : lnbd;
} else {
contour.parent = isHole ? lnbd : contour0.parent;
}
const k = this.#clockwiseNonZero(types, width, i, j, i2, j2, 0);
if (k === -1) {
types[ij] = -nbd;
if (types[ij] !== 1) {
lnbd = Math.abs(types[ij]);
}
continue;
}
let shiftI = this.#neighborIdToIndex[2 * k];
let shiftJ = this.#neighborIdToIndex[2 * k + 1];
const i1 = i + shiftI;
const j1 = j + shiftJ;
i2 = i1;
j2 = j1;
let i3 = i;
let j3 = j;
while (true) {
const kk = this.#counterClockwiseNonZero(
types,
width,
i3,
j3,
i2,
j2,
1
);
shiftI = this.#neighborIdToIndex[2 * kk];
shiftJ = this.#neighborIdToIndex[2 * kk + 1];
const i4 = i3 + shiftI;
const j4 = j3 + shiftJ;
points.push(j4, i4);
const ij3 = i3 * width + j3;
if (types[ij3 + 1] === 0) {
types[ij3] = -nbd;
} else if (types[ij3] === 1) {
types[ij3] = nbd;
}
if (i4 === i && j4 === j && i3 === i1 && j3 === j1) {
if (types[ij] !== 1) {
lnbd = Math.abs(types[ij]);
}
break;
} else {
i2 = i3;
j2 = j3;
i3 = i4;
j3 = j4;
}
}
}
}
return contours;
}
static #douglasPeuckerHelper(points, start, end, output) {
// Based on the Douglas-Peucker algorithm:
// https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm
if (end - start <= 4) {
for (let i = start; i < end - 2; i += 2) {
output.push(points[i], points[i + 1]);
}
return;
}
const ax = points[start];
const ay = points[start + 1];
const abx = points[end - 4] - ax;
const aby = points[end - 3] - ay;
const dist = Math.hypot(abx, aby);
const nabx = abx / dist;
const naby = aby / dist;
const aa = nabx * ay - naby * ax;
// Guessing the epsilon value.
// See "A novel framework for making dominant point detection methods
// non-parametric".
const m = aby / abx;
const invS = 1 / dist;
const phi = Math.atan(m);
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const tmax = invS * (Math.abs(cosPhi) + Math.abs(sinPhi));
const poly = invS * (1 - tmax + tmax ** 2);
const partialPhi = Math.max(
Math.atan(Math.abs(sinPhi + cosPhi) * poly),
Math.atan(Math.abs(sinPhi - cosPhi) * poly)
);
let dmax = 0;
let index = start;
for (let i = start + 2; i < end - 2; i += 2) {
const d = Math.abs(aa - nabx * points[i + 1] + naby * points[i]);
if (d > dmax) {
index = i;
dmax = d;
}
}
if (dmax > (dist * partialPhi) ** 2) {
this.#douglasPeuckerHelper(points, start, index + 2, output);
this.#douglasPeuckerHelper(points, index, end, output);
} else {
output.push(ax, ay);
}
}
static #douglasPeucker(points) {
const output = [];
const len = points.length;
this.#douglasPeuckerHelper(points, 0, len, output);
output.push(points[len - 2], points[len - 1]);
return output.length <= 4 ? null : output;
}
static #bilateralFilter(buf, width, height, sigmaS, sigmaR, kernelSize) {
// The bilateral filter is a nonlinear filter that does spatial averaging.
// Its main interest is to preserve edges while removing noise.
// See https://en.wikipedia.org/wiki/Bilateral_filter for more details.
// sigmaS is the standard deviation of the spatial gaussian.
// sigmaR is the standard deviation of the range (in term of pixel
// intensity) gaussian.
// Create a gaussian kernel
const kernel = new Float32Array(kernelSize ** 2);
const sigmaS2 = -2 * sigmaS ** 2;
const halfSize = kernelSize >> 1;
for (let i = 0; i < kernelSize; i++) {
const x = (i - halfSize) ** 2;
for (let j = 0; j < kernelSize; j++) {
kernel[i * kernelSize + j] = Math.exp(
(x + (j - halfSize) ** 2) / sigmaS2
);
}
}
// Create the range values to be used with the distance between pixels.
// It's a way faster with a lookup table than computing the exponential.
const rangeValues = new Float32Array(256);
const sigmaR2 = -2 * sigmaR ** 2;
for (let i = 0; i < 256; i++) {
rangeValues[i] = Math.exp(i ** 2 / sigmaR2);
}
const N = buf.length;
const out = new Uint8Array(N);
// We compute the histogram here instead of doing it later: it's slightly
// faster.
const histogram = new Uint32Array(256);
for (let i = 0; i < height; i++) {
for (let j = 0; j < width; j++) {
const ij = i * width + j;
const center = buf[ij];
let sum = 0;
let norm = 0;
for (let k = 0; k < kernelSize; k++) {
const y = i + k - halfSize;
if (y < 0 || y >= height) {
continue;
}
for (let l = 0; l < kernelSize; l++) {
const x = j + l - halfSize;
if (x < 0 || x >= width) {
continue;
}
const neighbour = buf[y * width + x];
const w =
kernel[k * kernelSize + l] *
rangeValues[Math.abs(neighbour - center)];
sum += neighbour * w;
norm += w;
}
}
const pix = (out[ij] = Math.round(sum / norm));
histogram[pix]++;
}
}
return [out, histogram];
}
static #toUint8(buf) {
// We have a RGBA buffer, containing a grayscale image.
// We want to convert it into a basic G buffer.
// Also, we want to normalize the values between 0 and 255 in order to
// increase the contrast.
const N = buf.length;
const out = new Uint8ClampedArray(N >> 2);
let max = -Infinity;
let min = Infinity;
for (let i = 0, ii = out.length; i < ii; i++) {
const A = buf[(i << 2) + 3];
if (A === 0) {
max = out[i] = 0xff;
continue;
}
const pix = (out[i] = buf[i << 2]);
if (pix > max) {
max = pix;
}
if (pix < min) {
min = pix;
}
}
const ratio = 255 / (max - min);
for (let i = 0; i < N; i++) {
out[i] = (out[i] - min) * ratio;
}
return out;
}
static #guessThreshold(histogram) {
// We want to find the threshold that will separate the background from the
// foreground.
// We could have used Otsu's method, but unfortunately it doesn't work well
// when the background has too much shade of greys.
// So the idea is to find a maximum in the black part of the histogram and
// figure out the value which will be the first one of the white part.
let i;
let M = -Infinity;
let L = -Infinity;
const min = histogram.findIndex(v => v !== 0);
let pos = min;
let spos = min;
for (i = min; i < 256; i++) {
const v = histogram[i];
if (v > M) {
if (i - pos > L) {
L = i - pos;
spos = i - 1;
}
M = v;
pos = i;
}
}
for (i = spos - 1; i >= 0; i--) {
if (histogram[i] > histogram[i + 1]) {
break;
}
}
return i;
}
static #getGrayPixels(bitmap) {
const originalBitmap = bitmap;
const { width, height } = bitmap;
const { maxDim } = this.#PARAMETERS;
let newWidth = width;
let newHeight = height;
if (width > maxDim || height > maxDim) {
let prevWidth = width;
let prevHeight = height;
let steps = Math.log2(Math.max(width, height) / maxDim);
const isteps = Math.floor(steps);
steps = steps === isteps ? isteps - 1 : isteps;
for (let i = 0; i < steps; i++) {
newWidth = prevWidth;
newHeight = prevHeight;
if (newWidth > maxDim) {
newWidth = Math.ceil(newWidth / 2);
}
if (newHeight > maxDim) {
newHeight = Math.ceil(newHeight / 2);
}
const offscreen = new OffscreenCanvas(newWidth, newHeight);
const ctx = offscreen.getContext("2d");
ctx.drawImage(
bitmap,
0,
0,
prevWidth,
prevHeight,
0,
0,
newWidth,
newHeight
);
prevWidth = newWidth;
prevHeight = newHeight;
// Release the resources associated with the bitmap.
if (bitmap !== originalBitmap) {
bitmap.close();
}
bitmap = offscreen.transferToImageBitmap();
}
const ratio = Math.min(maxDim / newWidth, maxDim / newHeight);
newWidth = Math.round(newWidth * ratio);
newHeight = Math.round(newHeight * ratio);
}
const offscreen = new OffscreenCanvas(newWidth, newHeight);
const ctx = offscreen.getContext("2d", { willReadFrequently: true });
ctx.filter = "grayscale(1)";
ctx.drawImage(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
0,
0,
newWidth,
newHeight
);
const grayImage = ctx.getImageData(0, 0, newWidth, newHeight).data;
const uint8Buf = this.#toUint8(grayImage);
return [uint8Buf, newWidth, newHeight];
}
static process(bitmap, pageWidth, pageHeight, rotation, innerMargin) {
const [uint8Buf, width, height] = this.#getGrayPixels(bitmap);
const [uint8Filtered, histogram] = this.#bilateralFilter(
uint8Buf,
width,
height,
Math.hypot(width, height) * this.#PARAMETERS.sigmaSFactor,
this.#PARAMETERS.sigmaR,
this.#PARAMETERS.kernelSize
);
const threshold = this.#guessThreshold(histogram);
const contourList = this.#findContours(
uint8Filtered,
width,
height,
threshold
);
const linesAndPoints = [];
if (rotation % 180 !== 0) {
[pageWidth, pageHeight] = [pageHeight, pageWidth];
}
// The points need to be converted into page coordinates.
const ratio = 0.5 * Math.min(pageWidth / width, pageHeight / height);
const xScale = ratio / pageWidth;
const yScale = ratio / pageHeight;
for (const { points } of contourList) {
const reducedPoints = this.#douglasPeucker(points);
if (!reducedPoints) {
continue;
}
const len = reducedPoints.length;
const newPoints = new Float32Array(len);
const line = new Float32Array(3 * (len - 2));
let [x1, y1, x2, y2] = reducedPoints;
x1 *= xScale;
y1 *= yScale;
x2 *= xScale;
y2 *= yScale;
newPoints.set([x1, y1, x2, y2], 0);
line.set([NaN, NaN, NaN, NaN, x1, y1], 0);
for (let i = 4; i < len; i += 2) {
const x = (newPoints[i] = reducedPoints[i] * xScale);
const y = (newPoints[i + 1] = reducedPoints[i + 1] * yScale);
line.set(Outline.createBezierPoints(x1, y1, x2, y2, x, y), (i - 2) * 3);
[x1, y1, x2, y2] = [x2, y2, x, y];
}
linesAndPoints.push({ line, points: newPoints });
}
const outline = new ContourDrawOutline();
outline.build(
linesAndPoints,
pageWidth,
pageHeight,
1,
rotation,
0,
innerMargin
);
return outline;
}
}
export { SignatureExtractor };

View file

@ -0,0 +1,159 @@
/* Copyright 2025 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, shadow } from "../../shared/util.js";
import { DrawingEditor, DrawingOptions } from "./draw.js";
import { AnnotationEditor } from "./editor.js";
import { SignatureExtractor } from "./drawers/signaturedraw.js";
import { StampEditor } from "./stamp.js";
class SignatureOptions extends DrawingOptions {
#viewParameters;
constructor(viewerParameters) {
super();
this.#viewParameters = viewerParameters;
super.updateProperties({
fill: "black",
"stroke-width": 0,
});
}
clone() {
const clone = new SignatureOptions(this.#viewParameters);
clone.updateAll(this);
return clone;
}
}
/**
* Basic editor in order to generate an Stamp annotation annotation containing
* a signature drawing.
*/
class SignatureEditor extends DrawingEditor {
static _type = "signature";
static _editorType = AnnotationEditorType.SIGNATURE;
static _defaultDrawingOptions = null;
constructor(params) {
super({ ...params, mustBeCommitted: true, name: "signatureEditor" });
this._willKeepAspectRatio = false;
}
/** @inheritdoc */
static initialize(l10n, uiManager) {
AnnotationEditor.initialize(l10n, uiManager);
this._defaultDrawingOptions = new SignatureOptions(
uiManager.viewParameters
);
}
/** @inheritdoc */
static getDefaultDrawingOptions(options) {
const clone = this._defaultDrawingOptions.clone();
clone.updateProperties(options);
return clone;
}
/** @inheritdoc */
static get supportMultipleDrawings() {
return false;
}
static get typesMap() {
return shadow(this, "typesMap", new Map());
}
static get isDrawer() {
return false;
}
/** @inheritdoc */
get isResizable() {
return true;
}
/** @inheritdoc */
render() {
if (this.div) {
return this.div;
}
super.render();
this.div.hidden = true;
this.div.setAttribute("role", "figure");
this.#extractSignature();
return this.div;
}
async #extractSignature() {
const input = document.createElement("input");
input.type = "file";
input.accept = StampEditor.supportedTypesStr;
const signal = this._uiManager._signal;
const { promise, resolve } = Promise.withResolvers();
input.addEventListener(
"change",
async () => {
if (!input.files || input.files.length === 0) {
resolve();
} else {
this._uiManager.enableWaiting(true);
const data = await this._uiManager.imageManager.getFromFile(
input.files[0]
);
this._uiManager.enableWaiting(false);
resolve(data);
}
resolve();
},
{ signal }
);
input.addEventListener("cancel", resolve, { signal });
input.click();
const bitmap = await promise;
if (!bitmap?.bitmap) {
this.remove();
return;
}
const {
rawDims: { pageWidth, pageHeight },
rotation,
} = this.parent.viewport;
const drawOutlines = SignatureExtractor.process(
bitmap.bitmap,
pageWidth,
pageHeight,
rotation,
SignatureEditor._INNER_MARGIN
);
this._addOutlines({
drawOutlines,
drawingOptions: SignatureEditor.getDefaultDrawingOptions(),
});
this.onScaleChanging();
this.rotate();
this.div.hidden = false;
}
}
export { SignatureEditor };

View file

@ -36,6 +36,7 @@ class EditorToolbar {
highlight: "pdfjs-editor-remove-highlight-button",
ink: "pdfjs-editor-remove-ink-button",
stamp: "pdfjs-editor-remove-stamp-button",
signature: "pdfjs-editor-remove-signature-button",
});
}

View file

@ -78,6 +78,7 @@ const AnnotationEditorType = {
HIGHLIGHT: 9,
STAMP: 13,
INK: 15,
SIGNATURE: 101,
};
const AnnotationEditorParamsType = {