dsh-book-reader 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core/pdf.js CHANGED
@@ -6,7 +6,9 @@
6
6
  * - a small LRU cache of parsed PDF.js documents (path + mtime + size),
7
7
  * - metadata (page labels, detected printed-page offset, resolved outline),
8
8
  * - whitespace-normalised page text,
9
- * - literal full-text search with physical *and* printed page numbers.
9
+ * - full-text search with physical *and* printed page numbers. Search is
10
+ * literal-ish on NFKC-normalised text, with compact (space-insensitive)
11
+ * and all-terms fallbacks, body pages ranked before front matter.
10
12
  *
11
13
  * Design constraints that shaped this file:
12
14
  *
@@ -850,19 +852,438 @@ export async function getPageText(path, pdfPage) {
850
852
  })
851
853
  }
852
854
 
855
+ // ---------------------------------------------------------------------------
856
+ // Query cleanup + searchable text layout
857
+ // ---------------------------------------------------------------------------
858
+
859
+ /** Longest accepted query after wrapper/whitespace cleanup. */
860
+ export const MAX_SEARCH_QUERY_LENGTH = 1000
861
+
862
+ /** At most this many front-matter hits are returned, after body hits. */
863
+ export const MAX_FRONT_MATTER_HITS = 3
864
+
865
+ /**
866
+ * Whole-query wrappers a model commonly adds. Only a pair surrounding the
867
+ * entire query is stripped, so `don't` or an inner quote is left alone.
868
+ */
869
+ const QUERY_WRAPPERS = [
870
+ ['"', '"'],
871
+ ["'", "'"],
872
+ ['“', '”'],
873
+ ['‘', '’'],
874
+ ['`', '`'],
875
+ ['$', '$'],
876
+ ]
877
+
878
+ /** Unicode dash variants deleted for hyphen-insensitive matching. */
879
+ const DASH_CHARACTERS = new Set(['-', '\u2010', '\u2011', '\u2012', '\u2013', '\u2014', '\u2015', '\u2212'])
880
+
881
+ const SOFT_HYPHEN = '\u00ad'
882
+ const WHITESPACE_RE = /\s/u
883
+
884
+ /** Term-AND fallback: ignore one-character noise, examine at most 8 words. */
885
+ const MAX_TERM_TOKENS = 8
886
+
887
+ /** @param {string} value */
888
+ function isWhitespace(value) {
889
+ return WHITESPACE_RE.test(value)
890
+ }
891
+
892
+ /**
893
+ * Trim a query, strip wrapping quotes/backticks/`$…$`, collapse whitespace,
894
+ * and reject empty or over-long results. This is deliberately done before
895
+ * opening the PDF:
896
+ *
897
+ * "eigenvalue" -> eigenvalue
898
+ * `L(V)` -> L(V)
899
+ * $T \in L(V)$ -> T \in L(V)
900
+ *
901
+ * @param {string} raw
902
+ * @returns {string}
903
+ */
904
+ export function cleanSearchQuery(raw) {
905
+ if (typeof raw !== 'string') {
906
+ throw new TypeError('searchPdf: query must be a non-empty string')
907
+ }
908
+
909
+ let query = raw.trim()
910
+ let stripped = true
911
+ while (stripped && query.length >= 2) {
912
+ stripped = false
913
+ for (const [open, close] of QUERY_WRAPPERS) {
914
+ if (query.startsWith(open) && query.endsWith(close)) {
915
+ query = query.slice(open.length, query.length - close.length).trim()
916
+ stripped = true
917
+ break
918
+ }
919
+ }
920
+ }
921
+
922
+ query = query.replace(/\s+/gu, ' ').trim()
923
+ if (query === '') {
924
+ throw new TypeError('searchPdf: query must be a non-empty string')
925
+ }
926
+ if (query.length > MAX_SEARCH_QUERY_LENGTH) {
927
+ const error = new RangeError(
928
+ `searchPdf: query is longer than ${MAX_SEARCH_QUERY_LENGTH} characters`,
929
+ )
930
+ error.code = 'PDF_SEARCH_QUERY_TOO_LONG'
931
+ throw error
932
+ }
933
+ return query
934
+ }
935
+
936
+ /** Read one code point (a surrogate pair stays together). */
937
+ function readCodePointAt(text, index) {
938
+ const code = text.charCodeAt(index)
939
+ if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
940
+ const next = text.charCodeAt(index + 1)
941
+ if (next >= 0xdc00 && next <= 0xdfff) {
942
+ return { ch: text.slice(index, index + 2), width: 2 }
943
+ }
944
+ }
945
+ return { ch: text[index], width: 1 }
946
+ }
947
+
948
+ /**
949
+ * Build NFKC-normalised text plus a `normalizedIndex -> originalIndex` map.
950
+ *
951
+ * Normalisation rules (matching-only; snippets still come from the original):
952
+ * - NFKC per code point: `ℒ (𝑉)` -> `L (V)`, math italics -> ASCII,
953
+ * `fi` -> `fi`, full-width letters/digits -> ASCII.
954
+ * - soft hyphens (U+00AD) are removed;
955
+ * - every dash (U+002D, U+2010–U+2015, U+2212) is removed. This makes
956
+ * matching hyphen-insensitive. When a dash is followed by whitespace --
957
+ * which is what a line-break hyphen looks like after extraction -- the
958
+ * whitespace is removed too, so `com- plex` becomes `complex`.
959
+ * - whitespace runs collapse to one space; leading/trailing space is
960
+ * dropped.
961
+ *
962
+ * The map covers every UTF-16 unit of the result, so callers can cut snippets
963
+ * from the original text.
964
+ *
965
+ * @param {string} original already whitespace-collapsed source text
966
+ * @returns {{text: string, map: number[]}}
967
+ */
968
+ function buildNormalizedText(original) {
969
+ const chars = []
970
+ const map = []
971
+ let pendingSpaceAt = -1
972
+ let index = 0
973
+
974
+ while (index < original.length) {
975
+ const { ch, width } = readCodePointAt(original, index)
976
+ if (ch === SOFT_HYPHEN) {
977
+ index += width
978
+ continue
979
+ }
980
+
981
+ // ASCII is NFKC-stable; skip the costlier normalisation call for it.
982
+ const folded = ch.charCodeAt(0) < 0x80 ? ch : ch.normalize('NFKC')
983
+
984
+ if (DASH_CHARACTERS.has(folded)) {
985
+ index += width
986
+ // Line-break hyphenation: join the two halves by dropping the
987
+ // whitespace that follows the dash as well.
988
+ while (index < original.length) {
989
+ const next = readCodePointAt(original, index)
990
+ if (!isWhitespace(next.ch)) break
991
+ index += next.width
992
+ }
993
+ continue
994
+ }
995
+
996
+ if (isWhitespace(folded)) {
997
+ if (chars.length > 0 && pendingSpaceAt < 0) pendingSpaceAt = index
998
+ index += width
999
+ continue
1000
+ }
1001
+
1002
+ if (pendingSpaceAt >= 0) {
1003
+ chars.push(' ')
1004
+ map.push(pendingSpaceAt)
1005
+ pendingSpaceAt = -1
1006
+ }
1007
+ // A code point may expand (`fi` -> `fi`); every produced unit maps back to
1008
+ // the same source code point.
1009
+ for (let offset = 0; offset < folded.length; offset += 1) {
1010
+ chars.push(folded[offset])
1011
+ map.push(index)
1012
+ }
1013
+ index += width
1014
+ }
1015
+
1016
+ return { text: chars.join(''), map }
1017
+ }
1018
+
1019
+ /**
1020
+ * Normalise text for matching (NFKC, dash-insensitive, single-spaced).
1021
+ * Exported for tests; searchPdf uses the same rules.
1022
+ *
1023
+ * @param {string} raw
1024
+ * @returns {string}
1025
+ */
1026
+ export function normalizeSearchText(raw) {
1027
+ const original = String(raw ?? '')
1028
+ .replace(/\s+/gu, ' ')
1029
+ .trim()
1030
+ return buildNormalizedText(original).text
1031
+ }
1032
+
1033
+ /**
1034
+ * One page's matching views. `original` is exactly what getPageText()
1035
+ * returns, so `indexOnPage` and snippets use the host-facing text.
1036
+ *
1037
+ * @typedef {Object} SearchLayout
1038
+ * @property {string} original
1039
+ * @property {string} normalized
1040
+ * @property {number[]} normalizedToOriginal
1041
+ * @property {string} compact
1042
+ * @property {number[]} compactToNormalized
1043
+ * @property {string} normalizedLower
1044
+ * @property {number[]} lowerToNormalized
1045
+ */
1046
+
1047
+ /**
1048
+ * @param {string} rawText
1049
+ * @returns {SearchLayout}
1050
+ */
1051
+ export function buildSearchLayout(rawText) {
1052
+ const original = String(rawText ?? '')
1053
+ .replace(/\s+/gu, ' ')
1054
+ .trim()
1055
+ const { text: normalized, map: normalizedToOriginal } = buildNormalizedText(original)
1056
+
1057
+ const compactChars = []
1058
+ const compactToNormalized = []
1059
+ for (let index = 0; index < normalized.length; index += 1) {
1060
+ if (normalized[index] === ' ') continue
1061
+ compactChars.push(normalized[index])
1062
+ compactToNormalized.push(index)
1063
+ }
1064
+
1065
+ // Case-folded view for the all-terms pass, with its own index map so a
1066
+ // snippet can still be traced back to `original`.
1067
+ const lowerChars = []
1068
+ const lowerToNormalized = []
1069
+ for (let index = 0; index < normalized.length; ) {
1070
+ const { ch, width } = readCodePointAt(normalized, index)
1071
+ const lower = ch.toLowerCase()
1072
+ for (let offset = 0; offset < lower.length; offset += 1) {
1073
+ lowerChars.push(lower[offset])
1074
+ lowerToNormalized.push(index)
1075
+ }
1076
+ index += width
1077
+ }
1078
+
1079
+ return {
1080
+ original,
1081
+ normalized,
1082
+ normalizedToOriginal,
1083
+ compact: compactChars.join(''),
1084
+ compactToNormalized,
1085
+ normalizedLower: lowerChars.join(''),
1086
+ lowerToNormalized,
1087
+ }
1088
+ }
1089
+
1090
+ /**
1091
+ * Prepare both the user-facing (cleaned) query and its matchable form.
1092
+ *
1093
+ * @param {string} raw
1094
+ * @returns {{query: string, needle: string}}
1095
+ */
1096
+ export function prepareSearchQuery(raw) {
1097
+ const query = cleanSearchQuery(raw)
1098
+ const needle = normalizeSearchText(query)
1099
+ if (needle === '') {
1100
+ // e.g. a query made only of dashes, which normalisation deletes.
1101
+ throw new TypeError('searchPdf: query must be a non-empty string')
1102
+ }
1103
+ return { query, needle }
1104
+ }
1105
+
1106
+ // ---------------------------------------------------------------------------
1107
+ // Matching helpers
1108
+ // ---------------------------------------------------------------------------
1109
+
853
1110
  function escapeRegExp(value) {
854
1111
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
855
1112
  }
