dsh-book-reader 0.3.2 → 0.3.3

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 CHANGED
@@ -37,6 +37,24 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
37
37
  rejects at registration time. The exception only reached the Host log, so the
38
38
  plugin looked enabled with two tools instead of three.
39
39
 
40
+ ## [0.3.3] — 2026-10-29
41
+
42
+ ### Fixed
43
+
44
+ - Page geometry is no longer measured while the PDF preview is scrolling. The
45
+ O(410) rect pass used to run on every lazy page render (ResizeObserver),
46
+ forcing a full preview layout against PDF.js and leaving a grey placeholder
47
+ for seconds at high zoom on Retina displays. Measurements now run ~260 ms
48
+ after scrolling stops, are skipped entirely while the saved page/zoom restore
49
+ is in flight, and page wrappers are cached between passes.
50
+ - PDF text, search snippets, outline titles and document metadata are now
51
+ returned as well-formed Unicode. Some pages' fonts make PDF.js emit an
52
+ unpaired UTF-16 surrogate (for example `U+D835`); that string is valid JSON
53
+ but invalid for the model API, and a single such tool result in a Session log
54
+ made every later DeepSeek Messages request fail with `400 INVALID_REQUEST`.
55
+ Unpaired surrogates are replaced with `U+FFFD` (length-preserving, so search
56
+ offsets stay aligned).
57
+
40
58
  ## [0.3.2] — 2026-10-23
41
59
 
42
60
  ### Fixed
@@ -123,6 +141,7 @@ First public release.
123
141
  - `pdfjs-dist` is pinned to `6.3.289` to match the host process.
124
142
  - MIT licensed.
125
143
 
144
+ [0.3.3]: https://github.com/Strepsiades2/dsh-book-reader/releases/tag/v0.3.3
126
145
  [0.3.2]: https://github.com/Strepsiades2/dsh-book-reader/releases/tag/v0.3.2
127
146
  [0.3.1]: https://github.com/Strepsiades2/dsh-book-reader/releases/tag/v0.3.1
128
147
  [0.3.0]: https://github.com/Strepsiades2/dsh-book-reader/releases/tag/v0.3.0
