xxyyy123 commited on
Commit
84c5464
·
1 Parent(s): 4addb16

Initial release of OvisOCR3

Browse files
.gitattributes CHANGED
@@ -19,6 +19,7 @@
19
  *.pb filter=lfs diff=lfs merge=lfs -text
20
  *.pickle filter=lfs diff=lfs merge=lfs -text
21
  *.pkl filter=lfs diff=lfs merge=lfs -text
 
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
  *.pth filter=lfs diff=lfs merge=lfs -text
24
  *.rar filter=lfs diff=lfs merge=lfs -text
@@ -33,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
19
  *.pb filter=lfs diff=lfs merge=lfs -text
20
  *.pickle filter=lfs diff=lfs merge=lfs -text
21
  *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.png filter=lfs diff=lfs merge=lfs -text
23
  *.pt filter=lfs diff=lfs merge=lfs -text
24
  *.pth filter=lfs diff=lfs merge=lfs -text
25
  *.rar filter=lfs diff=lfs merge=lfs -text
 
34
  *.zip filter=lfs diff=lfs merge=lfs -text
35
  *.zst filter=lfs diff=lfs merge=lfs -text
36
  *tfevents* filter=lfs diff=lfs merge=lfs -text
37
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Alibaba Cloud
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md CHANGED
@@ -1,3 +1,537 @@
1
  ---
2
  license: apache-2.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: apache-2.0
3
+ library_name: transformers
4
+ pipeline_tag: image-text-to-text
5
+ base_model: Qwen/Qwen3.5-0.8B
6
+ tags:
7
+ - ocr
8
+ - document-parsing
9
+ - multimodal
10
+ - markdown
11
+ - tables
12
+ - formulas
13
+ - key-information-extraction
14
+ - figure-captioning
15
+ - vllm
16
+ - qwen3_5
17
  ---