856
1113
 
857
1114
  function makeSnippet(text, index, matchLength, contextChars) {
858
- const start = Math.max(0, index - contextChars)
859
- const end = Math.min(text.length, index + matchLength + contextChars)
1115
+ let start = Math.max(0, index - contextChars)
1116
+ let end = Math.min(text.length, index + matchLength + contextChars)
1117
+ // Do not cut a surrogate pair in half: a math-italic glyph sits next to
1118
+ // almost every hit in a formula-heavy book, and half a pair renders as U+FFFD.
1119
+ if (start > 0 && start < text.length) {
1120
+ const code = text.charCodeAt(start)
1121
+ if (code >= 0xdc00 && code <= 0xdfff && isHighSurrogate(text.charCodeAt(start - 1))) start -= 1
1122
+ }
1123
+ if (end > 0 && end < text.length) {
1124
+ const code = text.charCodeAt(end - 1)
1125
+ if (isHighSurrogate(code) && isLowSurrogate(text.charCodeAt(end))) end += 1
1126
+ }
860
1127
  let snippet = text.slice(start, end).trim()
861
1128
  if (start > 0) snippet = `…${snippet}`
862
1129
  if (end < text.length) snippet = `${snippet}…`
863
1130
  return snippet
864
1131
  }
865
1132
 