package/lib/client.js CHANGED
@@ -1250,6 +1250,13 @@ window.__ModuleLoader__.load({
1250
1250
  }
1251
1251
  /** Latest sampled page, readable from the debounced view save. */
1252
1252
  const pageRef = useRef(1)
1253
+ /**
1254
+ * True while the saved page/zoom restore is in flight. Geometry
1255
+ * measurements must stay out of that window: measuring 410 wrappers
1256
+ * forces a full preview layout and would land exactly when PDF.js is
1257
+ * painting the first restored page.
1258
+ */
1259
+ const restoringViewRef = useRef(false)
1253
1260
  /** Installed by the view effect; `null` when this preview keeps no view. */
1254
1261
  const requestViewSaveRef = useRef(null)
1255
1262
 
@@ -1521,6 +1528,8 @@ window.__ModuleLoader__.load({
1521
1528
  let retryTimer
1522
1529
  let retryCount = 0
1523
1530
  let geometry = []
1531
+ let cachedPages = []
1532
+ let scrollIdleTimer
1524
1533
  let lastPage
1525
1534
  const view = typeof window !== 'undefined' ? window : globalThis
1526
1535
 
@@ -1567,6 +1576,10 @@ window.__ModuleLoader__.load({
1567
1576
 
1568
1577
  const measure = () => {
1569
1578
  if (disposed) return
1579
+ if (restoringViewRef.current) {
1580
+ scheduleMeasureOnIdle()
1581
+ return
1582
+ }
1570
1583
  const currentPane = resolvePane(host)
1571
1584
  if (currentPane === undefined) {
1572
1585
  scheduleRetry()
@@ -1578,7 +1591,13 @@ window.__ModuleLoader__.load({
1578
1591
  bind(currentPane, currentScrollport, currentSection)
1579
1592
  return
1580
1593
  }
1581
- const pages = pageElements(currentPane)
1594
+ let pages = cachedPages
1595
+ if (pages.length === 0
1596
+ || pages[0].element.isConnected === false
1597
+ || pages[pages.length - 1].element.isConnected === false) {
1598
+ pages = pageElements(currentPane)
1599
+ cachedPages = pages
1600
+ }
1582
1601
  const nextGeometry = []
1583
1602
  try {
1584
1603
  const portRect = currentScrollport === undefined ? { top: 0 } : currentScrollport.getBoundingClientRect()
@@ -1598,12 +1617,46 @@ window.__ModuleLoader__.load({
1598
1617
  sample()
1599
1618
  }
1600
1619
 
1620
+ /**
1621
+ * Measuring every page's rect forces a full layout of the preview's
1622
+ * page/text-layer DOM. Doing that while pages are being rendered (the
1623
+ * scroll-active window) serializes layout against PDF.js and can delay
1624
+ * a page's canvas paint by seconds. Record geometry only once the
1625
+ * scrollport has been quiet for a moment.
1626
+ */
1627
+ const clearScrollIdle = () => {
1628
+ if (scrollIdleTimer !== undefined) {
1629
+ globalThis.clearTimeout(scrollIdleTimer)
1630
+ scrollIdleTimer = undefined
1631
+ }
1632
+ }
1633
+
1634
+ const scheduleMeasureOnIdle = () => {
1635
+ clearScrollIdle()
1636
+ scrollIdleTimer = globalThis.setTimeout(() => {
1637
+ scrollIdleTimer = undefined
1638
+ if (disposed) return
1639
+ if (restoringViewRef.current) {
1640
+ scheduleMeasureOnIdle()
1641
+ return
1642
+ }
1643
+ measureDebouncer.schedule(() => {
1644
+ measure()
1645
+ sample()
1646
+ })
1647
+ }, 260)
1648
+ }
1649
+
1601
1650
  const onScroll = () => {
1651
+ scheduleMeasureOnIdle()
1602
1652
  throttle.schedule(sample)
1603
1653
  }
1604
1654
 
1605
1655
  const onResize = () => {
1606
1656
  if (disposed) return
1657
+ // Page renders change the section height while scrolling; defer that
1658
+ // measurement to the scroll-idle pass instead of thrashing layout now.
1659
+ if (scrollIdleTimer !== undefined || restoringViewRef.current) return
1607
1660
  measureDebouncer.schedule(() => {
1608
1661
  measure()
1609
1662
  sample()
@@ -1645,6 +1698,7 @@ window.__ModuleLoader__.load({
1645
1698
  }
1646
1699
 
1647
1700
  const detach = () => {
1701
+ clearScrollIdle()
1648
1702
  scrollport?.removeEventListener('scroll', onScroll)
1649
1703
  pane?.removeEventListener('scroll', onScroll, true)
1650
1704
  view.removeEventListener('resize', onResize)
@@ -1661,6 +1715,7 @@ window.__ModuleLoader__.load({
1661
1715
  scrollport = nextScrollport
1662
1716
  section = nextSection
1663
1717
  geometry = []
1718
+ cachedPages = nextPane === undefined ? [] : pageElements(nextPane)
1664
1719
  lastPage = undefined
1665
1720
  if (pane !== undefined) {
1666
1721
  pane.addEventListener('scroll', onScroll, true)
@@ -1675,6 +1730,13 @@ window.__ModuleLoader__.load({
1675
1730
  if (nextScrollport !== undefined) {
1676
1731
  nextScrollport.addEventListener('scroll', onScroll, { passive: true })
1677
1732
  }
1733
+ // Publish the page total from the wrapper count, then do the initial
1734
+ // geometry pass. This runs before the saved-view restore starts, on
1735
+ // placeholder layout only; later page-render driven re-measurement is
1736
+ // the expensive case and stays deferred.
1737
+ if (cachedPages.length > 0) {
1738
+ setDomTotal(cachedPages[cachedPages.length - 1].number)
1739
+ }
1678
1740
  measure()
1679
1741
  }
1680
1742
 
@@ -1723,6 +1785,7 @@ window.__ModuleLoader__.load({
1723
1785
  const host = hostRef.current
1724
1786
  if (host === null) return undefined
1725
1787
  const pdfPath = absolutePath
1788
+ restoringViewRef.current = true
1726
1789
 
1727
1790
  let disposed = false
1728
1791
  let finished = false
@@ -1808,6 +1871,7 @@ window.__ModuleLoader__.load({
1808
1871
  const finish = () => {
1809
1872
  if (disposed || finished) return
1810
1873
  finished = true
1874
+ restoringViewRef.current = false
1811
1875
  requestSave()
1812
1876
  }
1813
1877
 
@@ -2046,6 +2110,7 @@ window.__ModuleLoader__.load({
2046
2110
 
2047
2111
  return () => {
2048
2112
  disposed = true
2113
+ restoringViewRef.current = false
2049
2114
  restoreController.abort()
2050
2115
  saveController.abort()
2051
2116
  saveDebouncer.cancel()
@@ -115,8 +115,14 @@ Hits rank **body pages first** (numeric `printed`) in page/offset order, then
115
115
  front matter (roman labels) in order, with at most 3 front-matter hits while the
116
116
  overall `limit` is applied. `pages` counts every matched page in the active pass
117
117
  before ranking/limit. `indexOnPage` is a UTF-16 offset into the original
118
- `getPageText()` text, and snippets are cut from that original text (surrogate
119
- pairs preserved).
118
+ `getPageText()` text, and snippets are cut from that original text.
119
+
120
+ All returned text and metadata is **well-formed Unicode**. PDF.js can emit an
121
+ unpaired UTF-16 surrogate from a broken font `ToUnicode` map (for example
122
+ `U+D835` in LADR's math fonts); such strings are valid JSON but invalid for a
123
+ model API and one tool result containing one made every later DeepSeek Messages
124
+ request fail with `400 INVALID_REQUEST`. Unpaired surrogates are replaced with
125
+ `U+FFFD` (one code unit, so search offsets stay aligned).
120
126
 
121
127
  * `snippet` is at most about `2 * contextChars + query.length` characters, with
122
128
  leading/trailing `…` when the hit is mid-page.
package/lib/core/pdf.js CHANGED
@@ -599,8 +599,9 @@ async function flattenOutline(doc, items, meta) {
599
599
  for (const node of nodes) {
600
600
  if (!node || typeof node !== 'object') continue
601
601
 
602
- const title =
603
- typeof node.title === 'string' ? node.title.replace(/\s+/g, ' ').trim() : ''
602
+ const title = wellFormedText(
603
+ typeof node.title === 'string' ? node.title.replace(/\s+/g, ' ').trim() : '',
604
+ )
604
605
  if (title !== '') {
605
606
  const destination = await resolveDestination(doc, node.dest, namedCache)
606
607
  const pdfPage = await resolvePdfPage(doc, destination, pageCache)
@@ -637,7 +638,9 @@ async function readDocumentMetadata(doc) {
637
638
  try {
638
639
  const result = await doc.getMetadata()
639
640
  const info = result?.info ?? {}
640
- const str = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
641
+ const str = (value) => (typeof value === 'string' && value.trim() !== ''
642
+ ? wellFormedText(value.trim())
643
+ : null)
641
644
  return {
642
645
  title: str(info.Title),
643
646
  author: str(info.Author),
@@ -795,6 +798,30 @@ function normalizePageText(raw) {
795
798
  return raw.replace(/\s+/g, ' ').trim()
796
799
  }
797
800
 
801
+ /**
802
+ * Replace unpaired UTF-16 surrogates with U+FFFD.
803
+ *
804
+ * PDF.js text items can contain a lone high surrogate when a font's ToUnicode
805
+ * map is broken (typical for math fonts split across runs). Such strings are
806
+ * valid JSON (`"\ud835"`) but invalid Unicode for the model API; one such tool
807
+ * result in a Session log makes every later DeepSeek Messages request fail with
808
+ * 400 INVALID_REQUEST. Each plugin string that reaches a Tool result or route
809
+ * response must be well formed.
810
+ *
811
+ * `toWellFormed()` preserves the string length (one code unit -> one), which
812
+ * keeps search offset maps aligned.
813
+ * @param {unknown} value
814
+ * @returns {string}
815
+ */
816
+ function wellFormedText(value) {
817
+ if (typeof value !== 'string') return ''
818
+ if (typeof value.toWellFormed === 'function') return value.toWellFormed()
819
+ return value.replace(
820
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
821
+ '\uFFFD',
822
+ )
823
+ }
824
+
798
825
  function normalizePdfPageNumber(value, pageCount) {
799
826
  const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
800
827
  if (!Number.isInteger(number) || number < 1 || number > pageCount) {
@@ -815,7 +842,7 @@ async function extractPageText(entry, pdfPage) {
815
842
  const raw = content.items
816
843
  .map((item) => (typeof item?.str === 'string' ? item.str : ''))
817
844
  .join(' ')
818
- return normalizePageText(raw)
845
+ return wellFormedText(normalizePageText(raw))
819
846
  } finally {
820
847
  try {
821
848
  page.cleanup()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-book-reader",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",