docvortex 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. docvortex/__init__.py +21 -0
  2. docvortex/analyzers/__init__.py +3 -0
  3. docvortex/analyzers/native/__init__.py +37 -0
  4. docvortex/analyzers/native/_shared/__init__.py +3 -0
  5. docvortex/analyzers/native/_shared/hyperlink.py +16 -0
  6. docvortex/analyzers/native/_shared/image.py +8 -0
  7. docvortex/analyzers/native/_shared/markup/__init__.py +47 -0
  8. docvortex/analyzers/native/_shared/markup/anchors.py +30 -0
  9. docvortex/analyzers/native/_shared/markup/formula.py +37 -0
  10. docvortex/analyzers/native/_shared/markup/projector.py +57 -0
  11. docvortex/analyzers/native/_shared/markup/styles.py +19 -0
  12. docvortex/analyzers/native/_shared/mathml.py +17 -0
  13. docvortex/analyzers/native/_shared/names.py +7 -0
  14. docvortex/analyzers/native/_shared/xycut.py +414 -0
  15. docvortex/analyzers/native/contracts.py +44 -0
  16. docvortex/analyzers/native/csv.py +351 -0
  17. docvortex/analyzers/native/epub/__init__.py +18 -0
  18. docvortex/analyzers/native/epub/constants.py +45 -0
  19. docvortex/analyzers/native/epub/converter.py +80 -0
  20. docvortex/analyzers/native/epub/errors.py +20 -0
  21. docvortex/analyzers/native/epub/metadata.py +31 -0
  22. docvortex/analyzers/native/epub/package.py +649 -0
  23. docvortex/analyzers/native/epub/xhtml.py +306 -0
  24. docvortex/analyzers/native/html/__init__.py +6 -0
  25. docvortex/analyzers/native/html/anchors.py +271 -0
  26. docvortex/analyzers/native/html/constants.py +25 -0
  27. docvortex/analyzers/native/html/contracts.py +7 -0
  28. docvortex/analyzers/native/html/converter.py +117 -0
  29. docvortex/analyzers/native/html/document.py +389 -0
  30. docvortex/analyzers/native/html/errors.py +12 -0
  31. docvortex/analyzers/native/html/resources.py +365 -0
  32. docvortex/analyzers/native/html/selector.py +421 -0
  33. docvortex/analyzers/native/models.py +209 -0
  34. docvortex/analyzers/native/ofd/__init__.py +14 -0
  35. docvortex/analyzers/native/ofd/constants.py +59 -0
  36. docvortex/analyzers/native/ofd/converter.py +31 -0
  37. docvortex/analyzers/native/ofd/errors.py +16 -0
  38. docvortex/analyzers/native/ofd/geometry.py +215 -0
  39. docvortex/analyzers/native/ofd/images.py +124 -0
  40. docvortex/analyzers/native/ofd/metadata.py +53 -0
  41. docvortex/analyzers/native/ofd/models.py +170 -0
  42. docvortex/analyzers/native/ofd/package.py +346 -0
  43. docvortex/analyzers/native/ofd/path.py +201 -0
  44. docvortex/analyzers/native/ofd/reading_order.py +293 -0
  45. docvortex/analyzers/native/ofd/resources.py +122 -0
  46. docvortex/analyzers/native/ofd/scene.py +347 -0
  47. docvortex/analyzers/native/ofd/table.py +240 -0
  48. docvortex/analyzers/native/ofd/text.py +521 -0
  49. docvortex/analyzers/native/office/__init__.py +3 -0
  50. docvortex/analyzers/native/office/doc/__init__.py +3 -0
  51. docvortex/analyzers/native/office/doc/bookmarks.py +81 -0
  52. docvortex/analyzers/native/office/doc/doc_converter.py +573 -0
  53. docvortex/analyzers/native/office/doc/fib.py +261 -0
  54. docvortex/analyzers/native/office/doc/fields.py +101 -0
  55. docvortex/analyzers/native/office/doc/formatting.py +171 -0
  56. docvortex/analyzers/native/office/doc/images.py +162 -0
  57. docvortex/analyzers/native/office/doc/lists.py +356 -0
  58. docvortex/analyzers/native/office/doc/models.py +164 -0
  59. docvortex/analyzers/native/office/doc/parser.py +851 -0
  60. docvortex/analyzers/native/office/doc/pieces.py +256 -0
  61. docvortex/analyzers/native/office/doc/records.py +40 -0
  62. docvortex/analyzers/native/office/doc/sprm.py +269 -0
  63. docvortex/analyzers/native/office/doc/styles.py +215 -0
  64. docvortex/analyzers/native/office/docx/__init__.py +3 -0
  65. docvortex/analyzers/native/office/docx/context.py +71 -0
  66. docvortex/analyzers/native/office/docx/docx_converter.py +614 -0
  67. docvortex/analyzers/native/office/docx/equationxml.py +119 -0
  68. docvortex/analyzers/native/office/docx/fields.py +816 -0
  69. docvortex/analyzers/native/office/docx/formatting_types.py +23 -0
  70. docvortex/analyzers/native/office/docx/main.py +50 -0
  71. docvortex/analyzers/native/office/docx/numbering.py +491 -0
  72. docvortex/analyzers/native/office/docx/office_xml.py +57 -0
  73. docvortex/analyzers/native/office/docx/package_normalizer.py +248 -0
  74. docvortex/analyzers/native/office/docx/resources.py +526 -0
  75. docvortex/analyzers/native/office/docx/styles.py +693 -0
  76. docvortex/analyzers/native/office/docx/tables.py +515 -0
  77. docvortex/analyzers/native/office/equation/__init__.py +3 -0
  78. docvortex/analyzers/native/office/equation/image.py +470 -0
  79. docvortex/analyzers/native/office/equation/latex_dict.py +324 -0
  80. docvortex/analyzers/native/office/equation/mtef.py +885 -0
  81. docvortex/analyzers/native/office/equation/mtef_v5.py +941 -0
  82. docvortex/analyzers/native/office/equation/omml.py +561 -0
  83. docvortex/analyzers/native/office/equation/ooxml.py +62 -0
  84. docvortex/analyzers/native/office/errors.py +33 -0
  85. docvortex/analyzers/native/office/image.py +307 -0
  86. docvortex/analyzers/native/office/legacy/__init__.py +3 -0
  87. docvortex/analyzers/native/office/legacy/binary.py +43 -0
  88. docvortex/analyzers/native/office/legacy/officeart.py +362 -0
  89. docvortex/analyzers/native/office/legacy/ole.py +110 -0
  90. docvortex/analyzers/native/office/limits.py +12 -0
  91. docvortex/analyzers/native/office/odf/__init__.py +3 -0
  92. docvortex/analyzers/native/office/odf/chart.py +76 -0
  93. docvortex/analyzers/native/office/odf/constants.py +70 -0
  94. docvortex/analyzers/native/office/odf/converters.py +403 -0
  95. docvortex/analyzers/native/office/odf/errors.py +18 -0
  96. docvortex/analyzers/native/office/odf/metadata.py +91 -0
  97. docvortex/analyzers/native/office/odf/models.py +176 -0
  98. docvortex/analyzers/native/office/odf/package.py +270 -0
  99. docvortex/analyzers/native/office/odf/styles.py +329 -0
  100. docvortex/analyzers/native/office/odf/table.py +469 -0
  101. docvortex/analyzers/native/office/odf/text.py +1002 -0
  102. docvortex/analyzers/native/office/ooxml_chart.py +1016 -0
  103. docvortex/analyzers/native/office/opc.py +38 -0
  104. docvortex/analyzers/native/office/ppt/__init__.py +3 -0
  105. docvortex/analyzers/native/office/ppt/models.py +118 -0
  106. docvortex/analyzers/native/office/ppt/parser.py +1895 -0
  107. docvortex/analyzers/native/office/ppt/ppt_converter.py +292 -0
  108. docvortex/analyzers/native/office/ppt/records.py +131 -0
  109. docvortex/analyzers/native/office/ppt/style_text.py +247 -0
  110. docvortex/analyzers/native/office/pptx/__init__.py +3 -0
  111. docvortex/analyzers/native/office/pptx/context.py +113 -0
  112. docvortex/analyzers/native/office/pptx/lists.py +558 -0
  113. docvortex/analyzers/native/office/pptx/main.py +20 -0
  114. docvortex/analyzers/native/office/pptx/package_normalizer.py +321 -0
  115. docvortex/analyzers/native/office/pptx/pptx_converter.py +323 -0
  116. docvortex/analyzers/native/office/pptx/resources.py +329 -0
  117. docvortex/analyzers/native/office/pptx/shapes.py +393 -0
  118. docvortex/analyzers/native/office/pptx/text_styles.py +625 -0
  119. docvortex/analyzers/native/office/pptx/titles.py +178 -0
  120. docvortex/analyzers/native/office/rich_text.py +420 -0
  121. docvortex/analyzers/native/office/rtf/__init__.py +3 -0
  122. docvortex/analyzers/native/office/rtf/converter.py +708 -0
  123. docvortex/analyzers/native/office/rtf/lexer.py +213 -0
  124. docvortex/analyzers/native/office/rtf/math.py +339 -0
  125. docvortex/analyzers/native/office/rtf/models.py +185 -0
  126. docvortex/analyzers/native/office/rtf/parser.py +1553 -0
  127. docvortex/analyzers/native/office/spreadsheet/__init__.py +3 -0
  128. docvortex/analyzers/native/office/spreadsheet/html.py +81 -0
  129. docvortex/analyzers/native/office/spreadsheet/models.py +79 -0
  130. docvortex/analyzers/native/office/spreadsheet/projector.py +928 -0
  131. docvortex/analyzers/native/office/streams.py +18 -0
  132. docvortex/analyzers/native/office/xls/__init__.py +3 -0
  133. docvortex/analyzers/native/office/xls/chart.py +132 -0
  134. docvortex/analyzers/native/office/xls/embedded_chart.py +299 -0
  135. docvortex/analyzers/native/office/xls/models.py +109 -0
  136. docvortex/analyzers/native/office/xls/number_format.py +521 -0
  137. docvortex/analyzers/native/office/xls/parser.py +1145 -0
  138. docvortex/analyzers/native/office/xls/records.py +201 -0
  139. docvortex/analyzers/native/office/xls/strings.py +205 -0
  140. docvortex/analyzers/native/office/xls/xls_converter.py +354 -0
  141. docvortex/analyzers/native/office/xlsx/__init__.py +3 -0
  142. docvortex/analyzers/native/office/xlsx/main.py +20 -0
  143. docvortex/analyzers/native/office/xlsx/ooxml_ole.py +522 -0
  144. docvortex/analyzers/native/office/xlsx/package_normalizer.py +310 -0
  145. docvortex/analyzers/native/office/xlsx/xlsx_converter.py +716 -0
  146. docvortex/analyzers/native/pdf/__init__.py +3 -0
  147. docvortex/analyzers/native/pdf/auxiliary_text.py +1671 -0
  148. docvortex/analyzers/native/pdf/char_geometry.py +1662 -0
  149. docvortex/analyzers/native/pdf/code_blocks.py +535 -0
  150. docvortex/analyzers/native/pdf/formulas.py +1985 -0
  151. docvortex/analyzers/native/pdf/geometry.py +281 -0
  152. docvortex/analyzers/native/pdf/graphics.py +1501 -0
  153. docvortex/analyzers/native/pdf/index_blocks.py +268 -0
  154. docvortex/analyzers/native/pdf/inline/__init__.py +3 -0
  155. docvortex/analyzers/native/pdf/inline/common.py +112 -0
  156. docvortex/analyzers/native/pdf/inline/detection.py +590 -0
  157. docvortex/analyzers/native/pdf/inline/matching.py +1185 -0
  158. docvortex/analyzers/native/pdf/inline/materialize.py +457 -0
  159. docvortex/analyzers/native/pdf/inline/scripts.py +975 -0
  160. docvortex/analyzers/native/pdf/inline/types.py +385 -0
  161. docvortex/analyzers/native/pdf/line_layout.py +1106 -0
  162. docvortex/analyzers/native/pdf/line_merging.py +1223 -0
  163. docvortex/analyzers/native/pdf/models.py +246 -0
  164. docvortex/analyzers/native/pdf/native_text.py +1004 -0
  165. docvortex/analyzers/native/pdf/pipeline.py +1390 -0
  166. docvortex/analyzers/native/pdf/script_geometry.py +636 -0
  167. docvortex/analyzers/native/pdf/shared.py +30 -0
  168. docvortex/analyzers/native/pdf/spatial_text.py +383 -0
  169. docvortex/analyzers/native/pdf/table_annotations.py +446 -0
  170. docvortex/analyzers/native/pdf/table_constants.py +48 -0
  171. docvortex/analyzers/native/pdf/table_detection.py +227 -0
  172. docvortex/analyzers/native/pdf/table_filled_grid.py +218 -0
  173. docvortex/analyzers/native/pdf/table_geometry.py +40 -0
  174. docvortex/analyzers/native/pdf/table_materialization.py +444 -0
  175. docvortex/analyzers/native/pdf/table_recovery/__init__.py +15 -0
  176. docvortex/analyzers/native/pdf/table_recovery/candidate.py +356 -0
  177. docvortex/analyzers/native/pdf/table_recovery/contracts.py +154 -0
  178. docvortex/analyzers/native/pdf/table_recovery/engine.py +609 -0
  179. docvortex/analyzers/native/pdf/table_recovery/geometry.py +164 -0
  180. docvortex/analyzers/native/pdf/table_recovery/sparse_common.py +85 -0
  181. docvortex/analyzers/native/pdf/table_recovery/sparse_hybrid.py +804 -0
  182. docvortex/analyzers/native/pdf/table_recovery/sparse_multiline.py +1122 -0
  183. docvortex/analyzers/native/pdf/table_recovery/text.py +414 -0
  184. docvortex/analyzers/native/pdf/table_recovery/text_grid.py +618 -0
  185. docvortex/analyzers/native/pdf/table_recovery/vector.py +1931 -0
  186. docvortex/analyzers/native/pdf/table_rows.py +34 -0
  187. docvortex/analyzers/native/pdf/table_rules.py +1129 -0
  188. docvortex/analyzers/native/pdf/table_text_styles.py +283 -0
  189. docvortex/analyzers/native/pdf/tables.py +147 -0
  190. docvortex/analyzers/native/pdf/text_assembly/__init__.py +3 -0
  191. docvortex/analyzers/native/pdf/text_assembly/annotations.py +581 -0
  192. docvortex/analyzers/native/pdf/text_assembly/assembly.py +292 -0
  193. docvortex/analyzers/native/pdf/text_assembly/common.py +477 -0
  194. docvortex/analyzers/native/pdf/text_assembly/footnotes.py +394 -0
  195. docvortex/analyzers/native/pdf/text_assembly/merging.py +1274 -0
  196. docvortex/analyzers/native/pdf/text_assembly/rows.py +692 -0
  197. docvortex/analyzers/native/pdf/text_blocks.py +82 -0
  198. docvortex/analyzers/native/pdf/text_styles.py +55 -0
  199. docvortex/analyzers/native/pdf/title_analysis/__init__.py +3 -0
  200. docvortex/analyzers/native/pdf/title_analysis/body_profile.py +215 -0
  201. docvortex/analyzers/native/pdf/title_analysis/common.py +117 -0
  202. docvortex/analyzers/native/pdf/title_analysis/document_profile.py +164 -0
  203. docvortex/analyzers/native/pdf/title_analysis/lane_titles.py +758 -0
  204. docvortex/analyzers/native/pdf/title_analysis/page_titles.py +1024 -0
  205. docvortex/analyzers/native/pdf/title_analysis/prototype.py +194 -0
  206. docvortex/analyzers/native/pdf/title_analysis/structural.py +1081 -0
  207. docvortex/analyzers/native/pdf/titles.py +75 -0
  208. docvortex/analyzers/native/pdf/typography.py +19 -0
  209. docvortex/analyzers/native/pdf/visual_annotations.py +1262 -0
  210. docvortex/api.py +180 -0
  211. docvortex/assets/__init__.py +5 -0
  212. docvortex/assets/store.py +51 -0
  213. docvortex/cli.py +54 -0
  214. docvortex/codecs/__init__.py +3 -0
  215. docvortex/codecs/html/__init__.py +22 -0
  216. docvortex/codecs/html/contracts.py +236 -0
  217. docvortex/codecs/html/materializer.py +331 -0
  218. docvortex/codecs/html/parser.py +763 -0
  219. docvortex/codecs/html/resources.py +34 -0
  220. docvortex/codecs/json.py +17 -0
  221. docvortex/content/__init__.py +5 -0
  222. docvortex/content/inline.py +248 -0
  223. docvortex/content/markup/__init__.py +44 -0
  224. docvortex/content/markup/anchors.py +188 -0
  225. docvortex/content/markup/formula.py +280 -0
  226. docvortex/content/markup/projector.py +1237 -0
  227. docvortex/content/markup/styles.py +327 -0
  228. docvortex/content/mathml.py +167 -0
  229. docvortex/content/normalization.py +188 -0
  230. docvortex/content/spans.py +183 -0
  231. docvortex/content/table/__init__.py +18 -0
  232. docvortex/content/table/blocks.py +152 -0
  233. docvortex/content/table/content.py +425 -0
  234. docvortex/content/table/document.py +104 -0
  235. docvortex/content/table/html.py +399 -0
  236. docvortex/content/table/models.py +76 -0
  237. docvortex/content/table/rules.py +42 -0
  238. docvortex/content/table/structure.py +221 -0
  239. docvortex/content/tree.py +5 -0
  240. docvortex/document/__init__.py +3 -0
  241. docvortex/document/contracts.py +22 -0
  242. docvortex/document/detection.py +389 -0
  243. docvortex/document/filetypes.py +175 -0
  244. docvortex/document/page_range.py +167 -0
  245. docvortex/document/pdf/__init__.py +19 -0
  246. docvortex/document/pdf/classify.py +1138 -0
  247. docvortex/document/pdf/constants.py +132 -0
  248. docvortex/document/pdf/diagnostics.py +350 -0
  249. docvortex/document/pdf/document.py +582 -0
  250. docvortex/document/pdf/font_runtime.py +335 -0
  251. docvortex/document/pdf/geometry.py +31 -0
  252. docvortex/document/pdf/images.py +654 -0
  253. docvortex/document/pdf/native_annotations.py +367 -0
  254. docvortex/document/pdf/native_contracts.py +169 -0
  255. docvortex/document/pdf/native_coordinates.py +216 -0
  256. docvortex/document/pdf/native_lifecycle.py +16 -0
  257. docvortex/document/pdf/native_objects.py +902 -0
  258. docvortex/document/pdf/native_text_geometry.py +315 -0
  259. docvortex/document/pdf/pdfium.py +325 -0
  260. docvortex/document/pdf/raster.py +46 -0
  261. docvortex/document/pdf/text/__init__.py +62 -0
  262. docvortex/document/pdf/text/contracts.py +211 -0
  263. docvortex/document/pdf/text/extract.py +165 -0
  264. docvortex/document/pdf/text/geometry.py +16 -0
  265. docvortex/document/pdf/text/groups.py +162 -0
  266. docvortex/document/pdf/visual_geometry.py +201 -0
  267. docvortex/document/pdf/visuals.py +343 -0
  268. docvortex/document/source.py +92 -0
  269. docvortex/errors.py +21 -0
  270. docvortex/export/__init__.py +3 -0
  271. docvortex/export/bundle.py +98 -0
  272. docvortex/export/files.py +66 -0
  273. docvortex/export/middle.py +208 -0
  274. docvortex/foundation/__init__.py +3 -0
  275. docvortex/foundation/geometry.py +125 -0
  276. docvortex/foundation/hyperlink.py +65 -0
  277. docvortex/foundation/image.py +48 -0
  278. docvortex/foundation/image_encoding.py +30 -0
  279. docvortex/foundation/image_payload.py +280 -0
  280. docvortex/foundation/language.py +92 -0
  281. docvortex/foundation/platform.py +38 -0
  282. docvortex/foundation/text.py +153 -0
  283. docvortex/foundation/type_identity.py +20 -0
  284. docvortex/foundation/xml_names.py +20 -0
  285. docvortex/options.py +30 -0
  286. docvortex/postprocess/__init__.py +3 -0
  287. docvortex/postprocess/content.py +53 -0
  288. docvortex/postprocess/document.py +19 -0
  289. docvortex/postprocess/lists.py +236 -0
  290. docvortex/postprocess/page_blocks.py +214 -0
  291. docvortex/postprocess/pages.py +95 -0
  292. docvortex/postprocess/paragraphs.py +580 -0
  293. docvortex/postprocess/visual.py +715 -0
  294. docvortex/render/__init__.py +48 -0
  295. docvortex/render/_internal/__init__.py +3 -0
  296. docvortex/render/_internal/common/__init__.py +3 -0
  297. docvortex/render/_internal/common/context.py +43 -0
  298. docvortex/render/_internal/common/html_table.py +178 -0
  299. docvortex/render/_internal/common/index.py +33 -0
  300. docvortex/render/_internal/common/list_items.py +158 -0
  301. docvortex/render/_internal/common/planner.py +140 -0
  302. docvortex/render/_internal/docx/__init__.py +3 -0
  303. docvortex/render/_internal/docx/assets.py +202 -0
  304. docvortex/render/_internal/docx/inline.py +434 -0
  305. docvortex/render/_internal/docx/math.py +220 -0
  306. docvortex/render/_internal/docx/renderer.py +905 -0
  307. docvortex/render/_internal/docx/styles.py +195 -0
  308. docvortex/render/_internal/docx/table.py +442 -0
  309. docvortex/render/_internal/epub/__init__.py +5 -0
  310. docvortex/render/_internal/epub/assets.py +173 -0
  311. docvortex/render/_internal/epub/package.py +249 -0
  312. docvortex/render/_internal/epub/renderer.py +1156 -0
  313. docvortex/render/_internal/html/__init__.py +3 -0
  314. docvortex/render/_internal/html/inline.py +346 -0
  315. docvortex/render/_internal/html/renderer.py +1041 -0
  316. docvortex/render/_internal/html/sanitizer.py +478 -0
  317. docvortex/render/_internal/html/table.py +121 -0
  318. docvortex/render/_internal/latex/__init__.py +1 -0
  319. docvortex/render/_internal/latex/assets.py +85 -0
  320. docvortex/render/_internal/latex/inline.py +145 -0
  321. docvortex/render/_internal/latex/renderer.py +506 -0
  322. docvortex/render/_internal/latex/table.py +347 -0
  323. docvortex/render/_internal/markdown/__init__.py +3 -0
  324. docvortex/render/_internal/markdown/assets.py +78 -0
  325. docvortex/render/_internal/markdown/blocks.py +635 -0
  326. docvortex/render/_internal/markdown/escaping.py +51 -0
  327. docvortex/render/_internal/markdown/inline.py +260 -0
  328. docvortex/render/_internal/markdown/renderer.py +93 -0
  329. docvortex/render/_internal/markdown/table.py +281 -0
  330. docvortex/render/_internal/pdf/__init__.py +3 -0
  331. docvortex/render/_internal/pdf/assets.py +197 -0
  332. docvortex/render/_internal/pdf/formula.py +417 -0
  333. docvortex/render/_internal/pdf/inline.py +343 -0
  334. docvortex/render/_internal/pdf/renderer.py +734 -0
  335. docvortex/render/_internal/pdf/styles.py +206 -0
  336. docvortex/render/_internal/pdf/table.py +272 -0
  337. docvortex/render/_internal/structured_content/__init__.py +3 -0
  338. docvortex/render/_internal/structured_content/renderer.py +193 -0
  339. docvortex/render/api.py +199 -0
  340. docvortex/render/contracts.py +205 -0
  341. docvortex/render/docx.py +38 -0
  342. docvortex/render/epub.py +35 -0
  343. docvortex/render/fragments.py +70 -0
  344. docvortex/render/html.py +29 -0
  345. docvortex/render/latex.py +24 -0
  346. docvortex/render/markdown.py +50 -0
  347. docvortex/render/pdf.py +25 -0
  348. docvortex/render/structured_content.py +23 -0
  349. docvortex/resources/epub/docvortex.css +91 -0
  350. docvortex/resources/fasttext-langdetect/lid.176.ftz +0 -0
  351. docvortex/resources/fonts/DroidSansFallbackFull.ttf +0 -0
  352. docvortex/resources/fonts/NOTICE +190 -0
  353. docvortex/resources/fonts/manifest.json +9 -0
  354. docvortex/resources/html/docvortex.css +601 -0
  355. docvortex/resources/html/docvortex.min.css +1 -0
  356. docvortex/result.py +91 -0
  357. docvortex/schema.py +1141 -0
  358. docvortex/version.py +3 -0
  359. docvortex-0.2.1.dist-info/METADATA +193 -0
  360. docvortex-0.2.1.dist-info/RECORD +364 -0
  361. docvortex-0.2.1.dist-info/WHEEL +5 -0
  362. docvortex-0.2.1.dist-info/entry_points.txt +2 -0
  363. docvortex-0.2.1.dist-info/licenses/LICENSE.md +21 -0
  364. docvortex-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1262 @@
