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/index.js
CHANGED
|
@@ -828,8 +828,8 @@ export function printedPageFor(info, pdfPage, reported = null) {
|
|
|
828
828
|
* @param options - selection, explicit mode, `getInfo` result, and page cap.
|
|
829
829
|
* @returns physical pages, the resolved mode, and the offset used.
|
|
830
830
|
*/
|
|
831
|
-
export function resolveRequestedPages({ selection, pageNumbers, info, maxPages = DEFAULT_CONFIG.maxPagesPerCall }) {
|
|
832
|
-
const requested = parsePageSelection(selection, { maxPages })
|
|
831
|
+
export function resolveRequestedPages({ selection, pageNumbers, info, maxPages = DEFAULT_CONFIG.maxPagesPerCall, hardLimit = PAGE_SELECTION_HARD_LIMIT }) {
|
|
832
|
+
const requested = parsePageSelection(selection, { maxPages, hardLimit })
|
|
833
833
|
const detected = detectPageMode(info)
|
|
834
834
|
const mode = pageNumbers ?? (detected.detectable ? 'printed' : 'pdf')
|
|
835
835
|
if (mode !== 'printed' && mode !== 'pdf') {
|
|
@@ -1199,6 +1199,19 @@ const PRINTED_PAGE_SCHEMA = {
|
|
|
1199
1199
|
oneOf: [{ type: 'integer' }, { type: 'string' }, { type: 'null' }],
|
|
1200
1200
|
}
|
|
1201
1201
|
|
|
1202
|
+
/**
|
|
1203
|
+
* DSH's JsonSchema dialect marks a property required with `required: true`; a
|
|
1204
|
+
* property that may be absent simply omits `required`. `required: false` is
|
|
1205
|
+
* rejected by defineTool at registration time.
|
|
1206
|
+
*
|
|
1207
|
+
* `IMAGE_VALUE_SCHEMA` is required where it is used for a present image, so an
|
|
1208
|
+
* optional use must drop that marker instead of overriding it to false.
|
|
1209
|
+
*/
|
|
1210
|
+
function optionalPropertySchema(schema) {
|
|
1211
|
+
const { required: _required, ...rest } = schema
|
|
1212
|
+
return rest
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1202
1215
|
const SECTION_SCHEMA = {
|
|
1203
1216
|
type: 'object',
|
|
1204
1217
|
additionalProperties: false,
|
|
@@ -1432,13 +1445,12 @@ export function createRouteHandler({
|
|
|
1432
1445
|
sendJson(response, target.status, { ok: false, error: target.error })
|
|
1433
1446
|
return
|
|
1434
1447
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
sendJson(response, 400, { ok: false, error: 'query-too-long' })
|
|
1448
|
+
let query
|
|
1449
|
+
try {
|
|
1450
|
+
query = cleanPdfSearchQuery(url.searchParams.get('q') ?? '')
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
const tooLong = error instanceof RangeError
|
|
1453
|
+
sendJson(response, 400, { ok: false, error: tooLong ? 'query-too-long' : 'missing-query' })
|
|
1442
1454
|
return
|
|
1443
1455
|
}
|
|
1444
1456
|
const rawLimit = url.searchParams.get('limit')
|
|
@@ -1460,8 +1472,17 @@ export function createRouteHandler({
|
|
|
1460
1472
|
printed: hit.printed ?? null,
|
|
1461
1473
|
snippet: String(hit.snippet ?? ''),
|
|
1462
1474
|
}))
|
|
1463
|
-
|
|
1475
|
+
const mode = ['phrase', 'compact', 'terms'].includes(result?.mode) ? result.mode : 'phrase'
|
|
1476
|
+
sendJson(response, 200, { ok: true, mode, hits })
|
|
1464
1477
|
} catch (error) {
|
|
1478
|
+
if (
|
|
1479
|
+
error?.code === 'PDF_SEARCH_QUERY_TOO_LONG' ||
|
|
1480
|
+
(error instanceof TypeError && /query must be a non-empty string/u.test(error.message))
|
|
1481
|
+
) {
|
|
1482
|
+
const tooLong = error?.code === 'PDF_SEARCH_QUERY_TOO_LONG'
|
|
1483
|
+
sendJson(response, 400, { ok: false, error: tooLong ? 'query-too-long' : 'missing-query' })
|
|
1484
|
+
return
|
|
1485
|
+
}
|
|
1465
1486
|
sendJson(response, 500, {
|
|
1466
1487
|
ok: false,
|
|
1467
1488
|
error: 'search-failed',
|
|
@@ -1679,6 +1700,52 @@ export function trimPageText(raw, maxChars) {
|
|
|
1679
1700
|
return { text: normalized.slice(0, maxChars), truncated: true, unavailable: false }
|
|
1680
1701
|
}
|
|
1681
1702
|
|
|
1703
|
+
/** Longest search query accepted after wrapper/whitespace cleanup. */
|
|
1704
|
+
const MAX_SEARCH_QUERY_LENGTH = 1000
|
|
1705
|
+
|
|
1706
|
+
/** Whole-query wrappers the model may add around a search query. */
|
|
1707
|
+
const SEARCH_QUERY_WRAPPERS = [
|
|
1708
|
+
['"', '"'],
|
|
1709
|
+
["'", "'"],
|
|
1710
|
+
['“', '”'],
|
|
1711
|
+
['‘', '’'],
|
|
1712
|
+
['`', '`'],
|
|
1713
|
+
['$', '$'],
|
|
1714
|
+
]
|
|
1715
|
+
|
|
1716
|
+
/**
|
|
1717
|
+
* Tool/route-side search-query cleanup: trim, strip a pair of wrapping
|
|
1718
|
+
* quotes/backticks/`$…$`, collapse whitespace, and reject empty/over-long
|
|
1719
|
+
* input. Mirrors `cleanSearchQuery()` in core/pdf.js; kept local because the
|
|
1720
|
+
* PDF core is imported lazily, only when a PDF operation actually runs.
|
|
1721
|
+
*
|
|
1722
|
+
* @param {unknown} raw
|
|
1723
|
+
* @returns {string}
|
|
1724
|
+
*/
|
|
1725
|
+
function cleanPdfSearchQuery(raw) {
|
|
1726
|
+
if (typeof raw !== 'string') throw new TypeError('query must be a non-empty string')
|
|
1727
|
+
|
|
1728
|
+
let query = raw.trim()
|
|
1729
|
+
let stripped = true
|
|
1730
|
+
while (stripped && query.length >= 2) {
|
|
1731
|
+
stripped = false
|
|
1732
|
+
for (const [open, close] of SEARCH_QUERY_WRAPPERS) {
|
|
1733
|
+
if (query.startsWith(open) && query.endsWith(close)) {
|
|
1734
|
+
query = query.slice(open.length, query.length - close.length).trim()
|
|
1735
|
+
stripped = true
|
|
1736
|
+
break
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
query = query.replace(/\s+/gu, ' ').trim()
|
|
1742
|
+
if (query === '') throw new TypeError('query must be a non-empty string')
|
|
1743
|
+
if (query.length > MAX_SEARCH_QUERY_LENGTH) {
|
|
1744
|
+
throw new RangeError(`query is longer than ${MAX_SEARCH_QUERY_LENGTH} characters`)
|
|
1745
|
+
}
|
|
1746
|
+
return query
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1682
1749
|
/** Register `pdf_info` and `pdf_search`. */
|
|
1683
1750
|
export function createCheapTools({ defineTool: makeTool, config, core, infoCache, statFile = stat }) {
|
|
1684
1751
|
return [
|
|
@@ -1788,16 +1855,36 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1788
1855
|
makeTool({
|
|
1789
1856
|
name: 'pdf_search',
|
|
1790
1857
|
description:
|
|
1791
|
-
'Search a PDF\'s text layer; returns page numbers and
|
|
1858
|
+
'Search a PDF\'s text layer; returns page numbers, snippets, and the matching mode. Literal-ish search over normalized text: case-insensitive by default, tolerant of wrapping quotes/backticks and `$…$` LaTeX markup, hyphen/space-insensitive fallback, then term-AND fallback when no literal match. Body pages rank before front matter. Pass `pages` to restrict the scan to a page range (printed pages by default), e.g. when reading a later chapter. Text only, cheap.',
|
|
1792
1859
|
parameters: {
|
|
1793
1860
|
path: {
|
|
1794
1861
|
type: 'string',
|
|
1795
1862
|
required: true,
|
|
1796
1863
|
description: 'Absolute .pdf path (relative resolves against the session directory).',
|
|
1797
1864
|
},
|
|
1798
|
-
query: {
|
|
1865
|
+
query: {
|
|
1866
|
+
type: 'string',
|
|
1867
|
+
required: true,
|
|
1868
|
+
description:
|
|
1869
|
+
'Text or formula to find. Wrapping quotes/backticks/$…$ are stripped; use a distinctive phrase.',
|
|
1870
|
+
},
|
|
1871
|
+
pages: {
|
|
1872
|
+
description:
|
|
1873
|
+
'Optional 1-based pages to search within: "132-140", "132,135", 132, or [132,133]. Interpreted as printed numbers when the PDF has an offset, unless page_numbers is "pdf". Use it to scope a search to the chapter the user is reading.',
|
|
1874
|
+
oneOf: [
|
|
1875
|
+
{ type: 'integer' },
|
|
1876
|
+
{ type: 'string' },
|
|
1877
|
+
{ type: 'array', items: { oneOf: [{ type: 'integer' }, { type: 'string' }] } },
|
|
1878
|
+
],
|
|
1879
|
+
},
|
|
1880
|
+
page_numbers: {
|
|
1881
|
+
type: 'string',
|
|
1882
|
+
enum: ['printed', 'pdf'],
|
|
1883
|
+
description:
|
|
1884
|
+
'How to interpret `pages`. Defaults to "printed" when the PDF exposes numeric printed-page labels, otherwise "pdf".',
|
|
1885
|
+
},
|
|
1799
1886
|
ignore_case: { type: 'boolean', description: 'Case-insensitive search. Defaults to true.' },
|
|
1800
|
-
limit: { type: 'integer', description: 'Maximum hits to return. Defaults to
|
|
1887
|
+
limit: { type: 'integer', description: 'Maximum hits to return. Defaults to 30; capped by plugin config.' },
|
|
1801
1888
|
},
|
|
1802
1889
|
output: {
|
|
1803
1890
|
schema: {
|
|
@@ -1811,6 +1898,13 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1811
1898
|
required: true,
|
|
1812
1899
|
description: 'Number of pages with at least one hit; may exceed the returned hit count when limit truncates.',
|
|
1813
1900
|
},
|
|
1901
|
+
mode: {
|
|
1902
|
+
type: 'string',
|
|
1903
|
+
enum: ['phrase', 'compact', 'terms'],
|
|
1904
|
+
required: true,
|
|
1905
|
+
description:
|
|
1906
|
+
'Which pass produced the hits: literal phrase, space-insensitive, or all-terms.',
|
|
1907
|
+
},
|
|
1814
1908
|
hits: {
|
|
1815
1909
|
type: 'array',
|
|
1816
1910
|
required: true,
|
|
@@ -1834,13 +1928,51 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1834
1928
|
async execute(args, exec) {
|
|
1835
1929
|
const path = resolveToolPath(args.path, exec)
|
|
1836
1930
|
assertInsideWorkspace(path, exec, config)
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1931
|
+
let query
|
|
1932
|
+
try {
|
|
1933
|
+
query = cleanPdfSearchQuery(args.query)
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
throw codedError(
|
|
1936
|
+
error instanceof Error ? error.message : String(error),
|
|
1937
|
+
'PDF_READER_INVALID_QUERY',
|
|
1938
|
+
)
|
|
1939
|
+
}
|
|
1840
1940
|
await assertPdfFile(path, { statFile, maxFileBytes: config.maxFileBytes })
|
|
1841
|
-
const limit = Math.max(1, Math.min(Number.isInteger(args.limit) ? args.limit :
|
|
1941
|
+
const limit = Math.max(1, Math.min(Number.isInteger(args.limit) ? args.limit : 30, config.maxSearchResults))
|
|
1842
1942
|
const ignoreCase = args.ignore_case !== false
|
|
1843
|
-
|
|
1943
|
+
let pdfPages
|
|
1944
|
+
if (args.pages !== undefined) {
|
|
1945
|
+
const info = (await infoCache.load(path)).info
|
|
1946
|
+
const pageCount = Number.isInteger(info?.pageCount) && info.pageCount > 0
|
|
1947
|
+
? info.pageCount
|
|
1948
|
+
: PAGE_SELECTION_HARD_LIMIT
|
|
1949
|
+
const resolved = resolveRequestedPages({
|
|
1950
|
+
selection: args.pages,
|
|
1951
|
+
pageNumbers: args.page_numbers,
|
|
1952
|
+
info,
|
|
1953
|
+
maxPages: pageCount,
|
|
1954
|
+
hardLimit: Math.max(PAGE_SELECTION_HARD_LIMIT, pageCount),
|
|
1955
|
+
})
|
|
1956
|
+
pdfPages = resolved.pdfPages
|
|
1957
|
+
}
|
|
1958
|
+
let result
|
|
1959
|
+
try {
|
|
1960
|
+
result = await core.searchPdf(path, query, {
|
|
1961
|
+
ignoreCase,
|
|
1962
|
+
limit,
|
|
1963
|
+
...(pdfPages === undefined ? {} : { pdfPages }),
|
|
1964
|
+
})
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
// Cleanup above already rejects ordinary empties; core also rejects a
|
|
1967
|
+
// query that normalization erases entirely (e.g. "---").
|
|
1968
|
+
if (
|
|
1969
|
+
error?.code === 'PDF_SEARCH_QUERY_TOO_LONG' ||
|
|
1970
|
+
(error instanceof TypeError && /query must be a non-empty string/u.test(error.message))
|
|
1971
|
+
) {
|
|
1972
|
+
throw codedError(error.message, 'PDF_READER_INVALID_QUERY')
|
|
1973
|
+
}
|
|
1974
|
+
throw error
|
|
1975
|
+
}
|
|
1844
1976
|
const rawHits = Array.isArray(result?.hits) ? result.hits : []
|
|
1845
1977
|
const hits = rawHits.slice(0, limit).map((hit) => ({
|
|
1846
1978
|
pdf: Number(hit.pdf),
|
|
@@ -1852,6 +1984,7 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1852
1984
|
path,
|
|
1853
1985
|
query,
|
|
1854
1986
|
pages: Number.isInteger(result?.pages) ? result.pages : new Set(hits.map((hit) => hit.pdf)).size,
|
|
1987
|
+
mode: ['phrase', 'compact', 'terms'].includes(result?.mode) ? result.mode : 'phrase',
|
|
1855
1988
|
hits,
|
|
1856
1989
|
}
|
|
1857
1990
|
},
|
|
@@ -1952,7 +2085,7 @@ export function createReadPagesTool({ defineTool: makeTool, ctx, config, core, i
|
|
|
1952
2085
|
pdf: { type: 'integer', required: true },
|
|
1953
2086
|
printed: { oneOf: [{ type: 'integer' }, { type: 'string' }], required: true },
|
|
1954
2087
|
tokens: { type: 'integer' },
|
|
1955
|
-
image:
|
|
2088
|
+
image: optionalPropertySchema(IMAGE_VALUE_SCHEMA),
|
|
1956
2089
|
text: { type: 'string' },
|
|
1957
2090
|
textTruncated: { type: 'boolean' },
|
|
1958
2091
|
textUnavailable: { type: 'boolean' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-book-reader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Read books and papers in DSH: a reading toolbar on the official PDF preview that remembers your page and zoom, plus PDF tools for the model (page map, full-text search, page text and images).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|