From 31155740c35f5b270ff6222682b013f53351d427 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 3 Aug 2022 12:03:49 +0200 Subject: [PATCH] [Annotation] Add a div containing the text of a FreeText annotation (bug 1780375) An annotation doesn't have to be in the text flow, hence it's likely a bad idea to insert its text in the text layer. But the text must be visible from a screen reader point of view so it must somewhere in the DOM. So with this patch, the text from a FreeText annotation is extracted and added in a div in its HTML counterpart, and with the patch #15237 the text should be visible and positioned relatively to the text flow. --- src/core/annotation.js | 55 ++++++++++++++++++ src/core/document.js | 64 +++++++++++++++------ src/core/worker.js | 13 ++++- src/display/annotation_layer.js | 12 ++++ test/annotation_layer_builder_overrides.css | 10 ++++ test/unit/annotation_spec.js | 29 ++++++++++ web/annotation_layer_builder.css | 15 +++++ 7 files changed, 178 insertions(+), 20 deletions(-) diff --git a/src/core/annotation.js b/src/core/annotation.js index 99f8f3e7b..bab094937 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -941,6 +941,57 @@ class Annotation { return null; } + get hasTextContent() { + return false; + } + + async extractTextContent(evaluator, task, viewBox) { + if (!this.appearance) { + return; + } + + const resources = await this.loadResources( + ["ExtGState", "Font", "Properties", "XObject"], + this.appearance + ); + + const text = []; + const buffer = []; + const sink = { + desiredSize: Math.Infinity, + ready: true, + + enqueue(chunk, size) { + for (const item of chunk.items) { + buffer.push(item.str); + if (item.hasEOL) { + text.push(buffer.join("")); + buffer.length = 0; + } + } + }, + }; + + await evaluator.getTextContent({ + stream: this.appearance, + task, + resources, + includeMarkedContent: true, + combineTextItems: true, + sink, + viewBox, + }); + this.reset(); + + if (buffer.length) { + text.push(buffer.join("")); + } + + if (text.length > 0) { + this.data.textContent = text; + } + } + /** * Get field data for usage in JS sandbox. * @@ -3250,6 +3301,10 @@ class FreeTextAnnotation extends MarkupAnnotation { this.data.annotationType = AnnotationType.FREETEXT; } + get hasTextContent() { + return !!this.appearance; + } + static createNewDict(annotation, xref, { apRef, ap }) { const { color, fontSize, rect, rotation, user, value } = annotation; const freetext = new Dict(xref); diff --git a/src/core/document.js b/src/core/document.js index a9a4ec526..5fc6ebc3e 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -578,30 +578,56 @@ class Page { return tree; } - getAnnotationsData(intent) { - return this._parsedAnnotations.then(function (annotations) { - const annotationsData = []; + async getAnnotationsData(handler, task, intent) { + const annotations = await this._parsedAnnotations; + if (annotations.length === 0) { + return []; + } - if (annotations.length === 0) { - return annotationsData; + const textContentPromises = []; + const annotationsData = []; + let partialEvaluator; + + const intentAny = !!(intent & RenderingIntentFlag.ANY), + intentDisplay = !!(intent & RenderingIntentFlag.DISPLAY), + intentPrint = !!(intent & RenderingIntentFlag.PRINT); + + for (const annotation of annotations) { + // Get the annotation even if it's hidden because + // JS can change its display. + const isVisible = intentAny || (intentDisplay && annotation.viewable); + if (isVisible || (intentPrint && annotation.printable)) { + annotationsData.push(annotation.data); } - const intentAny = !!(intent & RenderingIntentFlag.ANY), - intentDisplay = !!(intent & RenderingIntentFlag.DISPLAY), - intentPrint = !!(intent & RenderingIntentFlag.PRINT); - for (const annotation of annotations) { - // Get the annotation even if it's hidden because - // JS can change its display. - if ( - intentAny || - (intentDisplay && annotation.viewable) || - (intentPrint && annotation.printable) - ) { - annotationsData.push(annotation.data); + if (annotation.hasTextContent && isVisible) { + if (!partialEvaluator) { + 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, + }); } + textContentPromises.push( + annotation + .extractTextContent(partialEvaluator, task, this.view) + .catch(function (reason) { + warn( + `getAnnotationsData - ignoring textContent during "${task.name}" task: "${reason}".` + ); + }) + ); } - return annotationsData; - }); + } + + await Promise.all(textContentPromises); + return annotationsData; } get annotations() { diff --git a/src/core/worker.js b/src/core/worker.js index 3a9b6c60e..ae5221f06 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -536,7 +536,18 @@ class WorkerMessageHandler { handler.on("GetAnnotations", function ({ pageIndex, intent }) { return pdfManager.getPage(pageIndex).then(function (page) { - return page.getAnnotationsData(intent); + const task = new WorkerTask(`GetAnnotations: page ${pageIndex}`); + startWorkerTask(task); + + return page.getAnnotationsData(handler, task, intent).then( + data => { + finishWorkerTask(task); + return data; + }, + reason => { + finishWorkerTask(task); + } + ); }); }); diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index d78c1801f..89e712a03 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -1932,11 +1932,23 @@ class FreeTextAnnotationElement extends AnnotationElement { parameters.data.richText?.str ); super(parameters, { isRenderable, ignoreBorder: true }); + this.textContent = parameters.data.textContent; } render() { this.container.className = "freeTextAnnotation"; + if (this.textContent) { + const content = document.createElement("div"); + content.className = "annotationTextContent"; + for (const line of this.textContent) { + const lineSpan = document.createElement("span"); + lineSpan.textContent = line; + content.append(lineSpan); + } + this.container.append(content); + } + if (!this.data.hasPopup) { this._createPopup(null, this.data); } diff --git a/test/annotation_layer_builder_overrides.css b/test/annotation_layer_builder_overrides.css index f892ea1b8..dfd683afb 100644 --- a/test/annotation_layer_builder_overrides.css +++ b/test/annotation_layer_builder_overrides.css @@ -48,3 +48,13 @@ margin: 0; padding: 0; } + +.annotationLayer .annotationTextContent { + position: absolute; + width: 100%; + height: 100%; + opacity: 0.4; + background-color: transparent; + color: red; + font-size: 10px; +} diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 1d5cc8a24..2530d315e 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -4101,6 +4101,35 @@ describe("annotation", function () { OPS.endAnnotation, ]); }); + + it("should extract the text from a FreeText annotation", async function () { + partialEvaluator.xref = new XRefMock(); + const task = new WorkerTask("test FreeText text extraction"); + const freetextAnnotation = ( + await AnnotationFactory.printNewAnnotations(partialEvaluator, task, [ + { + annotationType: AnnotationEditorType.FREETEXT, + rect: [12, 34, 56, 78], + rotation: 0, + fontSize: 10, + color: [0, 0, 0], + value: "Hello PDF.js\nWorld !", + }, + ]) + )[0]; + + await freetextAnnotation.extractTextContent(partialEvaluator, task, [ + -Infinity, + -Infinity, + Infinity, + Infinity, + ]); + + expect(freetextAnnotation.data.textContent).toEqual([ + "Hello PDF.js", + "World !", + ]); + }); }); describe("InkAnnotation", function () { diff --git a/web/annotation_layer_builder.css b/web/annotation_layer_builder.css index 494b0c2c5..2b54f5f54 100644 --- a/web/annotation_layer_builder.css +++ b/web/annotation_layer_builder.css @@ -271,3 +271,18 @@ width: 100%; height: 100%; } + +.annotationLayer .annotationTextContent { + position: absolute; + width: 100%; + height: 100%; + opacity: 0; + color: transparent; + user-select: none; + pointer-events: none; +} + +.annotationLayer .annotationTextContent span { + width: 100%; + display: inline-block; +}