dsh-book-reader 0.2.0 → 0.3.0
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/CHANGELOG.md +33 -2
- package/README.md +64 -28
- package/README.zh-CN.md +62 -26
- package/SKILL.md +22 -4
- package/lib/client.js +636 -217
- package/lib/core/README.md +47 -22
- package/lib/core/pdf.js +564 -35
- package/lib/index.js +152 -19
- package/package.json +1 -1
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
|
-
* -
|
|
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
|
-
|
|
859
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
887
|
-
*
|
|
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
|
-
|
|
900
|
-
|
|
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
|
|
904
|
-
const
|
|
905
|
-
const
|
|
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
|
-
|
|
910
|
-
|
|
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
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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 (
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
|