diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 97736b6c030e0..1deee596847ec 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -2307,9 +2307,19 @@ class PartialEvaluator { return; } // Other marked content types aren't supported yet. + let props = null; + if (args[1] instanceof Dict) { + const lang = args[1].get("Lang"); + if (typeof lang === "string") { + props = Object.create(null); + props.lang = stringToPDFString(lang); + } + } + args = [ args[0].name, args[1] instanceof Dict ? args[1].get("MCID") : null, + props, ]; break; @@ -3523,13 +3533,22 @@ class PartialEvaluator { if (includeMarkedContent) { markedContentData.level++; - const mcid = args[1] instanceof Dict ? args[1].get("MCID") : null; + let mcid = null; + let itemLang = null; + if (args[1] instanceof Dict) { + mcid = args[1].get("MCID"); + const langEntry = args[1].get("Lang"); + if (typeof langEntry === "string") { + itemLang = stringToPDFString(langEntry); + } + } textContent.items.push({ type: "beginMarkedContentProps", id: Number.isInteger(mcid) ? `${self.idFactory.getPageObjId()}_mc${mcid}` : null, tag: args[0] instanceof Name ? args[0].name : null, + lang: itemLang, }); } break; diff --git a/src/display/api.js b/src/display/api.js index 550b87a16b571..7bc0a42acf931 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1177,6 +1177,8 @@ class PDFDocumentProxy { * 'beginMarkedContentProps', or 'endMarkedContent'. * @property {string} id - The marked content identifier. Only used for type * 'beginMarkedContentProps'. + * @property {string|null} tag - The marked content tag. + * @property {string|null} lang - The lang attribute for the marked content. */ /** diff --git a/src/display/text_layer.js b/src/display/text_layer.js index 257c2ced0a58a..15d2e78466112 100644 --- a/src/display/text_layer.js +++ b/src/display/text_layer.js @@ -63,6 +63,8 @@ class TextLayer { #lang = null; + #langStack = []; + #layoutTextParams = null; #pageHeight = 0; @@ -127,7 +129,6 @@ class TextLayer { this.#layoutTextParams = { div: null, properties: null, - ctx: null, }; const { pageWidth, pageHeight, pageX, pageY } = viewport.rawDims; this.#transform = [1, 0, 0, -1, -pageX, pageY + pageHeight]; @@ -232,7 +233,6 @@ class TextLayer { const params = { div: null, properties: null, - ctx: TextLayer.#getCtx(this.#lang), }; for (const div of this.#textDivs) { params.properties = this.#textDivProperties.get(div); @@ -279,8 +279,6 @@ class TextLayer { if (this.#disableProcessItems) { return; } - this.#layoutTextParams.ctx ??= TextLayer.#getCtx(this.#lang); - const textDivs = this.#textDivs, textContentItemsStr = this.#textContentItemsStr; @@ -308,8 +306,18 @@ class TextLayer { if (item.tag === "Artifact") { this.#container.ariaHidden = true; } + // The lang is inherited from the closest enclosing marked content + // (or the top-level language), so that text measurements rely on the + // same font fallback as the one used by the browser to render it. + this.#langStack.push( + item.lang || this.#langStack.at(-1) || this.#lang + ); + if (item.lang) { + this.#container.setAttribute("lang", item.lang); + } parent.append(this.#container); } else if (item.type === "endMarkedContent") { + this.#langStack.pop(); this.#container = this.#container.parentNode; } continue; @@ -322,12 +330,14 @@ class TextLayer { #appendText(geom) { // Initialize all used properties to keep the caches monomorphic. const textDiv = document.createElement("span"); + const lang = this.#langStack.at(-1) || this.#lang; const textDivProperties = { angle: 0, canvasWidth: 0, hasText: geom.str !== "", hasEOL: geom.hasEOL, fontSize: 0, + lang, }; this.#textDivs.push(textDiv); @@ -346,7 +356,7 @@ class TextLayer { fontFamily = TextLayer.fontFamilyMap.get(fontFamily) || fontFamily; const fontHeight = Math.hypot(tx[2], tx[3]); const fontAscent = - fontHeight * TextLayer.#getAscent(fontFamily, style, this.#lang); + fontHeight * TextLayer.#getAscent(fontFamily, style, lang); let left, top; if (angle === 0) { @@ -422,12 +432,13 @@ class TextLayer { } #layout(params) { - const { div, properties, ctx } = params; + const { div, properties } = params; const { style } = div; if (properties.canvasWidth !== 0 && properties.hasText) { const { fontFamily } = style; const { canvasWidth, fontSize } = properties; + const ctx = TextLayer.#getCtx(properties.lang); TextLayer.#ensureCtxFont(ctx, fontSize * this.#scale, fontFamily); // Only measure the width for multi-char text divs, see `appendText`. @@ -521,9 +532,17 @@ class TextLayer { } static #getAscent(fontFamily, style, lang) { - const cachedAscent = this.#ascentCache.get(fontFamily); - if (cachedAscent) { - return cachedAscent; + // The font fallback (and thus its metrics) depends on the language, hence + // the ascent is cached per-language. + let langAscentCache = this.#ascentCache.get((lang ||= "")); + if (langAscentCache) { + const cachedAscent = langAscentCache.get(fontFamily); + if (cachedAscent) { + return cachedAscent; + } + } else { + langAscentCache = new Map(); + this.#ascentCache.set(lang, langAscentCache); } const ctx = this.#getCtx(lang); @@ -556,7 +575,7 @@ class TextLayer { } } - this.#ascentCache.set(fontFamily, ratio); + langAscentCache.set(fontFamily, ratio); return ratio; } } diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 54d04c8cf1eb3..70abe5ed4d91d 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -942,3 +942,4 @@ !issue17333.pdf !issue20504.pdf !issue20504_skia.pdf +!marked_content_lang.pdf diff --git a/test/pdfs/marked_content_lang.pdf b/test/pdfs/marked_content_lang.pdf new file mode 100644 index 0000000000000..606eef10afef4 Binary files /dev/null and b/test/pdfs/marked_content_lang.pdf differ diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index eb117ab297fa1..db2404023fe35 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -4974,6 +4974,21 @@ have written that much by now. So, here’s to squashing bugs.`); await loadingTask.destroy(); }); + it("gets operatorList, with marked content lang", async function () { + const loadingTask = getDocument( + buildGetDocumentParams("marked_content_lang.pdf") + ); + const pdfDoc = await loadingTask.promise; + const pdfPage = await pdfDoc.getPage(1); + const opList = await pdfPage.getOperatorList(); + expect(opList.fnArray[0]).toEqual(OPS.beginMarkedContentProps); + expect(opList.argsArray[0][0]).toEqual("P"); + expect(opList.argsArray[0][2]?.lang).toEqual("en-US"); + expect(opList.fnArray[10]).toEqual(OPS.beginMarkedContentProps); + expect(opList.argsArray[10][0]).toEqual("P"); + expect(opList.argsArray[10][2]?.lang).toEqual("es-ES"); + }); + it("gets operatorList, with page resources containing corrupt /CCITTFaxDecode data", async function () { const loadingTask = getDocument( buildGetDocumentParams("poppler-90-0-fuzzed.pdf") diff --git a/test/unit/text_layer_spec.js b/test/unit/text_layer_spec.js index ee097f80790f5..2f5314483d419 100644 --- a/test/unit/text_layer_spec.js +++ b/test/unit/text_layer_spec.js @@ -251,4 +251,29 @@ describe("textLayer", function () { await loadingTask.destroy(); }); + + it("handles lang attribute for marked content", async function () { + if (isNodeJS) { + pending("document.createElement is not supported in Node.js."); + } + const loadingTask = getDocument( + buildGetDocumentParams("marked_content_lang.pdf") + ); + const pdfDocument = await loadingTask.promise; + const page = await pdfDocument.getPage(1); + + const container = document.createElement("div"); + const textLayer = new TextLayer({ + textContentSource: page.streamTextContent({ + includeMarkedContent: true, + }), + container, + viewport: page.getViewport({ scale: 1 }), + }); + await textLayer.render(); + + const span = container.querySelector("#p17R_mc1"); + expect(span.getAttribute("lang")).toEqual("es-ES"); + expect(span.textContent).toEqual("Esto es español"); + }); });