1133
+ function isHighSurrogate(code) {
1134
+ return code >= 0xd800 && code <= 0xdbff
1135
+ }
1136
+
1137
+ function isLowSurrogate(code) {
1138
+ return code >= 0xdc00 && code <= 0xdfff
1139
+ }
1140
+
1141
+ /** @param {string} needle @param {boolean} ignoreCase */
1142
+ function makeSearchPattern(needle, ignoreCase) {
1143
+ return new RegExp(escapeRegExp(needle), ignoreCase ? 'giu' : 'gu')
1144
+ }
1145
+
1146
+ /** Yield every match; guards a future zero-length needle against a spin. */
1147
+ function* iterateMatches(pattern, text) {
1148
+ pattern.lastIndex = 0
1149
+ let match
1150
+ while ((match = pattern.exec(text)) !== null) {
1151
+ yield match
1152
+ if (match[0].length === 0) pattern.lastIndex += 1
1153
+ }
1154
+ }
1155
+
1156
+ /** Map a [start, end) range in `normalized` back to the original text. */
1157
+ function normalizedRangeToOriginal(layout, startIndex, endIndex) {
1158
+ const start = layout.normalizedToOriginal[startIndex] ?? 0
1159
+ const end =
1160
+ endIndex < layout.normalized.length
1161
+ ? layout.normalizedToOriginal[endIndex] ?? layout.original.length
1162
+ : layout.original.length
1163
+ return { start, length: Math.max(0, end - start) }
1164
+ }
1165
+
1166
+ /** Map a [start, end) range in `compact` back to the original text. */
1167
+ function compactRangeToOriginal(layout, startIndex, endIndex) {
1168
+ const normalizedStart = layout.compactToNormalized[startIndex] ?? 0
1169
+ const normalizedEnd =
1170
+ endIndex < layout.compact.length
1171
+ ? layout.compactToNormalized[endIndex] ?? layout.normalized.length
1172
+ : layout.normalized.length
1173
+ return normalizedRangeToOriginal(layout, normalizedStart, normalizedEnd)
1174
+ }
1175
+
1176
+ /** Map a [start, end) range in `normalizedLower` back to the original text. */
1177
+ function lowerRangeToOriginal(layout, startIndex, endIndex) {
1178
+ const normalizedStart = layout.lowerToNormalized[startIndex] ?? 0
1179
+ const normalizedEnd =
1180
+ endIndex < layout.normalizedLower.length
1181
+ ? layout.lowerToNormalized[endIndex] ?? layout.normalized.length
1182
+ : layout.normalized.length
1183
+ return normalizedRangeToOriginal(layout, normalizedStart, normalizedEnd)
1184
+ }
1185
+
1186
+ function makeSearchHit(layout, pdfPage, printed, range, contextChars) {
1187
+ return {
1188
+ pdf: pdfPage,
1189
+ printed,
1190
+ indexOnPage: range.start,
1191
+ snippet: makeSnippet(layout.original, range.start, range.length, contextChars),
1192
+ }
1193
+ }
1194
+
1195
+ /**
1196
+ * True when `printed` is a body-page number. Roman numerals / missing labels
1197
+ * (front matter) return false.
1198
+ *
1199
+ * @param {number|string|null} printed
1200
+ * @returns {boolean}
1201
+ */
1202
+ export function isBodyPage(printed) {
1203
+ return typeof printed === 'number' && Number.isFinite(printed)
1204
+ }
1205
+
1206
+ /**
1207
+ * Body hits first (page/offset order), then up to `frontMatterLimit` front
1208
+ * matter hits, filling no more than `limit` slots in total.
1209
+ *
1210
+ * @param {Array<{pdf:number, printed:number|string|null, indexOnPage?:number}>} hits
1211
+ * @param {{limit?: number, frontMatterLimit?: number}} [options]
1212
+ * @returns {Array<Object>}
1213
+ */
1214
+ export function rankSearchHits(hits, { limit = 200, frontMatterLimit = MAX_FRONT_MATTER_HITS } = {}) {
1215
+ if (!Array.isArray(hits) || hits.length === 0 || limit < 1) return []
1216
+ const byPosition = (a, b) =>
1217
+ (Number(a.pdf) || 0) - (Number(b.pdf) || 0) ||
1218
+ (Number(a.indexOnPage) || 0) - (Number(b.indexOnPage) || 0)
1219
+ const body = hits.filter((hit) => isBodyPage(hit.printed)).sort(byPosition)
1220
+ const front = hits.filter((hit) => !isBodyPage(hit.printed)).sort(byPosition)
1221
+ const selected = body.slice(0, limit)
1222
+ const room = Math.max(0, limit - selected.length)
1223
+ return [...selected, ...front.slice(0, Math.min(frontMatterLimit, room))]
1224
+ }
1225
+
1226
+ /**
1227
+ * Split a normalised query into term-AND tokens. One-character noise is
1228
+ * dropped and at most MAX_TERM_TOKENS words are examined.
1229
+ */
1230
+ function buildSearchTerms(needle, ignoreCase) {
1231
+ const tokens = needle
1232
+ .split(/[^\p{L}\p{N}]+/u)
1233
+ .filter((token) => token.length >= 2)
1234
+ .slice(0, MAX_TERM_TOKENS)
1235
+ return ignoreCase ? tokens.map((token) => token.toLowerCase()) : tokens
1236
+ }
1237
+
1238
+ /** Earliest position in a page where every term occurs, or null. */
1239
+ function findTermsSpan(layout, terms, ignoreCase) {
1240
+ if (terms.length === 0) return null
1241
+ const haystack = ignoreCase ? layout.normalizedLower : layout.normalized
1242
+ let span = null
1243
+ for (const term of terms) {
1244
+ const index = haystack.indexOf(term)
1245
+ if (index < 0) return null
1246
+ if (span === null || index < span.start) span = { start: index, length: term.length }
1247
+ }
1248
+ return span
1249
+ }
1250
+
1251
+ /**
1252
+ * Collect ranked hits for one matching pass without keeping the whole book in
1253
+ * memory. Pages may be noted even after the hit caps are full, because
1254
+ * `pages` must count every matched page.
1255
+ */
1256
+ function createSearchPass(limit, frontMatterLimit = MAX_FRONT_MATTER_HITS) {
1257
+ const bodyHits = []
1258
+ const frontHits = []
1259
+ let pages = 0
1260
+ let countedPdfPage = -1
1261
+
1262
+ return {
1263
+ get pages() {
1264
+ return pages
1265
+ },
1266
+ notePage(pdfPage) {
1267
+ if (countedPdfPage !== pdfPage) {
1268
+ countedPdfPage = pdfPage
1269
+ pages += 1
1270
+ }
1271
+ },
1272
+ canAdd(printed) {
1273
+ return isBodyPage(printed) ? bodyHits.length < limit : frontHits.length < frontMatterLimit
1274
+ },
1275
+ add(hit, printed) {
1276
+ if (isBodyPage(printed)) bodyHits.push(hit)
1277
+ else frontHits.push(hit)
1278
+ },
1279
+ finish() {
1280
+ // Body hits were added first; slice applies the overall limit while the
1281
+ // front-matter cap above keeps at most three from the tail.
1282
+ return { hits: [...bodyHits, ...frontHits].slice(0, limit), pages }
1283
+ },
1284
+ }
1285
+ }
1286
+
866
1287
  function normalizeSearchOptions(options) {
867
1288
  const opts = options && typeof options === 'object' ? options : {}
868
1289
  const ignoreCase = opts.ignoreCase !== false
@@ -879,37 +1300,91 @@ function normalizeSearchOptions(options) {
879
1300
  )
880
1301
  }
