diff --git a/src/core/annotation.js b/src/core/annotation.js index bcad74973..f4c91ff9f 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -16,6 +16,7 @@ import { AnnotationActionEventType, AnnotationBorderStyleType, + AnnotationEditorType, AnnotationFieldFlag, AnnotationFlag, AnnotationReplyType, @@ -24,12 +25,14 @@ import { escapeString, getModificationDate, isAscii, + LINE_DESCENT_FACTOR, LINE_FACTOR, OPS, RenderingIntentFlag, shadow, stringToPDFString, stringToUTF16BEString, + stringToUTF8String, unreachable, Util, warn, @@ -45,6 +48,7 @@ import { parseDefaultAppearance, } from "./default_appearance.js"; import { Dict, isName, Name, Ref, RefSet } from "./primitives.js"; +import { writeDict, writeObject } from "./writer.js"; import { BaseStream } from "./base_stream.js"; import { bidi } from "./bidi.js"; import { Catalog } from "./catalog.js"; @@ -53,7 +57,6 @@ import { FileSpec } from "./file_spec.js"; import { ObjectLoader } from "./object_loader.js"; import { OperatorList } from "./operator_list.js"; import { StringStream } from "./stream.js"; -import { writeDict } from "./writer.js"; import { XFAFactory } from "./xfa/factory.js"; class AnnotationFactory { @@ -237,6 +240,49 @@ class AnnotationFactory { return -1; } } + + static async saveNewAnnotations(evaluator, task, annotations) { + const xref = evaluator.xref; + let baseFontRef; + const results = []; + const dependencies = []; + const promises = []; + for (const annotation of annotations) { + switch (annotation.annotationType) { + case AnnotationEditorType.FREETEXT: + if (!baseFontRef) { + const baseFont = new Dict(xref); + baseFont.set("BaseFont", Name.get("Helvetica")); + baseFont.set("Type", Name.get("Font")); + baseFont.set("Subtype", Name.get("Type1")); + baseFont.set("Encoding", Name.get("WinAnsiEncoding")); + const buffer = []; + baseFontRef = xref.getNewRef(); + writeObject(baseFontRef, baseFont, buffer, null); + dependencies.push({ ref: baseFontRef, data: buffer.join("") }); + } + promises.push( + FreeTextAnnotation.createNewAnnotation( + xref, + evaluator, + task, + annotation, + baseFontRef, + results, + dependencies + ) + ); + break; + } + } + + await Promise.all(promises); + + return { + annotations: results, + dependencies, + }; + } } function getRgbColor(color, defaultColor = new Uint8ClampedArray(3)) { @@ -1617,7 +1663,12 @@ class WidgetAnnotation extends Annotation { ); } - const font = await this._getFontData(evaluator, task); + const font = await WidgetAnnotation._getFontData( + evaluator, + task, + this.data.defaultAppearanceData, + this._fieldResources.mergedResources + ); const [defaultAppearance, fontSize] = this._computeFontSize( totalHeight - defaultPadding, totalWidth - 2 * hPadding, @@ -1700,7 +1751,7 @@ class WidgetAnnotation extends Annotation { ); } - async _getFontData(evaluator, task) { + static async _getFontData(evaluator, task, appearanceData, resources) { const operatorList = new OperatorList(); const initialState = { font: null, @@ -1709,9 +1760,9 @@ class WidgetAnnotation extends Annotation { }, }; - const { fontName, fontSize } = this.data.defaultAppearanceData; + const { fontName, fontSize } = appearanceData; await evaluator.handleSetFont( - this._fieldResources.mergedResources, + resources, [fontName && Name.get(fontName), fontSize], /* fontRef = */ null, operatorList, @@ -2640,7 +2691,12 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation { ); } - const font = await this._getFontData(evaluator, task); + const font = await WidgetAnnotation._getFontData( + evaluator, + task, + this.data.defaultAppearanceData, + this._fieldResources.mergedResources + ); let defaultAppearance; let { fontSize } = this.data.defaultAppearanceData; @@ -2871,6 +2927,129 @@ class FreeTextAnnotation extends MarkupAnnotation { this.data.annotationType = AnnotationType.FREETEXT; } + + static async createNewAnnotation( + xref, + evaluator, + task, + annotation, + baseFontRef, + results, + dependencies + ) { + const { color, fontSize, rect, user, value } = annotation; + const freetextRef = xref.getNewRef(); + const freetext = new Dict(xref); + freetext.set("Type", Name.get("Annot")); + freetext.set("Subtype", Name.get("FreeText")); + freetext.set("CreationDate", `D:${getModificationDate()}`); + freetext.set("Rect", rect); + const da = `/Helv ${fontSize} Tf ${getPdfColor(color)}`; + freetext.set("DA", da); + freetext.set("Contents", value); + freetext.set("F", 4); + freetext.set("Border", [0, 0, 0]); + freetext.set("Rotate", 0); + + if (user) { + freetext.set("T", stringToUTF8String(user)); + } + + const resources = new Dict(xref); + const font = new Dict(xref); + font.set("Helv", baseFontRef); + resources.set("Font", font); + + const helv = await WidgetAnnotation._getFontData( + evaluator, + task, + { + fontName: "Helvetica", + fontSize, + }, + resources + ); + + const [x1, y1, x2, y2] = rect; + const w = x2 - x1; + const h = y2 - y1; + + const lines = value.split("\n"); + const scale = fontSize / 1000; + let totalWidth = -Infinity; + const encodedLines = []; + for (let line of lines) { + line = helv.encodeString(line).join(""); + encodedLines.push(line); + let lineWidth = 0; + const glyphs = helv.charsToGlyphs(line); + for (const glyph of glyphs) { + lineWidth += glyph.width * scale; + } + totalWidth = Math.max(totalWidth, lineWidth); + } + + let hscale = 1; + if (totalWidth > w) { + hscale = w / totalWidth; + } + let vscale = 1; + const lineHeight = LINE_FACTOR * fontSize; + const lineDescent = LINE_DESCENT_FACTOR * fontSize; + const totalHeight = lineHeight * lines.length; + if (totalHeight > h) { + vscale = h / totalHeight; + } + const fscale = Math.min(hscale, vscale); + const newFontSize = fontSize * fscale; + const buffer = [ + "q", + `0 0 ${numberToString(w)} ${numberToString(h)} re W n`, + `BT`, + `1 0 0 1 0 ${numberToString(h + lineDescent)} Tm 0 Tc ${getPdfColor( + color + )}`, + `/Helv ${numberToString(newFontSize)} Tf`, + ]; + + const vShift = numberToString(lineHeight); + for (const line of encodedLines) { + buffer.push(`0 -${vShift} Td (${escapeString(line)}) Tj`); + } + buffer.push("ET", "Q"); + const appearance = buffer.join("\n"); + + const appearanceStreamDict = new Dict(xref); + appearanceStreamDict.set("FormType", 1); + appearanceStreamDict.set("Subtype", Name.get("Form")); + appearanceStreamDict.set("Type", Name.get("XObject")); + appearanceStreamDict.set("BBox", [0, 0, w, h]); + appearanceStreamDict.set("Length", appearance.length); + appearanceStreamDict.set("Resources", resources); + + const ap = new StringStream(appearance); + ap.dict = appearanceStreamDict; + + buffer.length = 0; + const apRef = xref.getNewRef(); + let transform = xref.encrypt + ? xref.encrypt.createCipherTransform(apRef.num, apRef.gen) + : null; + writeObject(apRef, ap, buffer, transform); + dependencies.push({ ref: apRef, data: buffer.join("") }); + + const n = new Dict(xref); + n.set("N", apRef); + freetext.set("AP", n); + + buffer.length = 0; + transform = xref.encrypt + ? xref.encrypt.createCipherTransform(freetextRef.num, freetextRef.gen) + : null; + writeObject(freetextRef, freetext, buffer, transform); + + results.push({ ref: freetextRef, data: buffer.join("") }); + } } class LineAnnotation extends MarkupAnnotation { diff --git a/src/core/document.js b/src/core/document.js index f3baa93e9..44f64782e 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -55,6 +55,7 @@ import { OperatorList } from "./operator_list.js"; import { PartialEvaluator } from "./evaluator.js"; import { StreamsSequenceStream } from "./decode_stream.js"; import { StructTreePage } from "./struct_tree.js"; +import { writeObject } from "./writer.js"; import { XFAFactory } from "./xfa/factory.js"; import { XRef } from "./xref.js"; @@ -261,6 +262,60 @@ class Page { ); } + async saveNewAnnotations(handler, task, annotations) { + if (this.xfaFactory) { + throw new Error("XFA: Cannot save new annotations."); + } + + const partialEvaluator = new PartialEvaluator({ + xref: this.xref, + handler, + pageIndex: this.pageIndex, + idFactory: this._localIdFactory, + fontCache: this.fontCache, + builtInCMapCache: this.builtInCMapCache, + standardFontDataCache: this.standardFontDataCache, + globalImageCache: this.globalImageCache, + options: this.evaluatorOptions, + }); + + const pageDict = this.pageDict; + const annotationsArray = this.annotations.slice(); + const newData = await AnnotationFactory.saveNewAnnotations( + partialEvaluator, + task, + annotations + ); + + for (const { ref } of newData.annotations) { + annotationsArray.push(ref); + } + + const savedDict = pageDict.get("Annots"); + pageDict.set("Annots", annotationsArray); + const buffer = []; + + let transform = null; + if (this.xref.encrypt) { + transform = this.xref.encrypt.createCipherTransform( + this.ref.num, + this.ref.gen + ); + } + + writeObject(this.ref, pageDict, buffer, transform); + if (savedDict) { + pageDict.set("Annots", savedDict); + } + + const objects = newData.dependencies; + objects.push( + { ref: this.ref, data: buffer.join("") }, + ...newData.annotations + ); + return objects; + } + save(handler, task, annotationStorage) { const partialEvaluator = new PartialEvaluator({ xref: this.xref, diff --git a/src/core/worker.js b/src/core/worker.js index 21dbb190a..1c1ccd916 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -15,6 +15,7 @@ import { AbortException, + AnnotationEditorPrefix, arrayByteLength, arraysToBytes, createPromiseCapability, @@ -557,6 +558,23 @@ class WorkerMessageHandler { function ({ isPureXfa, numPages, annotationStorage, filename }) { pdfManager.requestLoadedStream(); + const newAnnotationsByPage = new Map(); + if (!isPureXfa) { + // The concept of page in a XFA is very different, so + // editing is just not implemented. + for (const [key, value] of annotationStorage) { + if (!key.startsWith(AnnotationEditorPrefix)) { + continue; + } + let annotations = newAnnotationsByPage.get(value.pageIndex); + if (!annotations) { + annotations = []; + newAnnotationsByPage.set(value.pageIndex, annotations); + } + annotations.push(value); + } + } + const promises = [ pdfManager.onLoadedStream(), pdfManager.ensureCatalog("acroForm"), @@ -565,6 +583,19 @@ class WorkerMessageHandler { pdfManager.ensureDoc("startXRef"), ]; + for (const [pageIndex, annotations] of newAnnotationsByPage) { + promises.push( + pdfManager.getPage(pageIndex).then(page => { + const task = new WorkerTask(`Save (editor): page ${pageIndex}`); + return page + .saveNewAnnotations(handler, task, annotations) + .finally(function () { + finishWorkerTask(task); + }); + }) + ); + } + if (isPureXfa) { promises.push(pdfManager.serializeXfaData(annotationStorage)); } else { diff --git a/src/core/writer.js b/src/core/writer.js index a30c7791d..4160ab721 100644 --- a/src/core/writer.js +++ b/src/core/writer.js @@ -20,6 +20,16 @@ import { SimpleDOMNode, SimpleXMLParser } from "./xml_parser.js"; import { BaseStream } from "./base_stream.js"; import { calculateMD5 } from "./crypto.js"; +function writeObject(ref, obj, buffer, transform) { + buffer.push(`${ref.num} ${ref.gen} obj\n`); + if (obj instanceof Dict) { + writeDict(obj, buffer, transform); + } else if (obj instanceof BaseStream) { + writeStream(obj, buffer, transform); + } + buffer.push("\nendobj\n"); +} + function writeDict(dict, buffer, transform) { buffer.push("<<"); for (const key of dict.getKeys()) { @@ -328,4 +338,4 @@ function incrementalUpdate({ return array; } -export { incrementalUpdate, writeDict }; +export { incrementalUpdate, writeDict, writeObject }; diff --git a/src/core/xref.js b/src/core/xref.js index acd3ec3a4..8aa4038d1 100644 --- a/src/core/xref.js +++ b/src/core/xref.js @@ -47,7 +47,7 @@ class XRef { getNewRef() { if (this._newRefNum === null) { - this._newRefNum = this.entries.length; + this._newRefNum = this.entries.length || 1; } return Ref.get(this._newRefNum++, 0); } diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index c89b37aee..78f79f72d 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -46,7 +46,7 @@ class AnnotationEditorLayer { #uiManager; - static _l10nInitialized = false; + static _initialized = false; static _keyboardManager = new KeyboardManager([ [["ctrl+a", "mac+meta+a"], AnnotationEditorLayer.prototype.selectAll], @@ -73,9 +73,9 @@ class AnnotationEditorLayer { * @param {AnnotationEditorLayerOptions} options */ constructor(options) { - if (!AnnotationEditorLayer._l10nInitialized) { - AnnotationEditorLayer._l10nInitialized = true; - FreeTextEditor.setL10n(options.l10n); + if (!AnnotationEditorLayer._initialized) { + AnnotationEditorLayer._initialized = true; + FreeTextEditor.initialize(options.l10n); } this.#uiManager = options.uiManager; this.annotationStorage = options.annotationStorage; diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index 0a83fa3b8..c086ea972 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -140,6 +140,14 @@ class AnnotationEditor { this.div.style.height = `${height}px`; } + /** + * Get the translation used to position this editor when it's created. + * @returns {Array} + */ + getInitialTranslation() { + return [0, 0]; + } + /** * Render this editor in a div. * @returns {HTMLDivElement} @@ -150,6 +158,11 @@ class AnnotationEditor { this.div.setAttribute("id", this.id); this.div.draggable = true; this.div.tabIndex = 100; + + const [tx, ty] = this.getInitialTranslation(); + this.x = Math.round(this.x + tx); + this.y = Math.round(this.y + ty); + this.div.style.left = `${this.x}px`; this.div.style.top = `${this.y}px`; diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 563327c53..bed3db1e3 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -13,9 +13,15 @@ * limitations under the License. */ -import { AnnotationEditorType, Util } from "../../shared/util.js"; +import { + AnnotationEditorType, + assert, + LINE_FACTOR, + Util, +} from "../../shared/util.js"; import { AnnotationEditor } from "./editor.js"; import { bindEvents } from "./tools.js"; +import { PixelsPerInch } from "../display_utils.js"; /** * Basic text editor in order to create a FreeTex annotation. @@ -33,14 +39,36 @@ class FreeTextEditor extends AnnotationEditor { static _l10nPromise; + static _internalPadding = 0; + constructor(params) { super({ ...params, name: "freeTextEditor" }); this.#color = params.color || "CanvasText"; this.#fontSize = params.fontSize || 10; } - static setL10n(l10n) { + static initialize(l10n) { this._l10nPromise = l10n.get("freetext_default_content"); + const style = getComputedStyle(document.documentElement); + + if ( + typeof PDFJSDev === "undefined" || + PDFJSDev.test("!PRODUCTION || TESTING") + ) { + const lineHeight = parseFloat( + style.getPropertyValue("--freetext-line-height"), + 10 + ); + assert( + lineHeight === LINE_FACTOR, + "Update the CSS variable to agree with the constant." + ); + } + + this._internalPadding = parseFloat( + style.getPropertyValue("--freetext-padding"), + 10 + ); } /** @inheritdoc */ @@ -62,6 +90,16 @@ class FreeTextEditor extends AnnotationEditor { return editor; } + /** @inheritdoc */ + getInitialTranslation() { + // The start of the base line is where the user clicked. + return [ + -FreeTextEditor._internalPadding * this.parent.zoomFactor, + -(FreeTextEditor._internalPadding + this.#fontSize) * + this.parent.zoomFactor, + ]; + } + /** @inheritdoc */ rebuild() { if (this.div === null) { @@ -174,7 +212,6 @@ class FreeTextEditor extends AnnotationEditor { const { style } = this.editorDiv; style.fontSize = `calc(${this.#fontSize}px * var(--zoom-factor))`; - style.minHeight = `calc(${1.5 * this.#fontSize}px * var(--zoom-factor))`; style.color = this.#color; this.div.appendChild(this.editorDiv); @@ -200,21 +237,21 @@ class FreeTextEditor extends AnnotationEditor { /** @inheritdoc */ serialize() { - const rect = this.div.getBoundingClientRect(); + const rect = this.editorDiv.getBoundingClientRect(); + const padding = FreeTextEditor._internalPadding * this.parent.zoomFactor; const [x1, y1] = Util.applyTransform( - [this.x, this.y + rect.height], + [this.x + padding, this.y + padding + rect.height], this.parent.inverseViewportTransform ); const [x2, y2] = Util.applyTransform( - [this.x + rect.width, this.y], + [this.x + padding + rect.width, this.y + padding], this.parent.inverseViewportTransform ); - return { annotationType: AnnotationEditorType.FREETEXT, color: [0, 0, 0], - fontSize: this.#fontSize, + fontSize: this.#fontSize / PixelsPerInch.PDF_TO_CSS_UNITS, value: this.#content, pageIndex: this.parent.pageIndex, rect: [x1, y1, x2, y2], diff --git a/src/shared/util.js b/src/shared/util.js index af175714f..b6a635c27 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -21,6 +21,7 @@ const FONT_IDENTITY_MATRIX = [0.001, 0, 0, 0.001, 0, 0]; // Represent the percentage of the height of a single-line field over // the font size. Acrobat seems to use this value. const LINE_FACTOR = 1.35; +const LINE_DESCENT_FACTOR = 0.35; /** * Refer to the `WorkerTransport.getRenderingIntent`-method in the API, to see @@ -1175,6 +1176,7 @@ export { isArrayBuffer, isArrayEqual, isAscii, + LINE_DESCENT_FACTOR, LINE_FACTOR, MissingPDFException, objectFromMap, diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 58c28a19c..7239bca8b 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -22,6 +22,7 @@ import { } from "../../src/core/annotation.js"; import { AnnotationBorderStyleType, + AnnotationEditorType, AnnotationFieldFlag, AnnotationFlag, AnnotationType, @@ -3857,6 +3858,61 @@ describe("annotation", function () { ); }); + describe("FreeTextAnnotation", () => { + it("should create an new FreeText annotation", async () => { + partialEvaluator.xref = new XRefMock(); + const task = new WorkerTask("test FreeText creation"); + const data = await AnnotationFactory.saveNewAnnotations( + partialEvaluator, + task, + [ + { + annotationType: AnnotationEditorType.FREETEXT, + rect: [12, 34, 56, 78], + fontSize: 10, + color: [0, 0, 0], + value: "Hello PDF.js World!", + }, + ] + ); + + const base = data.annotations[0].data.replace(/\(D:\d+\)/, "(date)"); + expect(base).toEqual( + "2 0 obj\n" + + "<< /Type /Annot /Subtype /FreeText /CreationDate (date) " + + "/Rect [12 34 56 78] /DA (/Helv 10 Tf 0 g) /Contents (Hello PDF.js World!) " + + "/F 4 /Border [0 0 0] /Rotate 0 /AP << /N 3 0 R>>>>\n" + + "endobj\n" + ); + + const font = data.dependencies[0].data; + expect(font).toEqual( + "1 0 obj\n" + + "<< /BaseFont /Helvetica /Type /Font /Subtype /Type1 /Encoding " + + "/WinAnsiEncoding>>\n" + + "endobj\n" + ); + + const appearance = data.dependencies[1].data; + expect(appearance).toEqual( + "3 0 obj\n" + + "<< /FormType 1 /Subtype /Form /Type /XObject /BBox [0 0 44 44] " + + "/Length 101 /Resources << /Font << /Helv 1 0 R>>>>>> stream\n" + + "q\n" + + "0 0 44 44 re W n\n" + + "BT\n" + + "1 0 0 1 0 47.5 Tm 0 Tc 0 g\n" + + "/Helv 10 Tf\n" + + "0 -13.5 Td (Hello PDF.js World!) Tj\n" + + "ET\n" + + "Q\n" + + "endstream\n" + + "\n" + + "endobj\n" + ); + }); + }); + describe("InkAnnotation", function () { it("should handle a single ink list", async function () { const inkDict = new Dict(); diff --git a/test/unit/test_utils.js b/test/unit/test_utils.js index 5e0366773..6d15dab7a 100644 --- a/test/unit/test_utils.js +++ b/test/unit/test_utils.js @@ -88,7 +88,7 @@ class XRefMock { getNewRef() { if (this._newRefNum === null) { - this._newRefNum = Object.keys(this._map).length; + this._newRefNum = Object.keys(this._map).length || 1; } return Ref.get(this._newRefNum++, 0); } diff --git a/web/annotation_editor_layer_builder.css b/web/annotation_editor_layer_builder.css index a85733b72..6abc16659 100644 --- a/web/annotation_editor_layer_builder.css +++ b/web/annotation_editor_layer_builder.css @@ -16,6 +16,8 @@ :root { --focus-outline: solid 2px red; --hover-outline: dashed 2px blue; + --freetext-line-height: 1.35; + --freetext-padding: 2px; } .annotationEditorLayer { @@ -31,7 +33,7 @@ position: absolute; background: transparent; border-radius: 3px; - padding: 5px; + padding: calc(var(--freetext-padding) * var(--zoom-factor)); resize: none; width: auto; height: auto; @@ -42,10 +44,11 @@ border: none; top: 0; left: 0; - min-height: 15px; overflow: visible; white-space: nowrap; resize: none; + font: 10px sans-serif; + line-height: var(--freetext-line-height); } .annotationEditorLayer .freeTextEditor .overlay {