diff --git a/l10n/en-US/viewer.properties b/l10n/en-US/viewer.properties index 22172bf31..08244a26a 100644 --- a/l10n/en-US/viewer.properties +++ b/l10n/en-US/viewer.properties @@ -265,6 +265,7 @@ editor_free_text_font_color=Font Color editor_free_text_font_size=Font Size editor_ink_line_color=Line Color editor_ink_line_thickness=Line Thickness +editor_ink_line_opacity=Line Opacity # Editor aria editor_free_text_aria_label=FreeText Editor diff --git a/src/core/annotation.js b/src/core/annotation.js index b7d203495..5f67d4f3e 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -3757,7 +3757,7 @@ class InkAnnotation extends MarkupAnnotation { } static async createNewAppearanceStream(annotation, xref, params) { - const { color, rect, rotation, paths, thickness } = annotation; + const { color, rect, rotation, paths, thickness, opacity } = annotation; const [x1, y1, x2, y2] = rect; let w = x2 - x1; let h = y2 - y1; @@ -3770,6 +3770,11 @@ class InkAnnotation extends MarkupAnnotation { `${thickness} w 1 J 1 j`, `${getPdfColor(color, /* isFill */ false)}`, ]; + + if (opacity !== 1) { + appearanceBuffer.push("/R0 gs"); + } + const buffer = []; for (const { bezier } of paths) { buffer.length = 0; @@ -3800,6 +3805,17 @@ class InkAnnotation extends MarkupAnnotation { appearanceStreamDict.set("Matrix", matrix); } + if (opacity !== 1) { + const resources = new Dict(xref); + const extGState = new Dict(xref); + const r0 = new Dict(xref); + r0.set("CA", opacity); + r0.set("Type", Name.get("ExtGState")); + extGState.set("R0", r0); + resources.set("ExtGState", extGState); + appearanceStreamDict.set("Resources", resources); + } + const ap = new StringStream(appearance); ap.dict = appearanceStreamDict; diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 3262e6419..19b8b4f5c 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -585,6 +585,14 @@ function getRGB(color) { .map(x => parseInt(x)); } + if (color.startsWith("rgba(")) { + return color + .slice(/* "rgba(".length */ 5, -1) // Strip out "rgba(" and ")". + .split(",") + .map(x => parseInt(x)) + .slice(0, 3); + } + warn(`Not a valid color format: "${color}"`); return [0, 0, 0]; } diff --git a/src/display/editor/ink.js b/src/display/editor/ink.js index d68604305..5b1871d49 100644 --- a/src/display/editor/ink.js +++ b/src/display/editor/ink.js @@ -20,6 +20,7 @@ import { } from "../../shared/util.js"; import { AnnotationEditor } from "./editor.js"; import { fitCurve } from "pdfjs-fitCurve"; +import { opacityToHex } from "./tools.js"; // The dimensions of the resizer is 15x15: // https://searchfox.org/mozilla-central/rev/1ce190047b9556c3c10ab4de70a0e61d893e2954/toolkit/content/minimal-xul.css#136-137 @@ -48,14 +49,20 @@ class InkEditor extends AnnotationEditor { #isCanvasInitialized = false; + #lastPoint = null; + #observer = null; #realWidth = 0; #realHeight = 0; + #requestFrameCallback = null; + static _defaultColor = null; + static _defaultOpacity = 1; + static _defaultThickness = 1; static _l10nPromise; @@ -64,6 +71,7 @@ class InkEditor extends AnnotationEditor { super({ ...params, name: "inkEditor" }); this.color = params.color || null; this.thickness = params.thickness || null; + this.opacity = params.opacity || null; this.paths = []; this.bezierPath2D = []; this.currentPath = []; @@ -90,6 +98,9 @@ class InkEditor extends AnnotationEditor { case AnnotationEditorParamsType.INK_COLOR: InkEditor._defaultColor = value; break; + case AnnotationEditorParamsType.INK_OPACITY: + InkEditor._defaultOpacity = value / 100; + break; } } @@ -102,6 +113,9 @@ class InkEditor extends AnnotationEditor { case AnnotationEditorParamsType.INK_COLOR: this.#updateColor(value); break; + case AnnotationEditorParamsType.INK_OPACITY: + this.#updateOpacity(value); + break; } } @@ -112,6 +126,10 @@ class InkEditor extends AnnotationEditor { AnnotationEditorParamsType.INK_COLOR, InkEditor._defaultColor || AnnotationEditor._defaultLineColor, ], + [ + AnnotationEditorParamsType.INK_OPACITY, + Math.round(InkEditor._defaultOpacity * 100), + ], ]; } @@ -128,6 +146,10 @@ class InkEditor extends AnnotationEditor { InkEditor._defaultColor || AnnotationEditor._defaultLineColor, ], + [ + AnnotationEditorParamsType.INK_OPACITY, + Math.round(100 * (this.opacity ?? InkEditor._defaultOpacity)), + ], ]; } @@ -175,6 +197,29 @@ class InkEditor extends AnnotationEditor { }); } + /** + * Update the opacity and make this action undoable. + * @param {number} opacity + */ + #updateOpacity(opacity) { + opacity /= 100; + const savedOpacity = this.opacity; + this.parent.addCommands({ + cmd: () => { + this.opacity = opacity; + this.#redraw(); + }, + undo: () => { + this.opacity = savedOpacity; + this.#redraw(); + }, + mustExec: true, + type: AnnotationEditorParamsType.INK_OPACITY, + overwriteIfSameType: true, + keepUndo: true, + }); + } + /** @inheritdoc */ rebuild() { super.rebuild(); @@ -282,7 +327,7 @@ class InkEditor extends AnnotationEditor { this.ctx.lineCap = "round"; this.ctx.lineJoin = "round"; this.ctx.miterLimit = 10; - this.ctx.strokeStyle = this.color; + this.ctx.strokeStyle = `${this.color}${opacityToHex(this.opacity)}`; } /** @@ -298,11 +343,35 @@ class InkEditor extends AnnotationEditor { this.thickness ||= InkEditor._defaultThickness; this.color ||= InkEditor._defaultColor || AnnotationEditor._defaultLineColor; + this.opacity ??= InkEditor._defaultOpacity; } this.currentPath.push([x, y]); + this.#lastPoint = null; this.#setStroke(); this.ctx.beginPath(); this.ctx.moveTo(x, y); + + this.#requestFrameCallback = () => { + if (!this.#requestFrameCallback) { + return; + } + + if (this.#lastPoint) { + if (this.isEmpty()) { + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + } else { + this.#redraw(); + } + + this.ctx.lineTo(...this.#lastPoint); + this.#lastPoint = null; + this.ctx.stroke(); + } + + window.requestAnimationFrame(this.#requestFrameCallback); + }; + window.requestAnimationFrame(this.#requestFrameCallback); } /** @@ -311,9 +380,12 @@ class InkEditor extends AnnotationEditor { * @param {number} y */ #draw(x, y) { + const [lastX, lastY] = this.currentPath.at(-1); + if (x === lastX && y === lastY) { + return; + } this.currentPath.push([x, y]); - this.ctx.lineTo(x, y); - this.ctx.stroke(); + this.#lastPoint = [x, y]; } /** @@ -322,20 +394,22 @@ class InkEditor extends AnnotationEditor { * @param {number} y */ #stopDrawing(x, y) { + this.ctx.closePath(); + this.#requestFrameCallback = null; + x = Math.min(Math.max(x, 0), this.canvas.width); y = Math.min(Math.max(y, 0), this.canvas.height); - this.currentPath.push([x, y]); + const [lastX, lastY] = this.currentPath.at(-1); + if (x !== lastX || y !== lastY) { + 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 - ) { + if (this.currentPath.length !== 1) { bezier = fitCurve(this.currentPath, 30, null); } else { // We have only one point finally. @@ -372,17 +446,15 @@ class InkEditor extends AnnotationEditor { * Redraw all the paths. */ #redraw() { - this.#setStroke(); - if (this.isEmpty()) { this.#updateTransform(); return; } + this.#setStroke(); - const [parentWidth, parentHeight] = this.parent.viewportBaseDimensions; - const { ctx, height, width } = this; + const { canvas, ctx } = this; ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, width * parentWidth, height * parentHeight); + ctx.clearRect(0, 0, canvas.width, canvas.height); this.#updateTransform(); for (const path of this.bezierPath2D) { ctx.stroke(path); @@ -919,6 +991,7 @@ class InkEditor extends AnnotationEditor { editor.thickness = data.thickness; editor.color = Util.makeHexColor(...data.color); + editor.opacity = data.opacity; const [pageWidth, pageHeight] = parent.pageDimensions; const width = editor.width * pageWidth; @@ -980,6 +1053,7 @@ class InkEditor extends AnnotationEditor { annotationType: AnnotationEditorType.INK, color, thickness: this.thickness, + opacity: this.opacity, paths: this.#serializePaths( this.scaleFactor / this.parent.scaleFactor, this.translationX, diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index f67579587..9fb053dba 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -30,6 +30,18 @@ function bindEvents(obj, element, names) { element.addEventListener(name, obj[name].bind(obj)); } } + +/** + * Convert a number between 0 and 100 into an hex number between 0 and 255. + * @param {number} opacity + * @return {string} + */ +function opacityToHex(opacity) { + return Math.round(Math.min(255, Math.max(1, 255 * opacity))) + .toString(16) + .padStart(2, "0"); +} + /** * Class to create some unique ids for the different editors. */ @@ -1025,4 +1037,5 @@ export { ColorManager, CommandManager, KeyboardManager, + opacityToHex, }; diff --git a/src/shared/util.js b/src/shared/util.js index 2586bdc45..53cbe66c8 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -62,10 +62,12 @@ const AnnotationEditorType = { }; const AnnotationEditorParamsType = { - FREETEXT_SIZE: 0, - FREETEXT_COLOR: 1, - INK_COLOR: 2, - INK_THICKNESS: 3, + FREETEXT_SIZE: 1, + FREETEXT_COLOR: 2, + FREETEXT_OPACITY: 3, + INK_COLOR: 11, + INK_THICKNESS: 12, + INK_OPACITY: 13, }; // Permission flags from Table 22, Section 7.6.3.2 of the PDF specification. diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 34d3cf154..98b647b1e 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -4185,6 +4185,7 @@ describe("annotation", function () { rect: [12, 34, 56, 78], rotation: 0, thickness: 1, + opacity: 1, color: [0, 0, 0], paths: [ { @@ -4234,6 +4235,70 @@ describe("annotation", function () { ); }); + it("should create a new Ink annotation with some transparency", async function () { + partialEvaluator.xref = new XRefMock(); + const task = new WorkerTask("test Ink creation"); + const data = await AnnotationFactory.saveNewAnnotations( + partialEvaluator, + task, + [ + { + annotationType: AnnotationEditorType.INK, + rect: [12, 34, 56, 78], + rotation: 0, + thickness: 1, + opacity: 0.12, + color: [0, 0, 0], + paths: [ + { + bezier: [ + 10, 11, 12, 13, 14, 15, 16, 17, 22, 23, 24, 25, 26, 27, + ], + points: [1, 2, 3, 4, 5, 6, 7, 8], + }, + { + bezier: [ + 910, 911, 912, 913, 914, 915, 916, 917, 922, 923, 924, 925, + 926, 927, + ], + points: [91, 92, 93, 94, 95, 96, 97, 98], + }, + ], + }, + ] + ); + + const base = data.annotations[0].data.replace(/\(D:\d+\)/, "(date)"); + expect(base).toEqual( + "1 0 obj\n" + + "<< /Type /Annot /Subtype /Ink /CreationDate (date) /Rect [12 34 56 78] " + + "/InkList [[1 2 3 4 5 6 7 8] [91 92 93 94 95 96 97 98]] /F 4 /Border [0 0 0] " + + "/Rotate 0 /AP << /N 2 0 R>>>>\n" + + "endobj\n" + ); + + const appearance = data.dependencies[0].data; + expect(appearance).toEqual( + "2 0 obj\n" + + "<< /FormType 1 /Subtype /Form /Type /XObject /BBox [0 0 44 44] /Length 136 /Resources " + + "<< /ExtGState << /R0 << /CA 0.12 /Type /ExtGState>>>>>>>> stream\n" + + "1 w 1 J 1 j\n" + + "0 G\n" + + "/R0 gs\n" + + "10 11 m\n" + + "12 13 14 15 16 17 c\n" + + "22 23 24 25 26 27 c\n" + + "S\n" + + "910 911 m\n" + + "912 913 914 915 916 917 c\n" + + "922 923 924 925 926 927 c\n" + + "S\n" + + "endstream\n" + + "\n" + + "endobj\n" + ); + }); + it("should render an added Ink annotation for printing", async function () { partialEvaluator.xref = new XRefMock(); const task = new WorkerTask("test Ink printing"); @@ -4244,6 +4309,7 @@ describe("annotation", function () { rect: [12, 34, 56, 78], rotation: 0, thickness: 3, + opacity: 1, color: [0, 255, 0], paths: [ { diff --git a/web/annotation_editor_params.js b/web/annotation_editor_params.js index bcd6af9a8..c6a085ec3 100644 --- a/web/annotation_editor_params.js +++ b/web/annotation_editor_params.js @@ -30,6 +30,7 @@ class AnnotationEditorParams { editorFreeTextColor, editorInkColor, editorInkThickness, + editorInkOpacity, }) { editorFreeTextFontSize.addEventListener("input", evt => { this.eventBus.dispatch("switchannotationeditorparams", { @@ -59,6 +60,13 @@ class AnnotationEditorParams { value: editorInkThickness.valueAsNumber, }); }); + editorInkOpacity.addEventListener("input", evt => { + this.eventBus.dispatch("switchannotationeditorparams", { + source: this, + type: AnnotationEditorParamsType.INK_OPACITY, + value: editorInkOpacity.valueAsNumber, + }); + }); this.eventBus._on("annotationeditorparamschanged", evt => { for (const [type, value] of evt.details) { @@ -75,6 +83,9 @@ class AnnotationEditorParams { case AnnotationEditorParamsType.INK_THICKNESS: editorInkThickness.value = value; break; + case AnnotationEditorParamsType.INK_OPACITY: + editorInkOpacity.value = value; + break; } } }); diff --git a/web/viewer.html b/web/viewer.html index dfbd10702..39da8a449 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -171,6 +171,10 @@ See https://github.com/adobe-type-tools/cmap-resources +