diff --git a/src/core/annotation.js b/src/core/annotation.js index 2150a82fc8a0b8..beb7887c34ddbe 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -21,9 +21,9 @@ import { AnnotationReplyType, AnnotationType, assert, + escapeString, isString, OPS, - stringToBytes, stringToPDFString, Util, warn, @@ -33,7 +33,7 @@ import { Dict, isDict, isName, isRef, isStream } from "./primitives.js"; import { ColorSpace } from "./colorspace.js"; import { getInheritableProperty } from "./core_utils.js"; import { OperatorList } from "./operator_list.js"; -import { Stream } from "./stream.js"; +import { StringStream } from "./stream.js"; class AnnotationFactory { /** @@ -889,18 +889,319 @@ class WidgetAnnotation extends Annotation { } getOperatorList(evaluator, task, renderForms, annotationStorage) { - // Do not render form elements on the canvas when interactive forms are - // enabled. The display layer is responsible for rendering them instead. if (renderForms) { return Promise.resolve(new OperatorList()); } - return super.getOperatorList( - evaluator, - task, - renderForms, - annotationStorage + + if (!this.data.hasText) { + return super.getOperatorList( + evaluator, + task, + renderForms, + annotationStorage + ); + } + + return this.getAppearance(evaluator, task, annotationStorage).then( + content => { + // Do not render form elements on the canvas when interactive forms are + // enabled. The display layer is responsible for rendering them instead. + if (this.appearance && content === null) { + return super.getOperatorList( + evaluator, + task, + renderForms, + annotationStorage + ); + } + + const operatorList = new OperatorList(); + + // Even if there is an appearance stream, ignore it. This is the + // behaviour used by Adobe Reader. + if (!this.data.defaultAppearance || content === null) { + return operatorList; + } + + const matrix = [1, 0, 0, 1, 0, 0]; + const bbox = [ + 0, + 0, + this.data.rect[2] - this.data.rect[0], + this.data.rect[3] - this.data.rect[1], + ]; + + const transform = getTransformMatrix(this.data.rect, bbox, matrix); + operatorList.addOp(OPS.beginAnnotation, [ + this.data.rect, + transform, + matrix, + ]); + + const stream = new StringStream(content); + return evaluator + .getOperatorList({ + stream, + task, + resources: this.fieldResources, + operatorList, + }) + .then(function () { + operatorList.addOp(OPS.endAnnotation, []); + return operatorList; + }); + } ); } + + async getAppearance(evaluator, task, annotationStorage) { + // If it's a password textfield then no rendering to avoid to leak it. + // see 12.7.4.3, table 228 + if (!annotationStorage || this.data.isPassword) { + return null; + } + let value = annotationStorage[this.data.id] || ""; + if (value === "") { + return null; + } + value = escapeString(value); + + // Magic value + const defaultPadding = 2; + + // Default horizontal padding: can we have an heuristic to guess it? + const hPadding = defaultPadding; + const totalHeight = this.data.rect[3] - this.data.rect[1]; + const totalWidth = this.data.rect[2] - this.data.rect[0]; + + const fontInfo = await this.getFontData(evaluator, task); + const [font, fontName] = fontInfo; + let [, , fontSize] = fontInfo; + + fontSize = this.computeAutoSizedFont( + fontName, + fontSize, + totalHeight, + 2 * defaultPadding + ); + + let descent = font.descent; + if (isNaN(descent)) { + descent = 0; + } + + const vPadding = defaultPadding + Math.abs(descent) * fontSize; + const defaultAppearance = this.data.defaultAppearance; + + if (this.data.comb) { + const combWidth = (totalWidth / this.data.maxLen).toFixed(2); + let buf = `/Tx BMC q BT ${defaultAppearance} 1 0 0 1 ${hPadding} ${vPadding} Tm`; + let first = true; + for (const character of value) { + if (first) { + buf += ` (${character}) Tj`; + first = false; + } else { + buf += ` ${combWidth} 0 Td (${character}) Tj`; + } + } + buf += " ET Q EMC"; + return buf; + } + + if (this.data.multiLine) { + const renderedText = this.handleMultiline( + value, + font, + fontSize, + totalWidth, + alignment, + hPadding, + vPadding + ); + return `/Tx BMC q BT ${defaultAppearance} 1 0 0 1 0 ${totalHeight} Tm ${renderedText} ET Q EMC`; + } + + const alignment = this.data.textAlignment; + if (alignment === 0 || alignment > 2) { + // Left alignment: nothing to do + return `/Tx BMC q BT ${defaultAppearance} 1 0 0 1 ${hPadding} ${vPadding} Tm (${value}) Tj ET Q EMC`; + } + + const renderedText = this.renderPDFText( + value, + font, + fontSize, + totalWidth, + alignment, + hPadding, + vPadding + ); + return `/Tx BMC q BT ${defaultAppearance} 1 0 0 1 0 0 Tm ${renderedText} ET Q EMC`; + } + + async getFontData(evaluator, task) { + const operatorList = new OperatorList(); + const initialState = { + fontSize: 0, + font: null, + fontName: null, + clone() { + return this; + }, + }; + + await evaluator.getOperatorList({ + stream: new StringStream(this.data.defaultAppearance), + task, + resources: this.fieldResources, + operatorList, + initialState, + }); + + return [initialState.font, initialState.fontName, initialState.fontSize]; + } + + computeAutoSizedFont(fontName, fontSize, height, spaceAround) { + if (fontSize === null || fontSize === 0) { + // fontSize should be computed as a function of totalHeight + if (spaceAround >= height) { + fontSize = Math.floor(0.8 * height); + } else { + fontSize = 0.8 * (height - spaceAround); + } + fontSize = fontSize.toFixed(2); + + let re = new RegExp(`/${fontName}\\s+[0-9\.]+\\s+Tf`); + if (this.data.defaultAppearance.search(re) === -1) { + // The font size is missing + re = new RegExp(`/${fontName}\\s+Tf`); + } + this.data.defaultAppearance = this.data.defaultAppearance.replace( + re, + `/${fontName} ${fontSize} Tf` + ); + } + return fontSize; + } + + renderPDFText( + text, + font, + fontSize, + totalWidth, + alignment, + hPadding, + vPadding + ) { + // We need to get the width of the text in order to align it correctly + const glyphs = font.charsToGlyphs(text); + const scale = fontSize / 1000; + let width = 0; + for (const glyph of glyphs) { + width += glyph.width * scale; + } + + let shift; + if (alignment === 1) { + // Center + shift = (totalWidth - width) / 2; + } else if (alignment === 2) { + // Right + shift = totalWidth - width - hPadding; + } else { + shift = hPadding; + } + shift = shift.toFixed(2); + vPadding = vPadding.toFixed(2); + + return `${shift} ${vPadding} Td (${text}) Tj`; + } + + handleMultiline(text, font, fontSize, width, alignment, hPadding, vPadding) { + const lines = text.replace("\r\n", "\n").split("\n"); + let buf = ""; + const totalWidth = alignment === 1 ? width : width - hPadding; + for (const line of lines) { + const chunks = this.splitLine(line, font, fontSize, totalWidth); + for (const chunk of chunks) { + if (buf === "") { + buf = this.renderPDFText( + chunk, + font, + fontSize, + width, + alignment, + hPadding, + -fontSize + ); + } else { + buf += + "\n" + + this.renderPDFText( + chunk, + font, + fontSize, + width, + alignment, + 0, + -fontSize + ); + } + } + } + + return buf; + } + + splitLine(line, font, fontSize, width) { + const scale = fontSize / 1000; + const white = font.charsToGlyphs(" ", true)[0].width * scale; + const chunks = []; + + let lastSpacePos = -1, + startChunk = 0, + currentWidth = 0; + + for (let i = 0; i < line.length; i++) { + const character = line.charAt(i); + if (character === " ") { + if (currentWidth + white > width) { + // We can break here + chunks.push(line.substring(startChunk, i)); + startChunk = i; + currentWidth = white; + lastSpacePos = -1; + } else { + currentWidth += white; + lastSpacePos = i; + } + } else { + const charWidth = font.charsToGlyphs(character, false)[0].width * scale; + if (currentWidth + charWidth > width) { + // We must break to the last white position (if one) + if (lastSpacePos !== -1) { + chunks.push(line.substring(startChunk, lastSpacePos + 1)); + startChunk = i = lastSpacePos + 1; + lastSpacePos = -1; + currentWidth = 0; + } else { + // Just break in the middle of the word + chunks.push(line.substring(startChunk, i)); + currentWidth = charWidth; + } + } else { + currentWidth += charWidth; + } + } + } + + if (startChunk < line.length) { + chunks.push(line.substring(startChunk, line.length)); + } + + return chunks; + } } class TextWidgetAnnotation extends WidgetAnnotation { @@ -925,6 +1226,8 @@ class TextWidgetAnnotation extends WidgetAnnotation { maximumLength = null; } this.data.maxLen = maximumLength; + this.data.isPassword = this.hasFieldFlag(AnnotationFieldFlag.PASSWORD); + this.data.hasText = true; // Process field flags for the display layer. this.data.multiLine = this.hasFieldFlag(AnnotationFieldFlag.MULTILINE); @@ -935,37 +1238,6 @@ class TextWidgetAnnotation extends WidgetAnnotation { !this.hasFieldFlag(AnnotationFieldFlag.FILESELECT) && this.data.maxLen !== null; } - - getOperatorList(evaluator, task, renderForms, annotationStorage) { - if (renderForms || this.appearance) { - return super.getOperatorList( - evaluator, - task, - renderForms, - annotationStorage - ); - } - - const operatorList = new OperatorList(); - - // Even if there is an appearance stream, ignore it. This is the - // behaviour used by Adobe Reader. - if (!this.data.defaultAppearance) { - return Promise.resolve(operatorList); - } - - const stream = new Stream(stringToBytes(this.data.defaultAppearance)); - return evaluator - .getOperatorList({ - stream, - task, - resources: this.fieldResources, - operatorList, - }) - .then(function () { - return operatorList; - }); - } } class ButtonWidgetAnnotation extends WidgetAnnotation { @@ -1134,6 +1406,7 @@ class ChoiceWidgetAnnotation extends WidgetAnnotation { // Process field flags for the display layer. this.data.combo = this.hasFieldFlag(AnnotationFieldFlag.COMBO); this.data.multiSelect = this.hasFieldFlag(AnnotationFieldFlag.MULTISELECT); + this.data.hasText = true; } } diff --git a/src/core/evaluator.js b/src/core/evaluator.js index a67ad4ef589067..5260fbdefc17f6 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -729,10 +729,12 @@ class PartialEvaluator { handleSetFont(resources, fontArgs, fontRef, operatorList, task, state) { // TODO(mack): Not needed? - var fontName; + var fontName, + fontSize = 0; if (fontArgs) { fontArgs = fontArgs.slice(); fontName = fontArgs[0].name; + fontSize = fontArgs[1]; } return this.loadFont(fontName, fontRef, resources) @@ -761,6 +763,8 @@ class PartialEvaluator { }) .then(translated => { state.font = translated.font; + state.fontSize = fontSize; + state.fontName = fontName; translated.send(this.handler); return translated.loadedName; }); diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index a5a1f5bc567805..157058b9c6b8e3 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -439,6 +439,8 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { */ render() { const TEXT_ALIGNMENT = ["left", "center", "right"]; + const storage = this.annotationStorage; + const id = this.data.id; this.container.className = "textWidgetAnnotation"; @@ -447,15 +449,21 @@ class TextWidgetAnnotationElement extends WidgetAnnotationElement { // NOTE: We cannot set the values using `element.value` below, since it // prevents the AnnotationLayer rasterizer in `test/driver.js` // from parsing the elements correctly for the reference tests. + const textContent = storage.getOrCreate(id, this.data.fieldValue); + if (this.data.multiLine) { element = document.createElement("textarea"); - element.textContent = this.data.fieldValue; + element.textContent = textContent; } else { element = document.createElement("input"); element.type = "text"; - element.setAttribute("value", this.data.fieldValue); + element.setAttribute("value", textContent); } + element.addEventListener("change", function (event) { + storage.setValue(id, event.target.value); + }); + element.disabled = this.data.readOnly; element.name = this.data.fieldName; @@ -649,6 +657,8 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { */ render() { this.container.className = "choiceWidgetAnnotation"; + const storage = this.annotationStorage; + const id = this.data.id; const selectElement = document.createElement("select"); selectElement.disabled = this.data.readOnly; @@ -673,6 +683,12 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { selectElement.appendChild(optionElement); } + selectElement.addEventListener("change", function (event) { + const options = event.target.options; + const value = options[options.selectedIndex].text; + storage.setValue(id, value); + }); + this.container.appendChild(selectElement); return this.container; } diff --git a/src/shared/util.js b/src/shared/util.js index c077f4957a77ed..c590e08c7b1d1c 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -785,6 +785,10 @@ function stringToPDFString(str) { return strBuf.join(""); } +function escapeString(str) { + return str.replace(/([\(\)\\])/g, "\\$1"); +} + function stringToUTF8String(str) { return decodeURIComponent(escape(str)); } @@ -940,4 +944,5 @@ export { utf8StringToString, warn, unreachable, + escapeString, }; diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 1387d7630ae77c..01b107343df88f 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -29,7 +29,7 @@ import { stringToUTF8String, } from "../../src/shared/util.js"; import { createIdFactory, XRefMock } from "./test_utils.js"; -import { Dict, Name, Ref } from "../../src/core/primitives.js"; +import { Dict, Name, Ref, RefSetCache } from "../../src/core/primitives.js"; import { Lexer, Parser } from "../../src/core/parser.js"; import { PartialEvaluator } from "../../src/core/evaluator.js"; import { StringStream } from "../../src/core/stream.js"; @@ -81,6 +81,7 @@ describe("annotation", function () { handler: new HandlerMock(), pageIndex: 0, idFactory: createIdFactory(/* pageIndex = */ 0), + fontCache: new RefSetCache(), }); done(); }); @@ -1384,18 +1385,36 @@ describe("annotation", function () { }); describe("TextWidgetAnnotation", function () { - let textWidgetDict; + let textWidgetDict, fontRefObj; beforeEach(function (done) { textWidgetDict = new Dict(); textWidgetDict.set("Type", Name.get("Annot")); textWidgetDict.set("Subtype", Name.get("Widget")); textWidgetDict.set("FT", Name.get("Tx")); + + const helvDict = new Dict(); + helvDict.set("BaseFont", Name.get("Helvetica")); + helvDict.set("Type", Name.get("Font")); + helvDict.set("Subtype", Name.get("Type1")); + + const fontRef = Ref.get(314, 0); + fontRefObj = { ref: fontRef, data: helvDict }; + const resourceDict = new Dict(); + const fontDict = new Dict(); + fontDict.set("Helv", fontRef); + resourceDict.set("Font", fontDict); + + textWidgetDict.set("DA", "/Helv 5 Tf"); + textWidgetDict.set("DR", resourceDict); + textWidgetDict.set("Rect", [0, 0, 32, 10]); + done(); }); afterEach(function () { textWidgetDict = null; + fontRefObj = null; }); it("should handle unknown text alignment, maximum length and flags", function (done) { @@ -1551,6 +1570,145 @@ describe("annotation", function () { } promise.then(done, done.fail); }); + + it("should get auto-sized text from annotation storage", function (done) { + textWidgetDict.set("DA", "/Helv 0 Tf"); + + const textWidgetRef = Ref.get(271, 0); + const xref = new XRefMock([ + { ref: textWidgetRef, data: textWidgetDict }, + fontRefObj, + ]); + const task = new WorkerTask("test print"); + partialEvaluator.xref = xref; + + AnnotationFactory.create( + xref, + textWidgetRef, + pdfManagerMock, + idFactoryMock + ) + .then(annotation => { + const id = annotation.data.id; + const annotationStorage = {}; + annotationStorage[id] = "test (print)"; + return annotation.getAppearance( + partialEvaluator, + task, + annotationStorage + ); + }, done.fail) + .then(appearance => { + expect(appearance).toEqual( + "/Tx BMC q BT /Helv 4.80 Tf 1 0 0 1 0 0 Tm 2.00 2.00 Td (test \\(print\\)) Tj ET Q EMC" + ); + done(); + }, done.fail); + }); + + it("should get null appearance for a password from annotation storage", function (done) { + textWidgetDict.set("Ff", AnnotationFieldFlag.PASSWORD); + + const textWidgetRef = Ref.get(271, 0); + const xref = new XRefMock([ + { ref: textWidgetRef, data: textWidgetDict }, + fontRefObj, + ]); + const task = new WorkerTask("test print"); + partialEvaluator.xref = xref; + + AnnotationFactory.create( + xref, + textWidgetRef, + pdfManagerMock, + idFactoryMock + ) + .then(annotation => { + const id = annotation.data.id; + const annotationStorage = {}; + annotationStorage[id] = "mypassword"; + return annotation.getAppearance( + partialEvaluator, + task, + annotationStorage + ); + }, done.fail) + .then(appearance => { + expect(appearance).toEqual(null); + done(); + }, done.fail); + }); + + it("should get splited text from annotation storage", function (done) { + textWidgetDict.set("Ff", AnnotationFieldFlag.MULTILINE); + + const textWidgetRef = Ref.get(271, 0); + const xref = new XRefMock([ + { ref: textWidgetRef, data: textWidgetDict }, + fontRefObj, + ]); + const task = new WorkerTask("test print"); + partialEvaluator.xref = xref; + + AnnotationFactory.create( + xref, + textWidgetRef, + pdfManagerMock, + idFactoryMock + ) + .then(annotation => { + const id = annotation.data.id; + const annotationStorage = {}; + annotationStorage[id] = "a aa aaa aaaa aaaaa aaaaaa"; + return annotation.getAppearance( + partialEvaluator, + task, + annotationStorage + ); + }, done.fail) + .then(appearance => { + expect(appearance).toEqual( + "/Tx BMC q BT /Helv 5 Tf 1 0 0 1 0 10 Tm 2.00 -5.00 Td (a aa aaa ) Tj\n0.00 -5.00 Td (aaaa aaaaa ) Tj\n0.00 -5.00 Td (aaaaaa) Tj ET Q EMC" + ); + done(); + }, done.fail); + }); + + it("should get text with MaxLen from annotation storage", function (done) { + textWidgetDict.set("Ff", AnnotationFieldFlag.COMB); + textWidgetDict.set("MaxLen", 4); + + const textWidgetRef = Ref.get(271, 0); + const xref = new XRefMock([ + { ref: textWidgetRef, data: textWidgetDict }, + fontRefObj, + ]); + const task = new WorkerTask("test print"); + partialEvaluator.xref = xref; + + AnnotationFactory.create( + xref, + textWidgetRef, + pdfManagerMock, + idFactoryMock + ) + .then(annotation => { + const id = annotation.data.id; + const annotationStorage = {}; + annotationStorage[id] = "aaaaaaaa"; + return annotation.getAppearance( + partialEvaluator, + task, + annotationStorage + ); + }, done.fail) + .then(appearance => { + expect(appearance).toEqual( + "/Tx BMC q BT /Helv 5 Tf 1 0 0 1 2 2 Tm (a) Tj 8.00 0 Td (a) Tj 8.00 0 Td (a) Tj 8.00 0 Td (a) Tj 8.00 0 Td (a) Tj 8.00 0 Td (a) Tj 8.00 0 Td (a) Tj 8.00 0 Td (a) Tj ET Q EMC" + ); + done(); + }, done.fail); + }); }); describe("ButtonWidgetAnnotation", function () { @@ -1741,18 +1899,36 @@ describe("annotation", function () { }); describe("ChoiceWidgetAnnotation", function () { - let choiceWidgetDict; + let choiceWidgetDict, fontRefObj; beforeEach(function (done) { choiceWidgetDict = new Dict(); choiceWidgetDict.set("Type", Name.get("Annot")); choiceWidgetDict.set("Subtype", Name.get("Widget")); choiceWidgetDict.set("FT", Name.get("Ch")); + + const helvDict = new Dict(); + helvDict.set("BaseFont", Name.get("Helvetica")); + helvDict.set("Type", Name.get("Font")); + helvDict.set("Subtype", Name.get("Type1")); + + const fontRef = Ref.get(314, 0); + fontRefObj = { ref: fontRef, data: helvDict }; + const resourceDict = new Dict(); + const fontDict = new Dict(); + fontDict.set("Helv", fontRef); + resourceDict.set("Font", fontDict); + + choiceWidgetDict.set("DA", "/Helv 5 Tf"); + choiceWidgetDict.set("DR", resourceDict); + choiceWidgetDict.set("Rect", [0, 0, 32, 10]); + done(); }); afterEach(function () { choiceWidgetDict = null; + fontRefObj = null; }); it("should handle missing option arrays", function (done) { @@ -2008,6 +2184,39 @@ describe("annotation", function () { done(); }, done.fail); }); + + it("should get choice from annotation storage", function (done) { + const choiceWidgetRef = Ref.get(271, 0); + const xref = new XRefMock([ + { ref: choiceWidgetRef, data: choiceWidgetDict }, + fontRefObj, + ]); + const task = new WorkerTask("test print"); + partialEvaluator.xref = xref; + + AnnotationFactory.create( + xref, + choiceWidgetRef, + pdfManagerMock, + idFactoryMock + ) + .then(annotation => { + const id = annotation.data.id; + const annotationStorage = {}; + annotationStorage[id] = "a value"; + return annotation.getAppearance( + partialEvaluator, + task, + annotationStorage + ); + }, done.fail) + .then(appearance => { + expect(appearance).toEqual( + "/Tx BMC q BT /Helv 5 Tf 1 0 0 1 0 0 Tm 2.00 2.00 Td (a value) Tj ET Q EMC" + ); + done(); + }, done.fail); + }); }); describe("LineAnnotation", function () { diff --git a/test/unit/test_utils.js b/test/unit/test_utils.js index 1e4854b6c28fda..3b73f64fe96273 100644 --- a/test/unit/test_utils.js +++ b/test/unit/test_utils.js @@ -66,6 +66,10 @@ function buildGetDocumentParams(filename, options) { class XRefMock { constructor(array) { this._map = Object.create(null); + this.stats = { + streamTypes: Object.create(null), + fontTypes: Object.create(null), + }; for (const key in array) { const obj = array[key];