puffinparse 0.1.4__tar.gz → 0.1.6__tar.gz

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.
Files changed (115) hide show
  1. {puffinparse-0.1.4 → puffinparse-0.1.6}/Cargo.lock +5 -5
  2. {puffinparse-0.1.4 → puffinparse-0.1.6}/Cargo.toml +2 -2
  3. {puffinparse-0.1.4 → puffinparse-0.1.6}/PKG-INFO +30 -4
  4. {puffinparse-0.1.4 → puffinparse-0.1.6}/README.md +29 -3
  5. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/bench.rs +139 -2
  6. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/http.rs +1 -1
  7. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/model.rs +181 -16
  8. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/pricing.json +20 -1
  9. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/provider.rs +1 -1
  10. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/anthropic.rs +136 -18
  11. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/gemini.rs +52 -3
  12. puffinparse-0.1.6/crates/puffinparse-core/src/providers/local.rs +340 -0
  13. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/mod.rs +5 -0
  14. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/openai.rs +10 -3
  15. puffinparse-0.1.6/crates/puffinparse-core/src/providers/opendocrouter.rs +1332 -0
  16. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/paddleocr.rs +68 -5
  17. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/tesseract.rs +11 -110
  18. puffinparse-0.1.6/crates/puffinparse-core/src/providers/vllm.rs +843 -0
  19. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/types.rs +54 -5
  20. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/README.md +1 -1
  21. puffinparse-0.1.6/crates/puffinparse-core/tests/fixtures/anthropic_messages_parse_auto_tool.json +36 -0
  22. puffinparse-0.1.6/crates/puffinparse-core/tests/fixtures/opendocrouter_get_expanded.json +36 -0
  23. puffinparse-0.1.6/crates/puffinparse-core/tests/fixtures/opendocrouter_parse_failed.json +21 -0
  24. puffinparse-0.1.6/crates/puffinparse-core/tests/fixtures/opendocrouter_parse_layout.json +61 -0
  25. puffinparse-0.1.6/crates/puffinparse-core/tests/fixtures/opendocrouter_parse_partial.json +45 -0
  26. puffinparse-0.1.6/crates/puffinparse-core/tests/fixtures/paddleocr_vl_layout_parsing.json +59 -0
  27. puffinparse-0.1.6/crates/puffinparse-core/tests/fixtures/vllm_dots_mocr_chat.json +26 -0
  28. puffinparse-0.1.6/crates/puffinparse-core/tests/fixtures/vllm_infinity_parser2_chat.json +27 -0
  29. {puffinparse-0.1.4 → puffinparse-0.1.6}/pyproject.toml +1 -1
  30. {puffinparse-0.1.4 → puffinparse-0.1.6}/python/puffinparse/jobs.py +3 -2
  31. puffinparse-0.1.4/crates/puffinparse-core/src/providers/local.rs +0 -162
  32. {puffinparse-0.1.4 → puffinparse-0.1.6}/LICENSE +0 -0
  33. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/Cargo.toml +0 -0
  34. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/README.md +0 -0
  35. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/bench/tables.rs +0 -0
  36. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/compat/extend.rs +0 -0
  37. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/compat/llamaparse.rs +0 -0
  38. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/compat/mod.rs +0 -0
  39. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/compat/reducto.rs +0 -0
  40. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/compat/roundtrip.rs +0 -0
  41. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/error.rs +0 -0
  42. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/jobs.rs +0 -0
  43. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/lib.rs +0 -0
  44. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/pricing.rs +0 -0
  45. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/azure.rs +0 -0
  46. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/datalab.rs +0 -0
  47. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/docling.rs +0 -0
  48. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/extend.rs +0 -0
  49. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/google_documentai.rs +0 -0
  50. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/landingai.rs +0 -0
  51. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/llamaparse.rs +0 -0
  52. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/mathpix.rs +0 -0
  53. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/mistral.rs +0 -0
  54. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/reducto.rs +0 -0
  55. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/textract.rs +0 -0
  56. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/unstructured.rs +0 -0
  57. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/upstage.rs +0 -0
  58. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/providers/vlm.rs +0 -0
  59. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/router.rs +0 -0
  60. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/testutil.rs +0 -0
  61. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/src/util.rs +0 -0
  62. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/benchmark_datasets.rs +0 -0
  63. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/anthropic_messages_extract.json +0 -0
  64. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/anthropic_messages_parse.json +0 -0
  65. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/azure_invoice.json +0 -0
  66. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/azure_layout.json +0 -0
  67. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/azure_read.json +0 -0
  68. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/datalab_convert.json +0 -0
  69. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/docling_headings.json +0 -0
  70. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/docling_multipage.json +0 -0
  71. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/extend_extract_run.json +0 -0
  72. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/extend_parse_run.json +0 -0
  73. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/extend_parse_run_url.json +0 -0
  74. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/extend_parse_run_url_output.json +0 -0
  75. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/gemini_extract_invoice.json +0 -0
  76. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/gemini_parse_multipage.json +0 -0
  77. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/google_documentai_form.json +0 -0
  78. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/google_documentai_layout.json +0 -0
  79. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/google_documentai_ocr.json +0 -0
  80. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/landingai_extract.json +0 -0
  81. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/landingai_parse.json +0 -0
  82. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/llamaparse_extract_job.json +0 -0
  83. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/llamaparse_result_json.json +0 -0
  84. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/mathpix_pdf_lines.json +0 -0
  85. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/mathpix_text.json +0 -0
  86. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/mistral_annotation.json +0 -0
  87. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/mistral_ocr.json +0 -0
  88. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/mistral_ocr_blocks.json +0 -0
  89. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/openai_responses_extract.json +0 -0
  90. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/openai_responses_parse.json +0 -0
  91. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/paddleocr_layout_parsing.json +0 -0
  92. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/paddleocr_ocr.json +0 -0
  93. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/reducto_extract.json +0 -0
  94. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/reducto_extract_plain.json +0 -0
  95. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/reducto_parse.json +0 -0
  96. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/reducto_parse_url.json +0 -0
  97. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/reducto_parse_url_result.json +0 -0
  98. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/rules_text_sparse_note.json +0 -0
  99. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/tesseract_headings.tsv +0 -0
  100. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/textract_detect_text.json +0 -0
  101. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/textract_forms.json +0 -0
  102. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/textract_layout.json +0 -0
  103. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/textract_queries.json +0 -0
  104. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/unstructured_elements.json +0 -0
  105. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/fixtures/upstage_document_parse.json +0 -0
  106. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/live.rs +0 -0
  107. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-core/tests/live_jobs.rs +0 -0
  108. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-python/Cargo.toml +0 -0
  109. {puffinparse-0.1.4 → puffinparse-0.1.6}/crates/puffinparse-python/src/lib.rs +0 -0
  110. {puffinparse-0.1.4 → puffinparse-0.1.6}/python/puffinparse/__init__.py +0 -0
  111. {puffinparse-0.1.4 → puffinparse-0.1.6}/python/puffinparse/_core.pyi +0 -0
  112. {puffinparse-0.1.4 → puffinparse-0.1.6}/python/puffinparse/exceptions.py +0 -0
  113. {puffinparse-0.1.4 → puffinparse-0.1.6}/python/puffinparse/main.py +0 -0
  114. {puffinparse-0.1.4 → puffinparse-0.1.6}/python/puffinparse/py.typed +0 -0
  115. {puffinparse-0.1.4 → puffinparse-0.1.6}/python/puffinparse/types.py +0 -0