881
1302
 
882
- return { ignoreCase, limit, contextChars }
1303
+ // Optional 1-based physical page filter. `null` means the whole document.
1304
+ let pdfPages = null
1305
+ if (opts.pdfPages !== undefined && opts.pdfPages !== null) {
1306
+ if (!Array.isArray(opts.pdfPages)) {
1307
+ throw new RangeError('searchPdf: pdfPages must be an array of 1-based page numbers')
1308
+ }
1309
+ const unique = []
1310
+ for (const page of opts.pdfPages) {
1311
+ if (!Number.isInteger(page) || page < 1) {
1312
+ throw new RangeError(
1313
+ `searchPdf: pdfPages must contain positive integers (got ${JSON.stringify(page)})`,
1314
+ )
1315
+ }
1316
+ if (!unique.includes(page)) unique.push(page)
1317
+ }
1318
+ if (unique.length === 0) throw new RangeError('searchPdf: pdfPages must not be empty')
1319
+ pdfPages = unique.sort((a, b) => a - b)
1320
+ }
1321
+
1322
+ return { ignoreCase, limit, contextChars, pdfPages }
883
1323
  }
884
1324
 
885
1325
  /**
886
- * Literal full-text search, one page at a time so the event loop is not
887
- * blocked for the whole book. Returns physical and printed page numbers.
1326
+ * Whole-document text search, one extraction pass per page.
1327
+ *
1328
+ * Matching is tried in this order, and the first pass with any hit decides the
1329
+ * result (and `mode`):
1330
+ *
1331
+ * 1. `phrase` -- literal search over NFKC-normalised page text, with
1332
+ * wrapping quotes and dashes ignored and case-insensitive by
1333
+ * default. Hyphenation is normalised on both sides, so
1334
+ * `complex` finds a page containing `com- plex`.
1335
+ * 2. `compact` -- only when no phrase matched: all spaces are removed from
1336
+ * both needle and page, so `L(V)` finds `ℒ (𝑉)` and
1337
+ * `T∈L(V)` finds `𝑆 , 𝑇 ∈ ℒ (𝑉)`.
1338
+ * 3. `terms` -- only when neither matched: every letter/digit token of the
1339
+ * query must occur somewhere on the page (AND), at most one
1340
+ * hit per page.
1341
+ *
1342
+ * Hits are ranked body pages first, then front matter (at most
1343
+ * MAX_FRONT_MATTER_HITS front hits). `pages` counts every matched page in the
1344
+ * active pass before ranking/limit.
888
1345
  *
889
1346
  * `indexOnPage` is a UTF-16 offset into the same whitespace-normalised text
890
- * that getPageText() returns.
1347
+ * that getPageText() returns (the original, not the normalised search view),
1348
+ * and snippets are cut from that original text.
891
1349
  *
892
1350
  * @param {string} path
893
1351
  * @param {string} query
894
1352
  * @param {{ignoreCase?: boolean, limit?: number, contextChars?: number}} [options]
895
1353
  * @returns {Promise<{hits: Array<{pdf:number, printed:number|string|null,
896
- * indexOnPage:number, snippet:string}>, pages:number}>}
1354
+ * indexOnPage:number, snippet:string}>, pages:number,
1355
+ * mode: 'phrase'|'compact'|'terms'}>}
897
1356
  */
