Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
125 changes: 34 additions & 91 deletions web/pdf_find_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,103 +15,46 @@

import { FeatureTest } from "pdfjs-lib";

const CharacterType = {
SPACE: 0,
ALPHA_LETTER: 1,
PUNCT: 2,
HAN_LETTER: 3,
KATAKANA_LETTER: 4,
HIRAGANA_LETTER: 5,
HALFWIDTH_KATAKANA_LETTER: 6,
THAI_LETTER: 7,
};

function isAlphabeticalScript(charCode) {
return charCode < 0x2e80;
}

function isAscii(charCode) {
return (charCode & 0xff80) === 0;
}

function isAsciiAlpha(charCode) {
return (
(charCode >= /* a = */ 0x61 && charCode <= /* z = */ 0x7a) ||
(charCode >= /* A = */ 0x41 && charCode <= /* Z = */ 0x5a)
);
}

function isAsciiDigit(charCode) {
return charCode >= /* 0 = */ 0x30 && charCode <= /* 9 = */ 0x39;
}

function isAsciiSpace(charCode) {
return (
charCode === /* SPACE = */ 0x20 ||
charCode === /* TAB = */ 0x09 ||
charCode === /* CR = */ 0x0d ||
charCode === /* LF = */ 0x0a
);
}
let wordSegmenter = null;
let graphemeSegmenter = null;

function isWordBreakAt(content, pos) {
// `pos` must be a grapheme boundary (so a combining mark stays attached to
// its base, and a surrogate pair isn't split) as well as a word boundary,
// the latter tested on the two adjacent grapheme clusters in isolation like
// Firefox's find (see `nsFind::BreakInBetween`).
graphemeSegmenter ||= new Intl.Segmenter(undefined, {
granularity: "grapheme",
});

const graphemes = graphemeSegmenter.segment(content);
const after = graphemes.containing(pos);
if (after.index !== pos) {
return false;
}

function isHan(charCode) {
wordSegmenter ||= new Intl.Segmenter(undefined, { granularity: "word" });
const before = graphemes.containing(pos - 1).segment;
return (
(charCode >= 0x3400 && charCode <= 0x9fff) ||
(charCode >= 0xf900 && charCode <= 0xfaff)
wordSegmenter.segment(before + after.segment).containing(before.length)
.index === before.length
);
}

function isKatakana(charCode) {
return charCode >= 0x30a0 && charCode <= 0x30ff;
}

function isHiragana(charCode) {
return charCode >= 0x3040 && charCode <= 0x309f;
}

function isHalfwidthKatakana(charCode) {
return charCode >= 0xff60 && charCode <= 0xff9f;
}

function isThai(charCode) {
return (charCode & 0xff80) === 0x0e00;
}

/**
* This function is based on the word-break detection implemented in:
* https://hg.mozilla.org/mozilla-central/file/tip/intl/lwbrk/WordBreaker.cpp
* Determine if the match spanning `[startIdx, startIdx + length)` in `content`
* is a whole word, i.e. there's a word break on each side of it. Each boundary
* is tested on its two adjacent characters in isolation rather than on the
* whole string, so a contraction such as "can't" doesn't prevent "can" from
* being an entire word, matching Firefox's find:
* https://searchfox.org/firefox-main/source/toolkit/components/find/nsFind.cpp
*/
function getCharacterType(charCode) {
if (isAlphabeticalScript(charCode)) {
if (isAscii(charCode)) {
if (isAsciiSpace(charCode)) {
return CharacterType.SPACE;
} else if (
isAsciiAlpha(charCode) ||
isAsciiDigit(charCode) ||
charCode === /* UNDERSCORE = */ 0x5f
) {
return CharacterType.ALPHA_LETTER;
}
return CharacterType.PUNCT;
} else if (isThai(charCode)) {
return CharacterType.THAI_LETTER;
} else if (charCode === /* NBSP = */ 0xa0) {
return CharacterType.SPACE;
}
return CharacterType.ALPHA_LETTER;
}

if (isHan(charCode)) {
return CharacterType.HAN_LETTER;
} else if (isKatakana(charCode)) {
return CharacterType.KATAKANA_LETTER;
} else if (isHiragana(charCode)) {
return CharacterType.HIRAGANA_LETTER;
} else if (isHalfwidthKatakana(charCode)) {
return CharacterType.HALFWIDTH_KATAKANA_LETTER;
}
return CharacterType.ALPHA_LETTER;
function isEntireWord(content, startIdx, length) {
const endIdx = startIdx + length;
return (
(startIdx === 0 || isWordBreakAt(content, startIdx)) &&
(endIdx === content.length || isWordBreakAt(content, endIdx))
);
}

let NormalizeWithNFKC;
Expand Down Expand Up @@ -191,4 +134,4 @@ function getNormalizeWithNFKC() {
return NormalizeWithNFKC;
}

export { CharacterType, getCharacterType, getNormalizeWithNFKC };
export { getNormalizeWithNFKC, isEntireWord };