@@ -1395,7 +1395,7 @@ dependencies = [
1395
1395
 
1396
1396
  [[package]]
1397
1397
  name = "puffinparse-cli"
1398
- version = "0.1.4"
1398
+ version = "0.1.6"
1399
1399
  dependencies = [
1400
1400
  "anyhow",
1401
1401
  "chrono",
@@ -1415,7 +1415,7 @@ dependencies = [
1415
1415
 
1416
1416
  [[package]]
1417
1417
  name = "puffinparse-core"
1418
- version = "0.1.4"
1418
+ version = "0.1.6"
1419
1419
  dependencies = [
1420
1420
  "async-trait",
1421
1421
  "bytes",
@@ -1439,7 +1439,7 @@ dependencies = [
1439
1439
 
1440
1440
  [[package]]
1441
1441
  name = "puffinparse-node"
1442
- version = "0.1.4"
1442
+ version = "0.1.6"
1443
1443
  dependencies = [
1444
1444
  "bytes",
1445
1445
  "napi",
@@ -1453,7 +1453,7 @@ dependencies = [
1453
1453
 
1454
1454
  [[package]]
1455
1455
  name = "puffinparse-python"
1456
- version = "0.1.4"
1456
+ version = "0.1.6"
1457
1457
  dependencies = [
1458
1458
  "bytes",
1459
1459
  "puffinparse-core",
@@ -1468,7 +1468,7 @@ dependencies = [
1468
1468
 
1469
1469
  [[package]]
1470
1470
  name = "puffinparse-server"
1471
- version = "0.1.4"
1471
+ version = "0.1.6"
1472
1472
  dependencies = [
1473
1473
  "axum",
1474
1474
  "bytes",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/puffinparse-core", "crates/puffinparse-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.1.4"
6
+ version = "0.1.6"
7
7
  edition = "2021"
8
8
  rust-version = "1.80"
9
9
  license = "MIT"
@@ -15,7 +15,7 @@ keywords = ["ocr", "document-parsing", "pdf", "api", "benchmark"]
15
15
  categories = ["api-bindings", "text-processing"]
16
16
 
17
17
  [workspace.dependencies]
18
- puffinparse-core = { path = "crates/puffinparse-core", version = "0.1.4" }
18
+ puffinparse-core = { path = "crates/puffinparse-core", version = "0.1.6" }
19
19
  reqwest = { version = "0.13", default-features = false, features = ["json", "multipart", "rustls", "stream", "gzip"] }
20
20
  tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "time", "sync"] }
21
21
  serde = { version = "1", features = ["derive"] }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: puffinparse
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -61,7 +61,7 @@ Two things make switching real rather than aspirational. **Modes**: every call n
61
61
 
62
62
  | | |
63
63
  |---|---|
64
- | **Providers (v0.1)** | 18 providers · 60 models · 3 modes. **Live-verified** against the real APIs: [Reducto](https://reducto.ai), [Extend](https://extend.ai), [LlamaParse](https://cloud.llamaindex.ai). **Verified locally**: the self-hosted Tesseract and Docling. **Docs-only** (implemented from the provider's API documentation and tested against fixture payloads, not yet run live): Mistral, Azure, Textract, Gemini, OpenAI, Anthropic, Mathpix, Datalab, Unstructured, Upstage, Landing AI, Google Document AI and the self-hosted PaddleOCR ([help verify them](https://github.com/ajinkyashejul/puffinparse/issues/10)). [Full table](#model-names) |
64
+ | **Providers (v0.1)** | 20 providers · 79 models · 3 modes. **Live-verified** against the real APIs: [Reducto](https://reducto.ai), [Extend](https://extend.ai), [LlamaParse](https://cloud.llamaindex.ai). **Verified locally**: the self-hosted Tesseract and Docling. **Docs-only** (implemented from the provider's API documentation and tested against fixture payloads, not yet run live): Mistral, Azure, Textract, Gemini, OpenAI, Anthropic, Mathpix, Datalab, Unstructured, Upstage, Landing AI, Google Document AI, OpenDocRouter and the self-hosted PaddleOCR and vLLM presets ([help verify them](https://github.com/ajinkyashejul/puffinparse/issues/10)). [Full table](#model-names) |
65
65
  | **Modes** | `parse` (markdown + blocks), `ocr` (plain text + boxes), `extract` (JSON from a schema) |
66
66
  | **Core** | Rust (`puffinparse-core`): `reqwest` + `tokio`, no vendor SDKs, `#![forbid(unsafe_code)]` |
67
67
  | **SDKs** | Python 3.9+ (sync + async, fully typed) and Node.js / TypeScript ([`js/`](https://github.com/ajinkyashejul/puffinparse/blob/main/js/README.md)), both on the same Rust core |
@@ -216,8 +216,9 @@ result = puffinparse.retrieve(job) # Job (still pending) or ParseRe
216
216
  result = puffinparse.handle_webhook(request.json(), model="reducto") # verify the signature first
217
217
  ```
218
218
 
219
- Reducto, Extend and LlamaParse support jobs; `webhook_url` maps to each provider's per-job webhook
220
- where one exists (Extend only has workspace-level webhooks, so it is rejected there). See
219
+ Reducto, Extend, LlamaParse and OpenDocRouter support jobs; `webhook_url` maps to each provider's
220
+ per-job webhook where one exists (Extend only has workspace-level webhooks and OpenDocRouter has
221
+ none, so it is rejected there). See
221
222
  [`docs/SPEC.md`](https://github.com/ajinkyashejul/puffinparse/blob/main/docs/SPEC.md) §15.
222
223
 
223
224
  ### TypeScript / Node.js
@@ -332,6 +333,8 @@ page per provider.
332
333
  | `gemini/3.5-flash` | parse, ocr, extract | $0.00945 · $0.00945 · $0.00495 | Gemini 3.5 Flash: frontier Flash generation (GA 2026-05-19) |
333
334
  | `gemini/3.5-flash-lite` | parse, ocr, extract | $0.00245 · $0.00245 · $0.0012 | Gemini 3.5 Flash-Lite: low-latency 3.x tier (GA 2026-07-21) |
334
335
  | `gemini/3.8-flash` | parse, ocr, extract | $0.004125 · $0.004125 · $0.00225 | Gemini 3.8 Flash: newest Flash model (GA 2026-09-02, introductory pricing) |
336
+ | `gemini/3.8-flash-low` | parse, ocr, extract | $0.004125 · $0.004125 · $0.00225 | Gemini 3.8 Flash with thinkingConfig.thinkingLevel=low (cheaper, faster transcription) |
337
+ | `gemini/3-flash-preview` | parse, ocr, extract | $0.00315 · $0.00315 · $0.00165 | Gemini 3 Flash preview (gemini-3-flash-preview; PDF and image input) |
335
338
 
336
339
  **OpenAI** · `OPENAI_API_KEY` · docs-only — Vision transcription through the Responses API. Per-page prices are estimates, as for Gemini.
337
340
 
@@ -341,6 +344,7 @@ page per provider.
341
344
  | `openai/gpt-5.6-terra` | parse, ocr, extract | $0.0114 · $0.0114 · $0.0114 | OpenAI Responses API, gpt-5.6-terra (balanced capability/price) |
342
345
  | `openai/gpt-5.6-sol` | parse, ocr, extract | $0.02 · $0.02 · $0.02 | OpenAI Responses API, gpt-5.6-sol (flagship GPT-5.6) |
343
346
  | `openai/gpt-6-astra` | parse, ocr, extract | $0.05 · $0.05 · $0.05 | OpenAI Responses API, gpt-6-astra (most capable, most expensive) |
347
+ | `openai/gpt-6-luna` | parse, ocr, extract | $0.0005 · $0.0005 · $0.0005 | OpenAI Responses API, gpt-6-luna (cheapest GPT-6, image and PDF input) |
344
348
 
345
349
  **Anthropic (Claude)** · `ANTHROPIC_API_KEY` · docs-only — Vision transcription through the Messages API. Per-page prices are estimates.
346
350
 
@@ -349,6 +353,8 @@ page per provider.
349
353
  | `anthropic/claude-sonnet-5` | parse `*`, ocr `*`, extract `*` | $0.01 · $0.01 · $0.01 | Claude Messages API, claude-sonnet-5 (balanced vision transcription) |
350
354
  | `anthropic/claude-haiku-4-5` | parse, ocr, extract | $0.005 · $0.005 · $0.005 | Claude Messages API, claude-haiku-4-5 (cheapest, 200K context) |
351
355
  | `anthropic/claude-opus-5` | parse, ocr, extract | $0.025 · $0.025 · $0.025 | Claude Messages API, claude-opus-5 (highest accuracy) |
356
+ | `anthropic/claude-opus-5-5` | parse, ocr, extract | $0.02 · $0.02 · $0.02 | Claude Messages API, claude-opus-5-5 (current Opus; tool_choice auto, no forced tool) |
357
+ | `anthropic/claude-haiku-5-5` | parse, ocr, extract | $0.0005 · $0.0005 · $0.0005 | Claude Messages API, claude-haiku-5-5 (cheapest Claude, 1M context) |
352
358
 
353
359
  **Mathpix** · `MATHPIX_APP_KEY` (+ `MATHPIX_APP_ID`) · docs-only — Maths-first OCR: Mathpix Markdown with LaTeX, line and word polygons.
354
360
 
@@ -395,13 +401,33 @@ page per provider.
395
401
  | `google_documentai/form` | parse, ocr, extract `*` | $0.03 · $0.03 · $0.03 | Form Parser processor: paragraphs + tables, entities as extraction |
396
402
  | `google_documentai/prebuilt` | parse, ocr, extract | $0.03 · $0.03 · $0.03 | Prebuilt or custom extractor (invoice, W2, ...): entities as extraction |
397
403
 
404
+ **OpenDocRouter** · `OPEN_DOC_ROUTER_API_KEY` · docs-only — LlamaIndex's hosted router: frontier VLMs and open OCR models behind one endpoint, billed per token at the providers' prices, per-page status and charge, layout boxes on request. Model ids keep the router's own `<vendor>/<model>` after the provider name. Prices here are the site's average charge per page (price version 2026-10-06); `cost_usd` is the actual `charge_usd`.
405
+
406
+ | Model | Modes | List price / page (parse · ocr · extract) | Notes |
407
+ |---|---|---|---|
408
+ | `opendocrouter/google/gemini-3.8-flash-low` | parse `*`, ocr `*` | $0.005908 · $0.005908 · — | Gemini 3.8 Flash (low thinking); the router's quickstart model |
409
+ | `opendocrouter/google/gemini-3-flash` | parse, ocr | $0.019667 · $0.019667 · — | Gemini 3 Flash |
410
+ | `opendocrouter/anthropic/claude-opus-5-5` | parse, ocr | $0.04882 · $0.04882 · — | Claude Opus 5.5 (frontier, most expensive) |
411
+ | `opendocrouter/anthropic/claude-haiku-5-5` | parse, ocr | $0.001225 · $0.001225 · — | Claude Haiku 5.5 |
412
+ | `opendocrouter/openai/gpt-5.6-terra` | parse, ocr | $0.019886 · $0.019886 · — | GPT-5.6 Terra |
413
+ | `opendocrouter/openai/gpt-6-luna` | parse, ocr | $0.000798 · $0.000798 · — | GPT-6 Luna (cheapest frontier model) |
414
+ | `opendocrouter/infly/infinity-parser2-flash` | parse, ocr | $0.004344 · $0.004344 · — | Infinity-Parser2-Flash (open model hosted by the router) |
415
+ | `opendocrouter/opendatalab/mineru2.5-pro` | parse, ocr | $0.000861 · $0.000861 · — | MinerU2.5-Pro (open model hosted by the router) |
416
+ | `opendocrouter/xingchen-agi/teleocr` | parse, ocr | $0.002702 · $0.002702 · — | TeleOCR (open model hosted by the router) |
417
+ | `opendocrouter/rednote-hilab/dots.mocr` | parse, ocr | $0.00397 · $0.00397 · — | dots.mocr (open model hosted by the router) |
418
+ | `opendocrouter/paddlepaddle/paddleocr-vl-1.6` | parse, ocr | $0.002111 · $0.002111 · — | PaddleOCR-VL-1.6 (open model hosted by the router) |
419
+
398
420
  **Self-hosted engines** · no key · $0/page — out-of-process: a local binary or a server you run. Tesseract and Docling are verified locally; PaddleOCR is docs-only.
421
+ **Self-hosted engines** · no key · $0/page — out-of-process: a local binary or a server you run. Tesseract and Docling are verified locally; PaddleOCR and vLLM are docs-only.
399
422
 
400
423
  | Model | Modes | List price / page (parse · ocr · extract) | Notes |
401
424
  |---|---|---|---|
402
425
  | `tesseract/default` | ocr `*` (native), parse `*` | $0 · $0 · — | local `tesseract` binary (`TESSERACT_CMD`), PDFs via `pdftoppm`; word/line boxes + confidences, no layout model; verified locally |
403
426
  | `docling/default` | parse `*`, ocr `*` | $0 · $0 · — | your docling-serve (`DOCLING_BASE_URL`): layout, tables, OCR; verified locally |
404
427
  | `paddleocr/default` | ocr `*` (native), parse `*` (PP-StructureV3) | $0 · $0 · — | your PaddleOCR serving (`PADDLEOCR_BASE_URL`); docs-only |
428
+ | `paddleocr/vl` | parse, ocr | $0 · $0 · — | your PaddleOCR-VL pipeline serving (`PADDLEOCR_VL_BASE_URL`; PaddleOCR-VL-1.6 by default): layout + 0.9B VLM; docs-only |
429
+ | `vllm/infinity-parser2-flash` | parse `*`, ocr `*` | $0 · $0 · — | `infly/Infinity-Parser2-Flash` on your `vllm serve` (`VLLM_BASE_URL`): page images, layout blocks with boxes; docs-only |
430
+ | `vllm/dots.mocr` | parse, ocr | $0 · $0 · — | `rednote-hilab/dots.mocr` on your `vllm serve` (`VLLM_BASE_URL`): page images, layout blocks with boxes; docs-only |
405
431
 
406
432
  ### Keep your Reducto / Extend / LlamaParse code
407
433
 
@@ -27,7 +27,7 @@ Two things make switching real rather than aspirational. **Modes**: every call n
27
27
 
28
28
  | | |
29
29
  |---|---|
30
- | **Providers (v0.1)** | 18 providers · 60 models · 3 modes. **Live-verified** against the real APIs: [Reducto](https://reducto.ai), [Extend](https://extend.ai), [LlamaParse](https://cloud.llamaindex.ai). **Verified locally**: the self-hosted Tesseract and Docling. **Docs-only** (implemented from the provider's API documentation and tested against fixture payloads, not yet run live): Mistral, Azure, Textract, Gemini, OpenAI, Anthropic, Mathpix, Datalab, Unstructured, Upstage, Landing AI, Google Document AI and the self-hosted PaddleOCR ([help verify them](https://github.com/ajinkyashejul/puffinparse/issues/10)). [Full table](#model-names) |
30
+ | **Providers (v0.1)** | 20 providers · 79 models · 3 modes. **Live-verified** against the real APIs: [Reducto](https://reducto.ai), [Extend](https://extend.ai), [LlamaParse](https://cloud.llamaindex.ai). **Verified locally**: the self-hosted Tesseract and Docling. **Docs-only** (implemented from the provider's API documentation and tested against fixture payloads, not yet run live): Mistral, Azure, Textract, Gemini, OpenAI, Anthropic, Mathpix, Datalab, Unstructured, Upstage, Landing AI, Google Document AI, OpenDocRouter and the self-hosted PaddleOCR and vLLM presets ([help verify them](https://github.com/ajinkyashejul/puffinparse/issues/10)). [Full table](#model-names) |
31
31
  | **Modes** | `parse` (markdown + blocks), `ocr` (plain text + boxes), `extract` (JSON from a schema) |
32
32
  | **Core** | Rust (`puffinparse-core`): `reqwest` + `tokio`, no vendor SDKs, `#![forbid(unsafe_code)]` |
33
33
  | **SDKs** | Python 3.9+ (sync + async, fully typed) and Node.js / TypeScript ([`js/`](https://github.com/ajinkyashejul/puffinparse/blob/main/js/README.md)), both on the same Rust core |
@@ -182,8 +182,9 @@ result = puffinparse.retrieve(job) # Job (still pending) or ParseRe
182
182
  result = puffinparse.handle_webhook(request.json(), model="reducto") # verify the signature first
183
183
  ```
184
184
 
185
- Reducto, Extend and LlamaParse support jobs; `webhook_url` maps to each provider's per-job webhook
186
- where one exists (Extend only has workspace-level webhooks, so it is rejected there). See
185
+ Reducto, Extend, LlamaParse and OpenDocRouter support jobs; `webhook_url` maps to each provider's
186
+ per-job webhook where one exists (Extend only has workspace-level webhooks and OpenDocRouter has
187
+ none, so it is rejected there). See
187
188
  [`docs/SPEC.md`](https://github.com/ajinkyashejul/puffinparse/blob/main/docs/SPEC.md) §15.
188
189
 
189
190
  ### TypeScript / Node.js
@@ -298,6 +299,8 @@ page per provider.
298
299
  | `gemini/3.5-flash` | parse, ocr, extract | $0.00945 · $0.00945 · $0.00495 | Gemini 3.5 Flash: frontier Flash generation (GA 2026-05-19) |
299
300
  | `gemini/3.5-flash-lite` | parse, ocr, extract | $0.00245 · $0.00245 · $0.0012 | Gemini 3.5 Flash-Lite: low-latency 3.x tier (GA 2026-07-21) |
300
301
  | `gemini/3.8-flash` | parse, ocr, extract | $0.004125 · $0.004125 · $0.00225 | Gemini 3.8 Flash: newest Flash model (GA 2026-09-02, introductory pricing) |
302
+ | `gemini/3.8-flash-low` | parse, ocr, extract | $0.004125 · $0.004125 · $0.00225 | Gemini 3.8 Flash with thinkingConfig.thinkingLevel=low (cheaper, faster transcription) |
303
+ | `gemini/3-flash-preview` | parse, ocr, extract | $0.00315 · $0.00315 · $0.00165 | Gemini 3 Flash preview (gemini-3-flash-preview; PDF and image input) |
301
304
 
302
305
  **OpenAI** · `OPENAI_API_KEY` · docs-only — Vision transcription through the Responses API. Per-page prices are estimates, as for Gemini.
303
306
 
@@ -307,6 +310,7 @@ page per provider.
307
310
  | `openai/gpt-5.6-terra` | parse, ocr, extract | $0.0114 · $0.0114 · $0.0114 | OpenAI Responses API, gpt-5.6-terra (balanced capability/price) |
308
311
  | `openai/gpt-5.6-sol` | parse, ocr, extract | $0.02 · $0.02 · $0.02 | OpenAI Responses API, gpt-5.6-sol (flagship GPT-5.6) |
309
312
  | `openai/gpt-6-astra` | parse, ocr, extract | $0.05 · $0.05 · $0.05 | OpenAI Responses API, gpt-6-astra (most capable, most expensive) |
313
+ | `openai/gpt-6-luna` | parse, ocr, extract | $0.0005 · $0.0005 · $0.0005 | OpenAI Responses API, gpt-6-luna (cheapest GPT-6, image and PDF input) |
310
314
 
311
315
  **Anthropic (Claude)** · `ANTHROPIC_API_KEY` · docs-only — Vision transcription through the Messages API. Per-page prices are estimates.
312
316
 
@@ -315,6 +319,8 @@ page per provider.
315
319
  | `anthropic/claude-sonnet-5` | parse `*`, ocr `*`, extract `*` | $0.01 · $0.01 · $0.01 | Claude Messages API, claude-sonnet-5 (balanced vision transcription) |
316
320
  | `anthropic/claude-haiku-4-5` | parse, ocr, extract | $0.005 · $0.005 · $0.005 | Claude Messages API, claude-haiku-4-5 (cheapest, 200K context) |
317
321
  | `anthropic/claude-opus-5` | parse, ocr, extract | $0.025 · $0.025 · $0.025 | Claude Messages API, claude-opus-5 (highest accuracy) |
322
+ | `anthropic/claude-opus-5-5` | parse, ocr, extract | $0.02 · $0.02 · $0.02 | Claude Messages API, claude-opus-5-5 (current Opus; tool_choice auto, no forced tool) |
323
+ | `anthropic/claude-haiku-5-5` | parse, ocr, extract | $0.0005 · $0.0005 · $0.0005 | Claude Messages API, claude-haiku-5-5 (cheapest Claude, 1M context) |
318
324
 
319
325
  **Mathpix** · `MATHPIX_APP_KEY` (+ `MATHPIX_APP_ID`) · docs-only — Maths-first OCR: Mathpix Markdown with LaTeX, line and word polygons.
320
326
 
@@ -361,13 +367,33 @@ page per provider.
361
367
  | `google_documentai/form` | parse, ocr, extract `*` | $0.03 · $0.03 · $0.03 | Form Parser processor: paragraphs + tables, entities as extraction |
362
368
  | `google_documentai/prebuilt` | parse, ocr, extract | $0.03 · $0.03 · $0.03 | Prebuilt or custom extractor (invoice, W2, ...): entities as extraction |
363
369
 
370
+ **OpenDocRouter** · `OPEN_DOC_ROUTER_API_KEY` · docs-only — LlamaIndex's hosted router: frontier VLMs and open OCR models behind one endpoint, billed per token at the providers' prices, per-page status and charge, layout boxes on request. Model ids keep the router's own `<vendor>/<model>` after the provider name. Prices here are the site's average charge per page (price version 2026-10-06); `cost_usd` is the actual `charge_usd`.
371
+
372
+ | Model | Modes | List price / page (parse · ocr · extract) | Notes |
373
+ |---|---|---|---|
374
+ | `opendocrouter/google/gemini-3.8-flash-low` | parse `*`, ocr `*` | $0.005908 · $0.005908 · — | Gemini 3.8 Flash (low thinking); the router's quickstart model |
375
+ | `opendocrouter/google/gemini-3-flash` | parse, ocr | $0.019667 · $0.019667 · — | Gemini 3 Flash |
376
+ | `opendocrouter/anthropic/claude-opus-5-5` | parse, ocr | $0.04882 · $0.04882 · — | Claude Opus 5.5 (frontier, most expensive) |
377
+ | `opendocrouter/anthropic/claude-haiku-5-5` | parse, ocr | $0.001225 · $0.001225 · — | Claude Haiku 5.5 |
378
+ | `opendocrouter/openai/gpt-5.6-terra` | parse, ocr | $0.019886 · $0.019886 · — | GPT-5.6 Terra |
379
+ | `opendocrouter/openai/gpt-6-luna` | parse, ocr | $0.000798 · $0.000798 · — | GPT-6 Luna (cheapest frontier model) |
380
+ | `opendocrouter/infly/infinity-parser2-flash` | parse, ocr | $0.004344 · $0.004344 · — | Infinity-Parser2-Flash (open model hosted by the router) |
381
+ | `opendocrouter/opendatalab/mineru2.5-pro` | parse, ocr | $0.000861 · $0.000861 · — | MinerU2.5-Pro (open model hosted by the router) |
382
+ | `opendocrouter/xingchen-agi/teleocr` | parse, ocr | $0.002702 · $0.002702 · — | TeleOCR (open model hosted by the router) |
383
+ | `opendocrouter/rednote-hilab/dots.mocr` | parse, ocr | $0.00397 · $0.00397 · — | dots.mocr (open model hosted by the router) |
384
+ | `opendocrouter/paddlepaddle/paddleocr-vl-1.6` | parse, ocr | $0.002111 · $0.002111 · — | PaddleOCR-VL-1.6 (open model hosted by the router) |
385
+
364
386
  **Self-hosted engines** · no key · $0/page — out-of-process: a local binary or a server you run. Tesseract and Docling are verified locally; PaddleOCR is docs-only.
387
+ **Self-hosted engines** · no key · $0/page — out-of-process: a local binary or a server you run. Tesseract and Docling are verified locally; PaddleOCR and vLLM are docs-only.
365
388
 
366
389
  | Model | Modes | List price / page (parse · ocr · extract) | Notes |
367
390
  |---|---|---|---|
368
391
  | `tesseract/default` | ocr `*` (native), parse `*` | $0 · $0 · — | local `tesseract` binary (`TESSERACT_CMD`), PDFs via `pdftoppm`; word/line boxes + confidences, no layout model; verified locally |
369
392
  | `docling/default` | parse `*`, ocr `*` | $0 · $0 · — | your docling-serve (`DOCLING_BASE_URL`): layout, tables, OCR; verified locally |
370
393
  | `paddleocr/default` | ocr `*` (native), parse `*` (PP-StructureV3) | $0 · $0 · — | your PaddleOCR serving (`PADDLEOCR_BASE_URL`); docs-only |
394
+ | `paddleocr/vl` | parse, ocr | $0 · $0 · — | your PaddleOCR-VL pipeline serving (`PADDLEOCR_VL_BASE_URL`; PaddleOCR-VL-1.6 by default): layout + 0.9B VLM; docs-only |
395
+ | `vllm/infinity-parser2-flash` | parse `*`, ocr `*` | $0 · $0 · — | `infly/Infinity-Parser2-Flash` on your `vllm serve` (`VLLM_BASE_URL`): page images, layout blocks with boxes; docs-only |
396
+ | `vllm/dots.mocr` | parse, ocr | $0 · $0 · — | `rednote-hilab/dots.mocr` on your `vllm serve` (`VLLM_BASE_URL`): page images, layout blocks with boxes; docs-only |
371
397
 
372
398
  ### Keep your Reducto / Extend / LlamaParse code
373
399
 
@@ -20,7 +20,10 @@ pub use tables::{extract_tables, teds_grid, Grid};
20
20
  /// * `1` — initial scorer (pipe tables only, exact rule matching, headline in `char_similarity`).
21
21
  /// * `2` — HTML tables, `teds_grid`, punctuation-spacing-insensitive rule matching, fuzzy
22
22
  /// `bag_of_sentences`, entity decoding, explicit `Summary::score`.
23
- pub const SCORER_VERSION: u32 = 2;
23
+ /// * `3` — single `*` / `_` emphasis stripped, inline HTML tags (`<sup>`, `<i>`) no longer split
24
+ /// words, dot leaders collapsed, figure markup and image descriptions dropped from transcript
25
+ /// predictions ([`strip_figures`]), and `table_cell` rules can match the header row.
26
+ pub const SCORER_VERSION: u32 = 3;
24
27
 
25
28
  /// A `bag_of_sentences` sentence counts as present when some window of the prediction is within
26
29
  /// this normalised character similarity of it (`1 - edit_distance / |sentence|`).
@@ -67,6 +70,7 @@ pub fn normalize(s: &str, opts: NormalizeOptions) -> String {
67
70
  .replace(['\u{2018}', '\u{2019}'], "'")
68
71
  .replace(['\u{201C}', '\u{201D}'], "\"")
69
72
  .replace(['\u{2013}', '\u{2014}'], "-");
73
+ let s = collapse_dot_leaders(&s);
70
74
  let s = if opts.case_insensitive { s.to_lowercase() } else { s };
71
75
  let mut out = String::with_capacity(s.len());
72
76
  let mut last_space = true;
@@ -86,6 +90,98 @@ pub fn normalize(s: &str, opts: NormalizeOptions) -> String {
86
90
  out.trim().to_string()
87
91
  }
88
92
 
93
+ /// Dot leaders (`Chapter 1 ........ 7`, `. . . .`) become one space: whether a parser keeps the
94
+ /// dots of a table of contents says nothing about its reading. Four or more dots, optionally
95
+ /// single-spaced; a three-dot ellipsis is text and stays.
96
+ fn collapse_dot_leaders(s: &str) -> String {
97
+ let chars: Vec<char> = s.chars().collect();
98
+ let mut out = String::with_capacity(s.len());
99
+ let mut i = 0;
100
+ while i < chars.len() {
101
+ if chars[i] == '.' {
102
+ // Walk a run of dots separated by at most one space.
103
+ let (mut j, mut dots, mut end) = (i, 0, i);
104
+ while j < chars.len() {
105
+ if chars[j] == '.' {
106
+ dots += 1;
107
+ j += 1;
108
+ end = j;
109
+ } else if chars[j] == ' ' && chars.get(j + 1) == Some(&'.') {
110
+ j += 1;
111
+ } else {
112
+ break;
113
+ }
114
+ }
115
+ if dots >= 4 {
116
+ out.push(' ');
117
+ i = end;
118
+ continue;
119
+ }
120
+ }
121
+ out.push(chars[i]);
122
+ i += 1;
123
+ }
124
+ out
125
+ }
126
+
127
+ /// Figure content a parser *adds*: `<figure>…</figure>` blocks (a description or caption a vision
128
+ /// model wrote) and markdown images (`![description](src)`). Transcript truths carry no figure or
129
+ /// chart content (DP-Bench drops them, as upstream ignores predictions inside figure regions), so
130
+ /// this text is not a reading of the page and is not scored. Tags are matched case-insensitively;
131
+ /// an unclosed `<figure>` runs to the end.
132
+ pub fn strip_figures(s: &str) -> String {
133
+ let lower = s.to_ascii_lowercase();
134
+ let mut out = String::with_capacity(s.len());
135
+ let mut i = 0;
136
+ while let Some(rel) = lower[i..].find("<figure") {
137
+ let start = i + rel;
138
+ // `<figure>` or `<figure ...>`, not `<figures>`.
139
+ let next = lower.as_bytes().get(start + 7).copied();
140
+ if !matches!(next, Some(b'>' | b' ' | b'\t' | b'\n' | b'\r' | b'/')) {
141
+ out.push_str(&s[i..start + 7]);
142
+ i = start + 7;
143
+ continue;
144
+ }
145
+ out.push_str(&s[i..start]);
146
+ out.push(' ');
147
+ i = match lower[start..].find("</figure>") {
148
+ Some(e) => start + e + "</figure>".len(),
149
+ None => s.len(),
150
+ };
151
+ }
152
+ out.push_str(&s[i..]);
153
+ strip_markdown_images(&out)
154
+ }
155
+
156
+ /// `![alt](src)` → nothing (`[` without a following `](…)` is kept as text).
157
+ fn strip_markdown_images(s: &str) -> String {
158
+ let mut out = String::with_capacity(s.len());
159
+ let mut rest = s;
160
+ while let Some(at) = rest.find("![") {
161
+ let after = &rest[at + 2..];
162
+ let image_end = after.find("](").and_then(|close| {
163
+ let alt = &after[..close];
164
+ if alt.contains('\n') {
165
+ return None;
166
+ }
167
+ after[close + 2..].find(')').map(|p| at + 2 + close + 2 + p + 1)
168
+ });
169
+ match image_end {
170
+ Some(end) => {
171
+ out.push_str(&rest[..at]);
172
+ out.push(' ');
173
+ rest = &rest[end..];
174
+ }
175
+ None => {
176
+ out.push_str(&rest[..at + 2]);
177
+ rest = &rest[at + 2..];
178
+ }
179
+ }
180
+ }
181
+ out.push_str(rest);
182
+ out
183
+ }
184
+
89
185
  /// Scores for one (prediction, truth) pair. All in `0..=1` unless noted.
90
186
  #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
91
187
  pub struct Metrics {
@@ -127,6 +223,7 @@ pub struct Metrics {
127
223
 
128
224
  /// Compute all metrics for a prediction vs truth (both raw; normalisation applied internally).
129
225
  pub fn score(pred: &str, truth: &str, opts: NormalizeOptions) -> Metrics {
226
+ let pred = &strip_figures(pred);
130
227
  let p = normalize(pred, opts);
131
228
  let t = normalize(truth, opts);
132
229
  let p_chars: Vec<char> = p.chars().collect();
@@ -918,7 +1015,9 @@ fn check_table_cell(cache: &PredictionCache<'_>, cell: &CellRule, cs: bool, k: u
918
1015
  }
919
1016
  None => None,
920
1017
  };
921
- for row in &table.rows {
1018
+ // The header row is a row too: a cell rule may be about a header cell (olmOCR's neighbour
1019
+ // tests are), and a parser that marks the header correctly must not lose that rule.
1020
+ for row in std::iter::once(&table.header).chain(&table.rows) {
922
1021
  if !row_matches(row, row_header.as_deref(), k) {
923
1022
  continue;
924
1023
  }
@@ -1040,6 +1139,44 @@ pub fn metrics_from_rules(score: &RuleScore) -> Metrics {
1040
1139
  mod tests {
1041
1140
  use super::*;
1042
1141
 
1142
+ #[test]
1143
+ fn scorer_v3_dot_leaders() {
1144
+ let o = NormalizeOptions::default();
1145
+ assert_eq!(normalize("Intro ........ 7", o), "intro 7");
1146
+ assert_eq!(normalize("Intro . . . . . 7", o), "intro 7");
1147
+ assert_eq!(normalize("Wait... what", o), "wait... what");
1148
+ assert_eq!(score("Intro 7", "Intro .......... 7", o).char_similarity, 1.0);
1149
+ }
1150
+
1151
+ #[test]
1152
+ fn scorer_v3_strips_figures_from_transcripts() {
1153
+ assert_eq!(strip_figures("a <figure><caption>A bar chart</caption></figure> b"), "a b");
1154
+ assert_eq!(strip_figures("a <FIGURE id=\"f\">x</Figure> b"), "a b");
1155
+ assert_eq!(strip_figures("<figures> stay"), "<figures> stay");
1156
+ assert_eq!(strip_figures("see ![chart of sales](img.png) here"), "see here");
1157
+ assert_eq!(strip_figures("[link](x) and ![ no close"), "[link](x) and ![ no close");
1158
+ let o = NormalizeOptions::default();
1159
+ let truth = "Revenue grew.";
1160
+ let pred = "Revenue grew.\n<figure><caption>The chart shows growth</caption></figure>";
1161
+ assert_eq!(score(pred, truth, o).char_similarity, 1.0);
1162
+ }
1163
+
1164
+ #[test]
1165
+ fn scorer_v3_emphasis_and_inline_tags() {
1166
+ let o = NormalizeOptions::default();
1167
+ assert_eq!(score("An *italic* word", "An italic word", o).char_similarity, 1.0);
1168
+ assert_eq!(score("the 9<sup>th</sup> day", "the 9th day", o).char_similarity, 1.0);
1169
+ }
1170
+
1171
+ #[test]
1172
+ fn scorer_v3_table_cell_can_match_the_header_row() {
1173
+ let pred = "| Name | Value |\n|---|---|\n| a | 1 |";
1174
+ let c = CellRule { value: "Value".into(), row_header: Some("Name".into()), col_header: None };
1175
+ let r = Rule { cell: Some(c), ..rule("hdr", RuleType::TableCell) };
1176
+ let got = score_rules(pred, &[r], NormalizeOptions::default());
1177
+ assert_eq!(got.passed, 1, "{got:?}");
1178
+ }
1179
+
1043
1180
  #[test]
1044
1181
  fn levenshtein_basic() {
1045
1182
  let a: Vec<char> = "kitten".chars().collect();
@@ -106,7 +106,7 @@ where
106
106
  }
107
107
  }
108
108
 
109
- fn is_transient(e: &Error) -> bool {
109
+ pub(crate) fn is_transient(e: &Error) -> bool {
110
110
  matches!(e.kind, ErrorKind::RateLimit | ErrorKind::Network)
111
111
  || (e.kind == ErrorKind::Provider && matches!(e.status_code, Some(500 | 502 | 503 | 504)))
112
112
  }