OpenCode commited on
Commit
e16bd16
·
1 Parent(s): 0e9ba6a

Fix Reader source IDs and bound PDF opening reads

Browse files
app.py CHANGED
@@ -257,9 +257,23 @@ BROWSER_FILE_ITEM_KEY_MAP = {
257
  READER_SHORT_ID_RE = re.compile(r"^[0-9a-z]{13}$")
258
 
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  def reader_short_id(value: str) -> str:
261
  digest = 1469598103934665603
262
- for byte in value.encode("utf-8"):
263
  digest = ((digest ^ byte) * 1099511628211) & ((1 << 64) - 1)
264
  return _base36(digest).rjust(13, "0")
265
 
@@ -1845,7 +1859,7 @@ async def api_reader_resolve(id: str = Query(..., min_length=13, max_length=13))
1845
  for index in reader_source_id_indices.get(id, []):
1846
  record = records[index]
1847
  matches.append(reader_resolved_record(record, assets.get("f", {}).get(build_reader_asset_key(record), {})))
1848
- if len({match["download"] for match in matches}) > 1:
1849
  return JSONResponse({"error": "阅读 ID 不唯一"}, status_code=409)
1850
  matches = matches[:1]
1851
  if matches:
 
257
  READER_SHORT_ID_RE = re.compile(r"^[0-9a-z]{13}$")
258
 
259
 
260
+ def canonical_reader_source_url(value: str) -> str:
261
+ # Match reader-contract.js byte normalization without decoding path separators
262
+ # or treating an encoded percent sign as a second escape sequence.
263
+ def path_replace(match):
264
+ def token_replace(token_match):
265
+ token = token_match.group()
266
+ if re.fullmatch(r"%[0-9a-fA-F]{2}", token):
267
+ char = chr(int(token[1:], 16))
268
+ return char if re.fullmatch(r"[A-Za-z0-9._~-]", char) else token.upper()
269
+ return quote(token, safe="")
270
+ return match[1] + re.sub(r"%[0-9a-fA-F]{2}|[^A-Za-z0-9._~/-]", token_replace, match[2])
271
+ return re.sub(r"^(https://huggingface\.co/datasets/VoiceOfML/)([^?#]*)", path_replace, value)
272
+
273
+
274
  def reader_short_id(value: str) -> str:
275
  digest = 1469598103934665603
276
+ for byte in canonical_reader_source_url(value).encode("utf-8"):
277
  digest = ((digest ^ byte) * 1099511628211) & ((1 << 64) - 1)
278
  return _base36(digest).rjust(13, "0")
279
 
 
1859
  for index in reader_source_id_indices.get(id, []):
1860
  record = records[index]
1861
  matches.append(reader_resolved_record(record, assets.get("f", {}).get(build_reader_asset_key(record), {})))
1862
+ if len({canonical_reader_source_url(match["download"]) for match in matches}) > 1:
1863
  return JSONResponse({"error": "阅读 ID 不唯一"}, status_code=409)
1864
  matches = matches[:1]
1865
  if matches:
static/reader-contract.js CHANGED
@@ -369,9 +369,23 @@
369
  params.set("folder_url", record.FolderUrl || record.folderUrl);
370
  return (basePath || "/static/reader.html") + "?" + params.toString();
371
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  function shortSourceId(value) {
373
  let hash = 1469598103934665603n;
374
- for (const byte of new TextEncoder().encode(value)) {
375
  hash ^= BigInt(byte);
376
  hash = BigInt.asUintN(64, hash * 1099511628211n);
377
  }
@@ -389,6 +403,7 @@
389
  isBucketPath,
390
  pdfPageSource,
391
  txtRelativePath,
 
392
  shortSourceId,
393
  readerUrl
394
  });
 
369
  params.set("folder_url", record.FolderUrl || record.folderUrl);
370
  return (basePath || "/static/reader.html") + "?" + params.toString();
371
  }
