diff --git a/src/common/components/view-popup/find-popup.js b/src/common/components/view-popup/find-popup.js
index 2f4abcd4d..e17a8c677 100644
--- a/src/common/components/view-popup/find-popup.js
+++ b/src/common/components/view-popup/find-popup.js
@@ -8,6 +8,7 @@ import IconChevronUp from '../../../../res/icons/20/chevron-up.svg';
import IconChevronDown from '../../../../res/icons/20/chevron-down.svg';
import IconClose from '../../../../res/icons/20/x.svg';
import { getCodeCombination, getKeyCombination } from '../../lib/utilities';
+import { compileFindRegExp } from '../../lib/find-pattern';
function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotation, tools }) {
const { l10n } = useLocalization();
@@ -18,15 +19,23 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati
currentParamsRef.current = params;
+ let invalid = !!(params.useRegex && query && !compileFindRegExp(query));
+
const debounceInputChange = useCallback(debounce(value => {
if (!inputRef.current) {
return;
}
let query = inputRef.current.value;
if (query !== currentParamsRef.current.query && !(query.length === 1 && RegExp(/^\p{Script=Latin}/, 'u').test(query))) {
+ if (currentParamsRef.current.useRegex && !compileFindRegExp(query)) {
+ // Record the query but don't search until the pattern is a valid regular expression
+ onChange({ ...currentParamsRef.current, query, active: false, result: null });
+ return;
+ }
onChange({ ...currentParamsRef.current, query, active: true, result: null });
}
- }, DEBOUNCE_FIND_POPUP_INPUT), [onChange]);
+ // Wait longer in regex mode -- half-typed patterns can be very broad
+ }, params.useRegex ? DEBOUNCE_FIND_POPUP_INPUT * 3 : DEBOUNCE_FIND_POPUP_INPUT), [onChange, params.useRegex]);
useLayoutEffect(() => {
if (params.popupOpen) {
@@ -56,6 +65,9 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati
let key = getKeyCombination(event);
let code = getCodeCombination(event);
if (key === 'Enter') {
+ if (invalid) {
+ return;
+ }
if (params.active) {
onFindNext();
}
@@ -64,6 +76,9 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati
}
}
else if (key === 'Shift-Enter') {
+ if (invalid) {
+ return;
+ }
if (params.active) {
onFindPrevious();
}
@@ -110,6 +125,18 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati
onChange({ ...params, entireWord: event.currentTarget.checked, result: null });
}
+ function handleUseRegexChange(event) {
+ let useRegex = event.currentTarget.checked;
+ if (useRegex && query && !compileFindRegExp(query)) {
+ // Deactivate the search until the pattern is a valid regular expression
+ onChange({ ...params, useRegex, active: false, result: null });
+ return;
+ }
+ // Reactivate the search if it was deactivated because of an invalid pattern
+ let wasInvalid = params.useRegex && !!params.query && !compileFindRegExp(params.query);
+ onChange({ ...params, useRegex, active: params.active || wasInvalid, result: null });
+ }
+
return (
{params.result &&
diff --git a/src/common/defines.js b/src/common/defines.js
index 14c9791d1..69b9ede48 100644
--- a/src/common/defines.js
+++ b/src/common/defines.js
@@ -42,6 +42,9 @@ export const FIND_RESULT_COLOR_ALL_LIGHT = 'rgba(180,0,170,0.3)';
export const FIND_RESULT_COLOR_CURRENT_LIGHT = 'rgba(0,100,0,0.3)';
export const FIND_RESULT_COLOR_ALL_DARK = 'rgba(180,0,170,0.6)';
export const FIND_RESULT_COLOR_CURRENT_DARK = 'rgba(0,100,0,0.6)';
+// Stop collecting find matches beyond this total -- highlighting and snippet
+// generation for unbounded match counts (e.g. a regex like '.') freezes the UI
+export const FIND_MAX_TOTAL_MATCHES = 5000;
export const ANNOTATION_POSITION_MAX_SIZE = 65000;
diff --git a/src/common/lib/find-pattern.js b/src/common/lib/find-pattern.js
new file mode 100644
index 000000000..d15e4c0b4
--- /dev/null
+++ b/src/common/lib/find-pattern.js
@@ -0,0 +1,10 @@
+// Find patterns use the strict Unicode-mode regex grammar, both for validation
+// in the find popup and for matching in the view-specific find implementations
+export function compileFindRegExp(source, flags = '') {
+ try {
+ return new RegExp(source, flags + 'u');
+ }
+ catch (e) {
+ return null;
+ }
+}
diff --git a/src/common/reader.js b/src/common/reader.js
index 7b71ee202..c9b89477e 100644
--- a/src/common/reader.js
+++ b/src/common/reader.js
@@ -281,6 +281,7 @@ class Reader {
highlightAll: true,
caseSensitive: false,
entireWord: false,
+ useRegex: false,
index: null,
result: null,
},
@@ -296,6 +297,7 @@ class Reader {
highlightAll: true,
caseSensitive: false,
entireWord: false,
+ useRegex: false,
index: null,
result: null
},
diff --git a/src/common/stylesheets/components/_view-popup.scss b/src/common/stylesheets/components/_view-popup.scss
index 081f41e39..ee4e83304 100644
--- a/src/common/stylesheets/components/_view-popup.scss
+++ b/src/common/stylesheets/components/_view-popup.scss
@@ -22,6 +22,14 @@
.row.input {
gap: 8px;
+ .toolbar-text-input.invalid {
+ border-color: var(--accent-red);
+
+ &:focus {
+ box-shadow: 0 0 0 var(--width-focus-border) var(--accent-red);
+ }
+ }
+
.input-box {
width: 100%;
display: flex;
@@ -56,10 +64,11 @@
}
.row.options {
- height: 16px;
- display: flex;
- align-items: flex-start;
- gap: 12px;
+ display: grid;
+ grid-template-columns: auto auto;
+ justify-content: start;
+ align-items: center;
+ gap: 6px 12px;
.option {
display: flex;
diff --git a/src/common/types.ts b/src/common/types.ts
index 5ad6fc688..ffcb7014a 100644
--- a/src/common/types.ts
+++ b/src/common/types.ts
@@ -189,6 +189,7 @@ export type FindState = {
highlightAll: boolean;
caseSensitive: boolean;
entireWord: boolean;
+ useRegex: boolean;
// For mobile app to focus specific result
index: number | null,
result: {
diff --git a/src/common/view.js b/src/common/view.js
index 913304477..c6fc41538 100644
--- a/src/common/view.js
+++ b/src/common/view.js
@@ -28,6 +28,7 @@ class View {
highlightAll: true,
caseSensitive: false,
entireWord: false,
+ useRegex: false,
index: null,
result: null,
// View can be created with an active search
@@ -171,6 +172,7 @@ class View {
* @param {String} [params.highlightAll]
* @param {String} [params.caseSensitive]
* @param {String} [params.entireWord]
+ * @param {String} [params.useRegex]
* @param {String} [params.index] Focus specific result
*/
find(params) {
@@ -188,6 +190,7 @@ class View {
highlightAll: true,
caseSensitive: false,
entireWord: false,
+ useRegex: false,
index: null,
result: null,
...(params || {})
diff --git a/src/dom/common/lib/find/index.ts b/src/dom/common/lib/find/index.ts
index 28a8b04a6..4578cd980 100644
--- a/src/dom/common/lib/find/index.ts
+++ b/src/dom/common/lib/find/index.ts
@@ -59,7 +59,8 @@ class DefaultFindProcessor implements FindProcessor {
this.findState.query,
{
caseSensitive: this.findState.caseSensitive,
- entireWord: this.findState.entireWord
+ entireWord: this.findState.entireWord,
+ useRegex: this.findState.useRegex
}
);
for (let internalOutputRange of ranges) {
@@ -231,6 +232,7 @@ class DefaultFindProcessor implements FindProcessor {
options: {
caseSensitive: boolean,
entireWord: boolean,
+ useRegex?: boolean,
}
): Promise {
if (this._worker) {
diff --git a/src/dom/common/lib/find/worker.ts b/src/dom/common/lib/find/worker.ts
index 9ea510abd..b291d4712 100644
--- a/src/dom/common/lib/find/worker.ts
+++ b/src/dom/common/lib/find/worker.ts
@@ -1,4 +1,6 @@
import type { InternalCharDataRange, InternalOutputRange, InternalSearchContext } from "./internal-types";
+import { FIND_MAX_TOTAL_MATCHES } from "../../../../common/defines";
+import { compileFindRegExp } from "../../../../common/lib/find-pattern";
onmessage = async (event) => {
let { context, term, options } = event.data;
@@ -11,6 +13,7 @@ export function executeSearch(
options: {
caseSensitive: boolean,
entireWord: boolean,
+ useRegex?: boolean,
}
): InternalOutputRange[] {
if (!term) {
@@ -21,14 +24,32 @@ export function executeSearch(
let ranges: InternalOutputRange[] = [];
// https://stackoverflow.com/a/6969486
- let termRe = normalize(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ let termRe = options.useRegex
+ ? normalize(term)
+ : normalize(term).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (options.entireWord) {
- termRe = '\\b' + termRe + '\\b';
+ termRe = '\\b(?:' + termRe + ')\\b';
+ }
+ let flags = 'g' + (options.caseSensitive ? '' : 'i');
+ let re;
+ if (options.useRegex) {
+ re = compileFindRegExp(termRe, flags);
+ if (!re) {
+ return [];
+ }
+ }
+ else {
+ re = new RegExp(termRe, flags);
}
- let re = new RegExp(termRe, 'g' + (options.caseSensitive ? '' : 'i'));
let matches;
while ((matches = re.exec(text))) {
let [match] = matches;
+ if (!match.length) {
+ // A pattern like 'a*' can match an empty string, which would
+ // otherwise loop forever
+ re.lastIndex++;
+ continue;
+ }
let { charDataID: startCharDataID, start: startOffset } = binarySearch(internalCharDataRanges, matches.index)!;
let { charDataID: endCharDataID, start: endOffset } = binarySearch(internalCharDataRanges, matches.index + match.length)!;
ranges.push({
@@ -37,6 +58,9 @@ export function executeSearch(
endCharDataID,
endIndex: matches.index + match.length - endOffset,
});
+ if (ranges.length >= FIND_MAX_TOTAL_MATCHES) {
+ break;
+ }
}
return ranges;
diff --git a/src/dom/epub/epub-view.ts b/src/dom/epub/epub-view.ts
index bfced30b7..37247fc1c 100644
--- a/src/dom/epub/epub-view.ts
+++ b/src/dom/epub/epub-view.ts
@@ -1246,6 +1246,7 @@ class EPUBView extends DOMView {
|| previousState.query !== state.query
|| previousState.caseSensitive !== state.caseSensitive
|| previousState.entireWord !== state.entireWord
+ || previousState.useRegex !== state.useRegex
|| previousState.active !== state.active) {
console.log('Initiating new search', state);
this._find?.cancel();
diff --git a/src/dom/snapshot/snapshot-view.ts b/src/dom/snapshot/snapshot-view.ts
index 53acd83cc..40254f0dd 100644
--- a/src/dom/snapshot/snapshot-view.ts
+++ b/src/dom/snapshot/snapshot-view.ts
@@ -630,6 +630,7 @@ class SnapshotView extends DOMView {
|| previousState.query !== state.query
|| previousState.caseSensitive !== state.caseSensitive
|| previousState.entireWord !== state.entireWord
+ || previousState.useRegex !== state.useRegex
|| previousState.active !== state.active) {
console.log('Initiating new search', state);
this._find = new DefaultFindProcessor({
diff --git a/src/pdf/pdf-find-controller.js b/src/pdf/pdf-find-controller.js
index 824b2c7d4..29642c949 100644
--- a/src/pdf/pdf-find-controller.js
+++ b/src/pdf/pdf-find-controller.js
@@ -26,6 +26,8 @@ Promise.withResolvers || (Promise.withResolvers = function withResolvers() {
});
import { getRangeRects } from './lib/utilities';
+import { FIND_MAX_TOTAL_MATCHES } from '../common/defines';
+import { compileFindRegExp } from '../common/lib/find-pattern';
// pdf_find_utils.js [
const CharacterType = {
@@ -536,8 +538,14 @@ function getSnippet(text, phraseStart, phraseEnd, numWordsAround) {
let phrase = text.substring(phraseStart, phraseEnd);
- let leftText = text.substring(0, phraseStart).trim();
- let rightText = text.substring(phraseEnd).trim();
+ // Only tokenize a bounded window around the phrase -- splitting the whole
+ // page text for every match freezes the UI on high-match-count searches
+ const windowSize = numWordsAround * 20;
+ let windowStart = Math.max(0, phraseStart - windowSize);
+ let windowEnd = Math.min(text.length, phraseEnd + windowSize);
+
+ let leftText = text.substring(windowStart, phraseStart).trim();
+ let rightText = text.substring(phraseEnd, windowEnd).trim();
let leftWords = leftText ? leftText.split(/\s+/) : [];
let rightWords = rightText ? rightText.split(/\s+/) : [];
@@ -549,8 +557,8 @@ function getSnippet(text, phraseStart, phraseEnd, numWordsAround) {
let rightSnippet = rightSnippetWords.join(' ');
// Optionally add ellipses if there are more words that we didn't include.
- let prefix = leftWords.length > numWordsAround ? '…' : '';
- let suffix = rightWords.length > numWordsAround ? '…' : '';
+ let prefix = leftWords.length > numWordsAround || windowStart > 0 ? '…' : '';
+ let suffix = rightWords.length > numWordsAround || windowEnd < text.length ? '…' : '';
// Determine if the phrase starts or ends mid-word.
// If phraseStart is in the middle of a word (i.e. the character immediately before is not whitespace)
@@ -904,9 +912,19 @@ class PDFFindController {
// been stripped out.
return;
}
+ const maxMatches = FIND_MAX_TOTAL_MATCHES - this._matchesCountTotal;
+ if (maxMatches <= 0) {
+ return;
+ }
const diffs = this._pageDiffs[pageIndex];
let match;
while ((match = query.exec(pageContent)) !== null) {
+ if (match[0].length === 0) {
+ // A pattern like 'a*' can match an empty string, which would
+ // otherwise loop forever
+ query.lastIndex++;
+ continue;
+ }
if (
entireWord &&
!this._isEntireWord(pageContent, match.index, match[0].length)
@@ -923,6 +941,9 @@ class PDFFindController {
if (matchLen) {
matches.push(matchPos);
matchesLength.push(matchLen);
+ if (matches.length >= maxMatches) {
+ break;
+ }
}
}
}
@@ -1002,29 +1023,38 @@ class PDFFindController {
if (query.length === 0) {
return; // Do nothing: the matches should be wiped out already.
}
- const { caseSensitive, entireWord } = this._state;
+ const { caseSensitive, entireWord, useRegex } = this._state;
const pageContent = this._pageContents[pageIndex];
const hasDiacritics = this._hasDiacritics[pageIndex];
let isUnicode = false;
- if (typeof query === "string") {
- [isUnicode, query] = this._convertToRegExpString(query, hasDiacritics);
+ if (useRegex) {
+ if (Array.isArray(query)) {
+ query = query.map(q => `(?:${q})`).join("|");
+ }
+ // Use the pattern as-is; null if invalid
+ query = compileFindRegExp(query, `g${caseSensitive ? "" : "i"}`);
}
else {
- // Words are sorted in reverse order to be sure that "foobar" is matched
- // before "foo" in case the query is "foobar foo".
- query = query.sort().reverse().map(q => {
- const [isUnicodePart, queryPart] = this._convertToRegExpString(
- q,
- hasDiacritics
- );
- isUnicode ||= isUnicodePart;
- return `(${queryPart})`;
- }).join("|");
- }
+ if (typeof query === "string") {
+ [isUnicode, query] = this._convertToRegExpString(query, hasDiacritics);
+ }
+ else {
+ // Words are sorted in reverse order to be sure that "foobar" is matched
+ // before "foo" in case the query is "foobar foo".
+ query = query.sort().reverse().map(q => {
+ const [isUnicodePart, queryPart] = this._convertToRegExpString(
+ q,
+ hasDiacritics
+ );
+ isUnicode ||= isUnicodePart;
+ return `(${queryPart})`;
+ }).join("|");
+ }
- const flags = `g${isUnicode ? "u" : ""}${caseSensitive ? "" : "i"}`;
- query = query ? new RegExp(query, flags) : null;
+ const flags = `g${isUnicode ? "u" : ""}${caseSensitive ? "" : "i"}`;
+ query = query ? new RegExp(query, flags) : null;
+ }
this._calculateRegExpMatch(query, entireWord, pageIndex, pageContent);
diff --git a/src/pdf/pdf-view.js b/src/pdf/pdf-view.js
index f866e918e..8b6e5e22e 100644
--- a/src/pdf/pdf-view.js
+++ b/src/pdf/pdf-view.js
@@ -1533,6 +1533,7 @@ class PDFView {
|| this._findState.highlightAll !== state.highlightAll
|| this._findState.caseSensitive !== state.caseSensitive
|| this._findState.entireWord !== state.entireWord
+ || this._findState.useRegex !== state.useRegex
|| this._findState.active !== state.active) {
// Immediately update find state because pdf.js find will trigger _updateFindMatchesCount
// and _updateFindControlState that update the current find state
@@ -1544,6 +1545,7 @@ class PDFView {
phraseSearch: true,
caseSensitive: state.caseSensitive,
entireWord: state.entireWord,
+ useRegex: state.useRegex,
highlightAll: state.highlightAll,
findPrevious: false
});
@@ -1565,6 +1567,7 @@ class PDFView {
phraseSearch: true,
caseSensitive: this._findState.caseSensitive,
entireWord: this._findState.entireWord,
+ useRegex: this._findState.useRegex,
highlightAll: this._findState.highlightAll,
findPrevious: false
});
@@ -1580,6 +1583,7 @@ class PDFView {
phraseSearch: true,
caseSensitive: this._findState.caseSensitive,
entireWord: this._findState.entireWord,
+ useRegex: this._findState.useRegex,
highlightAll: this._findState.highlightAll,
findPrevious: true
});