18
+
19
+ # OvisOCR3
20
+
21
+ <p align="center">
22
+ <img src="https://cdn-uploads.huggingface.co/production/uploads/658a8a837959448ef5500ce5/vRCIu5QD8VuIJolkC_ZHQ.png" alt="Ovis" width="30%" />
23
+ </p>
24
+
25
+ ## Introduction
26
+
27
+ OvisOCR3 is a **unified 0.8B multimodal model for OCR and document understanding**. It integrates **document parsing, general OCR, key information extraction, document figure captioning, and cross-page table reconstruction** into a single compact model. These capabilities are selected through task-specific prompts, bringing recognition, extraction, and understanding together without requiring separate model deployments for each task.
28
+
29
+ Built on Qwen3.5-0.8B, OvisOCR3 is trained in four stages: continued pretraining, mid-training, on-policy distillation, and reinforcement learning. This training process builds a strong foundation in text recognition and document structure understanding, then refines these capabilities through domain-specific supervision and rewards.
30
+
31
+ OvisOCR3 achieves **state-of-the-art performance among models of comparable size across all five domains**, demonstrating that a single compact model can combine strong document parsing with a broad range of OCR and document-understanding capabilities.
32
+
33
+ ![OvisOCR3 performance overview across five task domains.](./assets/ovisocr3_performance_overview.png)
34
+
35
+ ## Capabilities
36
+
37
+ | Capability | Input | Output |
38
+ | --- | --- | --- |
39
+ | Document parsing | A page image and a parsing-mode prompt | Structured JSON, layout JSON, or Markdown with LaTeX formulas and HTML tables |
40
+ | General OCR | An image, optionally with a region, references, a question, a target language, or candidate labels | Text, bounding boxes, LaTeX, chart data, a label, or an answer |
41
+ | Key information extraction | An image, optionally with specified fields or a JSON output template | Key-value records or JSON following the supplied template |
42
+ | Document figure captioning | A page image and a document-figure-caption-mode prompt | Structured page content with localized visual captions and Mermaid representations for flowcharts |
43
+ | Cross-page table reconstruction | Consecutive page images and their parsed Markdown | Complete HTML tables with source-page associations, or a no-continuation prediction |
44
+
45
+ The [task prompts](./prompts/) specify the output contracts. The examples below use these files directly.
46
+
47
+ ## Performance
48
+
49
+ Performance comparisons across five task domains are reported below. `--` denotes an unreported value, not zero. Bold scores indicate the best result in each column.
50
+
51
+ ### Document Parsing
52
+
53
+ | Model | OmniDocBench1.6 | MDPBench | PureDocBench | Real5-OmniDocBench |
54
+ |---|---:|---:|---:|---:|
55
+ | Qwen3.5-OCR | 91.27 | 60.60 | 68.38 | 82.48 |
56
+ | Qwen3.8-Max | 93.70 | 86.40 | 80.56 | 86.86 |
57
+ | GLM-OCR | 95.22 | 67.30 | 63.34 | 90.32 |
58
+ | PaddleOCR-VL-1.6 | 96.34 | 78.90 | 65.44 | 93.19 |
59
+ | MonkeyOCRv2-B-Parsing | 91.57 | 83.30 | 64.91 | 89.22 |
60
+ | OvisOCR2 | 96.58 | 69.00 | 75.10 | 92.29 |
61
+ | HunyuanOCR-1.5 | 94.74 | 76.80 | 67.58 | 88.53 |
62
+ | Xiaomi-OCR-0 | **96.83** | 73.00 | 74.60 | 92.73 |
63
+ | **OvisOCR3** | 96.74 | **88.80** | **82.31** | **93.63** |
64
+
65
+ ### General OCR
66
+
67
+ | Model | OCRBench | OCRBench v2 EN | OCRBench v2 ZH |
68
+ | --- | ---: | ---: | ---: |
69
+ | Qwen3.5-0.8B | 79.1 | 41.5 | 43.9 |
70
+ | Qwen3.5-2B | 85.4 | 48.1 | 55.4 |
71
+ | Qwen3.5-4B | 85.0 | 54.1 | 61.0 |
72
+ | Qwen3.5-OCR | 76.8 | 44.5 | 43.8 |
73
+ | PaddleOCR-VL-1.6 | 55.9 | 23.8 | 30.4 |
74
+ | HunyuanOCR-1.5 | 86.1 | 50.7 | 57.7 |
75
+ | GLM-OCR | 83.8 | 32.9 | 48.1 |
76
+ | Xiaomi-OCR-0 | 84.6 | 40.1 | 48.4 |
77
+ | **OvisOCR3** | **88.8** | **65.9** | **61.3** |
78
+
79
+ ### Key Information Extraction
80
+
81
+ | Model | OCRBench KIE | OCRBench v2 KIE | CC-OCR KIE | In-house KIE Bench |
82
+ | --- | ---: | ---: | ---: | ---: |
83
+ | Qwen3.5-OCR | 78.50 | 72.90 | 88.43 | 59.90 |
84
+ | GLM-OCR | 79.00 | 74.22 | 68.36 | 75.25 |
85
+ | HunyuanOCR-1.5 | 81.00 | 68.63 | 66.08 | 75.96 |
86
+ | Xiaomi-OCR-0 | 76.50 | 74.66 | 79.53 | 75.68 |
87
+ | **OvisOCR3** | **90.50** | **83.01** | **88.69** | **77.73** |
88
+
89
+ ### Document Figure Captioning
90
+
91
+ | Model | DocFigCapBench Localization F1 | DocFigCapBench Caption Score |
92
+ | --- | ---: | ---: |
93
+ | HunyuanOCR-1.5 | 48.24 | -- |
94
+ | Qwen3.5-OCR | 78.47 | 4.74 |
95
+ | OvisOCR2 | 82.18 | -- |
96
+ | MinerU2.5-Pro | 85.00 | 4.61 |
97
+ | PaddleOCR-VL-1.6 | 85.32 | 4.29 |
98
+ | Qwen3.8-Max | 86.67 | **7.30** |
99
+ | **OvisOCR3** | **90.97** | 6.93 |
100
+
101
+ ### Cross-Page Table Reconstruction
102
+
103
+ | Model | CrossTabBench Relation F1 (P/R) | CrossTabBench Reconstruction TEDS | MPDocBench-Parse Truncated Table TEDS |
104
+ | :--- | ---: | ---: | ---: |
105
+ | PaddleOCR-VL-1.6 | 42.61 (90.13/27.90) | 24.24 | 83.39 |
106
+ | Logics-Parsing-V3 | 44.55 (90.63/29.53) | 26.10 | 88.05 |
107
+ | MinerU2.5-Pro | 59.14 (93.81/43.18) | 41.02 | 91.44 |
108
+ | MinerU2.5-Pro + MinerU-PoPo | 54.76 (97.40/38.09) | 36.42 | 91.87 |
109
+ | Qwen3.8-Max | 94.47 (90.19/99.19) | **91.71** | 89.57 |
110
+ | **OvisOCR3** | **97.07** (96.20/97.96) | 89.66 | **92.04** |
111
+
112
+ ## Inference
113
+
114
+ The example uses a local model directory containing the weights, configuration, tokenizer, processor files, and `prompts/`. Run it from that directory, or set `MODEL_PATH` to its location. A GPU environment supported by vLLM is required.
115
+
116
+ ```bash
117
+ pip install "vllm==0.22.1" pillow
118
+ ```
119
+
120
+ ```python
121
+ import warnings
122
+ from pathlib import Path
123
+
124
+ from PIL import Image
125
+ from vllm import LLM, SamplingParams
126
+
127
+
128
+ MODEL_PATH = Path(".")
129
+
130
+ model = LLM(
131
+ model=str(MODEL_PATH.resolve()),
132
+ tensor_parallel_size=1,
133
+ gpu_memory_utilization=0.8,
134
+ gdn_prefill_backend="triton",
135
+ limit_mm_per_prompt={"image": 2},
136
+ )
137
+ tokenizer = model.get_tokenizer()
138
+ sampling_params = SamplingParams(max_tokens=16384, temperature=0.0)
139
+
140
+
141
+ def load_prompt(name: str) -> str:
142
+ return (MODEL_PATH / "prompts" / f"{name}.txt").read_text(encoding="utf-8")
143
+
144
+
145
+ def infer(
146
+ images: list[Image.Image], task_prompt: str, *,
147
+ return_metadata=False,
148
+ ):
149
+ if not 1 <= len(images) <= 2:
150
+ raise ValueError("This example accepts one or two images per request.")
151
+
152
+ content = [{"type": "image"} for _ in images]
153
+ content.append({"type": "text", "text": task_prompt})
154
+ prompt = tokenizer.apply_chat_template(
155
+ [{"role": "user", "content": content}],
156
+ tokenize=False,
157
+ add_generation_prompt=True,
158
+ enable_thinking=False,
159
+ )
160
+ request = {
161
+ "prompt": prompt,
162
+ "multi_modal_data": {"image": [image.convert("RGB") for image in images]},
163
+ "mm_processor_kwargs": {
164
+ "images_kwargs": {
165
+ "min_pixels": 448 * 448,
166
+ "max_pixels": 2880 * 2880,
167
+ }
168
+ },
169
+ }
170
+ output = model.generate([request], sampling_params)[0].outputs[0]
171
+ if output.finish_reason == "length":
172
+ warnings.warn("Generation reached a token limit; the output may be incomplete.")
173
+ return (output.text, output.finish_reason) if return_metadata else output.text
174
+
175
+
176
+ page = Image.open("test1.jpg").convert("RGB")
177
+ text = infer([page], load_prompt("plain_ocr"))
178
+ Path("output.txt").write_text(text, encoding="utf-8")
179
+ print(text)
180
+ ```
181
+
182
+ `infer` returns the raw generated text by default; `return_metadata=True` also returns the finish reason. It does not remove image tags, trim repetitions, repair JSON, or otherwise rewrite the response. All task examples share the inference settings above. These are usage settings, not benchmark reproduction settings.
183
+
184
+ An image list passed to `infer` belongs to **one request**. For independent pages, call `infer([page], prompt)` for each page. The two-image form is used for cross-page reconstruction below.
185
+
186
+ ## Task-Specific Usage
187
+
188
+ The following snippets reuse `page`, `infer`, and `load_prompt` from the inference example. Replace placeholder values with the fields, regions, or questions needed for your document. Use literal placeholder replacement rather than Python `str.format`, since prompts also contain JSON and LaTeX braces.
189
+
190
+ ### Document Parsing
191
+
192
+ Compared with OvisOCR2's Markdown parsing, OvisOCR3 adds structured document parsing and document layout detection. Structured parsing returns region-level records with bounding boxes, categories, and content; Markdown parsing returns a continuous document without explicit bounding boxes or category labels for individual text regions.
193
+
194
+ | Mode | Prompt | Output |
195
+ | --- | --- | --- |
196
+ | Markdown | [document_markdown.txt](./prompts/document_markdown.txt) | A document in natural reading order, with LaTeX formulas, HTML tables, and image-region references |
197
+ | Structured | [document_structured.txt](./prompts/document_structured.txt) | A JSON array with `bbox`, `category`, and `content` for each element |
198
+ | Layout only | [document_layout.txt](./prompts/document_layout.txt) | A JSON array with `bbox` and `category`, without transcription |
199
+
200
+ For `document_markdown`, follow the [OvisOCR2](https://huggingface.co/ATH-MaaS/OvisOCR2) postprocessing order: strip leading and trailing whitespace, optionally filter image-tag blocks (enabled by default), and then apply repeated-tail cleanup to every response regardless of the finish reason. Set `filter_imgtags=False` to retain visual-region references for rendering. Keep `raw_markdown` for inspection; structured and layout JSON outputs are left unchanged. The helper checks its own length and repetition thresholds. It does not recover missing content or make a truncated response complete.
201
+
202
+ ```python
203
+ def _clean_truncated_repeats(
204
+ text: str,
205
+ min_text_len: int = 8000,
206
+ max_period: int = 200,
207
+ min_period: int = 1,
208
+ min_repeat_chars: int = 100,
209
+ min_repeat_times: int = 5,
210
+ ) -> str:
211
+ n = len(text)
212
+ if n < min_text_len:
213
+ return text
214
+
215
+ max_period = min(max_period, n - 1)
216
+ for unit_len in range(min_period, max_period + 1):
217
+ if text[n - 1] != text[n - 1 - unit_len]:
218
+ continue
219
+
220
+ match_len = 1
221
+ idx = n - 2
222
+ while idx >= unit_len and text[idx] == text[idx - unit_len]:
223
+ match_len += 1
224
+ idx -= 1
225
+
226
+ total_len = match_len + unit_len
227
+ repeat_times = total_len // unit_len
228
+ tail_len = total_len % unit_len
229
+
230
+ if repeat_times >= min_repeat_times and total_len >= min_repeat_chars:
231
+ return text[: n - total_len + unit_len] + text[n - tail_len :]
232
+
233
+ return text
234
+
235
+
236
+ # Format 1: Markdown, with optional image-tag filtering and repeated-tail cleanup.
237
+ raw_markdown = infer([page], load_prompt("document_markdown"))
238
+ filter_imgtags = True
239
+ text = raw_markdown.strip()
240
+ if filter_imgtags:
241
+ text = "\n\n".join(
242
+ block
243
+ for block in text.split("\n\n")
244
+ if not block.strip().startswith('<img src="images/bbox_')
245
+ )
246
+ markdown = _clean_truncated_repeats(text)
247
+
248
+ # Format 2: Structured JSON with bbox, category, and content.
249
+ structured_json = infer([page], load_prompt("document_structured"))
250
+
251
+ # Format 3: Layout JSON with bbox and category only, without transcription.
252
+ layout_json = infer([page], load_prompt("document_layout"))
253
+ ```
254
+
255
+ Structured and layout outputs follow natural reading order. The allowed category lists and category-specific content formats are defined in their respective prompts.
256
+
257
+ #### Coordinates and Visual Regions
258
+
259
+ Bounding boxes use `[left, top, right, bottom]` order with integer coordinates in `[0, 1000)`, relative to the image supplied to the model. The origin is the top-left corner. Convert to pixels with `x * image_width / 1000` and `y * image_height / 1000`. For cropped inputs, coordinates are relative to that crop, not the original page.
260
+
261
+ Markdown and structured parsing can reference visual regions using `<img src="images/bbox_left_top_right_bottom.jpg" />`. These paths describe crops to create from the input image; the model does not write image files. Preserve the image orientation used for inference when extracting crops.
262
+
263
+ <details>
264
+ <summary>Save Markdown together with its referenced image crops</summary>
265
+
266
+ ```python
267
+ import re
268
+
269
+
270
+ BBOX_IMAGE_PATTERN = re.compile(r'images/bbox_(\d+)_(\d+)_(\d+)_(\d+)\.jpg')
271
+
272
+
273
+ def save_markdown_with_crops(markdown: str, page_image: Image.Image, output_dir: str):
274
+ output_dir = Path(output_dir)
275
+ images_dir = output_dir / "images"
276
+ images_dir.mkdir(parents=True, exist_ok=True)
277
+ width, height = page_image.size
278
+
279
+ for left, top, right, bottom in set(BBOX_IMAGE_PATTERN.findall(markdown)):
280
+ x1 = max(0, min(width, round(int(left) * width / 1000)))
281
+ y1 = max(0, min(height, round(int(top) * height / 1000)))
282
+ x2 = max(0, min(width, round(int(right) * width / 1000)))
283
+ y2 = max(0, min(height, round(int(bottom) * height / 1000)))
284
+ if x2 <= x1 or y2 <= y1:
285
+ warnings.warn(f"Skipping invalid crop: {(left, top, right, bottom)}")
286
+ continue
287
+ crop_path = images_dir / f"bbox_{left}_{top}_{right}_{bottom}.jpg"
288
+ page_image.crop((x1, y1, x2, y2)).convert("RGB").save(crop_path)
289
+
290
+ (output_dir / "output.md").write_text(markdown, encoding="utf-8")
291
+
292
+
293
+ save_markdown_with_crops(markdown, page, "output")
294
+ ```
295
+
296
+ This helper handles single-page Markdown references. For reconstructed tables, resolve each `page_N/` prefix against its corresponding source page and save crops under that page's directory.
297
+
298
+ </details>
299
+
300
+ ### General OCR
301
+
302
+ <a id="general-ocr-prompts"></a>
303
+
304
+ | Task | Prompt | Output |
305
+ | --- | --- | --- |
306
+ | Plain OCR | [plain_ocr.txt](./prompts/plain_ocr.txt) | Text without markup or bounding boxes |
307
+ | Word-level text spotting | [word_spotting.txt](./prompts/word_spotting.txt) | JSON records with `bbox` and `text`, one per word or text token |
308
+ | Reference grounding | [reference_grounding.txt](./prompts/reference_grounding.txt) | JSON records with `bbox` and the requested `reference` |
309
+ | Region OCR | [region_ocr.txt](./prompts/region_ocr.txt) | Text within a specified region |
310
+ | Formula recognition | [formula_recognition.txt](./prompts/formula_recognition.txt) | LaTeX expressions enclosed in `$$ ... $$` |
311
+ | Chart parsing | [chart_parsing.txt](./prompts/chart_parsing.txt) | JSON with `title`, `source`, `x_title`, `y_title`, and `values` |
312
+ | Text translation | [text_translation.txt](./prompts/text_translation.txt) | Text from a specified region translated into the target language |
313
+ | Image classification | [image_classification.txt](./prompts/image_classification.txt) | Exactly one label from the supplied candidates |
314
+ | Visual question answering | [visual_question_answering.txt](./prompts/visual_question_answering.txt) | Reasoning followed by a final answer inside `\boxed{}` |
315
+ | Position-aware VQA | [position_aware_vqa.txt](./prompts/position_aware_vqa.txt) | JSON with `answer` and a string-valued `bbox` |
316
+
317
+ All ten tasks read the linked `.txt` prompt files. Image classification takes a target description and candidate labels, and position-aware VQA uses its own answer-and-box instruction. Fill only the requested task inputs in each template.
318
+
319
+ ```python
320
+ import re
321
+
322
+
323
+ def general_ocr_prompt(name: str) -> str:
324
+ prompt = load_prompt(name)
325
+ return prompt if name == "position_aware_vqa" else prompt.strip()
326
+
327
+
328
+ def general_vqa_prompt(question: str) -> str:
329
+ prompt = general_ocr_prompt("visual_question_answering").replace("{question}", question)
330
+ return re.sub(r"\n{3,}", "\n\n", prompt).strip()
331
+
332
+ ```
333
+
334
+ ```python
335
+ import json
336
+
337
+ text = infer([page], general_ocr_prompt("plain_ocr"))
338
+ words_json = infer([page], general_ocr_prompt("word_spotting"))
339
+
340
+ references = json.dumps(["Invoice number", "Total"], ensure_ascii=False, separators=(",", ":"))
341
+ grounding_prompt = general_ocr_prompt("reference_grounding").replace("{references}", references)
342
+ grounding_json = infer([page], grounding_prompt)
343
+
344
+ region = json.dumps([100, 100, 900, 300], separators=(",", ":"))
345
+ region_prompt = general_ocr_prompt("region_ocr").replace("{target_region}", region)
346
+ region_text = infer([page], region_prompt)
347
+
348
+ formula_latex = infer([page], general_ocr_prompt("formula_recognition"))
349
+ chart_json = infer([page], general_ocr_prompt("chart_parsing"))
350
+
351
+ translation_prompt = general_ocr_prompt("text_translation").replace("{target_region}", region)
352
+ translation_prompt = translation_prompt.replace("{target_language}", "English")
353
+ translated_text = infer([page], translation_prompt)
354
+
355
+ target_description = "the main subject"
356
+ candidate_labels = json.dumps(["cat", "dog", "bird", "fish"], ensure_ascii=False)
357
+ classification_prompt = general_ocr_prompt("image_classification").replace(
358
+ "{target description}", target_description
359
+ ).replace("{candidate_labels}", candidate_labels)
360
+ selected_label = infer([page], classification_prompt)
361
+
362
+ question = "What is the invoice number?"
363
+ vqa_prompt = general_vqa_prompt(question)
364
+ answer_with_reasoning = infer([page], vqa_prompt)
365
+
366
+ position_prompt = general_ocr_prompt("position_aware_vqa").replace("{question}", question)
367
+ answer_json = infer([page], position_prompt)
368
+ ```
369
+
370
+ References and regions use compact JSON. Supply references expected to be visible in the image. For classification, `{target description}` specifies what to classify, and `{candidate_labels}` is a JSON array of candidate labels. VQA accepts the complete question, including answer choices when applicable; its visible reasoning ends with a boxed answer. Position-aware VQA accepts the question text and uses its own answer-and-box prompt. Formula recognition and chart parsing need no template substitutions.
371
+
372
+ Spotting and grounding boxes, as well as input regions, use `[x_min,y_min,x_max,y_max]` with integer coordinates in `[0,1000)`, normalized independently by image width and height. The origin is the top-left corner, and coordinates refer to the supplied image, including when it is a crop. Use `[0,0,999,999]` for whole-image region OCR or translation.
373
+
374
+ Chart parsing represents scalar data values as strings and uses the literal string `"None"` for missing title, source, or axis metadata. Position-aware VQA uses `"bbox": "(x1,y1,x2,y2)"`, unlike the numeric arrays used by spotting and grounding.
375
+
376
+ ### Key Information Extraction
377
+
378
+ | Mode | Prompt | Additional input |
379
+ | --- | --- | --- |
380
+ | Open extraction | [kie_open.txt](./prompts/kie_open.txt) | None; extract all visible key-value pairs |
381
+ | Specified-field extraction | [kie_fields.txt](./prompts/kie_fields.txt) | `{keys_json}`: a JSON array of field names |
382
+ | Schema-based extraction | [kie_schema.txt](./prompts/kie_schema.txt) | `{schema_json}`: a JSON template specifying the desired output structure |
383
+
384
+ Schema-based extraction uses an example of the desired JSON output, not a formal JSON Schema declaration. Empty strings serve as value placeholders, while nested objects and arrays define the output structure.
385
+
386
+ ```python
387
+ import json
388
+
389
+ open_json = infer([page], load_prompt("kie_open"))
390
+
391
+ keys = json.dumps(["invoice_number", "date", "total"], ensure_ascii=False)
392
+ fields_prompt = load_prompt("kie_fields").replace("{keys_json}", keys)
393
+ fields_json = infer([page], fields_prompt)
394
+
395
+ schema = {
396
+ "invoice_number": "",
397
+ "date": "",
398
+ "seller": {
399
+ "name": "",
400
+ "address": "",
401
+ },
402
+ "items": [
403
+ {
404
+ "name": "",
405
+ "quantity": "",
406
+ "unit_price": "",
407
+ "amount": "",
408
+ }
409
+ ],
410
+ "total": "",
411
+ }
412
+ schema_prompt = load_prompt("kie_schema").replace(
413
+ "{schema_json}", json.dumps(schema, ensure_ascii=False)
414
+ )
415
+ schema_json = infer([page], schema_prompt)
416
+ ```
417
+
418
+ Values should retain the visible source text. Open and specified-field extraction use arrays for repeated values and empty strings for empty values; specified-field extraction also uses empty strings for missing fields. Schema-based extraction follows the supplied template and uses empty strings for missing values. Adherence to the template is prompted, not enforced by constrained decoding in this example; validate generated JSON before downstream use.
419
+
420
+ ### Document Figure Captioning
421
+
422
+ Use the **full page image** with [figure_captioning.txt](./prompts/figure_captioning.txt):
423
+
424
+ ```python
425
+ caption_json = infer([page], load_prompt("figure_captioning"))
426
+ ```
427
+
428
+ The model returns a structured page JSON array with `bbox`, `category`, and `content`. For visual categories such as `image` and `chart`, `content` contains a concise description inside `<caption>...</caption>`, with a maximum of 100 words requested by the prompt. These captions replace the image references used for visual regions in structured document parsing. Flowcharts additionally include a Mermaid representation inside `<mermaid>...</mermaid>`. Other page elements retain their task-specific text, formula, or table content.
429
+
430
+ This is localized figure description within a document page, not just transcription of a printed figure caption.
431
+
432
+ ### Cross-Page Table Reconstruction
433
+
434
+ OvisOCR3 reconstructs cross-page tables in two stages using the same checkpoint:
435
+
436
+ 1. **Stage 1: page parsing.** Parse each page independently with [document_markdown.txt](./prompts/document_markdown.txt) or [document_structured.txt](./prompts/document_structured.txt). For structured JSON output, concatenate each element’s `content` in reading order to obtain Markdown, preserving HTML tables, formulas, and image references.
437
+ 2. **Stage 2: table reconstruction.** Supply two adjacent page images and their parsed Markdown with [crosspage_table.txt](./prompts/crosspage_table.txt). The model identifies whether a table continues across the page boundary and reconstructs continued fragments as a single HTML table; otherwise, it returns a negative prediction.
438
+
439
+ ```python
440
+ import re
441
+
442
+ pages = [
443
+ Image.open("page1.jpg").convert("RGB"),
444
+ Image.open("page2.jpg").convert("RGB"),
445
+ ]
446
+ page_results = [
447
+ infer([image], load_prompt("document_markdown"), return_metadata=True)
448
+ for image in pages
449
+ ]
450
+ page_markdowns = []
451
+ for index, (raw, reason) in enumerate(page_results, start=1):
452
+ if reason not in ("stop", "length"):
453
+ raise ValueError(f"Page {index}: finish_reason={reason}")
454
+ markdown = _clean_truncated_repeats(raw.strip())
455
+ markdown = re.sub(r'^```(?:markdown|html)?\s*\n|\n```\s*$', '', markdown.strip(), flags=re.I)
456
+ if not markdown:
457
+ raise ValueError(f"Page {index}: empty Markdown")
458
+ page_markdowns.append(re.sub(
459
+ r'(?<![\w/])(?:page_\d+/)?(images/bbox_\d+_\d+_\d+_\d+\.jpg)',
460
+ rf'page_{index}/\1', markdown,
461
+ ))
462
+
463
+ if all(re.search(r'<table\b[^>]*>.*?</table\s*>', md, re.I | re.S) for md in page_markdowns):
464
+ page_context = "\n\n".join(
465
+ f'<INPUT_PAGE index="{index}">\n{markdown}\n</INPUT_PAGE>'
466
+ for index, markdown in enumerate(page_markdowns, start=1)
467
+ )
468
+ prompt = load_prompt("crosspage_table").removesuffix("\n") + "\n\nInput Markdown:\n\n" + page_context
469
+ tables_json, reason = infer(pages, prompt, return_metadata=True)
470
+ if reason != "stop":
471
+ raise ValueError(f"Cross-page output is incomplete: finish_reason={reason}")
472
+ else:
473
+ tables_json = '[{"source_pages": [], "html": "No cross-page table exists between these pages."}]'
474
+ print(tables_json)
475
+ ```
476
+
477
+ The result is a JSON array. Each record has `source_pages`, using **input-local, 1-based page numbers**, and `html`, containing one complete HTML table. Images within tables retain page-qualified references such as `page_1/images/bbox_100_200_300_400.jpg`.
478
+
479
+ When no cross-page table exists, the specified response is:
480
+
481
+ ```json
482
+ [{"source_pages": [], "html": "No cross-page table exists between these pages."}]
483
+ ```
484
+
485
+ For tables spanning more than two pages, **Stage 2 is applied recursively**, processing adjacent pairs from the end of each consecutive sequence of table-bearing pages toward its beginning. After each merge, the reconstructed table is written into the earlier page and its updated Markdown is re-rendered before the next step, keeping the image and text consistent. For example, the model first merges pages 2 and 3, then pairs the updated page 2 with page 1. Each step uses only two page images while preserving the original source-page associations. A ready-to-run document-level implementation is available in [document_crosspage.py](./document_crosspage.py), supporting either `document_markdown.txt` or `document_structured.txt` for Stage 1 and converting structured JSON to Markdown before reconstruction.
486
+
487
+ ![Recursive reconstruction of a table spanning three pages.](./assets/crosspage_recursive.png)
488
+
489
+ *Stage 2 first reconstructs the table across pages 2 and 3, then pairs the re-rendered page 2 with page 1. Each step uses two page images and their current Markdown.*
490
+
491
+ The script uses the same vLLM version and inference settings as the example above. Install the additional dependencies and run the command below from the model repository root, supplying page images in reading order.
492
+
493
+ ```bash
494
+ python -m pip install beautifulsoup4 markdown-it-py playwright
495
+ python -m playwright install --with-deps chromium
496
+
497
+ python document_crosspage.py \
498
+ --model . \
499
+ --stage1-prompt document_structured.txt \
500
+ --output crosspage_output \
501
+ page1.jpg page2.jpg page3.jpg
502
+ ```
503
+
504
+ Use a new output directory for each run; it contains `document.md`, per-page Markdown, raw responses, and `result.json` with table provenance and any processing errors.
505
+
506
+ ## Output Handling
507
+
508
+ ### Validation and Rendering
509
+
510
+ - Preserve raw outputs before parsing, rendering, or applying cleanup. JSON, HTML, LaTeX, and Mermaid may need validation, and generation can stop before an output is complete.
511
+ - Render Markdown with support for HTML tables and LaTeX. Figure captions and Mermaid content require their own handling inside structured JSON.
512
+ - Treat generated HTML and Mermaid as untrusted content. Sanitize or isolate them before rendering in a browser.
513
+ - For evaluation, freeze prompts, preprocessing, generation parameters, and postprocessing. These usage examples do not define a benchmark protocol.
514
+
515
+ ## Limitations
516
+
517
+ OvisOCR3 can produce incorrect, incomplete, repeated, or malformed outputs. Small or blurred text, severe perspective distortion, dense layouts, complex formulas, and long tables may affect recognition and structure recovery. Captions and answers can include unsupported details; verify them against the source image.
518
+
519
+ Cross-page reconstruction also depends on the quality of the first-stage page parsing and correct page order. Missing table fragments, inconsistent page context, or truncated generations can lead to incorrect merges. Review results before using them in critical applications.
520
+
521
+ ## Technical Report and Citation
522
+
523
+ If you find OvisOCR3 useful, please consider citing our technical report:
524
+
525
+ ```bibtex
526
+ @misc{ovisocr3,
527
+ title = {{OvisOCR3 Technical Report}},
528
+ author = {{ATH-MaaS}},
529
+ year = {2026}
530
+ }
531
+ ```
532
+
533
+ The technical report link will be added when available.
534
+
535
+ ## License
536
+
537
+ This project is licensed under the [Apache License, Version 2.0](./LICENSE) (SPDX-License-Identifier: Apache-2.0).
assets/crosspage_recursive.png ADDED

Git LFS Details

  • SHA256: 1a5beede5c098bfdcb091790c3d5ec36c0e2ce50827eb0ae9b92325d60db1afe
  • Pointer size: 131 Bytes
  • Size of remote file: 159 kB
assets/mathjax.js ADDED
The diff for this file is too large to render. See raw diff
 
assets/ovisocr3_performance_overview.png ADDED

Git LFS Details

  • SHA256: dc3715147ca9591d7b235f81847a8bbe70c3947e4d0b82f75d198cc747464ecd
  • Pointer size: 131 Bytes
  • Size of remote file: 886 kB
chat_template.jinja ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set image_count = namespace(value=0) %}
2
+ {%- set video_count = namespace(value=0) %}
3
+ {%- macro render_content(content, do_vision_count, is_system_content=false) %}
4
+ {%- if content is string %}
5
+ {{- content }}
6
+ {%- elif content is iterable and content is not mapping %}
7
+ {%- for item in content %}
8
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
9
+ {%- if is_system_content %}
10
+ {{- raise_exception('System message cannot contain images.') }}
11
+ {%- endif %}
12
+ {%- if do_vision_count %}
13
+ {%- set image_count.value = image_count.value + 1 %}
14
+ {%- endif %}
15
+ {%- if add_vision_id %}
16
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
17
+ {%- endif %}
18
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
19
+ {%- elif 'video' in item or item.type == 'video' %}
20
+ {%- if is_system_content %}
21
+ {{- raise_exception('System message cannot contain videos.') }}
22
+ {%- endif %}
23
+ {%- if do_vision_count %}
24
+ {%- set video_count.value = video_count.value + 1 %}
25
+ {%- endif %}
26
+ {%- if add_vision_id %}
27
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
28
+ {%- endif %}
29
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
30
+ {%- elif 'text' in item %}
31
+ {{- item.text }}
32
+ {%- else %}
33
+ {{- raise_exception('Unexpected item type in content.') }}
34
+ {%- endif %}
35
+ {%- endfor %}
36
+ {%- elif content is none or content is undefined %}
37
+ {{- '' }}
38
+ {%- else %}
39
+ {{- raise_exception('Unexpected content type.') }}
40
+ {%- endif %}
41
+ {%- endmacro %}
42
+ {%- if not messages %}
43
+ {{- raise_exception('No messages provided.') }}
44
+ {%- endif %}
45
+ {%- if tools and tools is iterable and tools is not mapping %}
46
+ {{- '<|im_start|>system\n' }}
47
+ {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
48
+ {%- for tool in tools %}
49
+ {{- "\n" }}
50
+ {{- tool | tojson }}
51
+ {%- endfor %}
52
+ {{- "\n</tools>" }}
53
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
54
+ {%- if messages[0].role == 'system' %}
55
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
56
+ {%- if content %}
57
+ {{- '\n\n' + content }}
58
+ {%- endif %}
59
+ {%- endif %}
60
+ {{- '<|im_end|>\n' }}
61
+ {%- else %}
62
+ {%- if messages[0].role == 'system' %}
63
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
64
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
65
+ {%- endif %}
66
+ {%- endif %}
67
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
68
+ {%- for message in messages[::-1] %}
69
+ {%- set index = (messages|length - 1) - loop.index0 %}
70
+ {%- if ns.multi_step_tool and message.role == "user" %}
71
+ {%- set content = render_content(message.content, false)|trim %}
72
+ {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
73
+ {%- set ns.multi_step_tool = false %}
74
+ {%- set ns.last_query_index = index %}
75
+ {%- endif %}
76
+ {%- endif %}
77
+ {%- endfor %}
78
+ {%- if ns.multi_step_tool %}
79
+ {{- raise_exception('No user query found in messages.') }}
80
+ {%- endif %}
81
+ {%- for message in messages %}
82
+ {%- set content = render_content(message.content, true)|trim %}
83
+ {%- if message.role == "system" %}
84
+ {%- if not loop.first %}
85
+ {{- raise_exception('System message must be at the beginning.') }}
86
+ {%- endif %}
87
+ {%- elif message.role == "user" %}
88
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
89
+ {%- elif message.role == "assistant" %}
90
+ {%- set reasoning_content = '' %}
91
+ {%- if message.reasoning_content is string %}
92
+ {%- set reasoning_content = message.reasoning_content %}
93
+ {%- else %}
94
+ {%- if '</think>' in content %}
95
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
96
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
97
+ {%- endif %}
98
+ {%- endif %}
99
+ {%- set reasoning_content = reasoning_content|trim %}
100
+ {%- if loop.index0 > ns.last_query_index %}
101
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
102
+ {%- else %}
103
+ {{- '<|im_start|>' + message.role + '\n' + content }}
104
+ {%- endif %}
105
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
106
+ {%- for tool_call in message.tool_calls %}
107
+ {%- if tool_call.function is defined %}
108
+ {%- set tool_call = tool_call.function %}
109
+ {%- endif %}
110
+ {%- if loop.first %}
111
+ {%- if content|trim %}
112
+ {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
113
+ {%- else %}
114
+ {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
115
+ {%- endif %}
116
+ {%- else %}
117
+ {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
118
+ {%- endif %}
119
+ {%- if tool_call.arguments is defined %}
120
+ {%- for args_name, args_value in tool_call.arguments|items %}
121
+ {{- '<parameter=' + args_name + '>\n' }}
122
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
123
+ {{- args_value }}
124
+ {{- '\n</parameter>\n' }}
125
+ {%- endfor %}
126
+ {%- endif %}
127
+ {{- '</function>\n</tool_call>' }}
128
+ {%- endfor %}
129
+ {%- endif %}
130
+ {{- '<|im_end|>\n' }}
131
+ {%- elif message.role == "tool" %}
132
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
133
+ {{- '<|im_start|>user' }}
134
+ {%- endif %}
135
+ {{- '\n<tool_response>\n' }}
136
+ {{- content }}
137
+ {{- '\n</tool_response>' }}
138
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
139
+ {{- '<|im_end|>\n' }}
140
+ {%- elif loop.last %}
141
+ {{- '<|im_end|>\n' }}
142
+ {%- endif %}
143
+ {%- else %}
144
+ {{- raise_exception('Unexpected message role.') }}
145
+ {%- endif %}
146
+ {%- endfor %}
147
+ {%- if add_generation_prompt %}
148
+ {{- '<|im_start|>assistant\n' }}
149
+ {%- if enable_thinking is defined and enable_thinking is true %}
150
+ {{- '<think>\n' }}
151
+ {%- else %}
152
+ {{- '<think>\n\n</think>\n\n' }}
153
+ {%- endif %}
154
+ {%- endif %}
config.json ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Qwen3_5ForConditionalGeneration"
4
+ ],
5
+ "image_token_id": 248056,
6
+ "model_type": "qwen3_5",
7
+ "text_config": {
8
+ "attention_bias": false,
9
+ "attention_dropout": 0.0,
10
+ "attn_output_gate": true,
11
+ "dtype": "bfloat16",
12
+ "eos_token_id": 248044,
13
+ "full_attention_interval": 4,
14
+ "head_dim": 256,
15
+ "hidden_act": "silu",
16
+ "hidden_size": 1024,
17
+ "initializer_range": 0.02,
18
+ "intermediate_size": 3584,
19
+ "layer_types": [
20
+ "linear_attention",
21
+ "linear_attention",
22
+ "linear_attention",
23
+ "full_attention",
24
+ "linear_attention",
25
+ "linear_attention",
26
+ "linear_attention",
27
+ "full_attention",
28
+ "linear_attention",
29
+ "linear_attention",
30
+ "linear_attention",
31
+ "full_attention",
32
+ "linear_attention",
33
+ "linear_attention",
34
+ "linear_attention",
35
+ "full_attention",
36
+ "linear_attention",
37
+ "linear_attention",
38
+ "linear_attention",
39
+ "full_attention",
40
+ "linear_attention",
41
+ "linear_attention",
42
+ "linear_attention",
43
+ "full_attention"
44
+ ],
45
+ "linear_conv_kernel_dim": 4,
46
+ "linear_key_head_dim": 128,
47
+ "linear_num_key_heads": 16,
48
+ "linear_num_value_heads": 16,
49
+ "linear_value_head_dim": 128,
50
+ "max_position_embeddings": 262144,
51
+ "mlp_only_layers": [],
52
+ "model_type": "qwen3_5_text",
53
+ "mtp_num_hidden_layers": 0,
54
+ "mtp_use_dedicated_embeddings": false,
55
+ "num_attention_heads": 8,
56
+ "num_hidden_layers": 24,
57
+ "num_key_value_heads": 2,
58
+ "rms_norm_eps": 1e-06,
59
+ "tie_word_embeddings": true,
60
+ "use_cache": true,
61
+ "vocab_size": 248320,
62
+ "mamba_ssm_dtype": "float32",
63
+ "rope_parameters": {
64
+ "mrope_interleaved": true,
65
+ "mrope_section": [
66
+ 11,
67
+ 11,
68
+ 10
69
+ ],
70
+ "rope_type": "default",
71
+ "rope_theta": 10000000,
72
+ "partial_rotary_factor": 0.25
73
+ }
74
+ },
75
+ "tie_word_embeddings": true,
76
+ "transformers_version": "4.57.0.dev0",
77
+ "video_token_id": 248057,
78
+ "vision_config": {
79
+ "deepstack_visual_indexes": [],
80
+ "depth": 12,
81
+ "hidden_act": "gelu_pytorch_tanh",
82
+ "hidden_size": 768,
83
+ "in_channels": 3,
84
+ "initializer_range": 0.02,
85
+ "intermediate_size": 3072,
86
+ "model_type": "qwen3_5",
87
+ "num_heads": 12,
88
+ "num_position_embeddings": 2304,
89
+ "out_hidden_size": 1024,
90
+ "patch_size": 16,
91
+ "spatial_merge_size": 2,
92
+ "temporal_patch_size": 2
93
+ },
94
+ "vision_end_token_id": 248054,
95
+ "vision_start_token_id": 248053
96
+ }
document_crosspage.py ADDED
@@ -0,0 +1,1747 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-file OvisOCR3 document parsing and cross-page table reconstruction.
2
+
3
+ Reading guide: public entry point -> document pipeline -> text/table helpers ->
4
+ model backend -> browser renderer. Import the text helpers without installing
5
+ vLLM or browser dependencies; those are loaded only when used.
6
+
7
+ Algorithm: parse pages independently, then visit adjacent table-bearing pages
8
+ from last to first. Write each merged table into the earlier page and render
9
+ that updated page before its next prediction.
10
+ Keep non-table source untouched.
11
+
12
+ Support: the document pipeline accepts one merged table per adjacent pair and
13
+ binds the earlier page's last table to the later page's first table. Multiple
14
+ merges need explicit fragment bindings via apply_pair(); nested or incomplete
15
+ HTML tables are rejected on writeback. This is a conservative binding policy,
16
+ not a general table-fragment matching algorithm.
17
+
18
+ Page numbers are 1-based; table fragment indices are 0-based. Model predictions
19
+ use pair-local pages 1 and 2; saved Markdown and provenance use document pages.
20
+ Raw predictions, intermediate files and partial results are retained.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import base64
27
+ import copy
28
+ import html
29
+ import io
30
+ import json
31
+ import math
32
+ import re
33
+ import time
34
+ import traceback
35
+ import uuid
36
+ from dataclasses import asdict, dataclass, field
37
+ from pathlib import Path
38
+ from typing import Callable, Mapping, NamedTuple
39
+
40
+ NEGATIVE = "No cross-page table exists between these pages."
41
+ TABLE = re.compile(r"<table\b[^>]*>.*?</table\s*>", re.I | re.S)
42
+ IMAGE_REF = re.compile(
43
+ r"(?<![\w/])(?:page_(\d+)/)?images/bbox_(\d+)_(\d+)_(\d+)_(\d+)\.jpg"
44
+ )
45
+ REF = re.compile(r"page_(\d+)/images/")
46
+ CATEGORIES = set(
47
+ "abstract algorithm aside_text chart content display_formula doc_title figure_title footer footer_image footnote formula_number header header_image image inline_formula number paragraph_title reference_content seal table text vertical_text vision_footnote".split()
48
+ )
49
+
50
+ FragmentId = tuple[int, int]
51
+ Backend = Callable[[list[str], str, Path, int], tuple[str, str]]
52
+
53
+
54
+ class GenerationResult(NamedTuple):
55
+ """Tuple-compatible raw model output; cleaning belongs to the page adapter."""
56
+
57
+ text: str
58
+ finish_reason: str
59
+
60
+
61
+ class RenderResult(NamedTuple):
62
+ """Tuple-compatible render result; aliases map rendered crops to originals."""
63
+
64
+ image_path: str
65
+ rewritten_source: str
66
+ alias_to_original: dict[str, str]
67
+ diagnostics: dict
68
+
69
+
70
+ @dataclass
71
+ class PageState:
72
+ """One document page and the original fragments behind its current tables."""
73
+
74
+ number: int
75
+ image_path: str
76
+ original_markdown: str
77
+ markdown: str
78
+ finish_reason: str
79
+ parse_diagnostics: dict | None = None
80
+ table_sources: list[set[FragmentId]] = field(init=False)
81
+
82
+ def __post_init__(self):
83
+ self.table_sources = [
84
+ {(self.number, index)}
85
+ for index, _ in enumerate(TABLE.finditer(self.markdown))
86
+ ]
87
+
88
+
89
+ @dataclass
90
+ class PairInput:
91
+ """Two model inputs with explicit document/pair-local coordinate mapping."""
92
+
93
+ page_numbers: tuple[int, int]
94
+ image_paths: list[str]
95
+ markdowns: list[str]
96
+ alias_to_original: dict[str, str]
97
+ rendered_pages: set[int]
98
+
99
+
100
+ @dataclass
101
+ class DocumentState:
102
+ pages: list[PageState] = field(default_factory=list)
103
+ chunks: list[list[int]] = field(default_factory=list)
104
+ steps: list[dict] = field(default_factory=list)
105
+ merged_tables: list[dict] = field(default_factory=list)
106
+ parse_warnings: list[dict] = field(default_factory=list)
107
+
108
+
109
+ # Public entry point and document pipeline.
110
+
111
+
112
+ def reconstruct_document(
113
+ image_paths,
114
+ output,
115
+ *,
116
+ model_path=".",
117
+ stage1_prompt="document_structured",
118
+ mathjax=None,
119
+ backend: Backend | None = None,
120
+ renderer=None,
121
+ ):
122
+ """Reconstruct ordered page images into a document, keeping all evidence.
123
+
124
+ Use a fresh output directory. Stage 1 accepts document_markdown[.txt] or
125
+ document_structured[.txt]; prompts are loaded from model_path/prompts.
126
+ Returns the saved result.json dictionary. Truncated generations, recovered
127
+ JSON or invalid structured fields return PARTIAL with issue-specific warnings;
128
+ other processing failures save partial output and raise RuntimeError.
129
+
130
+ For research, inject backend(paths, prompt, folder, stage) -> (raw, reason)
131
+ and/or renderer.render(source, owner, paths, folder, name) -> RenderResult.
132
+ Injected renderers remain caller-owned. Defaults load vLLM and Chromium lazily.
133
+ Engine settings, prompt bytes and renderer resize policy are unchanged.
134
+ """
135
+ stage1_prompt = stage1_prompt.removesuffix(".txt")
136
+ if stage1_prompt not in {"document_markdown", "document_structured"}:
137
+ raise ValueError("Choose document_markdown or document_structured")
138
+ images = _document_images(image_paths)
139
+ if Path(output).exists():
140
+ raise FileExistsError("Use a fresh output directory")
141
+
142
+ prompt_dir = Path(model_path) / "prompts"
143
+ page_prompt = (prompt_dir / f"{stage1_prompt}.txt").read_text().removesuffix("\n")
144
+ table_prompt = (prompt_dir / "crosspage_table.txt").read_text().removesuffix("\n")
145
+ if backend is None:
146
+ backend = VllmBackend(model_path)
147
+ owns_renderer = renderer is None
148
+ if owns_renderer:
149
+ renderer = _BrowserRenderer(
150
+ mathjax or Path(__file__).resolve().parent / "assets/mathjax.js"
151
+ )
152
+ try:
153
+ result = _run_document(
154
+ images,
155
+ output,
156
+ backend,
157
+ renderer,
158
+ page_prompt=page_prompt,
159
+ table_prompt=table_prompt,
160
+ stage1_prompt=stage1_prompt,
161
+ )
162
+ finally:
163
+ if owns_renderer:
164
+ renderer.close()
165
+
166
+ if result["error"] and not result["error"].get("recoverable", False):
167
+ raise RuntimeError(f"Document incomplete; inspect {output}/failure.json")
168
+ return result
169
+
170
+
171
+ def _run_document(
172
+ image_paths, output, backend, renderer, *, page_prompt, table_prompt, stage1_prompt
173
+ ):
174
+ """Parse pages, reconstruct adjacent pairs backwards, then persist results.
175
+
176
+ Recovery is confined to Stage 1 output parsing. A failed window stops further
177
+ reconstruction while preserving earlier pages, merges and failure details.
178
+ No benchmark labels are used. The output directory must not already exist.
179
+ """
180
+ images = _document_images(image_paths)
181
+ output = Path(output)
182
+ output.mkdir(parents=True, exist_ok=False)
183
+ save(
184
+ output / "inputs.json",
185
+ {
186
+ "images": images,
187
+ "stage1_prompt": stage1_prompt,
188
+ "page_prompt": page_prompt,
189
+ "table_prompt": table_prompt,
190
+ },
191
+ )
192
+ state = DocumentState()
193
+ error = None
194
+ context = {"stage": 1, "pages": []}
195
+ try:
196
+ for number, image in enumerate(images, 1):
197
+ context = {"stage": 1, "pages": [number]}
198
+ page = _parse_page(
199
+ number, image, output, backend, page_prompt, stage1_prompt
200
+ )
201
+ state.pages.append(page)
202
+ warning = _page_warning(page)
203
+ if warning:
204
+ state.parse_warnings.append(warning)
205
+
206
+ state.chunks = find_chunks([page.markdown for page in state.pages])
207
+ save(
208
+ output / "chunks.json",
209
+ {
210
+ "chunks": state.chunks,
211
+ "basis": "Stage 1 predicted HTML tables only",
212
+ },
213
+ )
214
+ for left, right in _adjacent_pairs(state.chunks):
215
+ context = {"stage": 2, "pages": [left, right]}
216
+ folder = output / "stage2" / f"pair_{left:04d}_{right:04d}"
217
+ folder.mkdir(parents=True)
218
+ pair = _prepare_pair(
219
+ state.pages[left - 1 : right], images, folder, renderer
220
+ )
221
+ tables = _predict_pair(pair, folder, backend, table_prompt)
222
+ _apply_merge(state, pair, tables)
223
+ state.steps.append(
224
+ {
225
+ "pages": list(pair.page_numbers),
226
+ "merged": bool(tables),
227
+ "rendered_pages": sorted(pair.rendered_pages),
228
+ }
229
+ )
230
+ save(
231
+ folder / "result.json",
232
+ {
233
+ "tables": tables,
234
+ "pages": [
235
+ state.pages[number - 1].markdown for number in pair.page_numbers
236
+ ],
237
+ "groups": copy.deepcopy(state.merged_tables),
238
+ },
239
+ )
240
+ except Exception as exc:
241
+ error = {
242
+ "type": type(exc).__name__,
243
+ "message": str(exc),
244
+ "recoverable": False,
245
+ **context,
246
+ }
247
+ save(output / "failure.json", {**error, "traceback": traceback.format_exc()})
248
+ return _finalize_document(state, images, output, error)
249
+
250
+
251
+ def _parse_page(number, image, output, backend, page_prompt, stage1_prompt):
252
+ """Retain raw output, apply the existing cleanup, then adapt the page format."""
253
+ folder = output / "stage1" / f"page_{number:04d}"
254
+ folder.mkdir(parents=True)
255
+ generation = GenerationResult(*backend([image], page_prompt, folder, 1))
256
+ raw, reason = generation
257
+ (folder / "raw.txt").write_text(raw)
258
+ cleaned = _clean_truncated_repeats(raw.strip())
259
+ save(
260
+ folder / "generation.json",
261
+ {
262
+ "finish_reason": reason,
263
+ "complete": reason == "stop",
264
+ "tail_changed": cleaned != raw.strip(),
265
+ "raw_chars": len(raw.strip()),
266
+ "cleaned_chars": len(cleaned),
267
+ },
268
+ )
269
+ (folder / "cleaned_raw.txt").write_text(cleaned)
270
+ if reason not in ("stop", "length"):
271
+ raise ValueError(f"Stage 1 page {number}: finish_reason={reason}")
272
+
273
+ diagnostics = None
274
+ if stage1_prompt == "document_structured":
275
+ projected, blocks, diagnostics = structured_to_markdown(cleaned)
276
+ save(folder / "blocks.json", blocks)
277
+ save(folder / "adapter.json", diagnostics)
278
+ (folder / "projected.md").write_text(projected)
279
+ markdown = page_markdown(projected, number) if projected.strip() else ""
280
+ else:
281
+ markdown = page_markdown(cleaned, number)
282
+ (folder / "page.md").write_text(markdown)
283
+ return PageState(number, image, markdown, markdown, reason, diagnostics)
284
+
285
+
286
+ def _page_warning(page):
287
+ """Separate generation truncation, JSON recovery and structured schema issues."""
288
+ codes = []
289
+ if page.finish_reason == "length":
290
+ codes.append("generation_truncated")
291
+ diagnostics = page.parse_diagnostics
292
+ if diagnostics is not None:
293
+ if not diagnostics["json_complete"]:
294
+ codes.append("json_incomplete")
295
+ if diagnostics["schema_issues"]:
296
+ codes.append("schema_invalid")
297
+ if (
298
+ diagnostics["invalid_content_objects"]
299
+ or not diagnostics["emitted_contents"]
300
+ ):
301
+ codes.append("content_invalid")
302
+ if not codes:
303
+ return None
304
+ warning = {"page": page.number, "finish_reason": page.finish_reason, "codes": codes}
305
+ if diagnostics is not None:
306
+ warning["diagnostics"] = diagnostics
307
+ return warning
308
+
309
+
310
+ def _adjacent_pairs(chunks):
311
+ """Yield (2, 3), then (1, 2) for a three-page table-bearing run."""
312
+ for chunk in chunks:
313
+ for left in range(chunk[-1] - 1, chunk[0] - 1, -1):
314
+ yield left, left + 1
315
+
316
+
317
+ def _prepare_pair(pages, images, folder, renderer):
318
+ page_numbers = tuple(page.number for page in pages)
319
+ document_to_local = {number: local for local, number in enumerate(page_numbers, 1)}
320
+ paths, markdowns, aliases, rendered_pages = [], [], {}, set()
321
+ for page in pages:
322
+ path, markdown = page.image_path, page.markdown
323
+ if markdown != page.original_markdown:
324
+ rendered = RenderResult(
325
+ *renderer.render(
326
+ markdown, page.number, images, folder, f"page_{page.number:04d}"
327
+ )
328
+ )
329
+ path, markdown = rendered.image_path, rendered.rewritten_source
330
+ aliases.update(rendered.alias_to_original)
331
+ rendered_pages.add(page.number)
332
+ paths.append(path)
333
+ markdowns.append(remap(markdown, document_to_local))
334
+ return PairInput(page_numbers, paths, markdowns, aliases, rendered_pages)
335
+
336
+
337
+ def _predict_pair(pair, folder, backend, table_prompt):
338
+ page_context = "\n\n".join(
339
+ f'<INPUT_PAGE index="{index}">\n{markdown}\n</INPUT_PAGE>'
340
+ for index, markdown in enumerate(pair.markdowns, 1)
341
+ )
342
+ prompt = table_prompt.removesuffix("\n") + "\n\nInput Markdown:\n\n" + page_context
343
+ if all(TABLE.search(markdown) for markdown in pair.markdowns):
344
+ generation = GenerationResult(*backend(pair.image_paths, prompt, folder, 2))
345
+ else:
346
+ generation = GenerationResult(
347
+ json.dumps([{"source_pages": [], "html": NEGATIVE}]), "stop"
348
+ )
349
+ save(
350
+ folder / "gate_skip.json",
351
+ {"reason": "README table gate", "model_called": False},
352
+ )
353
+ (folder / "raw.txt").write_text(generation.text)
354
+ save(folder / "generation.json", {"finish_reason": generation.finish_reason})
355
+ if generation.finish_reason != "stop":
356
+ raise ValueError(
357
+ f"Stage 2 pair {list(pair.page_numbers)}: finish_reason={generation.finish_reason}"
358
+ )
359
+ tables = parse_tables(generation.text)
360
+ if len(tables) > 1:
361
+ raise ValueError(
362
+ "Multiple merges need explicit fragment bindings; output retained."
363
+ )
364
+ return [
365
+ dict(table, html=_restore_image_refs(table["html"], pair)) for table in tables
366
+ ]
367
+
368
+
369
+ def _restore_image_refs(table_html, pair):
370
+ """Restore document page IDs, then resolve crops from re-rendered pages exactly."""
371
+ local_to_document = {
372
+ local: number for local, number in enumerate(pair.page_numbers, 1)
373
+ }
374
+ table_html = remap(table_html, local_to_document)
375
+
376
+ def restore(match):
377
+ reference = match[0]
378
+ if match[1] is None:
379
+ raise ValueError("Output crop lacks a page number.")
380
+ if int(match[1]) in pair.rendered_pages:
381
+ if reference not in pair.alias_to_original:
382
+ raise ValueError("Unmappable crop from a rendered page: " + reference)
383
+ return pair.alias_to_original[reference]
384
+ return reference
385
+
386
+ return IMAGE_REF.sub(restore, table_html)
387
+
388
+
389
+ def _apply_merge(state, pair, tables):
390
+ """Validate writeback before updating page contents or fragment provenance."""
391
+ earlier, later = (state.pages[number - 1] for number in pair.page_numbers)
392
+ before = (earlier.markdown, later.markdown)
393
+ after = apply_pair(before, tables)
394
+ if any(TABLE.sub("", old) != TABLE.sub("", new) for old, new in zip(before, after)):
395
+ raise ValueError("Writeback changed non-table content.")
396
+ if tables:
397
+ sources = earlier.table_sources[-1] | later.table_sources[0]
398
+ state.merged_tables = [
399
+ group
400
+ for group in state.merged_tables
401
+ if not sources.intersection(map(tuple, group["fragment_ids"]))
402
+ ]
403
+ state.merged_tables.append(
404
+ {
405
+ "source_pages": sorted({number for number, _ in sources}),
406
+ "fragment_ids": sorted(sources),
407
+ "html": tables[0]["html"],
408
+ }
409
+ )
410
+ earlier.table_sources[-1] = sources
411
+ del later.table_sources[0]
412
+ earlier.markdown, later.markdown = after
413
+
414
+
415
+ def _save_document_crops(markdown, images, output):
416
+ """Materialize page-qualified references from the original input images."""
417
+ from PIL import Image
418
+
419
+ errors = []
420
+ references = {}
421
+ for match in IMAGE_REF.finditer(markdown):
422
+ references.setdefault(match[1], {})[match[0]] = match.groups()[1:]
423
+ for page, crops in references.items():
424
+ try:
425
+ if page is None or not 1 <= int(page) <= len(images):
426
+ raise ValueError("Image reference needs a valid source page number")
427
+ with Image.open(images[int(page) - 1]) as source:
428
+ for reference, coordinates in crops.items():
429
+ try:
430
+ left, top, right, bottom = map(int, coordinates)
431
+ width, height = source.size
432
+ box = (
433
+ max(0, min(width, round(left * width / 1000))),
434
+ max(0, min(height, round(top * height / 1000))),
435
+ max(0, min(width, round(right * width / 1000))),
436
+ max(0, min(height, round(bottom * height / 1000))),
437
+ )
438
+ if box[2] <= box[0] or box[3] <= box[1]:
439
+ raise ValueError("Image crop has zero or negative pixel area")
440
+ target = output / reference
441
+ target.parent.mkdir(parents=True, exist_ok=True)
442
+ source.crop(box).convert("RGB").save(target)
443
+ except Exception as exc:
444
+ errors.append({"reference": reference, "message": str(exc)})
445
+ except Exception as exc:
446
+ errors.extend(
447
+ {"reference": reference, "message": str(exc)} for reference in crops
448
+ )
449
+ return errors
450
+
451
+
452
+ def _finalize_document(state, images, output, error):
453
+ document = "\n\n".join(page.markdown for page in state.pages) + "\n"
454
+ crop_errors = _save_document_crops(document, images, output)
455
+ for page in state.pages:
456
+ target = output / "pages" / f"page_{page.number:04d}.md"
457
+ target.parent.mkdir(exist_ok=True)
458
+ # Per-page Markdown is one directory below the shared crop assets.
459
+ target.write_text(IMAGE_REF.sub(lambda m: "../" + m[0], page.markdown))
460
+ (output / "document.md").write_text(document)
461
+ if crop_errors and error is None:
462
+ error = {"type": "ImageCropExportFailed", "recoverable": False}
463
+ save(output / "failure.json", {**error, "image_export_errors": crop_errors})
464
+ if error is None and state.parse_warnings:
465
+ error = {
466
+ "type": "Stage1OutputIncomplete",
467
+ "recoverable": True,
468
+ "pages": [warning["page"] for warning in state.parse_warnings],
469
+ }
470
+ result = {
471
+ "status": "COMPLETE" if error is None else "PARTIAL",
472
+ "error": error,
473
+ "input_pages": len(images),
474
+ "parsed_pages": len(state.pages),
475
+ "chunks": state.chunks,
476
+ "steps": state.steps,
477
+ "merged_tables": state.merged_tables,
478
+ "stage1_parse_warnings": state.parse_warnings,
479
+ "image_export_errors": crop_errors,
480
+ }
481
+ save(output / "result.json", result)
482
+ return result
483
+
484
+
485
+ # Reusable text adapters and table operations (standard library only).
486
+
487
+
488
+ def _document_images(image_paths):
489
+ images = [str(Path(path).resolve()) for path in image_paths]
490
+ if not images or not all(Path(path).is_file() for path in images):
491
+ raise ValueError("Provide existing page images in reading order.")
492
+ return images
493
+
494
+
495
+ def save(path, value):
496
+ """Write a JSON artifact, creating its parent directory when needed."""
497
+ path = Path(path)
498
+ path.parent.mkdir(parents=True, exist_ok=True)
499
+ path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n")
500
+
501
+
502
+ def _is_valid_bbox(value):
503
+ if not isinstance(value, list) or len(value) != 4:
504
+ return False
505
+ if not all(type(coordinate) is int for coordinate in value):
506
+ return False
507
+ left, top, right, bottom = value
508
+ return 0 <= left <= right < 1000 and 0 <= top <= bottom < 1000
509
+
510
+
511
+ def _recover_json_prefix(text):
512
+ """Return only complete prefix objects; never guess or repair JSON syntax."""
513
+ blocks, unparsed_tail_chars = [], 0
514
+ if not text.startswith("["):
515
+ return blocks, unparsed_tail_chars
516
+ position = 1
517
+ decoder = json.JSONDecoder()
518
+ while position < len(text):
519
+ while position < len(text) and text[position].isspace():
520
+ position += 1
521
+ if position == len(text) or text[position] == "]":
522
+ break
523
+ try:
524
+ block, end = decoder.raw_decode(text, position)
525
+ except json.JSONDecodeError:
526
+ unparsed_tail_chars = len(text) - position
527
+ break
528
+ blocks.append(block)
529
+ position = end
530
+ while position < len(text) and text[position].isspace():
531
+ position += 1
532
+ if position < len(text) and text[position] == ",":
533
+ position += 1
534
+ elif position < len(text) and text[position] != "]":
535
+ unparsed_tail_chars = len(text) - position
536
+ break
537
+ return blocks, unparsed_tail_chars
538
+
539
+
540
+ def structured_to_markdown(text):
541
+ """Project content in emitted order, retaining diagnostics for incomplete JSON.
542
+
543
+ Preserve every category and keep only complete prefix objects on JSON failure.
544
+ Invalid bbox/category fields are reported without discarding valid content.
545
+ """
546
+ stripped = text.strip()
547
+ if stripped.startswith("```") and stripped.endswith("```"):
548
+ stripped = re.sub(r"^```(?:json)?\s*\n|\n```$", "", stripped, flags=re.I)
549
+ diagnostics = {
550
+ "json_complete": False,
551
+ "schema_issues": [],
552
+ "invalid_content_objects": [],
553
+ "unparsed_tail_chars": 0,
554
+ }
555
+ try:
556
+ blocks = json.loads(stripped)
557
+ if not isinstance(blocks, list):
558
+ raise ValueError("Expected one JSON array")
559
+ diagnostics["json_complete"] = True
560
+ except ValueError as exc:
561
+ diagnostics["parse_error"] = str(exc)
562
+ blocks, diagnostics["unparsed_tail_chars"] = _recover_json_prefix(stripped)
563
+
564
+ contents = []
565
+ for index, block in enumerate(blocks):
566
+ if not isinstance(block, dict) or not isinstance(block.get("content"), str):
567
+ diagnostics["invalid_content_objects"].append(index)
568
+ continue
569
+ contents.append(block["content"])
570
+ if not _is_valid_bbox(block.get("bbox")):
571
+ diagnostics["schema_issues"].append({"index": index, "field": "bbox"})
572
+ if (
573
+ not isinstance(block.get("category"), str)
574
+ or block["category"] not in CATEGORIES
575
+ ):
576
+ diagnostics["schema_issues"].append({"index": index, "field": "category"})
577
+ diagnostics.update(
578
+ parsed_objects=len(blocks),
579
+ emitted_contents=len(contents),
580
+ policy="join content in emitted array order; preserve every category; retain complete prefix objects only",
581
+ )
582
+ diagnostics["valid"] = (
583
+ diagnostics["json_complete"]
584
+ and not diagnostics["schema_issues"]
585
+ and not diagnostics["invalid_content_objects"]
586
+ and bool(contents)
587
+ )
588
+ return "\n\n".join(contents), blocks, diagnostics
589
+
590
+
591
+ def _clean_truncated_repeats(
592
+ text: str,
593
+ min_text_len: int = 8000,
594
+ max_period: int = 200,
595
+ min_period: int = 1,
596
+ min_repeat_chars: int = 100,
597
+ min_repeat_times: int = 5,
598
+ ) -> str:
599
+ n = len(text)
600
+ if n < min_text_len:
601
+ return text
602
+
603
+ max_period = min(max_period, n - 1)
604
+ for unit_len in range(min_period, max_period + 1):
605
+ if text[n - 1] != text[n - 1 - unit_len]:
606
+ continue
607
+
608
+ match_len = 1
609
+ idx = n - 2
610
+ while idx >= unit_len and text[idx] == text[idx - unit_len]:
611
+ match_len += 1
612
+ idx -= 1
613
+
614
+ total_len = match_len + unit_len
615
+ repeat_times = total_len // unit_len
616
+ tail_len = total_len % unit_len
617
+
618
+ if repeat_times >= min_repeat_times and total_len >= min_repeat_chars:
619
+ return text[: n - total_len + unit_len] + text[n - tail_len :]
620
+
621
+ return text
622
+
623
+
624
+ def parse_tables(raw: str) -> list[dict]:
625
+ """Return all positive objects or [] for the exact negative sentinel."""
626
+ items = json.loads(raw)
627
+ if not isinstance(items, list) or not items:
628
+ raise ValueError("Expected a nonempty JSON array.")
629
+ for item in items:
630
+ if not isinstance(item, dict) or set(item) != {"source_pages", "html"}:
631
+ raise ValueError("Each object needs exactly source_pages and html.")
632
+ if not isinstance(item["source_pages"], list) or not isinstance(
633
+ item["html"], str
634
+ ):
635
+ raise ValueError("Invalid field types.")
636
+ if not item["source_pages"]:
637
+ if len(items) == 1 and item["html"] == NEGATIVE:
638
+ return []
639
+ raise ValueError("Invalid or mixed negative sentinel.")
640
+ if item["source_pages"] != [1, 2] or any(
641
+ type(p) is not int for p in item["source_pages"]
642
+ ):
643
+ raise ValueError("A positive two-page result must use [1, 2].")
644
+ if not TABLE.fullmatch(item["html"].strip()):
645
+ raise ValueError("Expected complete HTML table output.")
646
+ return items
647
+
648
+
649
+ def apply_pair(
650
+ markdowns: tuple[str, str],
651
+ tables: list[dict],
652
+ bindings: list[tuple[int, int]] | None = None,
653
+ ) -> tuple[str, str]:
654
+ """Replace page 1's last table and remove page 2's first table by default.
655
+
656
+ Multiple merged tables require explicit zero-based fragment bindings.
657
+ Nested or incomplete HTML tables are rejected; non-table source is retained.
658
+ """
659
+ if len(markdowns) != 2:
660
+ raise ValueError("Need exactly two pages.")
661
+ spans = [list(TABLE.finditer(md)) for md in markdowns]
662
+ if bindings is None:
663
+ if not tables:
664
+ return tuple(markdowns)
665
+ if len(tables) != 1:
666
+ raise ValueError("Multiple merged tables require explicit source bindings.")
667
+ bindings = [(len(spans[0]) - 1, 0)]
668
+ if len(tables) != len(bindings):
669
+ raise ValueError("Need one explicit binding per merged table.")
670
+ for md, matches in zip(markdowns, spans):
671
+ if len(re.findall(r"<table\b", md, re.I)) != len(matches) or len(
672
+ re.findall(r"</table\s*>", md, re.I)
673
+ ) != len(matches):
674
+ raise ValueError(
675
+ "Nested or incomplete tables require a layout-aware editor."
676
+ )
677
+ edits = [{}, {}]
678
+ for item, binding in zip(tables, bindings):
679
+ parse_tables(json.dumps([item]))
680
+ if len(re.findall(r"<table\b", item["html"], re.I)) != 1:
681
+ raise ValueError(
682
+ "Nested or multiple tables cannot be written into one fragment."
683
+ )
684
+ if item["source_pages"] != [1, 2] or len(binding) != 2:
685
+ raise ValueError("Only positive two-page tables can be written back.")
686
+ for page, index in enumerate(binding):
687
+ if type(index) is not int or not 0 <= index < len(spans[page]):
688
+ raise ValueError("Source table index out of range.")
689
+ if index in edits[page]:
690
+ raise ValueError("Two results claim the same source fragment.")
691
+ edits[page][index] = item["html"] if page == 0 else ""
692
+ output = []
693
+ for page, md in enumerate(markdowns):
694
+ for index in sorted(edits[page], reverse=True):
695
+ span = spans[page][index]
696
+ md = md[: span.start()] + edits[page][index] + md[span.end() :]
697
+ output.append(md)
698
+ return tuple(output)
699
+
700
+
701
+ def find_chunks(pages):
702
+ """Maximal runs of >=2 pages with predicted HTML tables; 1-based IDs."""
703
+ chunks, run = [], []
704
+ for page, markdown in enumerate(pages, 1):
705
+ if TABLE.search(markdown):
706
+ run.append(page)
707
+ else:
708
+ if len(run) >= 2:
709
+ chunks.append(run)
710
+ run = []
711
+ if len(run) >= 2:
712
+ chunks.append(run)
713
+ return chunks
714
+
715
+
716
+ def remap(markdown: str, mapping: Mapping[int, int]) -> str:
717
+ def replace(match):
718
+ page = int(match[1])
719
+ if page not in mapping:
720
+ raise ValueError(f"Image page {page} is outside the current pair.")
721
+ return f"page_{mapping[page]}/images/"
722
+
723
+ return REF.sub(replace, markdown)
724
+
725
+
726
+ def page_markdown(raw, page):
727
+ """Accept Markdown, optionally fenced; qualify page-local crop references."""
728
+ markdown = re.sub(
729
+ r"^```(?:markdown|html)?\s*\n|\n```\s*$", "", raw.strip(), flags=re.I
730
+ )
731
+ if not markdown:
732
+ raise ValueError("Empty Stage 1 Markdown.")
733
+ # Each Stage 1 call contains one image, so crop IDs refer to this page.
734
+ return IMAGE_REF.sub(
735
+ lambda m: f"page_{page}/images/bbox_" + "_".join(m.groups()[1:]) + ".jpg",
736
+ markdown,
737
+ )
738
+
739
+
740
+ # Model inference and lazy browser lifecycle.
741
+
742
+
743
+ class VllmBackend:
744
+ """Keep engine, sampling and request settings aligned with README infer().
745
+
746
+ Benchmark-specific scheduling overrides do not belong in this backend.
747
+ """
748
+
749
+ def __init__(self, model_path):
750
+ from vllm import LLM, SamplingParams
751
+
752
+ self.model = LLM(
753
+ model=str(Path(model_path).resolve()),
754
+ tensor_parallel_size=1,
755
+ gpu_memory_utilization=0.8,
756
+ gdn_prefill_backend="triton",
757
+ limit_mm_per_prompt={"image": 2},
758
+ )
759
+ self.tokenizer = self.model.get_tokenizer()
760
+ self.sampling = SamplingParams(max_tokens=16384, temperature=0.0)
761
+
762
+ def __call__(self, paths, task_prompt, folder, stage):
763
+ from PIL import Image
764
+
765
+ images = []
766
+ for path in paths:
767
+ with Image.open(path) as image:
768
+ images.append(image.convert("RGB"))
769
+ content = [{"type": "image"} for _ in images]
770
+ content.append({"type": "text", "text": task_prompt})
771
+ prompt = self.tokenizer.apply_chat_template(
772
+ [{"role": "user", "content": content}],
773
+ tokenize=False,
774
+ add_generation_prompt=True,
775
+ enable_thinking=False,
776
+ )
777
+ request = {
778
+ "prompt": prompt,
779
+ "multi_modal_data": {"image": images},
780
+ "mm_processor_kwargs": {
781
+ "images_kwargs": {
782
+ "min_pixels": 448 * 448,
783
+ "max_pixels": 2880 * 2880,
784
+ }
785
+ },
786
+ }
787
+ save(
788
+ folder / "input.json",
789
+ {
790
+ "stage": stage,
791
+ "images": list(paths),
792
+ "prompt": prompt,
793
+ "mm_processor_kwargs": request["mm_processor_kwargs"],
794
+ },
795
+ )
796
+ output = self.model.generate([request], self.sampling)[0].outputs[0]
797
+ return GenerationResult(output.text, output.finish_reason)
798
+
799
+
800
+ class _BrowserRenderer:
801
+ def __init__(self, mathjax):
802
+ self.mathjax = mathjax
803
+ self.playwright = self.browser = self.renderer = None
804
+
805
+ def render(self, *args):
806
+ if self.renderer is None:
807
+ from playwright.sync_api import sync_playwright
808
+
809
+ self.playwright = sync_playwright().start()
810
+ self.browser = self.playwright.chromium.launch(headless=True)
811
+ self.renderer = Renderer(self.browser, self.mathjax)
812
+ return self.renderer.render(*args)
813
+
814
+ def close(self):
815
+ if self.browser:
816
+ self.browser.close()
817
+ if self.playwright:
818
+ self.playwright.stop()
819
+
820
+
821
+ # Content-preserving Markdown/HTML rendering.
822
+
823
+
824
+ CROP = re.compile(r"page_(\d+)/images/bbox_(\d+)_(\d+)_(\d+)_(\d+)\.jpg")
825
+ _CROP_ASSET = re.compile(
826
+ r"(?:page_(\d+)/)?images/(?:bbox_)?(\d+)_(\d+)_(\d+)_(\d+)\.(?:jpg|jpeg|png)", re.I
827
+ )
828
+ _TAG = re.compile(
829
+ r"<!--.*?-->|</?[A-Za-z][\w:-]*(?:[^>\'\"]|\"[^\"]*\"|\'[^\']*\')*>", re.S
830
+ )
831
+ _SRC = re.compile(r"""(?<![\w:-])src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I)
832
+ _ALLOWED = set(
833
+ "p div span br hr h1 h2 h3 h4 h5 h6 table thead tbody tfoot tr td th caption colgroup col ul ol li dl dt dd blockquote pre code b strong em i u s del sup sub a img figure figcaption section article main center font".split()
834
+ )
835
+ _DROP = set("script style iframe object embed link meta base template".split())
836
+ _CSS = """
837
+ html,body{margin:0;padding:0;background:#fff;color:#111}
838
+ #capture{padding:28px;box-sizing:border-box;font-family:Arial,'Noto Sans CJK SC',sans-serif;font-size:18px;line-height:1.4}
839
+ table{border-collapse:collapse;width:100%;margin:12px 0}td,th{border:1px solid #666;padding:5px;vertical-align:top;overflow-wrap:anywhere}
840
+ img{max-width:100%;height:auto}h1{font-size:28px}h2{font-size:24px}h3{font-size:21px}p{margin:10px 0}
841
+ pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}code{font-family:monospace;white-space:pre-wrap;overflow-wrap:anywhere}
842
+ .math-display{display:block;text-align:center;margin:10px 0}.math-inline{display:inline-block;vertical-align:middle}.math-fallback{white-space:pre-wrap;overflow-wrap:anywhere}
843
+ mjx-container{max-width:none}svg{overflow:visible}th{font-weight:600}
844
+ """
845
+
846
+
847
+ @dataclass(frozen=True)
848
+ class RenderLimits:
849
+ """Configurable engineering limits, not validated OCR accuracy thresholds."""
850
+
851
+ max_side: int = 32768
852
+ max_render_pixels: int = 64_000_000
853
+ max_source_chars: int = 4_000_000
854
+ min_effective_font_px: float = 12.0
855
+ min_effective_cell_height_px: float = 8.0
856
+ timeout_ms: int = 30000
857
+
858
+ def __post_init__(self):
859
+ for name, value in asdict(self).items():
860
+ if not math.isfinite(value) or value <= 0:
861
+ raise ValueError(name + " must be finite and positive")
862
+
863
+
864
+ class RenderError(ValueError):
865
+ """Concise caller-facing error; details remain in the render.json sidecar."""
866
+
867
+ def __init__(self, code, message):
868
+ self.code = code
869
+ super().__init__(message)
870
+
871
+
872
+ def _escaped(text, pos):
873
+ start = pos
874
+ while start and text[start - 1] == "\\":
875
+ start -= 1
876
+ return (pos - start) % 2 == 1
877
+
878
+
879
+ def _dollar_is_math(body, mode):
880
+ if mode == "all":
881
+ return True
882
+ if mode == "off" or not body or body != body.strip():
883
+ return False
884
+ if re.search(r"\\[A-Za-z]+", body):
885
+ return True
886
+ # Pure numbers and prose are ambiguous; preserve them, rather than invent math.
887
+ if re.fullmatch(r"[A-Za-z]", body):
888
+ return True
889
+ if re.search(r"[^\x00-\x7f]", body):
890
+ return bool(
891
+ re.fullmatch(r"[\w\s\d.,_^=+*/<>{}()−-]+", body)
892
+ and re.search(r"[_^=+*/<>−-]", body)
893
+ )
894
+ if re.fullmatch(r"\d+(?:\.\d+)?\\[,;!: ](?:[A-Za-z]+)", body):
895
+ return True
896
+ words = re.findall(r"[A-Za-z]+", body)
897
+ if any(
898
+ len(w) > 1
899
+ and w not in {"sin", "cos", "tan", "log", "ln", "exp", "max", "min", "lim"}
900
+ for w in words
901
+ ):
902
+ return False
903
+ return bool(re.search(r"[_^=+*/<>{}]", body) or re.search(r"\w\s*-\s*\w", body))
904
+
905
+
906
+ def _markdown_image(text, pos, parser, env):
907
+ from markdown_it.common.utils import normalizeReference
908
+ from markdown_it.helpers import parseLinkDestination, parseLinkLabel, parseLinkTitle
909
+ from markdown_it.rules_inline.state_inline import StateInline
910
+
911
+ state = StateInline(text, parser, env, [])
912
+ end_label = parseLinkLabel(state, pos + 1, False)
913
+ if end_label < 0:
914
+ return None
915
+ alt = text[pos + 2 : end_label]
916
+ cursor = end_label + 1
917
+ title = ""
918
+ if cursor < len(text) and text[cursor] == "(":
919
+ cursor += 1
920
+ while cursor < len(text) and text[cursor].isspace():
921
+ cursor += 1
922
+ dest = parseLinkDestination(text, cursor, len(text))
923
+ if not dest.ok:
924
+ return None
925
+ ref, cursor = dest.str, dest.pos
926
+ spaces = cursor
927
+ while cursor < len(text) and text[cursor].isspace():
928
+ cursor += 1
929
+ if cursor > spaces:
930
+ parsed_title = parseLinkTitle(text, cursor, len(text))
931
+ if parsed_title.ok:
932
+ title, cursor = parsed_title.str, parsed_title.pos
933
+ while cursor < len(text) and text[cursor].isspace():
934
+ cursor += 1
935
+ if cursor >= len(text) or text[cursor] != ")":
936
+ return None
937
+ end = cursor + 1
938
+ else:
939
+ label, end = alt, cursor
940
+ if cursor < len(text) and text[cursor] == "[":
941
+ close = text.find("]", cursor + 1)
942
+ if close < 0:
943
+ return None
944
+ label = text[cursor + 1 : close] or alt
945
+ end = close + 1
946
+ definition = env.get("references", {}).get(normalizeReference(label))
947
+ if not definition:
948
+ return None
949
+ ref, title = definition["href"], definition.get("title", "")
950
+ return end, ref, alt, title
951
+
952
+
953
+ def _image_markup(item, ref):
954
+ if item["kind"] == "html":
955
+ return html.escape(ref, quote=True)
956
+ title = (
957
+ (' "' + item["title"].replace("\\", "\\\\").replace('"', '\\"') + '"')
958
+ if item["title"]
959
+ else ""
960
+ )
961
+ return "![" + item["alt"] + "](<" + ref + ">" + title + ")"
962
+
963
+
964
+ def _edit_source(text, edits):
965
+ out, previous = [], 0
966
+ for start, end, value in sorted(edits):
967
+ if start < previous:
968
+ raise RenderError(
969
+ "overlapping_source_spans",
970
+ "Cannot safely map overlapping source elements.",
971
+ )
972
+ out.extend([text[previous:start], value])
973
+ previous = end
974
+ out.append(text[previous:])
975
+ return "".join(out)
976
+
977
+
978
+ def prepare_source(source, parser, dollar_math="auto", html_document=False):
979
+ """Protect math before CommonMark and identify each actual image occurrence.
980
+
981
+ Code fences, indented code, inline code, HTML attributes and comments remain
982
+ literal. Markdown image references are expanded per occurrence, not per URL.
983
+ """
984
+ from markdown_it.helpers import parseLinkDestination, parseLinkTitle
985
+
986
+ prefix = "RDR" + uuid.uuid4().hex.upper()
987
+ env = {}
988
+ tokens = [] if html_document else parser.parse(source, env)
989
+ offsets = [0]
990
+ for line in source.splitlines(keepends=True):
991
+ offsets.append(offsets[-1] + len(line))
992
+ literal = {
993
+ offsets[t.map[0]]: offsets[min(t.map[1], len(offsets) - 1)]
994
+ for t in tokens
995
+ if t.type in {"fence", "code_block"} and t.map
996
+ }
997
+ for definition in env.get("references", {}).values():
998
+ if definition.get("map"):
999
+ a, b = definition["map"]
1000
+ literal[offsets[a]] = offsets[min(b, len(offsets) - 1)]
1001
+ formulas, images, edits, notes = [], [], [], []
1002
+ i = 0
1003
+ while i < len(source):
1004
+ if i in literal:
1005
+ i = literal[i]
1006
+ continue
1007
+ if source[i] == "`" and not _escaped(source, i):
1008
+ run = re.match(r"`+", source[i:])[0]
1009
+ close = re.search(
1010
+ r"(?<!`)" + re.escape(run) + r"(?!`)", source[i + len(run) :]
1011
+ )
1012
+ if close:
1013
+ i += len(run) + close.end()
1014
+ continue
1015
+ if source.startswith("](", i):
1016
+ # Markdown link destinations are not mathematical prose.
1017
+ dest = parseLinkDestination(source, i + 2, len(source))
1018
+ if dest.ok:
1019
+ cursor = dest.pos
1020
+ while cursor < len(source) and source[cursor].isspace():
1021
+ cursor += 1
1022
+ title = parseLinkTitle(source, cursor, len(source))
1023
+ if title.ok:
1024
+ cursor = title.pos
1025
+ while cursor < len(source) and source[cursor].isspace():
1026
+ cursor += 1
1027
+ if cursor < len(source) and source[cursor] == ")":
1028
+ i = cursor + 1
1029
+ continue
1030
+ if source[i] == "<":
1031
+ tag = _TAG.match(source, i)
1032
+ if tag:
1033
+ raw = tag[0]
1034
+ kind = re.match(r"<([A-Za-z][\w:-]*)", raw)
1035
+ kind = kind[1].lower() if kind else ""
1036
+ if kind in _DROP | {"code"}:
1037
+ close = re.search(
1038
+ r"</" + re.escape(kind) + r"\s*>", source[tag.end() :], re.I
1039
+ )
1040
+ if close:
1041
+ i = tag.end() + close.end()
1042
+ continue
1043
+ if kind == "img":
1044
+ src = _SRC.search(raw)
1045
+ if src:
1046
+ group = next(j for j in (1, 2, 3) if src[j] is not None)
1047
+ start, end = i + src.start(group), i + src.end(group)
1048
+ item = dict(
1049
+ start=start,
1050
+ end=end,
1051
+ ref=html.unescape(src[group]),
1052
+ kind="html",
1053
+ token=prefix + "IMG" + str(len(images)),
1054
+ )
1055
+ images.append(item)
1056
+ edits.append((start, end, item["token"]))
1057
+ i = tag.end()
1058
+ continue
1059
+ if source.startswith("![", i) and not _escaped(source, i):
1060
+ found = _markdown_image(source, i, parser, env)
1061
+ if found:
1062
+ end, ref, alt, title = found
1063
+ item = dict(
1064
+ start=i,
1065
+ end=end,
1066
+ ref=ref,
1067
+ alt=alt,
1068
+ title=title,
1069
+ kind="markdown",
1070
+ token=prefix + "IMG" + str(len(images)),
1071
+ )
1072
+ images.append(item)
1073
+ edits.append((i, end, _image_markup(item, item["token"])))
1074
+ i = end
1075
+ continue
1076
+ opening = next(
1077
+ (s for s in ("\\(", "\\[", "$$", "$") if source.startswith(s, i)), None
1078
+ )
1079
+ if opening and not _escaped(source, i):
1080
+ closing = {"\\(": "\\)", "\\[": "\\]", "$$": "$$", "$": "$"}[opening]
1081
+ pos = i + len(opening)
1082
+ while True:
1083
+ end = source.find(closing, pos)
1084
+ if end < 0:
1085
+ break
1086
+ if not _escaped(source, end):
1087
+ break
1088
+ pos = end + len(closing)
1089
+ # Delimiters may not consume table cells or block boundaries.
1090
+ # An explicit unclosed formula remains visible in its own cell.
1091
+ boundary = re.search(
1092
+ r"</?(?:table|thead|tbody|tfoot|tr|td|th|div|p|h[1-6]|li|ul|ol|pre|blockquote)\b[^>]*>|<br\b[^>]*>",
1093
+ source[pos:],
1094
+ re.I,
1095
+ )
1096
+ if boundary and end >= 0 and pos + boundary.start() < end:
1097
+ notes.append({"code": "formula_block_boundary", "offset": i})
1098
+ end = -1
1099
+ if end >= 0:
1100
+ body = source[i + len(opening) : end]
1101
+ allowed = True
1102
+ if opening == "$":
1103
+ allowed = (
1104
+ "\n" not in body
1105
+ and not source.startswith("$$", end)
1106
+ and _dollar_is_math(body, dollar_math)
1107
+ )
1108
+ if not allowed:
1109
+ notes.append(
1110
+ {"code": "literal_or_ambiguous_dollar", "offset": i}
1111
+ )
1112
+ elif "\n\n" in body and opening == "\\(":
1113
+ allowed = False
1114
+ if allowed:
1115
+ token = prefix + "MATH" + str(len(formulas)) + "X"
1116
+ formulas.append(
1117
+ dict(
1118
+ token=token,
1119
+ tex=html.unescape(body),
1120
+ raw=source[i : end + len(closing)],
1121
+ display=opening in {"\\[", "$$"},
1122
+ )
1123
+ )
1124
+ edits.append((i, end + len(closing), token))
1125
+ i = end + len(closing)
1126
+ continue
1127
+ elif opening != "$":
1128
+ # Preserve malformed explicit notation visibly, even through Markdown.
1129
+ stop = source.find("\n\n", i)
1130
+ stop = len(source) if stop < 0 else stop
1131
+ tag_boundary = _TAG.search(source, i + len(opening))
1132
+ if tag_boundary:
1133
+ stop = min(stop, tag_boundary.start())
1134
+ token = prefix + "MATH" + str(len(formulas)) + "X"
1135
+ formulas.append(
1136
+ dict(
1137
+ token=token,
1138
+ tex="",
1139
+ raw=source[i:stop],
1140
+ display=False,
1141
+ malformed=True,
1142
+ )
1143
+ )
1144
+ edits.append((i, stop, token))
1145
+ i = stop
1146
+ continue
1147
+ # Consume an unrecognized delimiter as a unit (do not reinterpret $$ as $).
1148
+ i += len(opening)
1149
+ continue
1150
+ i += 1
1151
+ return _edit_source(source, edits), formulas, images, notes
1152
+
1153
+
1154
+ def sanitize(soup):
1155
+ from bs4 import Comment
1156
+
1157
+ removed = 0
1158
+ for node in list(soup.find_all(string=lambda t: isinstance(t, Comment))):
1159
+ node.extract()
1160
+ for tag in list(soup.find_all(True)):
1161
+ if tag.name is None:
1162
+ continue
1163
+ if tag.name in {"svg", "canvas", "math", "video", "audio"}:
1164
+ raise RenderError(
1165
+ "unsupported_embedded_media",
1166
+ "Embedded media needs an explicit image asset; source retained.",
1167
+ )
1168
+ if tag.name in _DROP:
1169
+ tag.decompose()
1170
+ removed += 1
1171
+ continue
1172
+ if tag.name not in _ALLOWED:
1173
+ tag.unwrap()
1174
+ removed += 1
1175
+ continue
1176
+ attrs = {}
1177
+ if tag.name in {"td", "th", "col", "colgroup"}:
1178
+ for key in ("rowspan", "colspan", "span"):
1179
+ if key in tag.attrs:
1180
+ value = str(tag[key])
1181
+ if not value.isdecimal() or not (
1182
+ 0 if key == "rowspan" else 1
1183
+ ) <= int(value) <= (65534 if key == "rowspan" else 1000):
1184
+ raise RenderError(
1185
+ "invalid_table_span",
1186
+ "Invalid table cell span; source retained.",
1187
+ )
1188
+ attrs[key] = value
1189
+ if tag.name == "img":
1190
+ attrs.update({k: tag[k] for k in ("src", "alt", "title") if k in tag.attrs})
1191
+ if tag.name == "ol" and str(tag.get("start", "")).lstrip("-").isdigit():
1192
+ attrs["start"] = tag["start"]
1193
+ # Retain safe alignment, but no CSS that can hide, move, clip or fetch.
1194
+ declarations = dict(
1195
+ (k.strip().lower(), v.strip().lower())
1196
+ for k, v in re.findall(r"([\w-]+)\s*:\s*([^;]+)", str(tag.get("style", "")))
1197
+ )
1198
+ align = declarations.get("text-align", str(tag.get("align", "")).lower())
1199
+ valign = declarations.get("vertical-align", str(tag.get("valign", "")).lower())
1200
+ style = []
1201
+ if align in {"left", "right", "center", "justify"}:
1202
+ style.append("text-align:" + align)
1203
+ if valign in {"top", "middle", "bottom", "baseline"}:
1204
+ style.append("vertical-align:" + valign)
1205
+ if style:
1206
+ attrs["style"] = ";".join(style)
1207
+ if dict(tag.attrs) != attrs:
1208
+ removed += 1
1209
+ tag.attrs = attrs
1210
+ return removed
1211
+
1212
+
1213
+ def _png_data(image):
1214
+ from PIL import Image
1215
+
1216
+ image = image.convert("RGBA")
1217
+ background = Image.new("RGBA", image.size, "white")
1218
+ background.alpha_composite(image)
1219
+ buf = io.BytesIO()
1220
+ background.convert("RGB").save(buf, format="PNG")
1221
+ return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
1222
+
1223
+
1224
+ def resolve_alias(ref, aliases, *, tolerance=2, min_iou=0.98, events=None):
1225
+ """Exact first; a small coordinate jitter must match one unique occurrence.
1226
+
1227
+ This is a configurable rounding tolerance, not semantic figure localization.
1228
+ An ambiguous or newly predicted region is never assigned to the nearest image.
1229
+ """
1230
+ if ref in aliases:
1231
+ return aliases[ref]
1232
+ target = CROP.fullmatch(ref)
1233
+ if not target:
1234
+ raise RenderError("unmapped_crop", "Cannot map an image region to its source.")
1235
+ page, *box = map(int, target.groups())
1236
+ candidates = []
1237
+ for key in aliases:
1238
+ m = CROP.fullmatch(key)
1239
+ if not m or int(m[1]) != page:
1240
+ continue
1241
+ other = list(map(int, m.groups()[1:]))
1242
+ if max(abs(a - b) for a, b in zip(box, other)) > tolerance:
1243
+ continue
1244
+ intersection = max(0, min(box[2], other[2]) - max(box[0], other[0])) * max(
1245
+ 0, min(box[3], other[3]) - max(box[1], other[1])
1246
+ )
1247
+ area = max(0, box[2] - box[0]) * max(0, box[3] - box[1])
1248
+ area2 = max(0, other[2] - other[0]) * max(0, other[3] - other[1])
1249
+ union = area + area2 - intersection
1250
+ if union and intersection / union >= min_iou:
1251
+ candidates.append(key)
1252
+ if len(candidates) != 1:
1253
+ raise RenderError(
1254
+ "ambiguous_crop" if candidates else "unmapped_crop",
1255
+ "Cannot uniquely map an image region to its source.",
1256
+ )
1257
+ if events is not None:
1258
+ events.append(
1259
+ {
1260
+ "code": "coordinate_rounding_match",
1261
+ "predicted": ref,
1262
+ "matched": candidates[0],
1263
+ }
1264
+ )
1265
+ return aliases[candidates[0]]
1266
+
1267
+
1268
+ class Renderer:
1269
+ def __init__(
1270
+ self,
1271
+ browser,
1272
+ mathjax,
1273
+ *,
1274
+ limits=None,
1275
+ dollar_math="auto",
1276
+ assets=None,
1277
+ asset_root=None,
1278
+ resize=None,
1279
+ resize_name=None,
1280
+ max_input_pixels=None,
1281
+ ):
1282
+ from markdown_it import MarkdownIt
1283
+
1284
+ if dollar_math not in {"auto", "all", "off"}:
1285
+ raise ValueError("dollar_math must be auto, all or off")
1286
+ self.mathjax = Path(mathjax).resolve()
1287
+ self.limits = limits or RenderLimits()
1288
+ self.dollar_math = dollar_math
1289
+ self.assets = dict(assets or {})
1290
+ self.asset_root = Path(asset_root).resolve() if asset_root else None
1291
+ self.resize = resize
1292
+ self.resize_name = resize_name or (
1293
+ "custom"
1294
+ if resize
1295
+ else "area_budget_estimate" if max_input_pixels else "unknown"
1296
+ )
1297
+ if max_input_pixels is not None and max_input_pixels <= 0:
1298
+ raise ValueError("max_input_pixels must be positive")
1299
+ self.max_input_pixels = max_input_pixels
1300
+ self.page = browser.new_page(
1301
+ viewport={"width": 1200, "height": 900}, device_scale_factor=1
1302
+ )
1303
+ self.page.set_default_timeout(self.limits.timeout_ms)
1304
+ # No external assets, CSS or network dependencies in model-supplied HTML.
1305
+ self.page.route("**/*", lambda route: route.abort())
1306
+ self.md = MarkdownIt("commonmark", {"html": True}).enable(
1307
+ ["table", "strikethrough"]
1308
+ )
1309
+
1310
+ def _asset(self, ref, owner, paths):
1311
+ from PIL import Image
1312
+
1313
+ crop = _CROP_ASSET.fullmatch(ref)
1314
+ if crop:
1315
+ page, left, top, right, bottom = crop.groups()
1316
+ page = int(page) if page else owner
1317
+ left, top, right, bottom = map(int, (left, top, right, bottom))
1318
+ if (
1319
+ not 1 <= page <= len(paths)
1320
+ or not 0 <= left < right <= 1000
1321
+ or not 0 <= top < bottom <= 1000
1322
+ ):
1323
+ raise RenderError(
1324
+ "invalid_crop",
1325
+ "An image crop has invalid coordinates; source retained.",
1326
+ )
1327
+ with Image.open(paths[page - 1]) as im:
1328
+ box = (
1329
+ round(left * im.width / 1000),
1330
+ round(top * im.height / 1000),
1331
+ round(right * im.width / 1000),
1332
+ round(bottom * im.height / 1000),
1333
+ )
1334
+ if box[0] >= box[2] or box[1] >= box[3]:
1335
+ raise RenderError(
1336
+ "empty_crop", "An image crop is smaller than one pixel."
1337
+ )
1338
+ if (box[2] - box[0]) * (
1339
+ box[3] - box[1]
1340
+ ) > self.limits.max_render_pixels:
1341
+ raise RenderError(
1342
+ "asset_limit",
1343
+ "An image asset exceeds the rendering pixel budget.",
1344
+ )
1345
+ return _png_data(im.crop(box))
1346
+ if ref.startswith("data:"):
1347
+ header, sep, payload = ref.partition(",")
1348
+ if not sep or not re.fullmatch(
1349
+ r"data:image/(?:png|jpeg|webp|gif);base64", header, re.I
1350
+ ):
1351
+ raise RenderError(
1352
+ "unsupported_asset",
1353
+ "An image format is unsupported; source retained.",
1354
+ )
1355
+ with Image.open(io.BytesIO(base64.b64decode(payload, validate=True))) as im:
1356
+ if im.width * im.height > self.limits.max_render_pixels:
1357
+ raise RenderError(
1358
+ "asset_limit",
1359
+ "An image asset exceeds the rendering pixel budget.",
1360
+ )
1361
+ return _png_data(im)
1362
+ path = self.assets.get(ref)
1363
+ if path is None and self.asset_root:
1364
+ from urllib.parse import unquote, urlsplit
1365
+
1366
+ if not urlsplit(ref).scheme and not ref.startswith("//"):
1367
+ candidate = (self.asset_root / unquote(ref)).resolve()
1368
+ if candidate.is_relative_to(self.asset_root):
1369
+ path = candidate
1370
+ if path is None:
1371
+ raise RenderError(
1372
+ "unresolved_asset",
1373
+ "An image needs a local asset mapping; source retained.",
1374
+ )
1375
+ with Image.open(path) as im:
1376
+ if im.width * im.height > self.limits.max_render_pixels:
1377
+ raise RenderError(
1378
+ "asset_limit", "An image asset exceeds the rendering pixel budget."
1379
+ )
1380
+ return _png_data(im)
1381
+
1382
+ def render(self, md, owner, paths, directory, name):
1383
+ directory = Path(directory)
1384
+ directory.mkdir(parents=True, exist_ok=True)
1385
+ if Path(name).name != name or not name:
1386
+ raise ValueError("name must be a filename stem")
1387
+ started = time.monotonic()
1388
+ diag = {
1389
+ "version": 2,
1390
+ "status": "ERROR",
1391
+ "math": 0,
1392
+ "errors": 0,
1393
+ "broken": 0,
1394
+ "images": [],
1395
+ "notes": [],
1396
+ "limits": asdict(self.limits),
1397
+ "resize_policy": self.resize_name,
1398
+ }
1399
+ (directory / (name + ".source.md")).write_text(md)
1400
+ try:
1401
+ result = self._render(md, owner, paths, directory, name, diag)
1402
+ diag["status"] = "DEGRADED" if diag.get("formula_fallbacks") else "COMPLETE"
1403
+ return result
1404
+ except Exception as exc:
1405
+ diag["error"] = {
1406
+ "code": getattr(exc, "code", "render_failed"),
1407
+ "type": type(exc).__name__,
1408
+ "message": str(exc)[:1000],
1409
+ }
1410
+ if isinstance(exc, RenderError):
1411
+ raise
1412
+ raise RenderError(
1413
+ "render_failed",
1414
+ "Page rendering failed; source and diagnostics were retained.",
1415
+ ) from exc
1416
+ finally:
1417
+ diag["seconds"] = time.monotonic() - started
1418
+ save(directory / (name + ".render.json"), diag)
1419
+
1420
+ def _render(self, md, owner, paths, directory, name, diag):
1421
+ from bs4 import BeautifulSoup, Comment, NavigableString
1422
+ from PIL import Image
1423
+
1424
+ if not 1 <= owner <= len(paths):
1425
+ raise RenderError("invalid_owner", "Page index is outside the document.")
1426
+ if len(md) > self.limits.max_source_chars:
1427
+ raise RenderError(
1428
+ "source_limit", "Page source exceeds the configured rendering limit."
1429
+ )
1430
+ probe = (
1431
+ BeautifulSoup(md, "html.parser") if md.lstrip().startswith("<") else None
1432
+ )
1433
+ html_document = bool(
1434
+ probe is not None
1435
+ and probe.find(True)
1436
+ and all(
1437
+ not isinstance(x, NavigableString)
1438
+ or isinstance(x, Comment)
1439
+ or not str(x).strip()
1440
+ for x in probe.contents
1441
+ )
1442
+ )
1443
+ prepared, formulas, occurrences, notes = prepare_source(
1444
+ md, self.md, self.dollar_math, html_document
1445
+ )
1446
+ diag["input_format"] = "html" if html_document else "markdown"
1447
+ diag["notes"].extend(notes)
1448
+ soup = BeautifulSoup(
1449
+ prepared if html_document else self.md.render(prepared), "html.parser"
1450
+ )
1451
+ diag["sanitized_elements"] = sanitize(soup)
1452
+ by_token = {x["token"]: x for x in occurrences}
1453
+ seen = set()
1454
+ for image in soup.find_all("img"):
1455
+ key = image.get("src", "")
1456
+ if key not in by_token or key in seen:
1457
+ raise RenderError(
1458
+ "image_mapping", "Cannot safely map image occurrences."
1459
+ )
1460
+ seen.add(key)
1461
+ item = by_token[key]
1462
+ image["src"] = self._asset(item["ref"], owner, paths)
1463
+ image["data-image-id"] = key
1464
+ if len(seen) != len(occurrences):
1465
+ raise RenderError(
1466
+ "image_mapping", "An image occurrence was lost during parsing."
1467
+ )
1468
+ by_math = {x["token"]: x for x in formulas}
1469
+ if by_math:
1470
+ pattern = re.compile(
1471
+ "|".join(map(re.escape, sorted(by_math, key=len, reverse=True)))
1472
+ )
1473
+ found = []
1474
+ for node in list(soup.find_all(string=pattern)):
1475
+ if isinstance(node, Comment):
1476
+ continue
1477
+ pieces = pattern.split(str(node))
1478
+ matches = list(pattern.finditer(str(node)))
1479
+ for k, part in enumerate(pieces):
1480
+ if part:
1481
+ node.insert_before(NavigableString(part))
1482
+ if k < len(matches):
1483
+ item = by_math[matches[k][0]]
1484
+ found.append(item["token"])
1485
+ tag = soup.new_tag("span")
1486
+ tag["data-math-id"] = item["token"]
1487
+ tag["class"] = (
1488
+ "math-display" if item["display"] else "math-inline"
1489
+ )
1490
+ tag.string = item["raw"]
1491
+ node.insert_before(tag)
1492
+ node.extract()
1493
+ if sorted(found) != sorted(by_math):
1494
+ raise RenderError(
1495
+ "formula_mapping", "A formula was lost during parsing."
1496
+ )
1497
+ tables = soup.find_all("table")
1498
+ expected_cells = len(soup.find_all(["td", "th"]))
1499
+ columns = max(
1500
+ (
1501
+ sum(
1502
+ int(c.get("colspan", 1))
1503
+ for c in row.find_all(["td", "th"], recursive=False)
1504
+ )
1505
+ for row in soup.find_all("tr")
1506
+ ),
1507
+ default=1,
1508
+ )
1509
+ with Image.open(paths[owner - 1]) as original:
1510
+ width = max(original.width, columns * 120 + 56)
1511
+ document = (
1512
+ '<!doctype html><html><head><meta charset="utf-8"><style>'
1513
+ + _CSS
1514
+ + '</style></head><body><div id="capture" style="width:'
1515
+ + str(width)
1516
+ + 'px">'
1517
+ + str(soup)
1518
+ + "</div></body></html>"
1519
+ )
1520
+ # Persist complete render input before any resource/readability rejection.
1521
+ (directory / (name + ".html")).write_text(document)
1522
+ if width > self.limits.max_side:
1523
+ raise RenderError(
1524
+ "size_limit",
1525
+ "Rendered page exceeds the configured size limit; complete source retained.",
1526
+ )
1527
+ page = self.page
1528
+ page.set_viewport_size({"width": width, "height": 900})
1529
+ page.set_content(document, wait_until="domcontentloaded")
1530
+ diag["expected_formulas"] = len(formulas)
1531
+ formula_results = []
1532
+ if formulas:
1533
+ if not self.mathjax.is_file():
1534
+ raise RenderError("mathjax_missing", "Local MathJax bundle is missing.")
1535
+ page.evaluate(
1536
+ "window.MathJax={startup:{typeset:false},svg:{fontCache:'none'},tex:{maxBuffer:100000,packages:['base','ams','newcommand']}}"
1537
+ )
1538
+ page.add_script_tag(path=str(self.mathjax))
1539
+ page.wait_for_function(
1540
+ "window.MathJax && MathJax.startup && MathJax.startup.promise"
1541
+ )
1542
+ formula_results = page.evaluate(
1543
+ """async ({items,timeout})=>{
1544
+ const work=async()=>{
1545
+ await MathJax.startup.promise;
1546
+ if(typeof MathJax.tex2svgPromise!=='function') throw Error('MathJax SVG API unavailable');
1547
+ const results=[];
1548
+ for(const item of items){
1549
+ const target=document.querySelector('[data-math-id="'+item.token+'"]');
1550
+ let error=null;
1551
+ try{
1552
+ if(item.malformed) throw Error('Unclosed formula delimiter');
1553
+ if(!item.tex.trim()) throw Error('Empty formula');
1554
+ const rendered=await MathJax.tex2svgPromise(item.tex,{display:item.display});
1555
+ if(rendered.querySelector('[data-mml-node="merror"],mjx-merror,[data-mjx-error]')) throw Error('Invalid TeX');
1556
+ // This screenshot renderer needs only SVG. Standalone tex2svg
1557
+ // may emit assistive MathML without the CSS that hides it.
1558
+ rendered.querySelectorAll('mjx-assistive-mml').forEach(node=>node.remove());
1559
+ target.replaceChildren(rendered);
1560
+ }catch(e){error=String(e.message||e);target.textContent=item.raw;target.classList.add('math-fallback');}
1561
+ results.push({id:item.token,rendered:!error,error});
1562
+ }
1563
+ return results;
1564
+ };
1565
+ let timer;try{return await Promise.race([work(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Formula rendering timeout')),timeout)})]);}finally{clearTimeout(timer);}
1566
+ }""",
1567
+ {"items": formulas, "timeout": self.limits.timeout_ms},
1568
+ )
1569
+ diag["formulas"] = formula_results
1570
+ diag["formula_fallbacks"] = sum(not x["rendered"] for x in formula_results)
1571
+ page.evaluate(
1572
+ """async timeout=>{
1573
+ let timer;try{await Promise.race([(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(i=>i.decode()));await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));})(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('Image/font readiness timeout')),timeout)})]);}finally{clearTimeout(timer);}
1574
+ }""",
1575
+ self.limits.timeout_ms,
1576
+ )
1577
+ # Allow intrinsically wide math/tables to grow once, within the resource budget.
1578
+ needed = page.evaluate("document.documentElement.scrollWidth")
1579
+ if needed > width:
1580
+ width = needed + 28
1581
+ if width > self.limits.max_side:
1582
+ raise RenderError(
1583
+ "size_limit",
1584
+ "Rendered content is too wide; complete source retained.",
1585
+ )
1586
+ page.set_viewport_size({"width": width, "height": 900})
1587
+ page.locator("#capture").evaluate('(e,w)=>e.style.width=w+"px"', width)
1588
+ layout = page.evaluate("""()=>{
1589
+ const root=document.getElementById('capture');const area=root.getBoundingClientRect();
1590
+ const boxes=[...root.querySelectorAll('table,td,th,img,[data-math-id]')];
1591
+ const bad=boxes.filter(e=>{const b=e.getBoundingClientRect(),s=getComputedStyle(e);return !b.width||!b.height||s.display==='none'||s.visibility==='hidden'||Number(s.opacity)===0||b.left<0||b.right>area.right+.5;}).map(e=>({tag:e.tagName,id:e.dataset.imageId||e.dataset.mathId||null}));
1592
+ const fonts=[];const walker=document.createTreeWalker(root,NodeFilter.SHOW_TEXT);let n;
1593
+ while(n=walker.nextNode()){if(n.textContent.trim()&&!n.parentElement.closest('mjx-container')){const s=getComputedStyle(n.parentElement);fonts.push(parseFloat(s.fontSize));}}
1594
+ const cellHeights=[...root.querySelectorAll('td,th')].map(e=>e.getBoundingClientRect().height);
1595
+ return {math:root.querySelectorAll('mjx-container').length,broken:[...root.querySelectorAll('img')].filter(i=>!i.complete||!i.naturalWidth).length,
1596
+ tables:root.querySelectorAll('table').length,cells:cellHeights.length,invisible_or_clipped:bad,
1597
+ min_font_px:fonts.length?fonts.reduce((a,b)=>Math.min(a,b),Infinity):18,min_cell_height_px:cellHeights.length?cellHeights.reduce((a,b)=>Math.min(a,b),Infinity):null,
1598
+ width:document.documentElement.scrollWidth,height:document.documentElement.scrollHeight,
1599
+ images:[...root.querySelectorAll('img')].map(i=>{const b=i.getBoundingClientRect();return {id:i.dataset.imageId,x:b.x,y:b.y,w:b.width,h:b.height}})};
1600
+ }""")
1601
+ diag.update(layout)
1602
+ diag["errors"] = diag["formula_fallbacks"]
1603
+ if layout["tables"] != len(tables) or layout["cells"] != expected_cells:
1604
+ raise RenderError(
1605
+ "table_structure",
1606
+ "Browser parsing changed the table structure; source retained.",
1607
+ )
1608
+ if layout["invisible_or_clipped"] or layout["broken"]:
1609
+ raise RenderError(
1610
+ "invisible_content",
1611
+ "Some page content is not visible; source retained.",
1612
+ )
1613
+ if layout["math"] + diag["formula_fallbacks"] != len(formulas):
1614
+ raise RenderError(
1615
+ "formula_coverage", "Formula rendering coverage is incomplete."
1616
+ )
1617
+ w, h = layout["width"], layout["height"]
1618
+ # Snapshot has no live external scripts or assets; it opens offline as rendered.
1619
+ (directory / (name + ".html")).write_text(
1620
+ page.evaluate(
1621
+ "() => { const root=document.documentElement.cloneNode(true); root.querySelectorAll('script').forEach(e=>e.remove()); return '<!doctype html>'+root.outerHTML; }"
1622
+ )
1623
+ )
1624
+ if max(w, h) > self.limits.max_side or w * h > self.limits.max_render_pixels:
1625
+ raise RenderError(
1626
+ "size_limit",
1627
+ "Rendered page exceeds the configured size limit; complete HTML retained.",
1628
+ )
1629
+ target = (w, h)
1630
+ if not self.resize and not self.max_input_pixels:
1631
+ diag["notes"].append({"code": "model_resize_unspecified"})
1632
+ if self.resize:
1633
+ try:
1634
+ target = tuple(self.resize(w, h))
1635
+ except Exception as exc:
1636
+ raise RenderError(
1637
+ "model_resize_limit",
1638
+ "Page exceeds the model image-size limits; complete HTML retained.",
1639
+ ) from exc
1640
+ elif self.max_input_pixels:
1641
+ scale = min(1, math.sqrt(self.max_input_pixels / (w * h)))
1642
+ target = (max(1, math.floor(w * scale)), max(1, math.floor(h * scale)))
1643
+ if len(target) != 2 or min(target) <= 0:
1644
+ raise RenderError(
1645
+ "resize_policy", "Invalid target dimensions from resize policy."
1646
+ )
1647
+ scale = min(target[0] / w, target[1] / h)
1648
+ font = layout["min_font_px"] * scale
1649
+ cell = (
1650
+ layout["min_cell_height_px"] * scale
1651
+ if layout["min_cell_height_px"]
1652
+ else None
1653
+ )
1654
+ diag["readability"] = {
1655
+ "input_size": [w, h],
1656
+ "model_size": list(target),
1657
+ "scale": scale,
1658
+ "effective_font_px": font,
1659
+ "effective_cell_height_px": cell,
1660
+ "model_resize_known": bool(self.resize),
1661
+ "estimated_from_pixel_budget": bool(
1662
+ not self.resize and self.max_input_pixels
1663
+ ),
1664
+ "thresholds_are_heuristic": True,
1665
+ }
1666
+ if font < self.limits.min_effective_font_px or (
1667
+ cell is not None and cell < self.limits.min_effective_cell_height_px
1668
+ ):
1669
+ raise RenderError(
1670
+ "readability_limit",
1671
+ "Page text would be too small after resizing; complete HTML retained.",
1672
+ )
1673
+ aliases, edits = {}, []
1674
+ for image in layout["images"]:
1675
+ item = by_token[image["id"]]
1676
+ image["ref"] = item["ref"]
1677
+ box = [
1678
+ round(image["x"] / w * 1000),
1679
+ round(image["y"] / h * 1000),
1680
+ round((image["x"] + image["w"]) / w * 1000),
1681
+ round((image["y"] + image["h"]) / h * 1000),
1682
+ ]
1683
+ if not 0 <= box[0] < box[2] <= 1000 or not 0 <= box[1] < box[3] <= 1000:
1684
+ raise RenderError(
1685
+ "crop_quantization",
1686
+ "An image is too small for normalized coordinate mapping.",
1687
+ )
1688
+ alias = f"page_{owner}/images/bbox_" + "_".join(map(str, box)) + ".jpg"
1689
+ if alias in aliases:
1690
+ raise RenderError(
1691
+ "crop_alias_collision",
1692
+ "Image positions cannot be uniquely represented.",
1693
+ )
1694
+ aliases[alias] = item["ref"]
1695
+ edits.append((item["start"], item["end"], _image_markup(item, alias)))
1696
+ rewritten = _edit_source(md, edits)
1697
+ path = directory / (name + ".png")
1698
+ page.screenshot(path=str(path), full_page=True, timeout=self.limits.timeout_ms)
1699
+ with Image.open(path) as rendered:
1700
+ if rendered.size != (w, h):
1701
+ raise RenderError(
1702
+ "screenshot_dimensions",
1703
+ "Screenshot dimensions differ from image coordinate mapping.",
1704
+ )
1705
+ (directory / (name + ".rewritten.md")).write_text(rewritten)
1706
+ save(directory / (name + ".aliases.json"), aliases)
1707
+ return RenderResult(str(path), rewritten, aliases, diag)
1708
+
1709
+
1710
+ # Command-line interface.
1711
+
1712
+
1713
+ def main():
1714
+ parser = argparse.ArgumentParser(
1715
+ description="OvisOCR3 document-level cross-page reconstruction"
1716
+ )
1717
+ parser.add_argument("images", nargs="+", help="Page images in reading order")
1718
+ parser.add_argument("--model", default=".")
1719
+ parser.add_argument("--output", required=True)
1720
+ parser.add_argument(
1721
+ "--stage1-prompt",
1722
+ default="document_structured.txt",
1723
+ choices=[
1724
+ "document_markdown",
1725
+ "document_markdown.txt",
1726
+ "document_structured",
1727
+ "document_structured.txt",
1728
+ ],
1729
+ )
1730
+ parser.add_argument(
1731
+ "--mathjax",
1732
+ default=None,
1733
+ help="Local MathJax bundle; defaults to assets/mathjax.js beside this script",
1734
+ )
1735
+ args = parser.parse_args()
1736
+ result = reconstruct_document(
1737
+ args.images,
1738
+ args.output,
1739
+ model_path=args.model,
1740
+ stage1_prompt=args.stage1_prompt,
1741
+ mathjax=args.mathjax,
1742
+ )
1743
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1744
+
1745
+
1746
+ if __name__ == "__main__":
1747
+ main()
merges.txt ADDED
The diff for this file is too large to render. See raw diff
 
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aa2311c6b22b07cf3afe9aac6e8bcb02a2b19dd8671b2a91d0b144071577e600
3
+ size 1706030528
preprocessor_config.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "size": {
3
+ "longest_edge": 16777216,
4
+ "shortest_edge": 65536
5
+ },
6
+ "patch_size": 16,
7
+ "temporal_patch_size": 2,
8
+ "merge_size": 2,
9
+ "image_mean": [
10
+ 0.5,
11
+ 0.5,
12
+ 0.5
13
+ ],
14
+ "image_std": [
15
+ 0.5,
16
+ 0.5,
17
+ 0.5
18
+ ],
19
+ "processor_class": "Qwen3VLProcessor",
20
+ "image_processor_type": "Qwen2VLImageProcessorFast"
21
+ }
prompts/chart_parsing.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ Parse all key information in the chart and output one valid JSON object using exactly this top-level schema:
2
+ {"title":"","source":"","x_title":"","y_title":"","values":{}}
3
+
4
+ Rules:
5
+ 1. Preserve visible title, source, axis titles, series or category labels, x-axis labels, and values without explanation or inference.
6
+ 2. Use the literal string "None" when the chart has no visible value for title, source, or an axis title. For a dual-axis chart, `y_title` may be an array of two strings in visual order; otherwise it must be one string.
7
+ 3. `values` must be a non-empty object. Preserve the chart hierarchy and encode every scalar data value as a JSON string. An empty series name may be represented by the key "" when the chart has no visible series label.
8
+ 4. Output JSON only. Do not output Python syntax, Markdown fences, comments, prose, or extra keys.
prompts/crosspage_table.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ Find and reconstruct every table whose content continues across two or more consecutive input pages.
2
+
3
+ Return only one JSON array. Each cross-page table is one object with exactly these fields: source_pages and html. source_pages contains the input-local 1-based pages of that table. html contains one complete HTML <table>...</table>.
4
+
5
+ If no cross-page table exists, return [{"source_pages":[],"html":"No cross-page table exists between these pages."}]. Preserve figures inside cross-page tables as <img src="page_N/images/bbox_left_top_right_bottom.jpg" />; page_N is 1-based. Return no explanation or code fence.
prompts/document_layout.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ Analyze the document structure and return a single JSON array in natural reading order.
2
+
3
+ Each element follows this schema:
4
+ `{"bbox": [x_min, y_min, x_max, y_max], "category": "string"}`
5
+
6
+ Rules:
7
+ 1. The bbox coordinates are integers scaled to [0, 1000).
8
+ 2. The category is exactly one of:
9
+ ["abstract", "algorithm", "aside_text", "chart", "content", "display_formula", "doc_title", "figure_title", "footer", "footer_image", "footnote", "formula_number", "header", "header_image", "image", "inline_formula", "number", "paragraph_title", "reference_content", "seal", "table", "text", "vertical_text", "vision_footnote"].
prompts/document_markdown.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Extract all readable content from the image in natural human reading order and output the result as a single Markdown document. For charts or images, represent them using an HTML image tag: <img src="images/bbox_{left}_{top}_{right}_{bottom}.jpg" />, where left, top, right, bottom are bounding box coordinates scaled to [0, 1000). Format formulas as LaTeX. Format tables as HTML: <table>...</table>. Transcribe all other text as standard Markdown. Preserve the original text without translation or paraphrasing.
prompts/document_structured.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Analyze the document structure and extract text content. Return a single JSON array in natural reading order.
2
+
3
+ Each element follows this schema:
4
+ `{"bbox": [x_min, y_min, x_max, y_max], "category": "string", "content": "string"}`
5
+
6
+ Rules:
7
+ 1. The bbox coordinates are integers scaled to [0, 1000).
8
+ 2. The category is exactly one of:
9
+ ["abstract", "algorithm", "aside_text", "chart", "content", "display_formula", "doc_title", "figure_title", "footer", "footer_image", "footnote", "formula_number", "header", "header_image", "image", "inline_formula", "number", "paragraph_title", "reference_content", "seal", "table", "text", "vertical_text", "vision_footnote"].
10
+ 3. The content formatting depends on category:
11
+ - For "footer_image", "header_image", "image", or "chart": write as an HTML image tag <img src="images/bbox_{x_min}_{y_min}_{x_max}_{y_max}.jpg" />.
12
+ - For "display_formula" or "inline_formula": write in LaTeX.
13
+ - For "table": write as a standard HTML `<table>...</table>`.
14
+ - Otherwise: write in Markdown.
15
+ 4. Output must be strictly valid JSON. Properly escape quotes and special characters inside content.
prompts/figure_captioning.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Analyze the document structure and extract text content. Return a single JSON array in natural reading order.
2
+
3
+ Each element follows this schema:
4
+ `{"bbox": [x_min, y_min, x_max, y_max], "category": "string", "content": "string"}`
5
+
6
+ Rules:
7
+ 1. The bbox coordinates are integers scaled to [0, 1000).
8
+ 2. The category is exactly one of:
9
+ ["abstract", "algorithm", "aside_text", "chart", "content", "display_formula", "doc_title", "figure_title", "footer", "footer_image", "footnote", "formula_number", "header", "header_image", "image", "inline_formula", "number", "paragraph_title", "reference_content", "seal", "table", "text", "vertical_text", "vision_footnote"].
10
+ 3. The content formatting depends on category:
11
+ - For "footer_image", "header_image", "image", or "chart":
12
+ - Always provide a concise English caption of no more than 100 words inside `<caption>...</caption>`, preserving visible labels in their original language when needed.
13
+ - For a flowchart, a valid Mermaid representation may follow the caption inside `<mermaid>...</mermaid>`.
14
+ - For "display_formula" or "inline_formula": write in LaTeX.
15
+ - For "table": write as a standard HTML `<table>...</table>`.
16
+ - Otherwise: write in Markdown.
17
+ 4. Output must be strictly valid JSON. Properly escape quotes and special characters inside content.
prompts/formula_recognition.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ Recognize and transcribe every mathematical expression in the image as LaTeX. Directly output the formula content.
2
+
3
+ Rules:
4
+ 1. Preserve every visible symbol, operator, delimiter, superscript, subscript, accent, matrix row, alignment, and line break without translation, paraphrasing, correction, completion, or omission.
5
+ 2. Wrap each standalone or display expression as `$$ ... $$`.
6
+ 3. If multiple standalone expressions are visible, output one `$$ ... $$` block per expression in natural reading order, separated by a single line break.
7
+ 4. Use one decoded backslash for a LaTeX command and two decoded backslashes for a LaTeX row break. JSON serialization may escape these backslashes on disk, but the decoded target must retain valid LaTeX semantics.
8
+ 5. Output formula content only. Do not output prose, explanations, translations, Markdown code fences, HTML, document-level table markup, or JSON.
prompts/image_classification.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ Classify {target description} in the image. Select exactly one label from the candidate list.
2
+
3
+ Candidate labels: {candidate_labels}
4
+
5
+ Directly output the selected label exactly as written. Do not output explanations, punctuation, JSON, Markdown, or any other text.
prompts/kie_fields.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Extract the values for the following keys from the image: {keys_json}. Return a JSON object using exactly these keys. Keep the values exactly as shown, use empty strings for missing values, and use arrays for repeated values. Output JSON only.
prompts/kie_open.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Extract all key-value pairs from the image and return them as a JSON object. Keep the values exactly as shown, use empty strings for empty values, and use arrays for repeated values. Output JSON only.
prompts/kie_schema.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Extract the information from the image using exactly this JSON schema:
2
+ {schema_json}
3
+ Keep the values exactly as shown and use empty strings for missing values. Output JSON only.
prompts/plain_ocr.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ Read all the text in the image. Directly output the content.
2
+
3
+ Rules:
4
+ 1. Preserve the original text exactly without translation, paraphrasing, correction, or completion.
5
+ 2. Follow the natural human reading order.
6
+ 3. Output plain text only. Use spaces and line breaks only to preserve text separation and reading order.
7
+ 4. Do not output Markdown, HTML, JSON, bounding boxes, category labels, explanations, or code fences.
prompts/position_aware_vqa.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {question} Output the answer with 'answer' and 'bbox'. 'bbox' refers to the bounding box position of the 'answer' content in the image. The output format is "answer:gt, bbox:(x1,y1,x2,y2)", where the bbox is the coordinates of the top-left corner and the bottom-right corners. The 'bbox' should be normalized coordinates ranging from 0 to 1000 by the image width and height.
2
+ Your answer should be in the JSON format:
3
+ {
4
+ "answer": "..", # The answer
5
+ "bbox": "(x1,y1,x2,y2)" # The bounding box position of the 'answer'
6
+ }
prompts/reference_grounding.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Locate every visible region corresponding to the requested reference or references in the image.
2
+
3
+ Requested references (JSON array, source order):
4
+ {references}
5
+
6
+ Return a single JSON array in the requested reference order.
7
+
8
+ Each element follows this schema:
9
+ `{"bbox": [x_min, y_min, x_max, y_max], "reference": "string"}`
10
+
11
+ Rules:
12
+ 1. Each element must represent exactly one requested reference and one corresponding visible region. If one requested reference corresponds to multiple separate regions, output one element for each bbox and repeat the same reference.
13
+ 2. The bbox coordinates are integers scaled to [0, 1000).
14
+ 3. Each bbox must tightly enclose its corresponding referenced entity or region and must not include unrelated content.
15
+ 4. Copy each reference exactly from the requested-references array without translation, paraphrasing, correction, or completion. Do not output any reference that was not requested.
16
+ 5. Do not add leading or trailing whitespace to reference.
17
+ 6. Return bounding boxes only. Do not return points, polygons, masks, explanations, or confidence scores.
18
+ 7. Every requested reference must appear in at least one output element.
19
+ 8. Output strictly valid JSON only. Do not output Markdown, explanations, or code fences. Properly escape quotes, backslashes, and other special characters inside reference.
prompts/region_ocr.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ Transcribe all readable text in the specified image region. Directly output the content.
2
+ Target region: {target_region}
3
+
4
+ Rules:
5
+ 1. Read only the visible text inside the target region.
6
+ 2. Preserve the original text exactly without translation, paraphrasing, correction, completion, or omission.
7
+ 3. Follow the natural human reading order within the target region.
8
+ 4. Output plain text only. Use spaces and line breaks only where needed to preserve text separation and reading order.
9
+ 5. Do not output the target region, bounding boxes, coordinates, category labels, Markdown formatting, HTML, JSON, explanations, or code fences.
prompts/text_translation.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ Translate all readable text in the specified image region into {target_language}. Directly output the translated content.
2
+ Target region: {target_region}
3
+
4
+ Rules:
5
+ 1. Translate only the visible text inside the target region.
6
+ 2. Preserve the original meaning, names, numbers, symbols, and line order. Do not add explanations, corrections, or omitted context.
7
+ 3. Keep content that should not be translated unchanged.
8
+ 4. Output plain translated text only. Use spaces and line breaks only where needed to preserve the source separation.
9
+ 5. Do not output the source text, language labels, bounding boxes, Markdown, JSON, or code fences.
prompts/visual_question_answering.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {question}
2
+
3
+ Please reason step by step, and put your final answer within \boxed{}.
prompts/word_spotting.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Locate and transcribe every readable word or text token in the image. Return a single JSON array in natural reading order.
2
+
3
+ Each element follows this schema:
4
+ `{"bbox": [x_min, y_min, x_max, y_max], "text": "string"}`
5
+
6
+ Rules:
7
+ 1. Use word-level annotations. Each element must represent exactly one visible word or text token. Do not merge multiple words into a text line, paragraph, label, or table cell.
8
+ 2. The bbox coordinates are integers scaled to [0, 1000).
9
+ 3. Each bbox must tightly enclose its corresponding visible word or text token and must not include adjacent words or unrelated text regions.
10
+ 4. Preserve the original text exactly without translation, paraphrasing, correction, or completion.
11
+ 5. Do not add leading or trailing whitespace to text.
12
+ 6. Include every readable word or text token and exclude non-text visual content.
13
+ 7. Output strictly valid JSON only. Do not output Markdown, explanations, or code fences. Properly escape quotes, backslashes, and other special characters inside text.
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42
3
+ size 12807982
tokenizer_config.json ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "added_tokens_decoder": {
4
+ "248044": {
5
+ "content": "<|endoftext|>",
6
+ "lstrip": false,
7
+ "normalized": false,
8
+ "rstrip": false,
9
+ "single_word": false,
10
+ "special": true
11
+ },
12
+ "248045": {
13
+ "content": "<|im_start|>",
14
+ "lstrip": false,
15
+ "normalized": false,
16
+ "rstrip": false,
17
+ "single_word": false,
18
+ "special": true
19
+ },
20
+ "248046": {
21
+ "content": "<|im_end|>",
22
+ "lstrip": false,
23
+ "normalized": false,
24
+ "rstrip": false,
25
+ "single_word": false,
26
+ "special": true
27
+ },
28
+ "248047": {
29
+ "content": "<|object_ref_start|>",
30
+ "lstrip": false,
31
+ "normalized": false,
32
+ "rstrip": false,
33
+ "single_word": false,
34
+ "special": true
35
+ },
36
+ "248048": {
37
+ "content": "<|object_ref_end|>",
38
+ "lstrip": false,
39
+ "normalized": false,
40
+ "rstrip": false,
41
+ "single_word": false,
42
+ "special": true
43
+ },
44
+ "248049": {
45
+ "content": "<|box_start|>",
46
+ "lstrip": false,
47
+ "normalized": false,
48
+ "rstrip": false,
49
+ "single_word": false,
50
+ "special": true
51
+ },
52
+ "248050": {
53
+ "content": "<|box_end|>",
54
+ "lstrip": false,
55
+ "normalized": false,
56
+ "rstrip": false,
57
+ "single_word": false,
58
+ "special": true
59
+ },
60
+ "248051": {
61
+ "content": "<|quad_start|>",
62
+ "lstrip": false,
63
+ "normalized": false,
64
+ "rstrip": false,
65
+ "single_word": false,
66
+ "special": true
67
+ },
68
+ "248052": {
69
+ "content": "<|quad_end|>",
70
+ "lstrip": false,
71
+ "normalized": false,
72
+ "rstrip": false,
73
+ "single_word": false,
74
+ "special": true
75
+ },
76
+ "248053": {
77
+ "content": "<|vision_start|>",
78
+ "lstrip": false,
79
+ "normalized": false,
80
+ "rstrip": false,
81
+ "single_word": false,
82
+ "special": true
83
+ },
84
+ "248054": {
85
+ "content": "<|vision_end|>",
86
+ "lstrip": false,
87
+ "normalized": false,
88
+ "rstrip": false,
89
+ "single_word": false,
90
+ "special": true
91
+ },
92
+ "248055": {
93
+ "content": "<|vision_pad|>",
94
+ "lstrip": false,
95
+ "normalized": false,
96
+ "rstrip": false,
97
+ "single_word": false,
98
+ "special": true
99
+ },
100
+ "248056": {
101
+ "content": "<|image_pad|>",
102
+ "lstrip": false,
103
+ "normalized": false,
104
+ "rstrip": false,
105
+ "single_word": false,
106
+ "special": true
107
+ },
108
+ "248057": {
109
+ "content": "<|video_pad|>",
110
+ "lstrip": false,
111
+ "normalized": false,
112
+ "rstrip": false,
113
+ "single_word": false,
114
+ "special": true
115
+ },
116
+ "248058": {
117
+ "content": "<tool_call>",
118
+ "lstrip": false,
119
+ "normalized": false,
120
+ "rstrip": false,
121
+ "single_word": false,
122
+ "special": false
123
+ },
124
+ "248059": {
125
+ "content": "</tool_call>",
126
+ "lstrip": false,
127
+ "normalized": false,
128
+ "rstrip": false,
129
+ "single_word": false,
130
+ "special": false
131
+ },
132
+ "248060": {
133
+ "content": "<|fim_prefix|>",
134
+ "lstrip": false,
135
+ "normalized": false,
136
+ "rstrip": false,
137
+ "single_word": false,
138
+ "special": false
139
+ },
140
+ "248061": {
141
+ "content": "<|fim_middle|>",
142
+ "lstrip": false,
143
+ "normalized": false,
144
+ "rstrip": false,
145
+ "single_word": false,
146
+ "special": false
147
+ },
148
+ "248062": {
149
+ "content": "<|fim_suffix|>",
150
+ "lstrip": false,
151
+ "normalized": false,
152
+ "rstrip": false,
153
+ "single_word": false,
154
+ "special": false
155
+ },
156
+ "248063": {
157
+ "content": "<|fim_pad|>",
158
+ "lstrip": false,
159
+ "normalized": false,
160
+ "rstrip": false,
161
+ "single_word": false,
162
+ "special": false
163
+ },
164
+ "248064": {
165
+ "content": "<|repo_name|>",
166
+ "lstrip": false,
167
+ "normalized": false,
168
+ "rstrip": false,
169
+ "single_word": false,
170
+ "special": false
171
+ },
172
+ "248065": {
173
+ "content": "<|file_sep|>",
174
+ "lstrip": false,
175
+ "normalized": false,
176
+ "rstrip": false,
177
+ "single_word": false,
178
+ "special": false
179
+ },
180
+ "248066": {
181
+ "content": "<tool_response>",
182
+ "lstrip": false,
183
+ "normalized": false,
184
+ "rstrip": false,
185
+ "single_word": false,
186
+ "special": false
187
+ },
188
+ "248067": {
189
+ "content": "</tool_response>",
190
+ "lstrip": false,
191
+ "normalized": false,
192
+ "rstrip": false,
193
+ "single_word": false,
194
+ "special": false
195
+ },
196
+ "248068": {
197
+ "content": "<think>",
198
+ "lstrip": false,
199
+ "normalized": false,
200
+ "rstrip": false,
201
+ "single_word": false,
202
+ "special": false
203
+ },
204
+ "248069": {
205
+ "content": "</think>",
206
+ "lstrip": false,
207
+ "normalized": false,
208
+ "rstrip": false,
209
+ "single_word": false,
210
+ "special": false
211
+ },
212
+ "248070": {
213
+ "content": "<|audio_start|>",
214
+ "lstrip": false,
215
+ "normalized": false,
216
+ "rstrip": false,
217
+ "single_word": false,
218
+ "special": true
219
+ },
220
+ "248071": {
221
+ "content": "<|audio_end|>",
222
+ "lstrip": false,
223
+ "normalized": false,
224
+ "rstrip": false,
225
+ "single_word": false,
226
+ "special": true
227
+ },
228
+ "248072": {
229
+ "content": "<tts_pad>",
230
+ "lstrip": false,
231
+ "normalized": false,
232
+ "rstrip": false,
233
+ "single_word": false,
234
+ "special": true
235
+ },
236
+ "248073": {
237
+ "content": "<tts_text_bos>",
238
+ "lstrip": false,
239
+ "normalized": false,
240
+ "rstrip": false,
241
+ "single_word": false,
242
+ "special": true
243
+ },
244
+ "248074": {
245
+ "content": "<tts_text_eod>",
246
+ "lstrip": false,
247
+ "normalized": false,
248
+ "rstrip": false,
249
+ "single_word": false,
250
+ "special": true
251
+ },
252
+ "248075": {
253
+ "content": "<tts_text_bos_single>",
254
+ "lstrip": false,
255
+ "normalized": false,
256
+ "rstrip": false,
257
+ "single_word": false,
258
+ "special": true
259
+ },
260
+ "248076": {
261
+ "content": "<|audio_pad|>",
262
+ "lstrip": false,
263
+ "normalized": false,
264
+ "rstrip": false,
265
+ "single_word": false,
266
+ "special": true
267
+ }
268
+ },
269
+ "additional_special_tokens": [
270
+ "<|im_start|>",
271
+ "<|im_end|>",
272
+ "<|object_ref_start|>",
273
+ "<|object_ref_end|>",
274
+ "<|box_start|>",
275
+ "<|box_end|>",
276
+ "<|quad_start|>",
277
+ "<|quad_end|>",
278
+ "<|vision_start|>",
279
+ "<|vision_end|>",
280
+ "<|vision_pad|>",
281
+ "<|image_pad|>",
282
+ "<|video_pad|>"
283
+ ],
284
+ "bos_token": null,
285
+ "chat_template": "{%- set image_count = namespace(value=0) %}\n{%- set video_count = namespace(value=0) %}\n{%- macro render_content(content, do_vision_count, is_system_content=false) %}\n {%- if content is string %}\n {{- content }}\n {%- elif content is iterable and content is not mapping %}\n {%- for item in content %}\n {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}\n {%- if is_system_content %}\n {{- raise_exception('System message cannot contain images.') }}\n {%- endif %}\n {%- if do_vision_count %}\n {%- set image_count.value = image_count.value + 1 %}\n {%- endif %}\n {%- if add_vision_id %}\n {{- 'Picture ' ~ image_count.value ~ ': ' }}\n {%- endif %}\n {{- '<|vision_start|><|image_pad|><|vision_end|>' }}\n {%- elif 'video' in item or item.type == 'video' %}\n {%- if is_system_content %}\n {{- raise_exception('System message cannot contain videos.') }}\n {%- endif %}\n {%- if do_vision_count %}\n {%- set video_count.value = video_count.value + 1 %}\n {%- endif %}\n {%- if add_vision_id %}\n {{- 'Video ' ~ video_count.value ~ ': ' }}\n {%- endif %}\n {{- '<|vision_start|><|video_pad|><|vision_end|>' }}\n {%- elif 'text' in item %}\n {{- item.text }}\n {%- else %}\n {{- raise_exception('Unexpected item type in content.') }}\n {%- endif %}\n {%- endfor %}\n {%- elif content is none or content is undefined %}\n {{- '' }}\n {%- else %}\n {{- raise_exception('Unexpected content type.') }}\n {%- endif %}\n{%- endmacro %}\n{%- if not messages %}\n {{- raise_exception('No messages provided.') }}\n{%- endif %}\n{%- if tools and tools is iterable and tools is not mapping %}\n {{- '<|im_start|>system\\n' }}\n {{- \"# Tools\\n\\nYou have access to the following functions:\\n\\n<tools>\" }}\n {%- for tool in tools %}\n {{- \"\\n\" }}\n {{- tool | tojson }}\n {%- endfor %}\n {{- \"\\n</tools>\" }}\n {{- '\\n\\nIf you choose to call a function ONLY reply in the following format with NO suffix:\\n\\n<tool_call>\\n<function=example_function_name>\\n<parameter=example_parameter_1>\\nvalue_1\\n</parameter>\\n<parameter=example_parameter_2>\\nThis is the value for the second parameter\\nthat can span\\nmultiple lines\\n</parameter>\\n</function>\\n</tool_call>\\n\\n<IMPORTANT>\\nReminder:\\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\\n- Required parameters MUST be specified\\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\\n</IMPORTANT>' }}\n {%- if messages[0].role == 'system' %}\n {%- set content = render_content(messages[0].content, false, true)|trim %}\n {%- if content %}\n {{- '\\n\\n' + content }}\n {%- endif %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n{%- else %}\n {%- if messages[0].role == 'system' %}\n {%- set content = render_content(messages[0].content, false, true)|trim %}\n {{- '<|im_start|>system\\n' + content + '<|im_end|>\\n' }}\n {%- endif %}\n{%- endif %}\n{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}\n{%- for message in messages[::-1] %}\n {%- set index = (messages|length - 1) - loop.index0 %}\n {%- if ns.multi_step_tool and message.role == \"user\" %}\n {%- set content = render_content(message.content, false)|trim %}\n {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}\n {%- set ns.multi_step_tool = false %}\n {%- set ns.last_query_index = index %}\n {%- endif %}\n {%- endif %}\n{%- endfor %}\n{%- if ns.multi_step_tool %}\n {{- raise_exception('No user query found in messages.') }}\n{%- endif %}\n{%- for message in messages %}\n {%- set content = render_content(message.content, true)|trim %}\n {%- if message.role == \"system\" %}\n {%- if not loop.first %}\n {{- raise_exception('System message must be at the beginning.') }}\n {%- endif %}\n {%- elif message.role == \"user\" %}\n {{- '<|im_start|>' + message.role + '\\n' + content + '<|im_end|>' + '\\n' }}\n {%- elif message.role == \"assistant\" %}\n {%- set reasoning_content = '' %}\n {%- if message.reasoning_content is string %}\n {%- set reasoning_content = message.reasoning_content %}\n {%- else %}\n {%- if '</think>' in content %}\n {%- set reasoning_content = content.split('</think>')[0].rstrip('\\n').split('<think>')[-1].lstrip('\\n') %}\n {%- set content = content.split('</think>')[-1].lstrip('\\n') %}\n {%- endif %}\n {%- endif %}\n {%- set reasoning_content = reasoning_content|trim %}\n {%- if loop.index0 > ns.last_query_index %}\n {{- '<|im_start|>' + message.role + '\\n<think>\\n' + reasoning_content + '\\n</think>\\n\\n' + content }}\n {%- else %}\n {{- '<|im_start|>' + message.role + '\\n' + content }}\n {%- endif %}\n {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}\n {%- for tool_call in message.tool_calls %}\n {%- if tool_call.function is defined %}\n {%- set tool_call = tool_call.function %}\n {%- endif %}\n {%- if loop.first %}\n {%- if content|trim %}\n {{- '\\n\\n<tool_call>\\n<function=' + tool_call.name + '>\\n' }}\n {%- else %}\n {{- '<tool_call>\\n<function=' + tool_call.name + '>\\n' }}\n {%- endif %}\n {%- else %}\n {{- '\\n<tool_call>\\n<function=' + tool_call.name + '>\\n' }}\n {%- endif %}\n {%- if tool_call.arguments is defined %}\n {%- for args_name, args_value in tool_call.arguments|items %}\n {{- '<parameter=' + args_name + '>\\n' }}\n {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}\n {{- args_value }}\n {{- '\\n</parameter>\\n' }}\n {%- endfor %}\n {%- endif %}\n {{- '</function>\\n</tool_call>' }}\n {%- endfor %}\n {%- endif %}\n {{- '<|im_end|>\\n' }}\n {%- elif message.role == \"tool\" %}\n {%- if loop.previtem and loop.previtem.role != \"tool\" %}\n {{- '<|im_start|>user' }}\n {%- endif %}\n {{- '\\n<tool_response>\\n' }}\n {{- content }}\n {{- '\\n</tool_response>' }}\n {%- if not loop.last and loop.nextitem.role != \"tool\" %}\n {{- '<|im_end|>\\n' }}\n {%- elif loop.last %}\n {{- '<|im_end|>\\n' }}\n {%- endif %}\n {%- else %}\n {{- raise_exception('Unexpected message role.') }}\n {%- endif %}\n{%- endfor %}\n{%- if add_generation_prompt %}\n {{- '<|im_start|>assistant\\n' }}\n {%- if enable_thinking is defined and enable_thinking is true %}\n {{- '<think>\\n' }}\n {%- else %}\n {{- '<think>\\n\\n</think>\\n\\n' }}\n {%- endif %}\n{%- endif %}",
286
+ "clean_up_tokenization_spaces": false,
287
+ "eos_token": "<|im_end|>",
288
+ "errors": "replace",
289
+ "model_max_length": 262144,
290
+ "pad_token": "<|endoftext|>",
291
+ "split_special_tokens": false,
292
+ "tokenizer_class": "Qwen2Tokenizer",
293
+ "unk_token": null,
294
+ "add_bos_token": false,
295
+ "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
296
+ "extra_special_tokens": {
297
+ "audio_bos_token": "<|audio_start|>",
298
+ "audio_eos_token": "<|audio_end|>",
299
+ "audio_token": "<|audio_pad|>",
300
+ "image_token": "<|image_pad|>",
301
+ "video_token": "<|video_pad|>",
302
+ "vision_bos_token": "<|vision_start|>",
303
+ "vision_eos_token": "<|vision_end|>"
304
+ }
305
+ }
video_preprocessor_config.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "size": {
3
+ "longest_edge": 25165824,
4
+ "shortest_edge": 4096
5
+ },
6
+ "patch_size": 16,
7
+ "temporal_patch_size": 2,
8
+ "merge_size": 2,
9
+ "image_mean": [
10
+ 0.5,
11
+ 0.5,
12
+ 0.5
13
+ ],
14
+ "image_std": [
15
+ 0.5,
16
+ 0.5,
17
+ 0.5
18
+ ],
19
+ "processor_class": "Qwen3VLProcessor",
20
+ "video_processor_type": "Qwen3VLVideoProcessor"
21
+ }
vocab.json ADDED
The diff for this file is too large to render. See raw diff