372
+ function canonicalSourceUrl(value) {
373
+ // Normalize path bytes, not decoded URLs: %2F must remain distinct from /,
374
+ // and %2528 must never become %28. Query/fragment delimiters stay intact.
375
+ return value.replace(/^(https:\/\/huggingface\.co\/datasets\/VoiceOfML\/)([^?#]*)/, (_, prefix, path) =>
376
+ prefix + path.replace(/%[0-9a-fA-F]{2}|[^A-Za-z0-9._~/-]/gu, (token) => {
377
+ if (/^%[0-9a-f]{2}$/i.test(token)) {
378
+ const char = String.fromCharCode(parseInt(token.slice(1), 16));
379
+ return /^[A-Za-z0-9._~-]$/.test(char) ? char : token.toUpperCase();
380
+ }
381
+ return encodeURIComponent(token).replace(/[!'()*]/g, (char) =>
382
+ "%" + char.charCodeAt(0).toString(16).toUpperCase());
383
+ })
384
+ );
385
+ }
386
  function shortSourceId(value) {
387
  let hash = 1469598103934665603n;
388
+ for (const byte of new TextEncoder().encode(canonicalSourceUrl(value))) {
389
  hash ^= BigInt(byte);
390
  hash = BigInt.asUintN(64, hash * 1099511628211n);
391
  }
 
403
  isBucketPath,
404
  pdfPageSource,
405
  txtRelativePath,
406
+ canonicalSourceUrl,
407
  shortSourceId,
408
  readerUrl
409
  });
static/reader.js CHANGED
@@ -3761,22 +3761,6 @@ function preloadPdfEngine() {
3761
  if (!pdfEnginePreload) pdfEnginePreload = loadPdfEngine();
3762
  return pdfEnginePreload;
3763
  }
3764
- function pdfRangeChunkSize() {
3765
- try {
3766
- // The range-optimized artifact has page-oriented object placement. A
3767
- // smaller chunk avoids pulling unrelated objects before the first page;
3768
- // original PDFs retain the conservative default because some require
3769
- // larger sequential reads or the complete file.
3770
- const source = decodeURIComponent(sourceUrl);
3771
- if (
3772
- /\/pdf-range-v1-[0-9a-f]{16}-(?:objects|objects-linearized)\/document\.pdf(?:$|[?#])/i.test(
3773
- source
3774
- )
3775
- )
3776
- return 262144;
3777
- } catch (_) {}
3778
- return 1048576;
3779
- }
3780
  async function loadPdfEngine() {
3781
  for (let attempt = 0; attempt < 3; attempt++) {
3782
  assertReaderActive();
@@ -3824,9 +3808,11 @@ function loadPdfDocument() {
3824
  pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
3825
  const options = (url) => ({
3826
  url,
3827
- rangeChunkSize: pdfRangeChunkSize(),
 
 
3828
  disableAutoFetch: true,
3829
- disableStream: false,
3830
  wasmUrl: PDFJS_WASM_URL,
3831
  cMapUrl: PDFJS_CMAP_URL,
3832
  cMapPacked: true,
 
3761
  if (!pdfEnginePreload) pdfEnginePreload = loadPdfEngine();
3762
  return pdfEnginePreload;
3763
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3764
  async function loadPdfEngine() {
3765
  for (let attempt = 0; attempt < 3; attempt++) {
3766
  assertReaderActive();
 
3808
  pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
3809
  const options = (url) => ({
3810
  url,
3811
+ // Bound demand reads for original and converted PDFs alike. Streaming
3812
+ // otherwise keeps the full download running alongside these ranges.
3813
+ rangeChunkSize: 262144,
3814
  disableAutoFetch: true,
3815
+ disableStream: true,
3816
  wasmUrl: PDFJS_WASM_URL,
3817
  cMapUrl: PDFJS_CMAP_URL,
3818
  cMapPacked: true,
tests/reader-contract-vectors.json CHANGED
@@ -5,6 +5,24 @@
5
  ["书/😀.epub", "1tgzoefbsl8ia"],
6
  ["https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/a%20b.txt", "1ckyy8f8m7pim"]
7
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  "ocr": [
9
  [{"Folder": ["edition.v2"], "File": "scan", "Extension": ""}, "edition.v2/scan.txt"],
10
  [{"Folder": ["edition.v2"], "File": "book.v1", "Extension": "pdf"}, "edition.v2/book.v1.txt"],
 
5
  ["书/😀.epub", "1tgzoefbsl8ia"],
6
  ["https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/a%20b.txt", "1ckyy8f8m7pim"]
7
  ],
8
+ "canonicalPaths": [
9
+ ["Kſ.pdf", "%E2%84%AA%C5%BF.pdf"],
10
+ ["a (1)!'*.pdf", "a%20%281%29%21%27%2A.pdf"],
11
+ ["a%20%281%29%21%27%2a.pdf", "a%20%281%29%21%27%2A.pdf"],
12
+ ["中文/😀.pdf", "%E4%B8%AD%E6%96%87/%F0%9F%98%80.pdf"],
13
+ ["%e4%b8%ad%e6%96%87/%f0%9f%98%80.pdf", "%E4%B8%AD%E6%96%87/%F0%9F%98%80.pdf"],
14
+ ["%41%7a%30%2d%2e%5f%7e.pdf", "Az0-._~.pdf"],
15
+ ["a%2fb.pdf", "a%2Fb.pdf"],
16
+ ["a/b.pdf", "a/b.pdf"],
17
+ ["a%2528.pdf", "a%2528.pdf"],
18
+ ["100% done%.pdf", "100%25%20done%25.pdf"],
19
+ ["a+b;:@=&.pdf", "a%2Bb%3B%3A%40%3D%26.pdf"],
20
+ ["a%23%3f.pdf?download=true#page=2", "a%23%3F.pdf?download=true#page=2"]
21
+ ],
22
+ "reportedBook": {
23
+ "path": "马列之声ebook小组第九批成果集成(2018.9.1)/马列之声ebook小组第九批成果集成(2018.9.1)part 1a/2018.8 part 1/20世纪的社会主义 刘俊奇著 2007.09_c__.pdf",
24
+ "id": "398vk0yyy8m29"
25
+ },
26
  "ocr": [
27
  [{"Folder": ["edition.v2"], "File": "scan", "Extension": ""}, "edition.v2/scan.txt"],
28
  [{"Folder": ["edition.v2"], "File": "book.v1", "Extension": "pdf"}, "edition.v2/book.v1.txt"],
tests/test_fastapi_endpoints.py CHANGED
@@ -166,6 +166,26 @@ class FastApiEndpointTests(unittest.TestCase):
166
  response = self.client.get("/api/reader-resolve", params={"id": "0ym7e7wbcxau0"})
167
  self.assertEqual(response.status_code, 404)
168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  def test_reader_resolve_uses_cached_assets_without_waiting_for_refresh(self):
170
  app.records = [{"Repo": "VoiceOfML/Test", "File": "book29", "Extension": "txt", "Folder": []}]
171
  app.build_indexes(build_words=False)
 
166
  response = self.client.get("/api/reader-resolve", params={"id": "0ym7e7wbcxau0"})
167
  self.assertEqual(response.status_code, 404)
168
 
169
+ def test_reader_source_id_normalizes_frontend_and_backend_paths(self):
170
+ folders = ["马列之声ebook小组第九批成果集成(2018.9.1)",
171
+ "马列之声ebook小组第九批成果集成(2018.9.1)part 1a", "2018.8 part 1"]
172
+ record = {"Repo": "VoiceOfML/VOMEBOOK", "Folder": folders,
173
+ "File": "20世纪的社会主义 刘俊奇著 2007.09_c__", "Extension": "pdf"}
174
+ canonical = app.build_record_link(record)
175
+ for link in (canonical, canonical.replace("%28", "(").replace("%29", ")")):
176
+ app.records = [{**record, "Link": link}]
177
+ app.build_indexes(build_words=False)
178
+ with mock.patch.object(app, "get_reader_assets", mock.AsyncMock(return_value={"v": 1, "f": {}})):
179
+ response = self.client.get("/api/reader-resolve", params={"id": "398vk0yyy8m29"})
180
+ self.assertEqual(response.status_code, 200)
181
+ self.assertEqual(response.json()["download"], link)
182
+ self.assertEqual(self.client.get("/api/reader-resolve", params={"id": "1np0zrhual4h1"}).status_code, 404)
183
+ # Different spellings of one source are not a hash collision.
184
+ app.records.append({**record, "Link": canonical})
185
+ app.build_indexes(build_words=False)
186
+ with mock.patch.object(app, "get_reader_assets", mock.AsyncMock(return_value={"v": 1, "f": {}})):
187
+ self.assertEqual(self.client.get("/api/reader-resolve", params={"id": "398vk0yyy8m29"}).status_code, 200)
188
+
189
  def test_reader_resolve_uses_cached_assets_without_waiting_for_refresh(self):
190
  app.records = [{"Repo": "VoiceOfML/Test", "File": "book29", "Extension": "txt", "Folder": []}]
191
  app.build_indexes(build_words=False)
tests/test_reader_backend.py CHANGED
@@ -57,6 +57,12 @@ class ReaderArchiveValidationTests(unittest.TestCase):
57
  vectors = json.loads(Path(__file__).with_name("reader-contract-vectors.json").read_text())
58
  for value, expected in vectors["ids"]:
59
  self.assertEqual(app.reader_short_id(value), expected)
 
 
 
 
 
 
60
  for record, expected in vectors["ocr"]:
61
  self.assertEqual(app.build_txt_relative_path(record), expected)
62
 
 
57
  vectors = json.loads(Path(__file__).with_name("reader-contract-vectors.json").read_text())
58
  for value, expected in vectors["ids"]:
59
  self.assertEqual(app.reader_short_id(value), expected)
60
+ prefix = "https://huggingface.co/datasets/VoiceOfML/VOMEBOOK/resolve/main/"
61
+ for value, expected in vectors["canonicalPaths"]:
62
+ self.assertEqual(app.canonical_reader_source_url(prefix + value), prefix + expected)
63
+ self.assertEqual(app.canonical_reader_source_url(prefix + expected), prefix + expected)
64
+ self.assertEqual(app.reader_short_id(prefix + value), app.reader_short_id(prefix + expected))
65
+ self.assertEqual(app.reader_short_id(prefix + vectors["reportedBook"]["path"]), vectors["reportedBook"]["id"])
66
  for record, expected in vectors["ocr"]:
67
  self.assertEqual(app.build_txt_relative_path(record), expected)
68
 
tests/test_reader_pdf_loading.py CHANGED
@@ -1,6 +1,9 @@
1
  """Actual module imports and PDF.js with cold cache and transport faults."""
2
  import contextlib
3
  import json
 
 
 
4
  import unittest
5
  import urllib.parse
6
  from unittest.mock import patch
@@ -78,6 +81,55 @@ class PdfLoadingTests(unittest.TestCase):
78
  return entries.findIndex((entry) => entry.name.includes('/api/reader-resolve?'));
79
  }'''))
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  def test_published_range_artifact_selects_small_chunk(self):
82
  source = 'https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/' \
83
  'objects/aa/' + 'a' * 64 + '/pdf-range-v1-0123456789abcdef-objects/document.pdf'
 
1
  """Actual module imports and PDF.js with cold cache and transport faults."""
2
  import contextlib
3
  import json
4
+ import re
5
+ import threading
6
+ import time
7
  import unittest
8
  import urllib.parse
9
  from unittest.mock import patch
 
81
  return entries.findIndex((entry) => entry.name.includes('/api/reader-resolve?'));
82
  }'''))
83
 
84
+ def test_large_original_pdf_renders_using_bounded_ranges_and_stops_transfer(self):
85
+ # A valid large stream object before the xref, not padding after EOF.
86
+ small = minimal_pdf()
87
+ xref = small.index(b'xref\n')
88
+ attachment = b'x' * (4 * 1024 * 1024)
89
+ extra = b'6 0 obj\n<< /Length ' + str(len(attachment)).encode() + b' >>\nstream\n' + attachment + b'\nendstream\nendobj\n'
90
+ suffix = small[xref:].replace(b'0 6\n', b'0 7\n').replace(b'trailer\n', f'{xref:010d} 00000 n \ntrailer\n'.encode()).replace(b'/Size 6', b'/Size 7')
91
+ suffix = re.sub(rb'startxref\n\d+', b'startxref\n' + str(xref + len(extra)).encode(), suffix)
92
+ payload = small[:xref] + extra + suffix
93
+ requests, stopped = [], threading.Event()
94
+ original = StaticHandler.do_GET
95
+ def serve(handler):
96
+ if not handler.path.startswith('/range-fixture.pdf'):
97
+ return original(handler)
98
+ match = re.fullmatch(r'bytes=(\d+)-(\d*)', handler.headers.get('Range', ''))
99
+ start, end = (int(match[1]), min(int(match[2] or len(payload)-1), len(payload)-1)) if match else (0, len(payload)-1)
100
+ request = {'range': (start, end) if match else None, 'sent': 0}
101
+ requests.append(request)
102
+ handler.send_response(206 if match else 200)
103
+ handler.send_header('Content-Type', 'application/pdf')
104
+ handler.send_header('Content-Length', str(end-start+1))
105
+ handler.send_header('Accept-Ranges', 'bytes')
106
+ if match:
107
+ handler.send_header('Content-Range', f'bytes {start}-{end}/{len(payload)}')
108
+ handler.end_headers()
109
+ try:
110
+ for offset in range(start, end+1, 16384):
111
+ chunk = payload[offset:min(offset+16384, end+1)]
112
+ handler.wfile.write(chunk)
113
+ handler.wfile.flush()
114
+ request['sent'] += len(chunk)
115
+ time.sleep(.005)
116
+ except (BrokenPipeError, ConnectionResetError):
117
+ pass
118
+ finally:
119
+ if not match: stopped.set()
120
+ self.page.unroute('**/api/reader-content**')
121
+ self.page.add_init_script('''const originalFetch = window.fetch;
122
+ window.fetch = (url, init) => originalFetch(String(url).includes('/api/reader-content?') ? '/range-fixture.pdf' : url, init);''')
123
+ with patch.object(StaticHandler, 'do_GET', serve):
124
+ self.open()
125
+ self.ready()
126
+ self.assertTrue(stopped.wait(2), 'full-file transfer must be cancelled')
127
+ ranges = [item['range'] for item in requests if item['range']]
128
+ self.assertIn((0, 262143), ranges)
129
+ self.assertTrue(any(end == len(payload)-1 for _, end in ranges))
130
+ self.assertLess(sum(item['sent'] for item in requests), 1024 * 1024)
131
+ self.assertEqual(len(self.modules), 1)
132
+
133
  def test_published_range_artifact_selects_small_chunk(self):
134
  source = 'https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/' \
135
  'objects/aa/' + 'a' * 64 + '/pdf-range-v1-0123456789abcdef-objects/document.pdf'
tests/test_reader_regression.js CHANGED
@@ -14,6 +14,19 @@ vm.runInNewContext(contract, sandbox)
14
  const assets = sandbox.self.VoiceOfMLReader
15
  const vectors = require('./reader-contract-vectors.json')
16
  for (const [value, expected] of vectors.ids) assert.strictEqual(assets.shortSourceId(value), expected)
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  for (const [record, expected] of vectors.ocr) {
18
  const relative = [...record.Folder, record.File + (record.Extension ? '.' + record.Extension : '')].join('/')
19
  assert.strictEqual(assets.txtRelativePath(relative), expected)
 
14
  const assets = sandbox.self.VoiceOfMLReader
15
  const vectors = require('./reader-contract-vectors.json')
16
  for (const [value, expected] of vectors.ids) assert.strictEqual(assets.shortSourceId(value), expected)
17
+ const idPrefix = 'https://huggingface.co/datasets/VoiceOfML/VOMEBOOK/resolve/main/'
18
+ for (const [value, expected] of vectors.canonicalPaths) {
19
+ assert.strictEqual(assets.canonicalSourceUrl(idPrefix + value), idPrefix + expected)
20
+ assert.strictEqual(assets.canonicalSourceUrl(idPrefix + expected), idPrefix + expected)
21
+ assert.strictEqual(assets.shortSourceId(idPrefix + value), assets.shortSourceId(idPrefix + expected))
22
+ }
23
+ for (const bookPath of [vectors.reportedBook.path, vectors.reportedBook.path.split('/').map(encodeURIComponent).join('/')]) {
24
+ const link = idPrefix + bookPath
25
+ assert.strictEqual(assets.shortSourceId(link), vectors.reportedBook.id)
26
+ assert.strictEqual(new URL(assets.readerUrl({ Link: link, Extension: 'pdf' }), 'https://site.test').searchParams.get('id'), vectors.reportedBook.id)
27
+ }
28
+ assert.notStrictEqual(assets.shortSourceId(idPrefix + 'a%2Fb.pdf'), assets.shortSourceId(idPrefix + 'a/b.pdf'))
29
+ assert.notStrictEqual(assets.shortSourceId(idPrefix + 'a%2528.pdf'), assets.shortSourceId(idPrefix + 'a%28.pdf'))
30
  for (const [record, expected] of vectors.ocr) {
31
  const relative = [...record.Folder, record.File + (record.Extension ? '.' + record.Extension : '')].join('/')
32
  assert.strictEqual(assets.txtRelativePath(relative), expected)
tests/test_static_contract.py CHANGED
@@ -74,10 +74,10 @@ class StaticFrontendContractTests(unittest.TestCase):
74
  self.assertIn("backToTopPending = true", self.js)
75
  self.assertIn("backToTopPending = false", self.js)
76
 
77
- def test_pdf_reader_allows_streaming_without_auto_fetch(self):
78
  reader = (ROOT / "static/reader.js").read_text(encoding="utf-8")
79
  self.assertIn("disableAutoFetch: true", reader)
80
- self.assertIn("disableStream: false", reader)
81
 
82
  def test_desktop_random_actions_match_labels_and_empty_state_behavior(self):
83
  self.assertIn('id="random-book-btn" class="random-txt-btn" title="随机下本书"', self.html)
@@ -162,7 +162,7 @@ class StaticFrontendContractTests(unittest.TestCase):
162
  assert_source_contains(self, 'cMapUrl: PDFJS_CMAP_URL', reader)
163
  assert_source_contains(self, 'standardFontDataUrl: PDFJS_STANDARD_FONT_URL', reader)
164
  assert_source_contains(self, 'disableAutoFetch: true', reader)
165
- assert_source_contains(self, 'disableStream: false', reader)
166
  self.assertIn('.reader-content[data-mode="pdf-pages"] .reader-page > img', reader_css)
167
  self.assertIn("'wasm-unsafe-eval'", reader_html)
168
  assert_source_contains(self, '"gb18030", "big5", "windows-1251", "windows-1252"', reader)
 
74
  self.assertIn("backToTopPending = true", self.js)
75
  self.assertIn("backToTopPending = false", self.js)
76
 
77
+ def test_pdf_reader_uses_demand_ranges_without_full_file_streaming(self):
78
  reader = (ROOT / "static/reader.js").read_text(encoding="utf-8")
79
  self.assertIn("disableAutoFetch: true", reader)
80
+ self.assertIn("disableStream: true", reader)
81
 
82
  def test_desktop_random_actions_match_labels_and_empty_state_behavior(self):
83
  self.assertIn('id="random-book-btn" class="random-txt-btn" title="随机下本书"', self.html)
 
162
  assert_source_contains(self, 'cMapUrl: PDFJS_CMAP_URL', reader)
163
  assert_source_contains(self, 'standardFontDataUrl: PDFJS_STANDARD_FONT_URL', reader)
164
  assert_source_contains(self, 'disableAutoFetch: true', reader)
165
+ assert_source_contains(self, 'disableStream: true', reader)
166
  self.assertIn('.reader-content[data-mode="pdf-pages"] .reader-page > img', reader_css)
167
  self.assertIn("'wasm-unsafe-eval'", reader_html)
168
  assert_source_contains(self, '"gb18030", "big5", "windows-1251", "windows-1252"', reader)