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/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
- blocks.push({ type: 'image', attachment: imageRefFromValue(page.image) })
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
- const query = (url.searchParams.get('q') ?? '').trim()
1436
- if (query === '') {
1437
- sendJson(response, 400, { ok: false, error: 'missing-query' })
1438
- return
1439
- }
1440
- if (query.length > 1000) {
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
- sendJson(response, 200, { ok: true, hits })
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 snippets. Text only, cheap.',
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: { type: 'string', required: true, description: 'Literal text to search for.' },
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 20; capped by plugin config.' },
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
- const query = typeof args.query === 'string' ? args.query.trim() : ''
1838
- if (query === '') throw codedError('query must be a non-empty string', 'PDF_READER_INVALID_QUERY')
1839
- if (query.length > 1000) throw codedError('query is longer than 1000 characters', 'PDF_READER_INVALID_QUERY')
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 : 20, config.maxSearchResults))
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
- const result = await core.searchPdf(path, query, { ignoreCase, limit })
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: { ...IMAGE_VALUE_SCHEMA, required: false },
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
- const attachments = ctx.get('attachments')
1974
- if (attachments === undefined) {
1975
- throw codedError('cannot read PDF pages as images: no attachment service is mounted', 'PDF_READER_NO_ATTACHMENTS')
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.2.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",