diff --git a/src/core/annotation.js b/src/core/annotation.js index d4c28cf3a..6e1bc1147 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -1207,6 +1207,7 @@ class Annotation { task, resources, includeMarkedContent: true, + keepWhiteSpace: true, sink, viewBox, }); @@ -1218,20 +1219,26 @@ class Annotation { if (text.length > 1 || text[0]) { const appearanceDict = this.appearance.dict; - const bbox = appearanceDict.getArray("BBox") || [0, 0, 1, 1]; - const matrix = appearanceDict.getArray("Matrix") || [1, 0, 0, 1, 0, 0]; - const rect = this.data.rect; - const transform = getTransformMatrix(rect, bbox, matrix); - transform[4] -= rect[0]; - transform[5] -= rect[1]; - firstPosition = Util.applyTransform(firstPosition, transform); - firstPosition = Util.applyTransform(firstPosition, matrix); - - this.data.textPosition = firstPosition; + this.data.textPosition = this._transformPoint( + firstPosition, + appearanceDict.getArray("BBox"), + appearanceDict.getArray("Matrix") + ); this.data.textContent = text; } } + _transformPoint(coords, bbox, matrix) { + const { rect } = this.data; + bbox ||= [0, 0, 1, 1]; + matrix ||= [1, 0, 0, 1, 0, 0]; + const transform = getTransformMatrix(rect, bbox, matrix); + transform[4] -= rect[0]; + transform[5] -= rect[1]; + coords = Util.applyTransform(coords, transform); + return Util.applyTransform(coords, matrix); + } + /** * Get field data for usage in JS sandbox. * @@ -3767,7 +3774,9 @@ class FreeTextAnnotation extends MarkupAnnotation { const { evaluatorOptions, xref } = params; this.data.annotationType = AnnotationType.FREETEXT; this.setDefaultAppearance(params); - if (this.appearance) { + this._hasAppearance = !!this.appearance; + + if (this._hasAppearance) { const { fontColor, fontSize } = parseAppearanceStream( this.appearance, evaluatorOptions, @@ -3775,29 +3784,40 @@ class FreeTextAnnotation extends MarkupAnnotation { ); this.data.defaultAppearanceData.fontColor = fontColor; this.data.defaultAppearanceData.fontSize = fontSize || 10; - } else if (this._isOffscreenCanvasSupported) { - const strokeAlpha = params.dict.get("CA"); - const fakeUnicodeFont = new FakeUnicodeFont(xref, "sans-serif"); + } else { this.data.defaultAppearanceData.fontSize ||= 10; const { fontColor, fontSize } = this.data.defaultAppearanceData; - this.appearance = fakeUnicodeFont.createAppearance( - this._contents.str, - this.rectangle, - this.rotation, - fontSize, - fontColor, - strokeAlpha - ); - this._streams.push(this.appearance, FakeUnicodeFont.toUnicodeStream); - } else { - warn( - "FreeTextAnnotation: OffscreenCanvas is not supported, annotation may not render correctly." - ); + if (this._contents.str) { + this.data.textContent = this._contents.str.split(/\r\n?|\n/); + const { coords, bbox, matrix } = FakeUnicodeFont.getFirstPositionInfo( + this.rectangle, + this.rotation, + fontSize + ); + this.data.textPosition = this._transformPoint(coords, bbox, matrix); + } + if (this._isOffscreenCanvasSupported) { + const strokeAlpha = params.dict.get("CA"); + const fakeUnicodeFont = new FakeUnicodeFont(xref, "sans-serif"); + this.appearance = fakeUnicodeFont.createAppearance( + this._contents.str, + this.rectangle, + this.rotation, + fontSize, + fontColor, + strokeAlpha + ); + this._streams.push(this.appearance, FakeUnicodeFont.toUnicodeStream); + } else { + warn( + "FreeTextAnnotation: OffscreenCanvas is not supported, annotation may not render correctly." + ); + } } } get hasTextContent() { - return !!this.appearance; + return this._hasAppearance; } static createNewDict(annotation, xref, { apRef, ap }) { diff --git a/src/core/default_appearance.js b/src/core/default_appearance.js index fc1635104..c01ea5987 100644 --- a/src/core/default_appearance.js +++ b/src/core/default_appearance.js @@ -390,6 +390,26 @@ endcmap CMapName currentdict /CMap defineresource pop end end`; return this.resources; } + static getFirstPositionInfo(rect, rotation, fontSize) { + // Get the position of the first char in the rect. + const [x1, y1, x2, y2] = rect; + let w = x2 - x1; + let h = y2 - y1; + + if (rotation % 180 !== 0) { + [w, h] = [h, w]; + } + const lineHeight = LINE_FACTOR * fontSize; + const lineDescent = LINE_DESCENT_FACTOR * fontSize; + + return { + coords: [0, h + lineDescent - lineHeight], + bbox: [0, 0, w, h], + matrix: + rotation !== 0 ? getRotationMatrix(rotation, h, lineHeight) : undefined, + }; + } + createAppearance(text, rect, rotation, fontSize, bgColor, strokeAlpha) { const ctx = this._createContext(); const lines = []; diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 1a0ac5806..f5646b362 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -2281,6 +2281,7 @@ class PartialEvaluator { viewBox, markedContentData = null, disableNormalization = false, + keepWhiteSpace = false, }) { // Ensure that `resources`/`stateManager` is correctly initialized, // even if the provided parameter is e.g. `null`. @@ -2347,11 +2348,12 @@ class PartialEvaluator { twoLastChars[twoLastCharsPos] = char; twoLastCharsPos = nextPos; - return ret; + return !keepWhiteSpace && ret; } function shouldAddWhitepsace() { return ( + !keepWhiteSpace && twoLastChars[twoLastCharsPos] !== " " && twoLastChars[(twoLastCharsPos + 1) % 2] === " " ); @@ -2836,7 +2838,7 @@ class PartialEvaluator { } let scaledDim = glyphWidth * scale; - if (category.isWhitespace) { + if (!keepWhiteSpace && category.isWhitespace) { // Don't push a " " in the textContentItem // (except when it's between two non-spaces chars), // it will be done (if required) in next call to @@ -3272,6 +3274,7 @@ class PartialEvaluator { viewBox, markedContentData, disableNormalization, + keepWhiteSpace, }) .then(function () { if (!sinkWrapper.enqueueInvoked) { diff --git a/src/display/editor/freetext.js b/src/display/editor/freetext.js index 8a91f7d55..adc0e3d50 100644 --- a/src/display/editor/freetext.js +++ b/src/display/editor/freetext.js @@ -648,6 +648,14 @@ class FreeTextEditor extends AnnotationEditor { } } + #serializeContent() { + return this.#content.replaceAll("\xa0", " "); + } + + static #deserializeContent(content) { + return content.replaceAll(" ", "\xa0"); + } + /** @inheritdoc */ get contentDiv() { return this.editorDiv; @@ -690,10 +698,9 @@ class FreeTextEditor extends AnnotationEditor { }; } const editor = super.deserialize(data, parent, uiManager); - editor.#fontSize = data.fontSize; editor.#color = Util.makeHexColor(...data.color); - editor.#content = data.value; + editor.#content = FreeTextEditor.#deserializeContent(data.value); editor.annotationElementId = data.id || null; editor.#initialData = initialData; @@ -726,7 +733,7 @@ class FreeTextEditor extends AnnotationEditor { annotationType: AnnotationEditorType.FREETEXT, color, fontSize: this.#fontSize, - value: this.#content, + value: this.#serializeContent(), pageIndex: this.pageIndex, rect, rotation: this.rotation, diff --git a/test/integration/freetext_editor_spec.mjs b/test/integration/freetext_editor_spec.mjs index 5aed27bfa..74cb5134d 100644 --- a/test/integration/freetext_editor_spec.mjs +++ b/test/integration/freetext_editor_spec.mjs @@ -209,11 +209,11 @@ describe("FreeText Editor", () => { await waitForStorageEntries(page, 2); const content = await page.$eval(getEditorSelector(0), el => - el.innerText.trimEnd() + el.innerText.trimEnd().replaceAll("\xa0", " ") ); let pastedContent = await page.$eval(getEditorSelector(1), el => - el.innerText.trimEnd() + el.innerText.trimEnd().replaceAll("\xa0", " ") ); expect(pastedContent).withContext(`In ${browserName}`).toEqual(content); @@ -225,7 +225,7 @@ describe("FreeText Editor", () => { await waitForStorageEntries(page, 3); pastedContent = await page.$eval(getEditorSelector(2), el => - el.innerText.trimEnd() + el.innerText.trimEnd().replaceAll("\xa0", " ") ); expect(pastedContent).withContext(`In ${browserName}`).toEqual(content); } @@ -3182,4 +3182,68 @@ describe("FreeText Editor", () => { ); }); }); + + describe("Consecutive white spaces in Freetext without appearance", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("bug1871353.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must check that consecutive white spaces are preserved when a freetext is edited", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToFreeText(page); + await page.click(getEditorSelector(0), { count: 2 }); + await page.type(`${getEditorSelector(0)} .internal`, "C"); + + await page.click("#editorFreeText"); + await page.waitForSelector( + `.annotationEditorLayer:not(.freetextEditing)` + ); + + const [value] = await getSerialized(page, x => x.value); + expect(value) + .withContext(`In ${browserName}`) + .toEqual("CA B"); + }) + ); + }); + }); + + describe("Consecutive white spaces in Freetext with appearance", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait("bug1871353.1.pdf", ".annotationEditorLayer"); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must check that consecutive white spaces are preserved when a freetext is edited", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await switchToFreeText(page); + await page.click(getEditorSelector(0), { count: 2 }); + await page.type(`${getEditorSelector(0)} .internal`, "Z"); + + await page.click("#editorFreeText"); + await page.waitForSelector( + `.annotationEditorLayer:not(.freetextEditing)` + ); + + const [value] = await getSerialized(page, x => x.value); + expect(value) + .withContext(`In ${browserName}`) + .toEqual("ZX Y"); + }) + ); + }); + }); }); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 4d3eefaad..1387f44ba 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -621,3 +621,5 @@ !bug1863910.pdf !bug1865341.pdf !bug1872721.pdf +!bug1871353.pdf +!bug1871353.1.pdf diff --git a/test/pdfs/bug1871353.1.pdf b/test/pdfs/bug1871353.1.pdf new file mode 100644 index 000000000..f719d0f7b Binary files /dev/null and b/test/pdfs/bug1871353.1.pdf differ diff --git a/test/pdfs/bug1871353.pdf b/test/pdfs/bug1871353.pdf new file mode 100644 index 000000000..000205796 Binary files /dev/null and b/test/pdfs/bug1871353.pdf differ