898
1357
  export async function searchPdf(path, query, options) {
899
- if (typeof query !== 'string' || query.trim() === '') {
900
- throw new TypeError('searchPdf: query must be a non-empty string')
901
- }
1358
+ const { needle } = prepareSearchQuery(query)
1359
+ const { ignoreCase, limit, contextChars, pdfPages } = normalizeSearchOptions(options)
1360
+
1361
+ const phrasePattern = makeSearchPattern(needle, ignoreCase)
1362
+ const compactNeedle = needle.replace(/ /gu, '')
1363
+ const compactPattern =
1364
+ compactNeedle === '' ? null : makeSearchPattern(compactNeedle, ignoreCase)
1365
+ const terms = buildSearchTerms(needle, ignoreCase)
902
1366
 
903
- const needle = normalizePageText(query)
904
- const { ignoreCase, limit, contextChars } = normalizeSearchOptions(options)
905
- const pattern = new RegExp(escapeRegExp(needle), ignoreCase ? 'gi' : 'g')
1367
+ const phrasePass = createSearchPass(limit)
1368
+ const compactPass = createSearchPass(limit)
1369
+ const termsPass = createSearchPass(limit)
906
1370
 
907
1371
  return withEntry(path, async (entry) => {
908
1372
  const meta = await ensureMetadata(entry)
909
- const hits = []
910
- let pages = 0
1373
+ if (pdfPages !== null) {
1374
+ for (const page of pdfPages) {
1375
+ if (page > entry.doc.numPages) {
1376
+ const error = new RangeError(
1377
+ `PDF page must be an integer between 1 and ${entry.doc.numPages} (got ${page})`,
1378
+ )
1379
+ error.code = 'PDF_PAGE_OUT_OF_RANGE'
1380
+ throw error
1381
+ }
1382
+ }
1383
+ }
1384
+ const scanCount = pdfPages === null ? entry.doc.numPages : pdfPages.length
911
1385
 
912
- for (let pdfPage = 1; pdfPage <= entry.doc.numPages; pdfPage += 1) {
1386
+ for (let scanIndex = 0; scanIndex < scanCount; scanIndex += 1) {
1387
+ const pdfPage = pdfPages === null ? scanIndex + 1 : pdfPages[scanIndex]
913
1388
  let text
914
1389
  try {
915
1390
  text = await extractPageText(entry, pdfPage)
@@ -924,29 +1399,83 @@ export async function searchPdf(path, query, options) {
924
1399
  throw wrapped
925
1400
  }
926
1401
 
927
- pattern.lastIndex = 0
928
- let match
929
- let pageCounted = false
930
- while ((match = pattern.exec(text)) !== null) {
931
- if (!pageCounted) {
932
- pages += 1
933
- pageCounted = true
1402
+ const layout = buildSearchLayout(text)
1403
+ const printed = printedForPdf(meta, pdfPage)
1404
+
1405
+ // 1. Phrase (always evaluated; it wins over any fallback).
1406
+ let pageMatched = false
1407
+ for (const match of iterateMatches(phrasePattern, layout.normalized)) {
1408
+ if (!pageMatched) {
1409
+ phrasePass.notePage(pdfPage)
1410
+ pageMatched = true
934
1411
  }
935
- if (hits.length < limit) {
936
- hits.push({
937
- pdf: pdfPage,
938
- printed: printedForPdf(meta, pdfPage),
939
- indexOnPage: match.index,
940
- snippet: makeSnippet(text, match.index, match[0].length, contextChars),
941
- })
1412
+ if (!phrasePass.canAdd(printed)) break
1413
+ phrasePass.add(
1414
+ makeSearchHit(
1415
+ layout,
1416
+ pdfPage,
1417
+ printed,
1418
+ normalizedRangeToOriginal(layout, match.index, match.index + match[0].length),
1419
+ contextChars,
1420
+ ),
1421
+ printed,
1422
+ )
1423
+ }
1424
+
1425
+ // Once any phrase hit exists, the fallbacks can no longer win. This
1426
+ // also keeps the common case to ~one regex pass per page.
1427
+ if (phrasePass.pages > 0) continue
1428
+
1429
+ // 2. Compact (space-insensitive).
1430
+ if (compactPattern !== null) {
1431
+ pageMatched = false
1432
+ for (const match of iterateMatches(compactPattern, layout.compact)) {
1433
+ if (!pageMatched) {
1434
+ compactPass.notePage(pdfPage)
1435
+ pageMatched = true
1436
+ }
1437
+ if (!compactPass.canAdd(printed)) break
1438
+ compactPass.add(
1439
+ makeSearchHit(
1440
+ layout,
1441
+ pdfPage,
1442
+ printed,
1443
+ compactRangeToOriginal(layout, match.index, match.index + match[0].length),
1444
+ contextChars,
1445
+ ),
1446
+ printed,
1447
+ )
942
1448
  }
943
- // A zero-length match is impossible (needle is non-empty), but guard
944
- // anyway so a future change cannot spin forever.
945
- if (match[0].length === 0) pattern.lastIndex += 1
1449
+ }
1450
+
1451
+ if (compactPass.pages > 0) continue
1452
+
1453
+ // 3. All-terms AND fallback.
1454
+ const span = findTermsSpan(layout, terms, ignoreCase)
1455
+ if (span !== null) {
1456
+ termsPass.notePage(pdfPage)
1457
+ if (termsPass.canAdd(printed)) {
1458
+ const range = ignoreCase
1459
+ ? lowerRangeToOriginal(layout, span.start, span.start + span.length)
1460
+ : normalizedRangeToOriginal(layout, span.start, span.start + span.length)
1461
+ termsPass.add(makeSearchHit(layout, pdfPage, printed, range, contextChars), printed)
1462
+ }
1463
+ }
1464
+ }
1465
+
1466
+ let mode = 'phrase'
1467
+ let active = phrasePass.finish()
1468
+ if (phrasePass.pages === 0) {
1469
+ if (compactPass.pages > 0) {
1470
+ mode = 'compact'
1471
+ active = compactPass.finish()
1472
+ } else if (termsPass.pages > 0) {
1473
+ mode = 'terms'
1474
+ active = termsPass.finish()
946
1475
  }
947
1476
  }
948
1477
 
949
- return { hits, pages }
1478
+ return { hits: active.hits, pages: active.pages, mode }
950
1479
  })
951
1480
  }
952
1481