From 1747d259f90646d5087b4b3ece7424f35bc655d0 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 5 Aug 2020 14:40:31 +0200 Subject: [PATCH] Support textfield and choice widgets for printing --- src/core/annotation.js | 226 ++++++++++++++++++++++++++------ src/core/evaluator.js | 6 +- src/display/annotation_layer.js | 21 ++- src/shared/util.js | 7 + test/unit/annotation_spec.js | 182 ++++++++++++++++++++++++- test/unit/test_utils.js | 4 + test/unit/util_spec.js | 9 ++ 7 files changed, 409 insertions(+), 46 deletions(-) diff --git a/src/core/annotation.js b/src/core/annotation.js index 8ec947ca9d299..3e584b183e63c 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 { /** @@ -893,19 +893,199 @@ class WidgetAnnotation extends Annotation { if (renderForms) { return Promise.resolve(new OperatorList()); } - return super.getOperatorList( - evaluator, - task, - renderForms, - annotationStorage + + if (!this._hasText) { + return super.getOperatorList( + evaluator, + task, + renderForms, + annotationStorage + ); + } + + return this._getAppearance(evaluator, task, annotationStorage).then( + content => { + 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) { + const isPassword = this.hasFieldFlag(AnnotationFieldFlag.PASSWORD); + if (!annotationStorage || isPassword) { + return null; + } + let value = annotationStorage[this.data.id] || ""; + if (value === "") { + return null; + } + value = escapeString(value); + + const defaultPadding = 2; + 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[2]; + + fontSize = this._computeFontSize(font, fontName, fontSize, totalHeight); + + let descent = font.descent; + if (isNaN(descent)) { + descent = 0; + } + + const vPadding = defaultPadding + Math.abs(descent) * fontSize; + const defaultAppearance = this.data.defaultAppearance; + 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._renderText( + 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]; + } + + _computeFontSize(font, fontName, fontSize, height) { + if (fontSize === null || fontSize === 0) { + const em = font.charsToGlyphs("M", true)[0].width / 1000; + // According to https://en.wikipedia.org/wiki/Em_(typography) + // an average cap height should be 70% of 1em + const capHeight = 0.7 * em; + // 1.5 * capHeight * fontSize seems to be a good value for lineHeight + fontSize = Math.max(1, Math.floor(height / (1.5 * capHeight))); + + let fontRegex = new RegExp(`/${fontName}\\s+[0-9\.]+\\s+Tf`); + if (this.data.defaultAppearance.search(fontRegex) === -1) { + // The font size is missing + fontRegex = new RegExp(`/${fontName}\\s+Tf`); + } + this.data.defaultAppearance = this.data.defaultAppearance.replace( + fontRegex, + `/${fontName} ${fontSize} Tf` + ); + } + return fontSize; + } + + _renderText(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`; + } } class TextWidgetAnnotation extends WidgetAnnotation { constructor(params) { super(params); + this._hasText = true; + const dict = params.dict; // The field value is always a string. @@ -934,37 +1114,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 { @@ -1148,6 +1297,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._hasText = true; } } diff --git a/src/core/evaluator.js b/src/core/evaluator.js index e81d9ac81259d..ea1d2ae54848e 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -727,10 +727,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) @@ -763,6 +765,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 1b99c6d59acc8..e7c7de38c2f0e 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -441,6 +441,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"; @@ -449,15 +451,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.getOrCreateValue(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; @@ -654,6 +662,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; @@ -674,10 +684,17 @@ class ChoiceWidgetAnnotationElement extends WidgetAnnotationElement { optionElement.value = option.exportValue; if (this.data.fieldValue.includes(option.displayValue)) { optionElement.setAttribute("selected", true); + storage.setValue(id, option.displayValue); } 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 159f438101695..68a8921bc58ef 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -793,6 +793,12 @@ function stringToPDFString(str) { return strBuf.join(""); } +function escapeString(str) { + // replace "(", ")" and "\" by "\(", "\)" and "\\" + // in order to write it in a PDF file. + return str.replace(/([\(\)\\])/g, "\\$1"); +} + function stringToUTF8String(str) { return decodeURIComponent(escape(str)); } @@ -927,6 +933,7 @@ export { bytesToString, createPromiseCapability, createObjectURL, + escapeString, getVerbosityLevel, info, isArrayBuffer, diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index cfec3878988e3..f67c98e147d61 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -30,7 +30,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"; @@ -82,6 +82,7 @@ describe("annotation", function () { handler: new HandlerMock(), pageIndex: 0, idFactory: createIdFactory(/* pageIndex = */ 0), + fontCache: new RefSetCache(), }); done(); }); @@ -1385,18 +1386,35 @@ 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; + textWidgetDict = fontRefObj = null; }); it("should handle unknown text alignment, maximum length and flags", function (done) { @@ -1552,6 +1570,109 @@ describe("annotation", function () { } promise.then(done, done.fail); }); + + it("should render regular text for printing", function (done) { + 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 5 Tf 1 0 0 1 0 0 Tm" + + " 2.00 2.00 Td (test\\\\print) Tj ET Q EMC" + ); + done(); + }, done.fail); + }); + + it("should render auto-sized text for printing", 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 11 Tf 1 0 0 1 0 0 Tm" + + " 2.00 2.00 Td (test \\(print\\)) Tj ET Q EMC" + ); + done(); + }, done.fail); + }); + + it("should not render a password for printing", 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); + }); }); describe("ButtonWidgetAnnotation", function () { @@ -1861,18 +1982,35 @@ 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; + choiceWidgetDict = fontRefObj = null; }); it("should handle missing option arrays", function (done) { @@ -2128,6 +2266,40 @@ describe("annotation", function () { done(); }, done.fail); }); + + it("should render choice for printing", 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 1e4854b6c28fd..3b73f64fe9627 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]; diff --git a/test/unit/util_spec.js b/test/unit/util_spec.js index e6e8e7549dc0b..5b8346589d9d3 100644 --- a/test/unit/util_spec.js +++ b/test/unit/util_spec.js @@ -17,6 +17,7 @@ import { bytesToString, createPromiseCapability, createValidAbsoluteUrl, + escapeString, isArrayBuffer, isBool, isNum, @@ -314,4 +315,12 @@ describe("util", function () { }); }); }); + + describe("escapeString", function () { + it("should escape (, ) and \\", function () { + expect(escapeString("((a\\a))(b(b\\b)b)")).toEqual( + "\\(\\(a\\\\a\\)\\)\\(b\\(b\\\\b\\)b\\)" + ); + }); + }); });