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 +19 -0
- package/lib/client.js +66 -1
- package/lib/core/README.md +8 -2
- package/lib/core/pdf.js +31 -4
- package/package.json +1 -1
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
|
-
|
|
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()
|
package/lib/core/README.md
CHANGED
|
@@ -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
|
|
119
|
-
|
|
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() !== ''
|
|
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.
|
|
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",
|