1
+ """识别独立视觉块的强规则 caption/footnote,并构造局部阅读区域。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import statistics
7
+ import unicodedata
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Any, Literal
11
+
12
+
13
+ from .._shared.xycut import sort_entries
14
+ from ....schema import BBox
15
+
16
+ from .geometry import (
17
+ _bbox_axis_overlap_ratio,
18
+ _bbox_center_x,
19
+ _bbox_center_y,
20
+ _bbox_union_many,
21
+ _coerce_bbox,
22
+ _rotate_bbox_to_upright,
23
+ )
24
+
25
+
26
+ _VISUAL_BLOCK_TYPES = {"image", "table", "code"}
27
+ _CAPTION_MAX_GAP_IN_LINE_HEIGHTS = 2.5
28
+ _FOOTNOTE_MAX_GAP_IN_LINE_HEIGHTS = 6.0
29
+ _MIN_PROJECTION_OVERLAP = 0.8
30
+ _MIN_ANNOTATION_COVERAGE = 0.65
31
+ _COMPONENT_COVERAGE_GAIN = 0.15
32
+ _CROSS_LANE_CAPTION_ROW_TOP_TOLERANCE = 0.25
33
+ _CROSS_LANE_CAPTION_MIN_LINE_HEIGHT_RATIO = 0.85
34
+ _CROSS_LANE_CAPTION_MAX_LINE_HEIGHT_RATIO = 1.15
35
+ _TABLE_FOOTNOTE_CONTINUATION_MAX_GAP = 1.5
36
+ _TABLE_FOOTNOTE_FIRST_INDENT_MIN = 0.5
37
+ _TABLE_FOOTNOTE_FIRST_INDENT_MAX = 2.0
38
+ _TABLE_FOOTNOTE_FOLLOWING_INDENT_TOLERANCE = 0.5
39
+ _TABLE_FOOTNOTE_LANE_TOLERANCE = 0.5
40
+ _TABLE_FOOTNOTE_MIN_LINE_HEIGHT_RATIO = 0.75
41
+ _TABLE_FOOTNOTE_MAX_LINE_HEIGHT_RATIO = 1.25
42
+
43
+ _IDENTIFIER_PATTERN = (
44
+ r"(?:"
45
+ r"[A-Z]?\d+(?:\s*[-./–—]\s*[A-Z]?\d+)*[A-Z]?"
46
+ r"|[IVXLCDM]+"
47
+ r"|[零〇一二三四五六七八九十百千两]+"
48
+ r")"
49
+ )
50
+ _ENGLISH_CAPTION_RE = re.compile(
51
+ rf"^\s*(?:fig(?:ure)?|tab(?:le)?|alg(?:orithm)?|listing|chart|scheme)\.?(?:\s*)"
52
+ rf"(?P<identifier>{_IDENTIFIER_PATTERN})(?P<tail>.*)$",
53
+ re.IGNORECASE | re.DOTALL,
54
+ )
55
+ _CHINESE_CAPTION_RE = re.compile(
56
+ rf"^\s*(?:程序清单|图表|表格|算法|图|表)\s*"
57
+ rf"(?P<identifier>{_IDENTIFIER_PATTERN})(?P<tail>.*)$",
58
+ re.IGNORECASE | re.DOTALL,
59
+ )
60
+ _FOOTNOTE_RE = re.compile(
61
+ r"^\s*(?:source(?:s|\(s\))?|data\s+source|note(?:s|\(s\))?"
62
+ r"|资料来源|数据来源|来源|注|备注)\s*[::]\s*\S",
63
+ re.IGNORECASE | re.DOTALL,
64
+ )
65
+ _ENGLISH_REFERENCE_TAIL_RE = re.compile(
66
+ r"^(?:[,;]|and\b|or\b|&|shows?\b|illustrates?\b|presents?\b|depicts?\b|"
67
+ r"demonstrates?\b|lists?\b|summari[sz]es?\b|reports?\b|compares?\b|"
68
+ r"provides?\b|is\b|are\b|was\b|were\b|has\b|have\b|can\b)",
69
+ re.IGNORECASE,
70
+ )
71
+ _CHINESE_REFERENCE_TAIL_RE = re.compile(
72
+ r"^(?:[,;,;、]|和|及|与|为|是|展示|显示|给出|说明|列出|分别|可见|表明|描述|呈现|汇总|所示|中(?:的|为)?)",
73
+ )
74
+ _SUBFIGURE_REFERENCE_TAIL_RE = re.compile(
75
+ r"^[((][^))]+[))]\s*(?:[、,,]|和|及|与)",
76
+ )
77
+
78
+ _Direction = Literal["above", "below", "left", "right"]
79
+ _AnnotationKind = Literal["caption", "footnote"]
80
+ _TextBlockGroupMerger = Callable[
81
+ [list[dict[str, Any]], list[int]],
82
+ dict[str, Any],
83
+ ]
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class _VisualParent:
88
+ """记录单个视觉块或只用于关联的多图片组件。"""
89
+
90
+ member_indices: tuple[int, ...]
91
+ angle: int
92
+ local_bbox: BBox
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class _AnnotationRelation:
97
+ """记录注释与候选父块之间的方向和归一化几何代价。"""
98
+
99
+ parent: _VisualParent
100
+ direction: _Direction
101
+ normalized_gap: float
102
+ projection_overlap: float
103
+ annotation_coverage: float
104
+ center_offset: float
105
+
106
+
107
+ def _normalize_annotation_text(text: str) -> str:
108
+ """统一全角字符和兼容罗马数字,保留原文仅供规则判断。"""
109
+
110
+ return unicodedata.normalize("NFKC", text).strip()
111
+
112
+
113
+ def _caption_tail_is_reference(tail: str) -> bool:
114
+ """排除编号后紧接叙述谓语、并列编号或正文连接词的引用句。"""
115
+
116
+ stripped = tail.lstrip()
117
+ if not stripped:
118
+ return False
119
+ if _SUBFIGURE_REFERENCE_TAIL_RE.match(stripped):
120
+ return True
121
+ if stripped[0] in ".::-–—()[]()":
122
+ return False
123
+ if _ENGLISH_REFERENCE_TAIL_RE.match(stripped) or _CHINESE_REFERENCE_TAIL_RE.match(stripped):
124
+ return True
125
+ return stripped[0].isascii() and stripped[0].isalpha() and stripped[0].islower()
126
+
127
+
128
+ def _is_strong_caption_text(text: str) -> bool:
129
+ """判断文本是否以带编号的中英文强图表标题标记开头。"""
130
+
131
+ normalized = _normalize_annotation_text(text)
132
+ match = _ENGLISH_CAPTION_RE.match(normalized) or _CHINESE_CAPTION_RE.match(normalized)
133
+ return match is not None and not _caption_tail_is_reference(match.group("tail"))
134
+
135
+
136
+ def _caption_identifier(text: str) -> str | None:
137
+ """返回中英文强图表题共用的规范化编号。"""
138
+
139
+ normalized = _normalize_annotation_text(text)
140
+ match = _ENGLISH_CAPTION_RE.match(normalized) or _CHINESE_CAPTION_RE.match(normalized)
141
+ return (
142
+ unicodedata.normalize(
143
+ "NFKC",
144
+ match.group("identifier"),
145
+ ).casefold()
146
+ if match is not None
147
+ else None
148
+ )
149
+
150
+
151
+ def _is_strong_footnote_text(text: str) -> bool:
152
+ """判断文本是否以带冒号的来源或注释强标记开头。"""
153
+
154
+ return bool(_FOOTNOTE_RE.match(_normalize_annotation_text(text)))
155
+
156
+
157
+ def _block_angle(block: dict[str, Any]) -> int:
158
+ """将块角度规范到四个正交方向。"""
159
+
160
+ return int(block.get("angle", 0) or 0) % 360
161
+
162
+
163
+ def _block_local_bbox(
164
+ block: dict[str, Any],
165
+ page_size: tuple[float, float],
166
+ angle: int | None = None,
167
+ ) -> BBox | None:
168
+ """读取有效 bbox 并转换到指定文本方向的局部坐标。"""
169
+
170
+ bbox = _coerce_bbox(block.get("bbox"))
171
+ if bbox is None:
172
+ return None
173
+ return _rotate_bbox_to_upright(
174
+ bbox,
175
+ page_size,
176
+ _block_angle(block) if angle is None else angle,
177
+ )
178
+
179
+
180
+ def _block_median_line_height(
181
+ block: dict[str, Any],
182
+ page_size: tuple[float, float],
183
+ ) -> float:
184
+ """优先使用原生行高,缺失时按局部块高和行框数量保守估计。"""
185
+
186
+ heights = [
187
+ float(height) for height in block.get("_line_heights", []) if isinstance(height, (int, float)) and float(height) > 0
188
+ ]
189
+ if heights:
190
+ return statistics.median(heights)
191
+ local_bbox = _block_local_bbox(block, page_size)
192
+ if local_bbox is None:
193
+ return 1.0
194
+ local_rows = block.get("_local_line_bboxes")
195
+ row_count = len(local_rows) if isinstance(local_rows, list) and local_rows else 1
196
+ return max(1.0, (local_bbox[3] - local_bbox[1]) / row_count)
197
+
198
+
199
+ def _collect_annotation_candidates(
200
+ blocks: list[dict[str, Any]],
201
+ ) -> dict[int, _AnnotationKind]:
202
+ """从独立 text 及预分类 caption/footnote 中收集视觉绑定候选。"""
203
+
204
+ output: dict[int, _AnnotationKind] = {}
205
+ for index, block in enumerate(blocks):
206
+ block_type = block.get("type")
207
+ if block_type in {"caption", "footnote"}:
208
+ output[index] = block_type
209
+ continue
210
+ if block_type != "text" or not isinstance(block.get("content"), str):
211
+ continue
212
+ content = str(block["content"])
213
+ if _is_strong_caption_text(content):
214
+ output[index] = "caption"
215
+ elif _is_strong_footnote_text(content):
216
+ output[index] = "footnote"
217
+ return output
218
+
219
+
220
+ def _coerce_lane_interval(value: object) -> tuple[float, float] | None:
221
+ """读取内部栏带区间,拒绝缺失、逆序或非数值元数据。"""
222
+
223
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
224
+ return None
225
+ try:
226
+ start, end = float(value[0]), float(value[1])
227
+ except (TypeError, ValueError):
228
+ return None
229
+ if end <= start:
230
+ return None
231
+ return start, end
232
+
233
+
234
+ def _block_first_local_row_bbox(
235
+ block: dict[str, Any],
236
+ page_size: tuple[float, float],
237
+ angle: int,
238
+ ) -> BBox | None:
239
+ """返回块在指定方向局部坐标中的首个文本行框。"""
240
+
241
+ rows = block.get("_local_line_bboxes")
242
+ local_rows = [bbox for row in rows if (bbox := _coerce_bbox(row)) is not None] if isinstance(rows, list) else []
243
+ if local_rows:
244
+ return min(local_rows, key=lambda bbox: (bbox[1], bbox[0]))
245
+ return _block_local_bbox(block, page_size, angle)
246
+
247
+
248
+ def _blocks_share_table_footnote_lane(
249
+ anchor: dict[str, Any],
250
+ candidate: dict[str, Any],
251
+ line_height: float,
252
+ ) -> bool:
253
+ """要求表注锚点与续块来自同一内部栏带和 span 层级。"""
254
+
255
+ if anchor.get("_lane_is_span") != candidate.get("_lane_is_span"):
256
+ return False
257
+ anchor_interval = _coerce_lane_interval(anchor.get("_lane_interval"))
258
+ candidate_interval = _coerce_lane_interval(candidate.get("_lane_interval"))
259
+ if anchor_interval is None or candidate_interval is None:
260
+ return False
261
+ tolerance = _TABLE_FOOTNOTE_LANE_TOLERANCE * line_height
262
+ return (
263
+ abs(anchor_interval[0] - candidate_interval[0]) <= tolerance
264
+ and abs(anchor_interval[1] - candidate_interval[1]) <= tolerance
265
+ )
266
+
267
+
268
+ def _block_overlaps_table_footnote_lane(
269
+ block: dict[str, Any],
270
+ lane_interval: tuple[float, float],
271
+ page_size: tuple[float, float],
272
+ angle: int,
273
+ ) -> bool:
274
+ """判断任意块是否横穿表注栏带,用于保证收集过程不跨越障碍。"""
275
+
276
+ local_bbox = _block_local_bbox(block, page_size, angle)
277
+ if local_bbox is None:
278
+ return False
279
+ overlap = max(
280
+ 0.0,
281
+ min(local_bbox[2], lane_interval[1]) - max(local_bbox[0], lane_interval[0]),
282
+ )
283
+ shorter_width = max(
284
+ 0.1,
285
+ min(
286
+ local_bbox[2] - local_bbox[0],
287
+ lane_interval[1] - lane_interval[0],
288
+ ),
289
+ )
290
+ return overlap / shorter_width >= 0.5
291
+
292
+
293
+ def _table_footnote_fonts_are_compatible(
294
+ previous: dict[str, Any],
295
+ candidate: dict[str, Any],
296
+ ) -> bool:
297
+ """仅在两侧都有可靠字体信息且完全不相交时拒绝续块。"""
298
+
299
+ previous_fonts = previous.get("_font_signatures")
300
+ candidate_fonts = candidate.get("_font_signatures")
301
+ return not (
302
+ isinstance(previous_fonts, set)
303
+ and isinstance(candidate_fonts, set)
304
+ and previous_fonts
305
+ and candidate_fonts
306
+ and previous_fonts.isdisjoint(candidate_fonts)
307
+ )
308
+
309
+
310
+ def _table_footnote_candidate_fits_parent(
311
+ candidate_bbox: BBox,
312
+ parent_bbox: BBox,
313
+ line_height: float,
314
+ ) -> bool:
315
+ """要求续块位于父表格下方并保持既有横向投影覆盖标准。"""
316
+
317
+ if _bbox_center_y(candidate_bbox) < _bbox_center_y(parent_bbox):
318
+ return False
319
+ projection_overlap, annotation_coverage, _center_offset = _axis_overlap_metrics(
320
+ candidate_bbox,
321
+ parent_bbox,
322
+ axis="x",
323
+ float_margin=line_height,
324
+ )
325
+ return projection_overlap >= _MIN_PROJECTION_OVERLAP and annotation_coverage >= _MIN_ANNOTATION_COVERAGE
326
+
327
+
328
+ def _table_footnote_continuation_is_compatible(
329
+ anchor: dict[str, Any],
330
+ previous: dict[str, Any],
331
+ candidate: dict[str, Any],
332
+ parent_bbox: BBox,
333
+ page_size: tuple[float, float],
334
+ angle: int,
335
+ continuation_left: float | None,
336
+ ) -> tuple[bool, float | None]:
337
+ """按栏带、字体、行高、净空和悬挂缩进确认单个表注续块。"""
338
+
339
+ previous_bbox = _block_local_bbox(previous, page_size, angle)
340
+ candidate_bbox = _block_local_bbox(candidate, page_size, angle)
341
+ anchor_first_row = _block_first_local_row_bbox(anchor, page_size, angle)
342
+ candidate_first_row = _block_first_local_row_bbox(candidate, page_size, angle)
343
+ if previous_bbox is None or candidate_bbox is None or anchor_first_row is None or candidate_first_row is None:
344
+ return False, continuation_left
345
+
346
+ previous_height = _block_median_line_height(previous, page_size)
347
+ candidate_height = _block_median_line_height(candidate, page_size)
348
+ if previous_height <= 0 or candidate_height <= 0:
349
+ return False, continuation_left
350
+ height_ratio = candidate_height / previous_height
351
+ pair_height = statistics.median((previous_height, candidate_height))
352
+ vertical_gap = max(0.0, candidate_bbox[1] - previous_bbox[3])
353
+ if (
354
+ not _TABLE_FOOTNOTE_MIN_LINE_HEIGHT_RATIO <= height_ratio <= _TABLE_FOOTNOTE_MAX_LINE_HEIGHT_RATIO
355
+ or vertical_gap > _TABLE_FOOTNOTE_CONTINUATION_MAX_GAP * pair_height
356
+ or not _blocks_share_table_footnote_lane(anchor, candidate, pair_height)
357
+ or not _table_footnote_fonts_are_compatible(previous, candidate)
358
+ or not _table_footnote_candidate_fits_parent(
359
+ candidate_bbox,
360
+ parent_bbox,
361
+ pair_height,
362
+ )
363
+ ):
364
+ return False, continuation_left
365
+
366
+ candidate_left = candidate_first_row[0]
367
+ if continuation_left is None:
368
+ indent = candidate_left - anchor_first_row[0]
369
+ if not (_TABLE_FOOTNOTE_FIRST_INDENT_MIN * pair_height <= indent <= _TABLE_FOOTNOTE_FIRST_INDENT_MAX * pair_height):
370
+ return False, continuation_left
371
+ return True, candidate_left
372
+ if abs(candidate_left - continuation_left) > _TABLE_FOOTNOTE_FOLLOWING_INDENT_TOLERANCE * pair_height:
373
+ return False, continuation_left
374
+ return True, continuation_left
375
+
376
+
377
+ def _collect_table_footnote_continuation_indices(
378
+ anchor_index: int,
379
+ relation: _AnnotationRelation,
380
+ blocks: list[dict[str, Any]],
381
+ page_size: tuple[float, float],
382
+ annotation_indices: set[int],
383
+ consumed_indices: set[int],
384
+ ) -> list[int]:
385
+ """从表注锚点向下连续收集同栏悬挂缩进文本,不跨越首个障碍。"""
386
+
387
+ anchor = blocks[anchor_index]
388
+ angle = relation.parent.angle
389
+ anchor_bbox = _block_local_bbox(anchor, page_size, angle)
390
+ lane_interval = _coerce_lane_interval(anchor.get("_lane_interval"))
391
+ if anchor_bbox is None or lane_interval is None:
392
+ return []
393
+
394
+ ordered_indices = sorted(
395
+ (
396
+ index
397
+ for index, block in enumerate(blocks)
398
+ if index != anchor_index
399
+ and index not in relation.parent.member_indices
400
+ and index not in consumed_indices
401
+ and _block_angle(block) == angle
402
+ and (local_bbox := _block_local_bbox(block, page_size, angle)) is not None
403
+ and _bbox_center_y(local_bbox) > _bbox_center_y(anchor_bbox)
404
+ and _block_overlaps_table_footnote_lane(
405
+ block,
406
+ lane_interval,
407
+ page_size,
408
+ angle,
409
+ )
410
+ ),
411
+ key=lambda index: (
412
+ _block_local_bbox(blocks[index], page_size, angle)[1],
413
+ _block_local_bbox(blocks[index], page_size, angle)[0],
414
+ index,
415
+ ),
416
+ )
417
+
418
+ output: list[int] = []
419
+ previous = anchor
420
+ continuation_left: float | None = None
421
+ for index in ordered_indices:
422
+ candidate = blocks[index]
423
+ if index in annotation_indices or candidate.get("type") != "text":
424
+ break
425
+ compatible, continuation_left = _table_footnote_continuation_is_compatible(
426
+ anchor,
427
+ previous,
428
+ candidate,
429
+ relation.parent.local_bbox,
430
+ page_size,
431
+ angle,
432
+ continuation_left,
433
+ )
434
+ if not compatible:
435
+ break
436
+ output.append(index)
437
+ previous = candidate
438
+ return output
439
+
440
+
441
+ def _merge_table_footnote_continuations(
442
+ blocks: list[dict[str, Any]],
443
+ candidates: dict[int, _AnnotationKind],
444
+ assignments: dict[int, _AnnotationRelation],
445
+ page_size: tuple[float, float],
446
+ merge_text_block_group: _TextBlockGroupMerger | None,
447
+ ) -> set[int]:
448
+ """合并已绑定单表格的强标记脚注及其页内续块,并返回被消费索引。"""
449
+
450
+ if merge_text_block_group is None:
451
+ return set()
452
+ annotation_indices = set(candidates)
453
+ consumed_indices: set[int] = set()
454
+ for anchor_index in sorted(
455
+ assignments,
456
+ key=lambda index: _annotation_local_sort_key(index, blocks, page_size),
457
+ ):
458
+ relation = assignments[anchor_index]
459
+ parent_indices = relation.parent.member_indices
460
+ anchor = blocks[anchor_index]
461
+ if (
462
+ candidates.get(anchor_index) != "footnote"
463
+ or anchor.get("_table_annotation_complete") is True
464
+ or anchor.get("type") != "text"
465
+ or not isinstance(anchor.get("content"), str)
466
+ or not _is_strong_footnote_text(str(anchor["content"]))
467
+ or relation.direction != "below"
468
+ or len(parent_indices) != 1
469
+ or blocks[parent_indices[0]].get("type") != "table"
470
+ ):
471
+ continue
472
+ continuation_indices = _collect_table_footnote_continuation_indices(
473
+ anchor_index,
474
+ relation,
475
+ blocks,
476
+ page_size,
477
+ annotation_indices,
478
+ consumed_indices,
479
+ )
480
+ if not continuation_indices:
481
+ continue
482
+ blocks[anchor_index] = merge_text_block_group(
483
+ blocks,
484
+ [anchor_index, *continuation_indices],
485
+ )
486
+ consumed_indices.update(continuation_indices)
487
+ return consumed_indices
488
+
489
+
490
+ def _axis_overlap_metrics(
491
+ annotation_bbox: BBox,
492
+ parent_bbox: BBox,
493
+ *,
494
+ axis: Literal["x", "y"],
495
+ float_margin: float,
496
+ ) -> tuple[float, float, float]:
497
+ """在正交轴允许一行高浮动后计算较短投影、注释覆盖和中心偏移。"""
498
+
499
+ if axis == "x":
500
+ annotation_start, annotation_end = annotation_bbox[0], annotation_bbox[2]
501
+ parent_start, parent_end = parent_bbox[0], parent_bbox[2]
502
+ else:
503
+ annotation_start, annotation_end = annotation_bbox[1], annotation_bbox[3]
504
+ parent_start, parent_end = parent_bbox[1], parent_bbox[3]
505
+ expanded_parent_start = parent_start - float_margin
506
+ expanded_parent_end = parent_end + float_margin
507
+ overlap = max(
508
+ 0.0,
509
+ min(annotation_end, expanded_parent_end) - max(annotation_start, expanded_parent_start),
510
+ )
511
+ annotation_length = max(0.1, annotation_end - annotation_start)
512
+ parent_length = max(0.1, expanded_parent_end - expanded_parent_start)
513
+ projection_overlap = overlap / min(annotation_length, parent_length)
514
+ annotation_coverage = overlap / annotation_length
515
+ center_offset = abs((annotation_start + annotation_end) / 2.0 - (parent_start + parent_end) / 2.0) / max(
516
+ annotation_length, parent_end - parent_start, 0.1
517
+ )
518
+ return projection_overlap, annotation_coverage, center_offset
519
+
520
+
521
+ def _direction_relation(
522
+ parent: _VisualParent,
523
+ annotation_bbox: BBox,
524
+ line_height: float,
525
+ direction: _Direction,
526
+ max_gap_in_line_heights: float,
527
+ ) -> _AnnotationRelation | None:
528
+ """按一个方向检查边缘距离、深入量和正交投影约束。"""
529
+
530
+ parent_bbox = parent.local_bbox
531
+ if direction == "above":
532
+ if _bbox_center_y(annotation_bbox) > _bbox_center_y(parent_bbox):
533
+ return None
534
+ signed_gap = parent_bbox[1] - annotation_bbox[3]
535
+ projection_axis: Literal["x", "y"] = "x"
536
+ elif direction == "below":
537
+ if _bbox_center_y(annotation_bbox) < _bbox_center_y(parent_bbox):
538
+ return None
539
+ signed_gap = annotation_bbox[1] - parent_bbox[3]
540
+ projection_axis = "x"
541
+ elif direction == "left":
542
+ if _bbox_center_x(annotation_bbox) > _bbox_center_x(parent_bbox):
543
+ return None
544
+ signed_gap = parent_bbox[0] - annotation_bbox[2]
545
+ projection_axis = "y"
546
+ else:
547
+ if _bbox_center_x(annotation_bbox) < _bbox_center_x(parent_bbox):
548
+ return None
549
+ signed_gap = annotation_bbox[0] - parent_bbox[2]
550
+ projection_axis = "y"
551
+ gap = max(0.0, signed_gap)
552
+ penetration = max(0.0, -signed_gap)
553
+ if gap > max_gap_in_line_heights * line_height or penetration > line_height:
554
+ return None
555
+ projection_overlap, annotation_coverage, center_offset = _axis_overlap_metrics(
556
+ annotation_bbox,
557
+ parent_bbox,
558
+ axis=projection_axis,
559
+ float_margin=line_height,
560
+ )
561
+ if projection_overlap < _MIN_PROJECTION_OVERLAP or annotation_coverage < _MIN_ANNOTATION_COVERAGE:
562
+ return None
563
+ return _AnnotationRelation(
564
+ parent=parent,
565
+ direction=direction,
566
+ normalized_gap=gap / line_height,
567
+ projection_overlap=projection_overlap,
568
+ annotation_coverage=annotation_coverage,
569
+ center_offset=center_offset,
570
+ )
571
+
572
+
573
+ def _best_parent_relation(
574
+ parent: _VisualParent,
575
+ annotation_bbox: BBox,
576
+ line_height: float,
577
+ kind: _AnnotationKind,
578
+ ) -> _AnnotationRelation | None:
579
+ """为单个父块选择几何代价最小的合法注释方向。"""
580
+
581
+ directions: tuple[_Direction, ...]
582
+ if kind == "footnote":
583
+ directions = ("below",)
584
+ max_gap = _FOOTNOTE_MAX_GAP_IN_LINE_HEIGHTS
585
+ else:
586
+ directions = ("above", "below", "left", "right")
587
+ max_gap = _CAPTION_MAX_GAP_IN_LINE_HEIGHTS
588
+ relations = [
589
+ relation
590
+ for direction in directions
591
+ if (
592
+ relation := _direction_relation(
593
+ parent,
594
+ annotation_bbox,
595
+ line_height,
596
+ direction,
597
+ max_gap,
598
+ )
599
+ )
600
+ is not None
601
+ ]
602
+ if not relations:
603
+ return None
604
+ return min(
605
+ relations,
606
+ key=lambda relation: (
607
+ relation.normalized_gap,
608
+ -relation.annotation_coverage,
609
+ -relation.projection_overlap,
610
+ relation.center_offset,
611
+ ),
612
+ )
613
+
614
+
615
+ def _image_blocks_form_component(
616
+ first_bbox: BBox,
617
+ second_bbox: BBox,
618
+ line_height: float,
619
+ ) -> bool:
620
+ """判断两张图片是否以小净空和足够正交投影组成相邻面板。"""
621
+
622
+ horizontal_gap = max(first_bbox[0] - second_bbox[2], second_bbox[0] - first_bbox[2], 0.0)
623
+ vertical_gap = max(first_bbox[1] - second_bbox[3], second_bbox[1] - first_bbox[3], 0.0)
624
+ x_overlap = max(0.0, min(first_bbox[2], second_bbox[2]) - max(first_bbox[0], second_bbox[0]))
625
+ y_overlap = max(0.0, min(first_bbox[3], second_bbox[3]) - max(first_bbox[1], second_bbox[1]))
626
+ min_width = max(0.1, min(first_bbox[2] - first_bbox[0], second_bbox[2] - second_bbox[0]))
627
+ min_height = max(0.1, min(first_bbox[3] - first_bbox[1], second_bbox[3] - second_bbox[1]))
628
+ return (horizontal_gap <= 2.0 * line_height and y_overlap / min_height >= 0.5) or (
629
+ vertical_gap <= 2.0 * line_height and x_overlap / min_width >= 0.5
630
+ )
631
+
632
+
633
+ def _build_visual_parents(
634
+ blocks: list[dict[str, Any]],
635
+ page_size: tuple[float, float],
636
+ component_line_height: float,
637
+ ) -> list[_VisualParent]:
638
+ """构造单视觉块父候选,并为相邻图片补充只用于关联的连通组件。"""
639
+
640
+ parents: list[_VisualParent] = []
641
+ image_indices_by_angle: dict[int, list[int]] = {}
642
+ local_bboxes: dict[int, BBox] = {}
643
+ for index, block in enumerate(blocks):
644
+ if block.get("type") not in _VISUAL_BLOCK_TYPES:
645
+ continue
646
+ angle = _block_angle(block)
647
+ local_bbox = _block_local_bbox(block, page_size, angle)
648
+ if local_bbox is None:
649
+ continue
650
+ local_bboxes[index] = local_bbox
651
+ parents.append(_VisualParent((index,), angle, local_bbox))
652
+ if block.get("type") == "image":
653
+ image_indices_by_angle.setdefault(angle, []).append(index)
654
+
655
+ for angle, image_indices in image_indices_by_angle.items():
656
+ remaining = set(image_indices)
657
+ while remaining:
658
+ seed = min(remaining)
659
+ component = {seed}
660
+ remaining.remove(seed)
661
+ changed = True
662
+ while changed:
663
+ changed = False
664
+ for candidate in list(remaining):
665
+ if any(
666
+ _image_blocks_form_component(
667
+ local_bboxes[member],
668
+ local_bboxes[candidate],
669
+ component_line_height,
670
+ )
671
+ for member in component
672
+ ):
673
+ component.add(candidate)
674
+ remaining.remove(candidate)
675
+ changed = True
676
+ if len(component) < 2:
677
+ continue
678
+ member_indices = tuple(sorted(component))
679
+ parents.append(
680
+ _VisualParent(
681
+ member_indices,
682
+ angle,
683
+ _bbox_union_many([local_bboxes[index] for index in member_indices]),
684
+ )
685
+ )
686
+ return parents
687
+
688
+
689
+ def _relation_has_intervening_block(
690
+ relation: _AnnotationRelation,
691
+ annotation_index: int,
692
+ annotation_bbox: BBox,
693
+ line_height: float,
694
+ blocks: list[dict[str, Any]],
695
+ page_size: tuple[float, float],
696
+ annotation_indices: set[int],
697
+ ) -> bool:
698
+ """检查注释与父块净空走廊内是否横隔正文或另一个视觉块。"""
699
+
700
+ if relation.normalized_gap <= 0:
701
+ return False
702
+ parent_bbox = relation.parent.local_bbox
703
+ if relation.direction in {"above", "below"}:
704
+ orth_start = max(annotation_bbox[0], parent_bbox[0]) - 0.25 * line_height
705
+ orth_end = min(annotation_bbox[2], parent_bbox[2]) + 0.25 * line_height
706
+ if relation.direction == "above":
707
+ corridor = (orth_start, annotation_bbox[3], orth_end, parent_bbox[1])
708
+ else:
709
+ corridor = (orth_start, parent_bbox[3], orth_end, annotation_bbox[1])
710
+ else:
711
+ orth_start = max(annotation_bbox[1], parent_bbox[1]) - 0.25 * line_height
712
+ orth_end = min(annotation_bbox[3], parent_bbox[3]) + 0.25 * line_height
713
+ if relation.direction == "left":
714
+ corridor = (annotation_bbox[2], orth_start, parent_bbox[0], orth_end)
715
+ else:
716
+ corridor = (parent_bbox[2], orth_start, annotation_bbox[0], orth_end)
717
+ if corridor[2] <= corridor[0] or corridor[3] <= corridor[1]:
718
+ return False
719
+ ignored_indices = {
720
+ annotation_index,
721
+ *relation.parent.member_indices,
722
+ *annotation_indices,
723
+ }
724
+ for index, block in enumerate(blocks):
725
+ if index in ignored_indices or _block_angle(block) != relation.parent.angle:
726
+ continue
727
+ bbox = _block_local_bbox(block, page_size, relation.parent.angle)
728
+ if bbox is None:
729
+ continue
730
+ if min(bbox[2], corridor[2]) > max(bbox[0], corridor[0]) and min(bbox[3], corridor[3]) > max(bbox[1], corridor[1]):
731
+ return True
732
+ return False
733
+
734
+
735
+ def _component_relation_is_materially_better(
736
+ relation: _AnnotationRelation,
737
+ single_relations: list[_AnnotationRelation],
738
+ ) -> bool:
739
+ """仅当图片并集明显提高注释覆盖时允许组件替代单图父块。"""
740
+
741
+ if len(relation.parent.member_indices) < 2:
742
+ return True
743
+ member_indices = set(relation.parent.member_indices)
744
+ comparable = [
745
+ single
746
+ for single in single_relations
747
+ if single.direction == relation.direction and single.parent.member_indices[0] in member_indices
748
+ ]
749
+ if not comparable:
750
+ return True
751
+ best_single_coverage = max(single.annotation_coverage for single in comparable)
752
+ return relation.annotation_coverage >= best_single_coverage + _COMPONENT_COVERAGE_GAIN
753
+
754
+
755
+ def _choose_annotation_relation(
756
+ annotation_index: int,
757
+ kind: _AnnotationKind,
758
+ blocks: list[dict[str, Any]],
759
+ page_size: tuple[float, float],
760
+ parents: list[_VisualParent],
761
+ annotation_indices: set[int],
762
+ ) -> _AnnotationRelation | None:
763
+ """先过滤空间、组件收益和阻挡关系,再按距离、覆盖和居中选父块。"""
764
+
765
+ annotation = blocks[annotation_index]
766
+ angle = _block_angle(annotation)
767
+ annotation_bbox = _block_local_bbox(annotation, page_size, angle)
768
+ if annotation_bbox is None:
769
+ return None
770
+ line_height = _block_median_line_height(annotation, page_size)
771
+ relations = [
772
+ relation
773
+ for parent in parents
774
+ if parent.angle == angle
775
+ and (
776
+ relation := _best_parent_relation(
777
+ parent,
778
+ annotation_bbox,
779
+ line_height,
780
+ kind,
781
+ )
782
+ )
783
+ is not None
784
+ ]
785
+ single_relations = [relation for relation in relations if len(relation.parent.member_indices) == 1]
786
+ relations = [
787
+ relation
788
+ for relation in relations
789
+ if _component_relation_is_materially_better(relation, single_relations)
790
+ and not _relation_has_intervening_block(
791
+ relation,
792
+ annotation_index,
793
+ annotation_bbox,
794
+ line_height,
795
+ blocks,
796
+ page_size,
797
+ annotation_indices,
798
+ )
799
+ ]
800
+ if not relations:
801
+ return None
802
+ return min(
803
+ relations,
804
+ key=lambda relation: (
805
+ relation.normalized_gap,
806
+ -relation.annotation_coverage,
807
+ -relation.projection_overlap,
808
+ relation.center_offset,
809
+ len(relation.parent.member_indices),
810
+ relation.parent.member_indices,
811
+ ),
812
+ )
813
+
814
+
815
+ def _caption_blocks_use_distinct_regular_lanes(
816
+ anchor: dict[str, Any],
817
+ candidate: dict[str, Any],
818
+ ) -> bool:
819
+ """确认两个标题块分别属于互不重叠的普通栏带。"""
820
+
821
+ if anchor.get("_lane_is_span") is not False or candidate.get("_lane_is_span") is not False:
822
+ return False
823
+ anchor_interval = _coerce_lane_interval(anchor.get("_lane_interval"))
824
+ candidate_interval = _coerce_lane_interval(candidate.get("_lane_interval"))
825
+ if anchor_interval is None or candidate_interval is None:
826
+ return False
827
+ return anchor_interval[1] <= candidate_interval[0] or candidate_interval[1] <= anchor_interval[0]
828
+
829
+
830
+ def _caption_blocks_have_compatible_typography(
831
+ anchor: dict[str, Any],
832
+ candidate: dict[str, Any],
833
+ page_size: tuple[float, float],
834
+ ) -> bool:
835
+ """用行高和字体交集确认跨栏标题块来自同一排版层级。"""
836
+
837
+ anchor_height = _block_median_line_height(anchor, page_size)
838
+ candidate_height = _block_median_line_height(candidate, page_size)
839
+ if anchor_height <= 0 or candidate_height <= 0:
840
+ return False
841
+ height_ratio = candidate_height / anchor_height
842
+ if not (_CROSS_LANE_CAPTION_MIN_LINE_HEIGHT_RATIO <= height_ratio <= _CROSS_LANE_CAPTION_MAX_LINE_HEIGHT_RATIO):
843
+ return False
844
+
845
+ anchor_fonts = anchor.get("_font_signatures")
846
+ candidate_fonts = candidate.get("_font_signatures")
847
+ if not (
848
+ isinstance(anchor_fonts, (set, frozenset))
849
+ and isinstance(candidate_fonts, (set, frozenset))
850
+ and anchor_fonts
851
+ and candidate_fonts
852
+ ):
853
+ return False
854
+ return not anchor_fonts.isdisjoint(candidate_fonts)
855
+
856
+
857
+ def _cross_lane_caption_companion_relation(
858
+ anchor_index: int,
859
+ candidate_index: int,
860
+ anchor_relation: _AnnotationRelation,
861
+ blocks: list[dict[str, Any]],
862
+ page_size: tuple[float, float],
863
+ parents: list[_VisualParent],
864
+ annotation_indices: set[int],
865
+ ) -> _AnnotationRelation | None:
866
+ """仅凭空间、栏带和排版信息确认一个跨栏标题同伴。"""
867
+
868
+ if anchor_relation.direction not in {"above", "below"}:
869
+ return None
870
+ anchor = blocks[anchor_index]
871
+ candidate = blocks[candidate_index]
872
+ if candidate.get("type") != "text" or _block_angle(candidate) != anchor_relation.parent.angle:
873
+ return None
874
+ if not _caption_blocks_use_distinct_regular_lanes(anchor, candidate):
875
+ return None
876
+ if not _caption_blocks_have_compatible_typography(
877
+ anchor,
878
+ candidate,
879
+ page_size,
880
+ ):
881
+ return None
882
+
883
+ angle = anchor_relation.parent.angle
884
+ anchor_first_row = _block_first_local_row_bbox(anchor, page_size, angle)
885
+ candidate_first_row = _block_first_local_row_bbox(
886
+ candidate,
887
+ page_size,
888
+ angle,
889
+ )
890
+ if anchor_first_row is None or candidate_first_row is None:
891
+ return None
892
+ pair_height = statistics.median(
893
+ (
894
+ _block_median_line_height(anchor, page_size),
895
+ _block_median_line_height(candidate, page_size),
896
+ )
897
+ )
898
+ if abs(anchor_first_row[1] - candidate_first_row[1]) > _CROSS_LANE_CAPTION_ROW_TOP_TOLERANCE * pair_height:
899
+ return None
900
+
901
+ relation = _choose_annotation_relation(
902
+ candidate_index,
903
+ "caption",
904
+ blocks,
905
+ page_size,
906
+ parents,
907
+ annotation_indices | {candidate_index},
908
+ )
909
+ if relation is None or relation.parent != anchor_relation.parent or relation.direction != anchor_relation.direction:
910
+ return None
911
+ return relation
912
+
913
+
914
+ def _expand_cross_lane_caption_assignments(
915
+ blocks: list[dict[str, Any]],
916
+ page_size: tuple[float, float],
917
+ parents: list[_VisualParent],
918
+ candidates: dict[int, _AnnotationKind],
919
+ assignments: dict[int, _AnnotationRelation],
920
+ ) -> dict[int, _AnnotationRelation]:
921
+ """从原始已绑定标题出发,保守补标唯一的跨栏空间同伴。"""
922
+
923
+ annotation_indices = set(candidates)
924
+ proposals: dict[int, list[_AnnotationRelation]] = {}
925
+ for anchor_index, anchor_relation in assignments.items():
926
+ if candidates.get(anchor_index) != "caption":
927
+ continue
928
+ matches = [
929
+ (index, relation)
930
+ for index in range(len(blocks))
931
+ if index not in annotation_indices
932
+ and (
933
+ relation := _cross_lane_caption_companion_relation(
934
+ anchor_index,
935
+ index,
936
+ anchor_relation,
937
+ blocks,
938
+ page_size,
939
+ parents,
940
+ annotation_indices,
941
+ )
942
+ )
943
+ is not None
944
+ ]
945
+ if len(matches) != 1:
946
+ continue
947
+ index, relation = matches[0]
948
+ proposals.setdefault(index, []).append(relation)
949
+
950
+ output: dict[int, _AnnotationRelation] = {}
951
+ for index, relations in proposals.items():
952
+ # 同伴同时被多个锚点认领时归属不唯一,保守地维持正文分类。
953
+ if len(relations) == 1:
954
+ output[index] = relations[0]
955
+ return output
956
+
957
+
958
+ def _merge_parent_assignment_groups(
959
+ assignments: dict[int, _AnnotationRelation],
960
+ ) -> list[tuple[set[int], dict[int, _AnnotationRelation]]]:
961
+ """合并共享视觉成员的父候选,防止组件与单图重复展开同一主体。"""
962
+
963
+ groups: list[tuple[set[int], dict[int, _AnnotationRelation]]] = []
964
+ for annotation_index, relation in assignments.items():
965
+ parent_indices = set(relation.parent.member_indices)
966
+ overlapping = [index for index, (members, _relations) in enumerate(groups) if members & parent_indices]
967
+ merged_members = set(parent_indices)
968
+ merged_relations = {annotation_index: relation}
969
+ for group_index in reversed(overlapping):
970
+ members, relations = groups.pop(group_index)
971
+ merged_members.update(members)
972
+ merged_relations.update(relations)
973
+ groups.append((merged_members, merged_relations))
974
+ return groups
975
+
976
+
977
+ def _sort_visual_body_members(
978
+ member_indices: set[int],
979
+ blocks: list[dict[str, Any]],
980
+ page_size: tuple[float, float],
981
+ ) -> list[dict[str, Any]]:
982
+ """在共同局部方向内用 XYCut++ 排列区域中的视觉主体成员。"""
983
+
984
+ if not member_indices:
985
+ return []
986
+ angle = _block_angle(blocks[min(member_indices)])
987
+ proxies: list[dict[str, Any]] = []
988
+ for index in sorted(member_indices):
989
+ local_bbox = _block_local_bbox(blocks[index], page_size, angle)
990
+ if local_bbox is None:
991
+ continue
992
+ proxies.append({"bbox": local_bbox, "_block": blocks[index]})
993
+ return [proxy["_block"] for proxy in sort_entries(proxies)]
994
+
995
+
996
+ def _annotation_local_sort_key(
997
+ index: int,
998
+ blocks: list[dict[str, Any]],
999
+ page_size: tuple[float, float],
1000
+ ) -> tuple[float, float, int]:
1001
+ """按注释自身方向的局部上、左坐标提供稳定排序键。"""
1002
+
1003
+ block = blocks[index]
1004
+ local_bbox = _block_local_bbox(block, page_size)
1005
+ if local_bbox is None:
1006
+ return (float("inf"), float("inf"), index)
1007
+ return (local_bbox[1], local_bbox[0], index)
1008
+
1009
+
1010
+ def _sort_annotation_indices_by_visual_rows(
1011
+ indices: list[int],
1012
+ blocks: list[dict[str, Any]],
1013
+ page_size: tuple[float, float],
1014
+ relations: dict[int, _AnnotationRelation],
1015
+ ) -> list[int]:
1016
+ """先聚合首行对齐的标题视觉行,再按行内左到右稳定排序。"""
1017
+
1018
+ positioned: list[tuple[int, BBox, float]] = []
1019
+ fallback: list[int] = []
1020
+ for index in indices:
1021
+ block = blocks[index]
1022
+ angle = _block_angle(block)
1023
+ first_row = _block_first_local_row_bbox(block, page_size, angle)
1024
+ if first_row is None:
1025
+ fallback.append(index)
1026
+ continue
1027
+ positioned.append(
1028
+ (
1029
+ index,
1030
+ first_row,
1031
+ _block_median_line_height(block, page_size),
1032
+ )
1033
+ )
1034
+ positioned.sort(key=lambda item: (item[1][1], item[1][0], item[0]))
1035
+
1036
+ rows: list[list[tuple[int, BBox, float]]] = []
1037
+ for item in positioned:
1038
+ if rows:
1039
+ current_row = rows[-1]
1040
+ item_relation = relations.get(item[0])
1041
+ shares_cross_lane_row = (
1042
+ item_relation is not None
1043
+ and item_relation.direction in {"above", "below"}
1044
+ and all(
1045
+ (member_relation := relations.get(member[0])) is not None
1046
+ and member_relation.direction == item_relation.direction
1047
+ and _caption_blocks_use_distinct_regular_lanes(
1048
+ blocks[member[0]],
1049
+ blocks[item[0]],
1050
+ )
1051
+ for member in current_row
1052
+ )
1053
+ )
1054
+ reference_top = statistics.median(member[1][1] for member in current_row)
1055
+ reference_height = statistics.median(member[2] for member in current_row)
1056
+ pair_height = statistics.median((reference_height, item[2]))
1057
+ if shares_cross_lane_row and abs(item[1][1] - reference_top) <= _CROSS_LANE_CAPTION_ROW_TOP_TOLERANCE * pair_height:
1058
+ current_row.append(item)
1059
+ continue
1060
+ rows.append([item])
1061
+
1062
+ output: list[int] = []
1063
+ for row in rows:
1064
+ row.sort(key=lambda item: (item[1][0], item[1][1], item[0]))
1065
+ output.extend(item[0] for item in row)
1066
+ output.extend(
1067
+ sorted(
1068
+ fallback,
1069
+ key=lambda index: _annotation_local_sort_key(
1070
+ index,
1071
+ blocks,
1072
+ page_size,
1073
+ ),
1074
+ )
1075
+ )
1076
+ return output
1077
+
1078
+
1079
+ def _build_visual_annotation_regions(
1080
+ assignments: dict[int, _AnnotationRelation],
1081
+ blocks: list[dict[str, Any]],
1082
+ page_size: tuple[float, float],
1083
+ ) -> list[list[dict[str, Any]]]:
1084
+ """按前置标题、主体、后置标题、脚注顺序展开每个虚拟小区域。"""
1085
+
1086
+ regions: list[list[dict[str, Any]]] = []
1087
+ for parent_indices, relations in _merge_parent_assignment_groups(assignments):
1088
+ leading = [
1089
+ index
1090
+ for index, relation in relations.items()
1091
+ if blocks[index].get("type") == "caption" and relation.direction in {"above", "left"}
1092
+ ]
1093
+ trailing = [
1094
+ index
1095
+ for index, relation in relations.items()
1096
+ if blocks[index].get("type") == "caption" and relation.direction in {"below", "right"}
1097
+ ]
1098
+ footnotes = [index for index in relations if blocks[index].get("type") == "footnote"]
1099
+ regions.append(
1100
+ [
1101
+ *(
1102
+ blocks[index]
1103
+ for index in _sort_annotation_indices_by_visual_rows(
1104
+ leading,
1105
+ blocks,
1106
+ page_size,
1107
+ relations,
1108
+ )
1109
+ ),
1110
+ *_sort_visual_body_members(parent_indices, blocks, page_size),
1111
+ *(
1112
+ blocks[index]
1113
+ for index in _sort_annotation_indices_by_visual_rows(
1114
+ trailing,
1115
+ blocks,
1116
+ page_size,
1117
+ relations,
1118
+ )
1119
+ ),
1120
+ *(
1121
+ blocks[index]
1122
+ for index in sorted(
1123
+ footnotes,
1124
+ key=lambda value: _annotation_local_sort_key(
1125
+ value,
1126
+ blocks,
1127
+ page_size,
1128
+ ),
1129
+ )
1130
+ ),
1131
+ ]
1132
+ )
1133
+ return regions
1134
+
1135
+
1136
+ def _classify_and_bind_visual_annotations(
1137
+ blocks: list[dict[str, Any]],
1138
+ page_size: tuple[float, float],
1139
+ *,
1140
+ merge_text_block_group: _TextBlockGroupMerger | None = None,
1141
+ ) -> list[list[dict[str, Any]]]:
1142
+ """重分类强规则独立注释,并返回供全局 XYCut++ 使用的有序视觉区域。"""
1143
+
1144
+ candidates = _collect_annotation_candidates(blocks)
1145
+ if not candidates:
1146
+ return []
1147
+ line_heights = [_block_median_line_height(blocks[index], page_size) for index in candidates]
1148
+ component_line_height = statistics.median(line_heights) if line_heights else 8.0
1149
+ parents = _build_visual_parents(blocks, page_size, component_line_height)
1150
+ if not parents:
1151
+ return []
1152
+ annotation_indices = set(candidates)
1153
+ assignments = {
1154
+ index: relation
1155
+ for index, kind in candidates.items()
1156
+ if (
1157
+ relation := _choose_annotation_relation(
1158
+ index,
1159
+ kind,
1160
+ blocks,
1161
+ page_size,
1162
+ parents,
1163
+ annotation_indices,
1164
+ )
1165
+ )
1166
+ is not None
1167
+ }
1168
+ assignments.update(
1169
+ _expand_stacked_bilingual_caption_assignments(
1170
+ blocks,
1171
+ page_size,
1172
+ candidates,
1173
+ assignments,
1174
+ )
1175
+ )
1176
+ cross_lane_assignments = _expand_cross_lane_caption_assignments(
1177
+ blocks,
1178
+ page_size,
1179
+ parents,
1180
+ candidates,
1181
+ assignments,
1182
+ )
1183
+ candidates.update(dict.fromkeys(cross_lane_assignments, "caption"))
1184
+ assignments.update(cross_lane_assignments)
1185
+ consumed_indices = _merge_table_footnote_continuations(
1186
+ blocks,
1187
+ candidates,
1188
+ assignments,
1189
+ page_size,
1190
+ merge_text_block_group,
1191
+ )
1192
+ for index, relation in assignments.items():
1193
+ blocks[index]["type"] = candidates[index]
1194
+ blocks[index]["_visual_annotation_direction"] = relation.direction
1195
+ regions = _build_visual_annotation_regions(assignments, blocks, page_size)
1196
+ if consumed_indices:
1197
+ blocks[:] = [block for index, block in enumerate(blocks) if index not in consumed_indices]
1198
+ return regions
1199
+
1200
+
1201
+ def _expand_stacked_bilingual_caption_assignments(
1202
+ blocks: list[dict[str, Any]],
1203
+ page_size: tuple[float, float],
1204
+ candidates: dict[int, _AnnotationKind],
1205
+ assignments: dict[int, _AnnotationRelation],
1206
+ ) -> dict[int, _AnnotationRelation]:
1207
+ """把紧邻且编号相同的中英文双语图题绑定到同一视觉主体。"""
1208
+
1209
+ output: dict[int, _AnnotationRelation] = {}
1210
+ assigned_captions = [index for index in assignments if candidates.get(index) == "caption"]
1211
+ for candidate_index, kind in candidates.items():
1212
+ if kind != "caption" or candidate_index in assignments:
1213
+ continue
1214
+ candidate_identifier = _caption_identifier(
1215
+ str(blocks[candidate_index].get("content") or ""),
1216
+ )
1217
+ candidate_bbox = _block_local_bbox(
1218
+ blocks[candidate_index],
1219
+ page_size,
1220
+ )
1221
+ if candidate_identifier is None or candidate_bbox is None:
1222
+ continue
1223
+ candidate_height = _block_median_line_height(
1224
+ blocks[candidate_index],
1225
+ page_size,
1226
+ )
1227
+ matches: list[tuple[float, int]] = []
1228
+ for anchor_index in assigned_captions:
1229
+ if _caption_identifier(
1230
+ str(blocks[anchor_index].get("content") or ""),
1231
+ ) != candidate_identifier or _block_angle(blocks[anchor_index]) != _block_angle(blocks[candidate_index]):
1232
+ continue
1233
+ anchor_bbox = _block_local_bbox(
1234
+ blocks[anchor_index],
1235
+ page_size,
1236
+ )
1237
+ if anchor_bbox is None:
1238
+ continue
1239
+ anchor_height = _block_median_line_height(
1240
+ blocks[anchor_index],
1241
+ page_size,
1242
+ )
1243
+ gap = max(
1244
+ candidate_bbox[1] - anchor_bbox[3],
1245
+ anchor_bbox[1] - candidate_bbox[3],
1246
+ 0.0,
1247
+ )
1248
+ if (
1249
+ gap <= 1.5 * max(candidate_height, anchor_height)
1250
+ and _bbox_axis_overlap_ratio(
1251
+ candidate_bbox,
1252
+ anchor_bbox,
1253
+ axis="x",
1254
+ )
1255
+ >= 0.35
1256
+ ):
1257
+ matches.append((gap, anchor_index))
1258
+ if not matches:
1259
+ continue
1260
+ _gap, anchor_index = min(matches)
1261
+ output[candidate_index] = assignments[anchor_index]
1262
+ return output