foliate-js has no npm release and no build step; upstream recommends a git submodule. Copy it instead: this repo has no submodules and already vendors pdf.js the same way under static/pdfjs, and a submodule would pull in 231 files / 13 MB of which 191 files / 12 MB is a bundled pdf.js build that Chitai does not use. Only the import closure reachable from view.js is vendored — 15 files, 584K. pdf.js is replaced by a stub that throws: view.js reaches it through a static-string dynamic import inside makeBook, which Rollup resolves at build time even though Chitai serves PDFs from static/pdfjs/web/viewer.html, and upstream's version opens with a bare `import '@pdfjs/pdf.min.mjs'` that does not resolve here. scripts/vendor-foliate.sh pins the commit and makes the next update a one-line change. fixed-layout.js needs construct-style-sheets-polyfill, so add it.
318 lines
13 KiB
JavaScript
Vendored
318 lines
13 KiB
JavaScript
Vendored
// length for context in excerpts
|
|
const CONTEXT_LENGTH = 50
|
|
|
|
const normalizeWhitespace = str => str.replace(/\s+/g, ' ')
|
|
|
|
// Gather context preceding the match by walking back across text nodes until we
|
|
// have enough for the excerpt. A match can sit in its own text node (e.g. a word
|
|
// wrapped in <i>/<em>/<b>), leaving no context within the start node itself.
|
|
const collectBefore = (strs, index, offset) => {
|
|
let str = strs[index].slice(0, offset)
|
|
for (let i = index - 1; i >= 0 && normalizeWhitespace(str).trim().length < CONTEXT_LENGTH; i--)
|
|
str = strs[i] + str
|
|
return str
|
|
}
|
|
|
|
const collectAfter = (strs, index, offset) => {
|
|
let str = strs[index].slice(offset)
|
|
for (let i = index + 1; i < strs.length && normalizeWhitespace(str).trim().length < CONTEXT_LENGTH; i++)
|
|
str += strs[i]
|
|
return str
|
|
}
|
|
|
|
const makeExcerpt = (strs, { startIndex, startOffset, endIndex, endOffset }) => {
|
|
const start = strs[startIndex]
|
|
const end = strs[endIndex]
|
|
const match = startIndex === endIndex
|
|
? start.slice(startOffset, endOffset)
|
|
: start.slice(startOffset)
|
|
+ strs.slice(startIndex + 1, endIndex).join('')
|
|
+ end.slice(0, endOffset)
|
|
const trimmedStart = normalizeWhitespace(collectBefore(strs, startIndex, startOffset)).trimStart()
|
|
const trimmedEnd = normalizeWhitespace(collectAfter(strs, endIndex, endOffset)).trimEnd()
|
|
const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…'
|
|
const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…'
|
|
const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}`
|
|
const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}`
|
|
return { pre, match, post }
|
|
}
|
|
|
|
// Cumulative character offsets of the joined `strs`, so a flat offset into
|
|
// `strs.join('')` can be mapped back to a (node index, in-node offset) pair.
|
|
const buildCum = strs => {
|
|
const cum = [0]
|
|
for (let i = 0; i < strs.length; i++) cum.push(cum[i] + strs[i].length)
|
|
return cum
|
|
}
|
|
|
|
// Largest node i with cum[i] <= offset; clamps the end-of-text offset to the
|
|
// last node's end. Works for both range start and (exclusive) end positions.
|
|
const nodeAt = (cum, offset) => {
|
|
let lo = 0, hi = cum.length - 2
|
|
if (hi < 0) return { index: 0, offset: 0 }
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1
|
|
if (cum[mid] <= offset) lo = mid
|
|
else hi = mid - 1
|
|
}
|
|
return { index: lo, offset: offset - cum[lo] }
|
|
}
|
|
|
|
const rangeFromFlat = (cum, start, end) => {
|
|
const s = nodeAt(cum, start)
|
|
const e = nodeAt(cum, end)
|
|
return { startIndex: s.index, startOffset: s.offset, endIndex: e.index, endOffset: e.offset }
|
|
}
|
|
|
|
const simpleSearch = function* (strs, query, options = {}) {
|
|
const { locales = 'en', sensitivity } = options
|
|
const matchCase = sensitivity === 'variant'
|
|
const haystack = strs.join('')
|
|
const lowerHaystack = matchCase ? haystack : haystack.toLocaleLowerCase(locales)
|
|
const needle = matchCase ? query : query.toLocaleLowerCase(locales)
|
|
const needleLength = needle.length
|
|
let index = -1
|
|
let strIndex = -1
|
|
let sum = 0
|
|
do {
|
|
index = lowerHaystack.indexOf(needle, index + 1)
|
|
if (index > -1) {
|
|
while (sum <= index) sum += strs[++strIndex].length
|
|
const startIndex = strIndex
|
|
const startOffset = index - (sum - strs[strIndex].length)
|
|
const end = index + needleLength
|
|
while (sum <= end) sum += strs[++strIndex].length
|
|
const endIndex = strIndex
|
|
const endOffset = end - (sum - strs[strIndex].length)
|
|
const range = { startIndex, startOffset, endIndex, endOffset }
|
|
yield { range, excerpt: makeExcerpt(strs, range) }
|
|
}
|
|
} while (index > -1)
|
|
}
|
|
|
|
const segmenterSearch = function* (strs, query, options = {}) {
|
|
const { locales = 'en', granularity = 'word', sensitivity = 'base' } = options
|
|
let segmenter, collator
|
|
try {
|
|
segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity })
|
|
collator = new Intl.Collator(locales, { sensitivity })
|
|
} catch (e) {
|
|
console.warn(e)
|
|
segmenter = new Intl.Segmenter('en', { usage: 'search', granularity })
|
|
collator = new Intl.Collator('en', { sensitivity })
|
|
}
|
|
const queryLength = Array.from(segmenter.segment(query)).length
|
|
|
|
const substrArr = []
|
|
let strIndex = 0
|
|
let segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
|
|
main: while (strIndex < strs.length) {
|
|
while (substrArr.length < queryLength) {
|
|
const { done, value } = segments.next()
|
|
if (done) {
|
|
// the current string is exhausted
|
|
// move on to the next string
|
|
strIndex++
|
|
if (strIndex < strs.length) {
|
|
segments = segmenter.segment(strs[strIndex])[Symbol.iterator]()
|
|
continue
|
|
} else break main
|
|
}
|
|
const { index, segment } = value
|
|
// ignore formatting characters
|
|
if (!/[^\p{Format}]/u.test(segment)) continue
|
|
// normalize whitespace
|
|
if (/\s/u.test(segment)) {
|
|
if (!/\s/u.test(substrArr[substrArr.length - 1]?.segment))
|
|
substrArr.push({ strIndex, index, segment: ' ' })
|
|
continue
|
|
}
|
|
value.strIndex = strIndex
|
|
substrArr.push(value)
|
|
}
|
|
const substr = substrArr.map(x => x.segment).join('')
|
|
if (collator.compare(query, substr) === 0) {
|
|
const endIndex = strIndex
|
|
const lastSeg = substrArr[substrArr.length - 1]
|
|
const endOffset = lastSeg.index + lastSeg.segment.length
|
|
const startIndex = substrArr[0].strIndex
|
|
const startOffset = substrArr[0].index
|
|
const range = { startIndex, startOffset, endIndex, endOffset }
|
|
yield { range, excerpt: makeExcerpt(strs, range) }
|
|
}
|
|
substrArr.shift()
|
|
}
|
|
}
|
|
|
|
// Calibre-parity regex mode (#4560). Runs a JS RegExp over the joined text and
|
|
// maps each match back to a node range. Note: RegExp.exec is synchronous, so a
|
|
// catastrophic pattern can still stall this pass — true interruption (a Web
|
|
// Worker) is left to a follow-up; here we only cap match count and reject
|
|
// invalid patterns. The caller surfaces INVALID_REGEX as a calm inline error.
|
|
const MAX_REGEX_MATCHES = 10000
|
|
const regexSearch = function* (strs, query, options = {}) {
|
|
const { matchCase } = options
|
|
const flags = matchCase ? 'g' : 'gi'
|
|
let re
|
|
try {
|
|
re = new RegExp(query, flags + 'u')
|
|
} catch {
|
|
// Some patterns are valid only without the unicode flag; fall back.
|
|
try {
|
|
re = new RegExp(query, flags)
|
|
} catch (e) {
|
|
const err = new Error(`Invalid regular expression: ${e.message}`)
|
|
err.code = 'INVALID_REGEX'
|
|
throw err
|
|
}
|
|
}
|
|
const haystack = strs.join('')
|
|
const cum = buildCum(strs)
|
|
let count = 0
|
|
let m
|
|
while ((m = re.exec(haystack)) !== null) {
|
|
if (m[0].length === 0) {
|
|
// zero-width match: advance to avoid an infinite loop
|
|
re.lastIndex = m.index + 1
|
|
continue
|
|
}
|
|
const range = rangeFromFlat(cum, m.index, m.index + m[0].length)
|
|
yield { range, excerpt: makeExcerpt(strs, range) }
|
|
if (++count >= MAX_REGEX_MATCHES) break
|
|
}
|
|
}
|
|
|
|
// Segmented excerpt for nearby-words: emphasizes only the matched words inside
|
|
// the cluster window, leaving the gap text un-emphasized. `pre`/`match`/`post`
|
|
// stay populated for consumers that don't render segments.
|
|
const makeNearbyExcerpt = (haystack, matched) => {
|
|
const clusterStart = matched[0].start
|
|
const clusterEnd = matched[matched.length - 1].end
|
|
const trimmedStart = normalizeWhitespace(haystack.slice(0, clusterStart)).trimStart()
|
|
const trimmedEnd = normalizeWhitespace(haystack.slice(clusterEnd)).trimEnd()
|
|
const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…'
|
|
const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…'
|
|
const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}`
|
|
const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}`
|
|
const segments = []
|
|
let cursor = clusterStart
|
|
for (const o of matched) {
|
|
if (o.start > cursor) {
|
|
const gap = normalizeWhitespace(haystack.slice(cursor, o.start))
|
|
if (gap) segments.push({ text: gap, emphasized: false })
|
|
}
|
|
segments.push({ text: normalizeWhitespace(haystack.slice(o.start, o.end)), emphasized: true })
|
|
cursor = o.end
|
|
}
|
|
const match = normalizeWhitespace(haystack.slice(clusterStart, clusterEnd))
|
|
return { pre, match, post, segments }
|
|
}
|
|
|
|
// Calibre-parity nearby-words mode (#4560): matches places where all of the
|
|
// query's distinct whole words occur within `nearbyWords` words of each other.
|
|
// Distance is measured in words (not characters) and comes from the option, not
|
|
// from the query string, so trailing numbers stay literal search words.
|
|
const nearbyWordsSearch = function* (strs, query, options = {}) {
|
|
const { locales = 'en', sensitivity = 'base', nearbyWords = 10 } = options
|
|
const queryWords = []
|
|
for (const w of query.split(/\s+/).filter(Boolean)) if (!queryWords.includes(w)) queryWords.push(w)
|
|
if (queryWords.length < 2) {
|
|
const err = new Error('Nearby words search needs at least two words')
|
|
err.code = 'NEARBY_NEEDS_TWO_WORDS'
|
|
throw err
|
|
}
|
|
let segmenter, collator
|
|
try {
|
|
segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity: 'word' })
|
|
collator = new Intl.Collator(locales, { sensitivity })
|
|
} catch (e) {
|
|
console.warn(e)
|
|
segmenter = new Intl.Segmenter('en', { usage: 'search', granularity: 'word' })
|
|
collator = new Intl.Collator('en', { sensitivity })
|
|
}
|
|
const haystack = strs.join('')
|
|
const cum = buildCum(strs)
|
|
const K = queryWords.length
|
|
|
|
// Word occurrences of any query word, tagged with a global word index.
|
|
const occ = []
|
|
let wordIndex = -1
|
|
for (const seg of segmenter.segment(haystack)) {
|
|
if (!seg.isWordLike) continue
|
|
wordIndex++
|
|
for (let q = 0; q < K; q++) {
|
|
if (collator.compare(queryWords[q], seg.segment) === 0) {
|
|
occ.push({ wordIndex, qIdx: q, start: seg.index, end: seg.index + seg.segment.length })
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Smallest window covering all K distinct query words, two-pointer scan.
|
|
const have = new Array(K).fill(0)
|
|
let distinct = 0
|
|
let lo = 0
|
|
const windows = []
|
|
for (let hi = 0; hi < occ.length; hi++) {
|
|
if (have[occ[hi].qIdx]++ === 0) distinct++
|
|
let minimal = null
|
|
while (distinct === K) {
|
|
minimal = { lo, hi }
|
|
if (--have[occ[lo].qIdx] === 0) distinct--
|
|
lo++
|
|
}
|
|
if (minimal && occ[minimal.hi].wordIndex - occ[minimal.lo].wordIndex <= nearbyWords)
|
|
windows.push(minimal)
|
|
}
|
|
|
|
// One cluster per window; suppress windows overlapping an already-emitted one.
|
|
let lastHi = -1
|
|
for (const w of windows) {
|
|
if (w.lo <= lastHi) continue
|
|
lastHi = w.hi
|
|
const matched = occ.slice(w.lo, w.hi + 1)
|
|
const excerpt = makeNearbyExcerpt(haystack, matched)
|
|
const range = rangeFromFlat(cum, matched[0].start, matched[matched.length - 1].end)
|
|
const subRanges = matched.map(o => rangeFromFlat(cum, o.start, o.end))
|
|
yield { range, excerpt, subRanges }
|
|
}
|
|
}
|
|
|
|
export const search = (strs, query, options) => {
|
|
const { mode } = options
|
|
if (mode === 'regex') return regexSearch(strs, query, options)
|
|
if (mode === 'nearby-words') return nearbyWordsSearch(strs, query, options)
|
|
const { granularity = 'grapheme', sensitivity = 'base' } = options
|
|
if (!Intl?.Segmenter || granularity === 'grapheme'
|
|
&& (sensitivity === 'variant' || sensitivity === 'accent'))
|
|
return simpleSearch(strs, query, options)
|
|
return segmenterSearch(strs, query, options)
|
|
}
|
|
|
|
export const searchMatcher = (textWalker, opts) => {
|
|
const { defaultLocale, matchCase, matchDiacritics, matchWholeWords, mode, nearbyWords, acceptNode } = opts
|
|
const effectiveMode = mode ?? (matchWholeWords ? 'whole-words' : 'contains')
|
|
return function* (doc, query) {
|
|
const iter = textWalker(doc, function* (strs, makeRange) {
|
|
for (const result of search(strs, query, {
|
|
mode: effectiveMode,
|
|
nearbyWords,
|
|
matchCase,
|
|
locales: doc.body.lang || doc.documentElement.lang || defaultLocale || 'en',
|
|
granularity: effectiveMode === 'whole-words' ? 'word' : 'grapheme',
|
|
sensitivity: matchDiacritics && matchCase ? 'variant'
|
|
: matchDiacritics && !matchCase ? 'accent'
|
|
: !matchDiacritics && matchCase ? 'case'
|
|
: 'base',
|
|
})) {
|
|
const { startIndex, startOffset, endIndex, endOffset } = result.range
|
|
result.range = makeRange(startIndex, startOffset, endIndex, endOffset)
|
|
if (result.subRanges) result.subRanges = result.subRanges.map(
|
|
r => makeRange(r.startIndex, r.startOffset, r.endIndex, r.endOffset))
|
|
yield result
|
|
}
|
|
}, acceptNode)
|
|
for (const result of iter) yield result
|
|
}
|
|
}
|