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/CHANGELOG.md +46 -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 +167 -25
- 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,
|
|
@@ -1233,7 +1246,11 @@ export function renderPageReadContent(value) {
|
|
|
1233
1246
|
type: 'text',
|
|
1234
1247
|
text: `<page pdf="${page.pdf}" printed="${page.printed}">\n${body}${truncation}\n</page>`,
|
|
1235
1248
|
})
|
|
1236
|
-
|
|
1249
|
+
// Text-only reads (include_image:false) never carry an image; emitting an
|
|
1250
|
+
// image block for them crashed output.render on page.image.attachmentId.
|
|
1251
|
+
if (page.image !== undefined) {
|
|
1252
|
+
blocks.push({ type: 'image', attachment: imageRefFromValue(page.image) })
|
|
1253
|
+
}
|
|
1237
1254
|
}
|
|
1238
1255
|
return blocks
|
|
1239
1256
|
}
|
|
@@ -1432,13 +1449,12 @@ export function createRouteHandler({
|
|
|
1432
1449
|
sendJson(response, target.status, { ok: false, error: target.error })
|
|
1433
1450
|
return
|
|
1434
1451
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
sendJson(response, 400, { ok: false, error: 'query-too-long' })
|
|
1452
|
+
let query
|
|
1453
|
+
try {
|
|
1454
|
+
query = cleanPdfSearchQuery(url.searchParams.get('q') ?? '')
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
const tooLong = error instanceof RangeError
|
|
1457
|
+
sendJson(response, 400, { ok: false, error: tooLong ? 'query-too-long' : 'missing-query' })
|
|
1442
1458
|
return
|
|
1443
1459
|
}
|
|
1444
1460
|
const rawLimit = url.searchParams.get('limit')
|
|
@@ -1460,8 +1476,17 @@ export function createRouteHandler({
|
|
|
1460
1476
|
printed: hit.printed ?? null,
|
|
1461
1477
|
snippet: String(hit.snippet ?? ''),
|
|
1462
1478
|
}))
|
|
1463
|
-
|
|
1479
|
+
const mode = ['phrase', 'compact', 'terms'].includes(result?.mode) ? result.mode : 'phrase'
|
|
1480
|
+
sendJson(response, 200, { ok: true, mode, hits })
|
|
1464
1481
|
} catch (error) {
|
|
1482
|
+
if (
|
|
1483
|
+
error?.code === 'PDF_SEARCH_QUERY_TOO_LONG' ||
|
|
1484
|
+
(error instanceof TypeError && /query must be a non-empty string/u.test(error.message))
|
|
1485
|
+
) {
|
|
1486
|
+
const tooLong = error?.code === 'PDF_SEARCH_QUERY_TOO_LONG'
|
|
1487
|
+
sendJson(response, 400, { ok: false, error: tooLong ? 'query-too-long' : 'missing-query' })
|
|
1488
|
+
return
|
|
1489
|
+
}
|
|
1465
1490
|
sendJson(response, 500, {
|
|
1466
1491
|
ok: false,
|
|
1467
1492
|
error: 'search-failed',
|
|
@@ -1679,6 +1704,52 @@ export function trimPageText(raw, maxChars) {
|
|
|
1679
1704
|
return { text: normalized.slice(0, maxChars), truncated: true, unavailable: false }
|
|
1680
1705
|
}
|
|
1681
1706
|
|
|
1707
|
+
/** Longest search query accepted after wrapper/whitespace cleanup. */
|
|
1708
|
+
const MAX_SEARCH_QUERY_LENGTH = 1000
|
|
1709
|
+
|
|
1710
|
+
/** Whole-query wrappers the model may add around a search query. */
|
|
1711
|
+
const SEARCH_QUERY_WRAPPERS = [
|
|
1712
|
+
['"', '"'],
|
|
1713
|
+
["'", "'"],
|
|
1714
|
+
['“', '”'],
|
|
1715
|
+
['‘', '’'],
|
|
1716
|
+
['`', '`'],
|
|
1717
|
+
['$', '$'],
|
|
1718
|
+
]
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Tool/route-side search-query cleanup: trim, strip a pair of wrapping
|
|
1722
|
+
* quotes/backticks/`$…$`, collapse whitespace, and reject empty/over-long
|
|
1723
|
+
* input. Mirrors `cleanSearchQuery()` in core/pdf.js; kept local because the
|
|
1724
|
+
* PDF core is imported lazily, only when a PDF operation actually runs.
|
|
1725
|
+
*
|
|
1726
|
+
* @param {unknown} raw
|
|
1727
|
+
* @returns {string}
|
|
1728
|
+
*/
|
|
1729
|
+
function cleanPdfSearchQuery(raw) {
|
|
1730
|
+
if (typeof raw !== 'string') throw new TypeError('query must be a non-empty string')
|
|
1731
|
+
|
|
1732
|
+
let query = raw.trim()
|
|
1733
|
+
let stripped = true
|
|
1734
|
+
while (stripped && query.length >= 2) {
|
|
1735
|
+
stripped = false
|
|
1736
|
+
for (const [open, close] of SEARCH_QUERY_WRAPPERS) {
|
|
1737
|
+
if (query.startsWith(open) && query.endsWith(close)) {
|
|
1738
|
+
query = query.slice(open.length, query.length - close.length).trim()
|
|
1739
|
+
stripped = true
|
|
1740
|
+
break
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
query = query.replace(/\s+/gu, ' ').trim()
|
|
1746
|
+
if (query === '') throw new TypeError('query must be a non-empty string')
|
|
1747
|
+
if (query.length > MAX_SEARCH_QUERY_LENGTH) {
|
|
1748
|
+
throw new RangeError(`query is longer than ${MAX_SEARCH_QUERY_LENGTH} characters`)
|
|
1749
|
+
}
|
|
1750
|
+
return query
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1682
1753
|
/** Register `pdf_info` and `pdf_search`. */
|
|
1683
1754
|
export function createCheapTools({ defineTool: makeTool, config, core, infoCache, statFile = stat }) {
|
|
1684
1755
|
return [
|
|
@@ -1788,16 +1859,36 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1788
1859
|
makeTool({
|
|
1789
1860
|
name: 'pdf_search',
|
|
1790
1861
|
description:
|
|
1791
|
-
'Search a PDF\'s text layer; returns page numbers and
|
|
1862
|
+
'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
1863
|
parameters: {
|
|
1793
1864
|
path: {
|
|
1794
1865
|
type: 'string',
|
|
1795
1866
|
required: true,
|
|
1796
1867
|
description: 'Absolute .pdf path (relative resolves against the session directory).',
|
|
1797
1868
|
},
|
|
1798
|
-
query: {
|
|
1869
|
+
query: {
|
|
1870
|
+
type: 'string',
|
|
1871
|
+
required: true,
|
|
1872
|
+
description:
|
|
1873
|
+
'Text or formula to find. Wrapping quotes/backticks/$…$ are stripped; use a distinctive phrase.',
|
|
1874
|
+
},
|
|
1875
|
+
pages: {
|
|
1876
|
+
description:
|
|
1877
|
+
'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.',
|
|
1878
|
+
oneOf: [
|
|
1879
|
+
{ type: 'integer' },
|
|
1880
|
+
{ type: 'string' },
|
|
1881
|
+
{ type: 'array', items: { oneOf: [{ type: 'integer' }, { type: 'string' }] } },
|
|
1882
|
+
],
|
|
1883
|
+
},
|
|
1884
|
+
page_numbers: {
|
|
1885
|
+
type: 'string',
|
|
1886
|
+
enum: ['printed', 'pdf'],
|
|
1887
|
+
description:
|
|
1888
|
+
'How to interpret `pages`. Defaults to "printed" when the PDF exposes numeric printed-page labels, otherwise "pdf".',
|
|
1889
|
+
},
|
|
1799
1890
|
ignore_case: { type: 'boolean', description: 'Case-insensitive search. Defaults to true.' },
|
|
1800
|
-
limit: { type: 'integer', description: 'Maximum hits to return. Defaults to
|
|
1891
|
+
limit: { type: 'integer', description: 'Maximum hits to return. Defaults to 30; capped by plugin config.' },
|
|
1801
1892
|
},
|
|
1802
1893
|
output: {
|
|
1803
1894
|
schema: {
|
|
@@ -1811,6 +1902,13 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1811
1902
|
required: true,
|
|
1812
1903
|
description: 'Number of pages with at least one hit; may exceed the returned hit count when limit truncates.',
|
|
1813
1904
|
},
|
|
1905
|
+
mode: {
|
|
1906
|
+
type: 'string',
|
|
1907
|
+
enum: ['phrase', 'compact', 'terms'],
|
|
1908
|
+
required: true,
|
|
1909
|
+
description:
|
|
1910
|
+
'Which pass produced the hits: literal phrase, space-insensitive, or all-terms.',
|
|
1911
|
+
},
|
|
1814
1912
|
hits: {
|
|
1815
1913
|
type: 'array',
|
|
1816
1914
|
required: true,
|
|
@@ -1834,13 +1932,51 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1834
1932
|
async execute(args, exec) {
|
|
1835
1933
|
const path = resolveToolPath(args.path, exec)
|
|
1836
1934
|
assertInsideWorkspace(path, exec, config)
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1935
|
+
let query
|
|
1936
|
+
try {
|
|
1937
|
+
query = cleanPdfSearchQuery(args.query)
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
throw codedError(
|
|
1940
|
+
error instanceof Error ? error.message : String(error),
|
|
1941
|
+
'PDF_READER_INVALID_QUERY',
|
|
1942
|
+
)
|
|
1943
|
+
}
|
|
1840
1944
|
await assertPdfFile(path, { statFile, maxFileBytes: config.maxFileBytes })
|
|
1841
|
-
const limit = Math.max(1, Math.min(Number.isInteger(args.limit) ? args.limit :
|
|
1945
|
+
const limit = Math.max(1, Math.min(Number.isInteger(args.limit) ? args.limit : 30, config.maxSearchResults))
|
|
1842
1946
|
const ignoreCase = args.ignore_case !== false
|
|
1843
|
-
|
|
1947
|
+
let pdfPages
|
|
1948
|
+
if (args.pages !== undefined) {
|
|
1949
|
+
const info = (await infoCache.load(path)).info
|
|
1950
|
+
const pageCount = Number.isInteger(info?.pageCount) && info.pageCount > 0
|
|
1951
|
+
? info.pageCount
|
|
1952
|
+
: PAGE_SELECTION_HARD_LIMIT
|
|
1953
|
+
const resolved = resolveRequestedPages({
|
|
1954
|
+
selection: args.pages,
|
|
1955
|
+
pageNumbers: args.page_numbers,
|
|
1956
|
+
info,
|
|
1957
|
+
maxPages: pageCount,
|
|
1958
|
+
hardLimit: Math.max(PAGE_SELECTION_HARD_LIMIT, pageCount),
|
|
1959
|
+
})
|
|
1960
|
+
pdfPages = resolved.pdfPages
|
|
1961
|
+
}
|
|
1962
|
+
let result
|
|
1963
|
+
try {
|
|
1964
|
+
result = await core.searchPdf(path, query, {
|
|
1965
|
+
ignoreCase,
|
|
1966
|
+
limit,
|
|
1967
|
+
...(pdfPages === undefined ? {} : { pdfPages }),
|
|
1968
|
+
})
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
// Cleanup above already rejects ordinary empties; core also rejects a
|
|
1971
|
+
// query that normalization erases entirely (e.g. "---").
|
|
1972
|
+
if (
|
|
1973
|
+
error?.code === 'PDF_SEARCH_QUERY_TOO_LONG' ||
|
|
1974
|
+
(error instanceof TypeError && /query must be a non-empty string/u.test(error.message))
|
|
1975
|
+
) {
|
|
1976
|
+
throw codedError(error.message, 'PDF_READER_INVALID_QUERY')
|
|
1977
|
+
}
|
|
1978
|
+
throw error
|
|
1979
|
+
}
|
|
1844
1980
|
const rawHits = Array.isArray(result?.hits) ? result.hits : []
|
|
1845
1981
|
const hits = rawHits.slice(0, limit).map((hit) => ({
|
|
1846
1982
|
pdf: Number(hit.pdf),
|
|
@@ -1852,6 +1988,7 @@ export function createCheapTools({ defineTool: makeTool, config, core, infoCache
|
|
|
1852
1988
|
path,
|
|
1853
1989
|
query,
|
|
1854
1990
|
pages: Number.isInteger(result?.pages) ? result.pages : new Set(hits.map((hit) => hit.pdf)).size,
|
|
1991
|
+
mode: ['phrase', 'compact', 'terms'].includes(result?.mode) ? result.mode : 'phrase',
|
|
1855
1992
|
hits,
|
|
1856
1993
|
}
|
|
1857
1994
|
},
|
|
@@ -1952,7 +2089,7 @@ export function createReadPagesTool({ defineTool: makeTool, ctx, config, core, i
|
|
|
1952
2089
|
pdf: { type: 'integer', required: true },
|
|
1953
2090
|
printed: { oneOf: [{ type: 'integer' }, { type: 'string' }], required: true },
|
|
1954
2091
|
tokens: { type: 'integer' },
|
|
1955
|
-
image:
|
|
2092
|
+
image: optionalPropertySchema(IMAGE_VALUE_SCHEMA),
|
|
1956
2093
|
text: { type: 'string' },
|
|
1957
2094
|
textTruncated: { type: 'boolean' },
|
|
1958
2095
|
textUnavailable: { type: 'boolean' },
|
|
@@ -1970,11 +2107,17 @@ export function createReadPagesTool({ defineTool: makeTool, ctx, config, core, i
|
|
|
1970
2107
|
const path = resolveToolPath(args.path, exec)
|
|
1971
2108
|
assertInsideWorkspace(path, exec, config)
|
|
1972
2109
|
const targetStat = await assertPdfFile(path, { statFile, maxFileBytes: config.maxFileBytes })
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
2110
|
+
// Images need the attachment store and an image-capable route; a
|
|
2111
|
+
// text-only read needs neither and must keep working without them.
|
|
2112
|
+
const includeImage = args.include_image !== false
|
|
2113
|
+
let attachments
|
|
2114
|
+
if (includeImage) {
|
|
2115
|
+
attachments = ctx.get('attachments')
|
|
2116
|
+
if (attachments === undefined) {
|
|
2117
|
+
throw codedError('cannot read PDF pages as images: no attachment service is mounted', 'PDF_READER_NO_ATTACHMENTS')
|
|
2118
|
+
}
|
|
2119
|
+
await assertImageCapableRoute(ctx, exec, path)
|
|
1976
2120
|
}
|
|
1977
|
-
await assertImageCapableRoute(ctx, exec, path)
|
|
1978
2121
|
const info = (await infoCache.load(path, targetStat)).info
|
|
1979
2122
|
const resolved = resolveRequestedPages({
|
|
1980
2123
|
selection: args.pages,
|
|
@@ -1982,7 +2125,6 @@ export function createReadPagesTool({ defineTool: makeTool, ctx, config, core, i
|
|
|
1982
2125
|
info,
|
|
1983
2126
|
maxPages: config.maxPagesPerCall,
|
|
1984
2127
|
})
|
|
1985
|
-
const includeImage = args.include_image !== false
|
|
1986
2128
|
if (!includeImage) {
|
|
1987
2129
|
const book = titleFromPath(path)
|
|
1988
2130
|
const includeText = args.include_text !== false && config.maxTextCharsPerPage > 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-book-reader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|