Skip to content
2 changes: 1 addition & 1 deletion src/core/fonts.js
Original file line number Diff line number Diff line change
Expand Up @@ -3710,7 +3710,7 @@ class Font {

for (let i = 0, ii = str.length; i < ii; i++) {
const unicode = str.codePointAt(i);
if (unicode > 0xd7ff && (unicode < 0xe000 || unicode > 0xfffd)) {
if (unicode > 0xffff) {
// unicode is represented by two uint16
i++;
}
Expand Down
18 changes: 11 additions & 7 deletions src/core/type1_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ class Type1Parser {
privateData,
},
};
let token, length, data, lenIV;
let token, length, data;
// Some fonts (e.g. those embedded in issue18548.pdf) define a second
// `/Subrs` and `/CharStrings` block that the PostScript runtime selects
// conditionally (e.g. high-resolution variants). Testing with other
Expand Down Expand Up @@ -603,8 +603,10 @@ class Type1Parser {
length = this.readInt();
this.getToken(); // read in 'RD' or '-|'
data = length > 0 ? stream.getBytes(length) : new Uint8Array(0);
lenIV = privateData.get("lenIV");
const encoded = this.readCharStrings(data, lenIV);
const encoded = this.readCharStrings(
data,
privateData.get("lenIV")
);
this.nextChar();
token = this.getToken(); // read in 'ND' or '|-'
if (token === "noaccess") {
Expand Down Expand Up @@ -632,8 +634,10 @@ class Type1Parser {
length = this.readInt();
this.getToken(); // read in 'RD' or '-|'
data = length > 0 ? stream.getBytes(length) : new Uint8Array(0);
lenIV = privateData.get("lenIV");
const encoded = this.readCharStrings(data, lenIV);
const encoded = this.readCharStrings(
data,
privateData.get("lenIV")
);
this.nextChar();
token = this.getToken(); // read in 'NP' or '|'
if (token === "noaccess") {
Expand All @@ -650,9 +654,9 @@ class Type1Parser {
// *Blue* values may contain invalid data: disables reading of
// those values when hinting is disabled.
if (
HINTING_ENABLED &&
blueArray.length > 0 &&
blueArray.length % 2 === 0 &&
HINTING_ENABLED
blueArray.length % 2 === 0
) {
privateData.set(token, blueArray);
}
Expand Down
2 changes: 1 addition & 1 deletion src/display/text_layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ class TextLayer {
this.#container = document.createElement("span");
this.#container.classList.add("markedContent");
if (item.id) {
this.#container.setAttribute("id", `${item.id}`);
this.#container.setAttribute("id", item.id);
}
if (item.tag === "Artifact") {
this.#container.ariaHidden = true;
Expand Down
1 change: 1 addition & 0 deletions test/unit/clitests.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"event_utils_spec.js",
"fetch_stream_spec.js",
"font_substitutions_spec.js",
"fonts_spec.js",
"image_utils_spec.js",
"message_handler_spec.js",
"metadata_spec.js",
Expand Down
44 changes: 44 additions & 0 deletions test/unit/fonts_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Font } from "../../src/core/fonts.js";
import { IdentityToUnicodeMap } from "../../src/core/to_unicode_map.js";

describe("Font", () => {
describe("encodeString", () => {
// `encodeString` only reads `this.toUnicode` and `this.cMap`, so a
// full `Font` (which needs a complete properties/font-file setup) isn't
// necessary to exercise it in isolation.
function encodeString(str, { cMap = null } = {}) {
const fakeFont = {
toUnicode: new IdentityToUnicodeMap(0, 0x10ffff),
cMap,
};
return Font.prototype.encodeString.call(fakeFont, str);
}

it("should keep the character after U+FFFE or U+FFFF", () => {
expect(encodeString("￿A")).toEqual(["\xffA"]);
expect(encodeString("￾B")).toEqual(["\xfeB"]);
});

it("should still treat a real surrogate pair as one code point", () => {
// U+1F602 ("😂") is genuinely represented by a surrogate pair; the
// character after it must still be kept, and the pair itself must
// not be split into its two unpaired halves.
expect(encodeString("😂C")).toEqual(["\x02C"]);
});
});
});
1 change: 1 addition & 0 deletions test/unit/jasmine-boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ async function initializePDFJS(callback) {
"pdfjs-test/unit/event_utils_spec.js",
"pdfjs-test/unit/fetch_stream_spec.js",
"pdfjs-test/unit/font_substitutions_spec.js",
"pdfjs-test/unit/fonts_spec.js",
"pdfjs-test/unit/image_utils_spec.js",
"pdfjs-test/unit/message_handler_spec.js",
"pdfjs-test/unit/metadata_spec.js",
Expand Down
123 changes: 88 additions & 35 deletions test/unit/pdf_find_utils_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,44 +13,97 @@
* limitations under the License.
*/

import { CharacterType, getCharacterType } from "../../web/pdf_find_utils.js";
import { isEntireWord } from "../../web/pdf_find_utils.js";

describe("pdf_find_utils", function () {
describe("getCharacterType", function () {
it("gets expected character types", function () {
const characters = {
A: CharacterType.ALPHA_LETTER,
a: CharacterType.ALPHA_LETTER,
0: CharacterType.ALPHA_LETTER,
5: CharacterType.ALPHA_LETTER,
"\xC4": CharacterType.ALPHA_LETTER, // "Ä"
"\xE4": CharacterType.ALPHA_LETTER, // "ä"
_: CharacterType.ALPHA_LETTER,
" ": CharacterType.SPACE,
"\t": CharacterType.SPACE,
"\r": CharacterType.SPACE,
"\n": CharacterType.SPACE,
"\xA0": CharacterType.SPACE, // nbsp
"-": CharacterType.PUNCT,
",": CharacterType.PUNCT,
".": CharacterType.PUNCT,
";": CharacterType.PUNCT,
":": CharacterType.PUNCT,
"\u2122": CharacterType.ALPHA_LETTER, // trademark
"\u0E25": CharacterType.THAI_LETTER,
"\u4000": CharacterType.HAN_LETTER,
"\uF950": CharacterType.HAN_LETTER,
"\u30C0": CharacterType.KATAKANA_LETTER,
"\u3050": CharacterType.HIRAGANA_LETTER,
"\uFF80": CharacterType.HALFWIDTH_KATAKANA_LETTER,
};

for (const character in characters) {
const charCode = character.charCodeAt(0);
const type = characters[character];

expect(getCharacterType(charCode)).toEqual(type);
describe("isEntireWord", function () {
it("matches whole words and rejects sub-words", function () {
const content = "the quick brown fox";
// [startIdx, length, expected]
const cases = [
[0, 3, true], // "the" (start of the content)
[4, 5, true], // "quick"
[10, 5, true], // "brown"
[16, 3, true], // "fox" (end of the content)
[1, 2, false], // "he" inside "the"
[5, 3, false], // "uic" inside "quick"
[10, 4, false], // "brow" inside "brown"
];
for (const [startIdx, length, expected] of cases) {
expect(isEntireWord(content, startIdx, length)).toEqual(expected);
}
});

it("treats ASCII punctuation as a word boundary", function () {
const content = "foo-bar (baz)";
expect(isEntireWord(content, 0, 3)).toEqual(true); // "foo" before "-"
expect(isEntireWord(content, 4, 3)).toEqual(true); // "bar" before " "
expect(isEntireWord(content, 9, 3)).toEqual(true); // "baz" inside "()"
expect(isEntireWord(content, 0, 7)).toEqual(true); // "foo-bar"
});

it("treats non-ASCII punctuation as a word boundary", function () {
// These were previously misclassified as letters, so a word wrapped in
// them wasn't recognized as an entire word.
for (const [open, close] of [
["«", "»"], // « »
["“", "”"], // “ ”
["—", "—"], // em dashes
]) {
const content = `${open}word${close}`;
expect(isEntireWord(content, 1, 4)).toEqual(true); // "word"
expect(isEntireWord(content, 1, 3)).toEqual(false); // "wor"
}
});

it("treats a trailing superscript as a word boundary", function () {
// A footnote/reference marker or an exponent must not prevent the
// preceding word from matching as an entire word.
for (const superscript of ["¹", "²", "³", "⁴"]) {
const content = `word${superscript}`;
expect(isEntireWord(content, 0, 4)).toEqual(true); // "word"
}
});

it("matches a word next to a contraction apostrophe", function () {
// The apostrophe is a word break in isolation (as in Firefox's find), so
// the leading part of a contraction is still an entire word.
expect(isEntireWord("I can't do that", 2, 3)).toEqual(true); // "can"
expect(isEntireWord("don't", 0, 3)).toEqual(true); // "don"
expect(isEntireWord("it's", 0, 2)).toEqual(true); // "it"
});

it("keeps a word joined by a connecting character", function () {
expect(isEntireWord("foo_bar", 0, 3)).toEqual(false); // "foo" in "foo_bar"
expect(isEntireWord("foo_bar", 4, 3)).toEqual(false); // "bar" in "foo_bar"
});

it("handles combining marks (NFD) within a word", function () {
// "café" in the normalized page content is NFD: "cafe" followed by
// U+0301 COMBINING ACUTE ACCENT.
const content = "café".normalize("NFD");
expect(isEntireWord(content, 0, content.length)).toEqual(true); // whole
expect(isEntireWord(content, 0, 3)).toEqual(false); // "caf"
expect(isEntireWord(content, 0, 4)).toEqual(false); // "cafe", before U+0301
});

it("keeps a base character and its combining mark together", function () {
// "áb" in NFD is "a" + U+0301 + "b"; it's a single word.
const ab = "áb".normalize("NFD");
expect(isEntireWord(ab, 2, 1)).toEqual(false); // "b" inside "áb"
expect(isEntireWord(ab, 0, 2)).toEqual(false); // "á" inside "áb"
// "café" is a whole word before a space, but not inside "caféteria".
expect(isEntireWord("café bar".normalize("NFD"), 0, 5)).toEqual(true);
expect(isEntireWord("caféteria".normalize("NFD"), 0, 5)).toEqual(false);
});

it("keeps non-BMP characters intact", function () {
// "a𝐀b" is a single word (𝐀 = U+1D400, a surrogate pair).
const withBoldA = "a\u{1D400}b";
expect(isEntireWord(withBoldA, 0, 1)).toEqual(false); // "a"
expect(isEntireWord(withBoldA, 3, 1)).toEqual(false); // "b"
// Each CJK ideograph is its own word (𠀀 = U+20000, a surrogate pair).
expect(isEntireWord("中\u{20000}国", 0, 1)).toEqual(true); // "中"
});
});
});
36 changes: 2 additions & 34 deletions web/pdf_find_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
/** @typedef {import("./event_utils").EventBus} EventBus */
/** @typedef {import("./pdf_link_service.js").PDFLinkService} PDFLinkService */

import { getCharacterType, getNormalizeWithNFKC } from "./pdf_find_utils.js";
import { getNormalizeWithNFKC, isEntireWord } from "./pdf_find_utils.js";
import { binarySearchFirstItem } from "./ui_utils.js";
import { internalOpt } from "./internal_evt.js";

Expand Down Expand Up @@ -76,8 +76,6 @@ let DIACRITICS_EXCEPTION_STR; // Lazily initialized, see below.

const DIACRITICS_REG_EXP = /\p{M}+/gu;
const SPECIAL_CHARS_REG_EXP = /([+^$|])|(\p{P}+)|(\s+)|(\p{M})|(\p{L})/gu;
const NOT_DIACRITIC_FROM_END_REG_EXP = /(\P{M})\p{M}*$/u;
const NOT_DIACRITIC_FROM_START_REG_EXP = /^\p{M}*(\P{M})/u;

// The range [AC00-D7AF] corresponds to the Hangul syllables.
// The few other chars are some CJK Compatibility Ideographs.
Expand Down Expand Up @@ -680,36 +678,6 @@ class PDFFindController {
return true;
}

/**
* Determine if the search query constitutes a "whole word", by comparing the
* first/last character type with the preceding/following character type.
*/
#isEntireWord(content, startIdx, length) {
let match = content
.slice(0, startIdx)
.match(NOT_DIACRITIC_FROM_END_REG_EXP);
if (match) {
const first = content.charCodeAt(startIdx);
const limit = match[1].charCodeAt(0);
if (getCharacterType(first) === getCharacterType(limit)) {
return false;
}
}

match = content
.slice(startIdx + length)
.match(NOT_DIACRITIC_FROM_START_REG_EXP);
if (match) {
const last = content.charCodeAt(startIdx + length - 1);
const limit = match[1].charCodeAt(0);
if (getCharacterType(last) === getCharacterType(limit)) {
return false;
}
}

return true;
}

#convertToRegExpString(query, hasDiacritics) {
const { matchDiacritics } = this.#state;
let isUnicode = false;
Expand Down Expand Up @@ -890,7 +858,7 @@ class PDFFindController {
while ((match = query.exec(pageContent)) !== null) {
if (
entireWord &&
!this.#isEntireWord(pageContent, match.index, match[0].length)
!isEntireWord(pageContent, match.index, match[0].length)
) {
continue;
}
Expand Down
Loading
Loading