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,1501 @@
1
+ """检测 Form、矢量图形和栅格图片并认领内部文本。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ import math
7
+ import re
8
+ import statistics
9
+ from typing import Any
10
+
11
+
12
+ from ....schema import BBox
13
+ from ....document.pdf.document import PDFPathInfo
14
+
15
+ from .models import _AxisLine, _GraphicCandidate, _LineItem, _PageSource, _TextLane
16
+ from .geometry import (
17
+ _bbox_area,
18
+ _bbox_axis_overlap_ratio,
19
+ _bbox_center_x,
20
+ _bbox_center_y,
21
+ _bbox_distance,
22
+ _bbox_intersects,
23
+ _bbox_overlap_in_first,
24
+ _bbox_overlap_in_smaller,
25
+ _bbox_union,
26
+ _bbox_union_many,
27
+ _clip_bbox,
28
+ _coerce_bbox,
29
+ _point_in_bbox,
30
+ _rotate_bbox_to_upright,
31
+ )
32
+ from .native_text import _fill_native_typography, _normalize_native_run_text, _sanitize_pdf_control_text
33
+ from .line_layout import _infer_text_lanes, _line_effective_height
34
+ from .line_merging import _join_formula_visual_row
35
+
36
+
37
+ _MIN_RASTER_IMAGE_PAGE_AREA_RATIO = 0.0038
38
+
39
+
40
+ _SIGNATURE_IMAGE_BBOX_DEDUP_TOLERANCE = 0.5
41
+
42
+
43
+ _MIN_FORM_IMAGE_PAGE_AREA_RATIO = 0.01
44
+
45
+
46
+ _MAX_FORM_IMAGE_PAGE_AREA_RATIO = 0.8
47
+
48
+
49
+ _IMAGE_CONTAINER_OVERLAP_THRESHOLD = 0.5
50
+
51
+
52
+ _FIGURE_CAPTION_LINE_RE = re.compile(
53
+ r"^\s*(?:fig(?:ure)?\.?)[ \t]*\d+[A-Za-z]?(?:\s*[.:])?",
54
+ re.IGNORECASE,
55
+ )
56
+
57
+
58
+ def _form_supersedes_nested_bbox(form_bbox: BBox, nested_bbox: BBox) -> bool:
59
+ """判断 Form 是否应整体吞并其内部面积明显更小的候选容器。"""
60
+
61
+ form_area = _bbox_area(form_bbox)
62
+ nested_area = _bbox_area(nested_bbox)
63
+ return form_area > 0 and nested_area < 0.5 * form_area and _bbox_overlap_in_first(nested_bbox, form_bbox) >= 0.9
64
+
65
+
66
+ def _tighten_form_image_bbox(
67
+ source: _PageSource,
68
+ form_bbox: BBox,
69
+ ) -> BBox:
70
+ """用充分的 Form 内部矢量与文本证据收紧空白容器,证据不足时保留原框。"""
71
+
72
+ internal_paths = [
73
+ path_info.bbox
74
+ for path_info in source.path_infos
75
+ if path_info.form_depth > 0 and _bbox_overlap_in_first(path_info.bbox, form_bbox) >= 0.9
76
+ ]
77
+ internal_drawing_lines = [
78
+ drawing_line.bbox
79
+ for drawing_line in source.drawing_lines
80
+ if _bbox_overlap_in_first(drawing_line.bbox, form_bbox) >= 0.9
81
+ ]
82
+ # 至少两个嵌套 Path 和四个矢量元素,避免只凭普通边框或少量文本裁剪 Form。
83
+ if len(internal_paths) < 2 or len(internal_paths) + len(internal_drawing_lines) < 4:
84
+ return form_bbox
85
+ internal_text = [line.bbox for line in source.lines if _bbox_overlap_in_first(line.bbox, form_bbox) >= 0.9]
86
+ evidence_bbox = _clip_bbox(
87
+ _bbox_union_many(internal_paths + internal_drawing_lines + internal_text),
88
+ source.page_size,
89
+ )
90
+ if evidence_bbox is None:
91
+ return form_bbox
92
+ form_width = max(0.1, form_bbox[2] - form_bbox[0])
93
+ form_height = max(0.1, form_bbox[3] - form_bbox[1])
94
+ evidence_width = evidence_bbox[2] - evidence_bbox[0]
95
+ evidence_height = evidence_bbox[3] - evidence_bbox[1]
96
+ if (
97
+ evidence_width < 0.5 * form_width
98
+ or evidence_height < 0.5 * form_height
99
+ or _bbox_area(evidence_bbox) < 0.25 * _bbox_area(form_bbox)
100
+ ):
101
+ return form_bbox
102
+ return evidence_bbox
103
+
104
+
105
+ def _select_form_image_bboxes(source: _PageSource) -> list[BBox]:
106
+ """按页面占比、行高和内部视觉证据筛选矢量 Form 图片候选。"""
107
+
108
+ page_area = max(0.0, source.page_size[0]) * max(0.0, source.page_size[1])
109
+ if page_area <= 0 or not source.form_bboxes:
110
+ return []
111
+ effective_heights = [_line_effective_height(line, line.bbox) for line in source.lines if line.angle == 0]
112
+ median_height = statistics.median(effective_heights) if effective_heights else 1.0
113
+ output: list[BBox] = []
114
+ for raw_bbox in source.form_bboxes:
115
+ bbox = _clip_bbox(_coerce_bbox(raw_bbox), source.page_size)
116
+ if bbox is None:
117
+ continue
118
+ width = bbox[2] - bbox[0]
119
+ height = bbox[3] - bbox[1]
120
+ area_ratio = _bbox_area(bbox) / page_area
121
+ if not (
122
+ _MIN_FORM_IMAGE_PAGE_AREA_RATIO <= area_ratio <= _MAX_FORM_IMAGE_PAGE_AREA_RATIO
123
+ and width >= 4.0 * median_height
124
+ and height >= 4.0 * median_height
125
+ ):
126
+ continue
127
+
128
+ member_rows = {
129
+ line.visual_row_id if line.visual_row_id is not None else line.source_index
130
+ for line in source.lines
131
+ if _bbox_overlap_in_first(line.bbox, bbox) >= 0.9
132
+ }
133
+ internal_drawing_count = sum(
134
+ _bbox_overlap_in_first(drawing_line.bbox, bbox) >= 0.9 for drawing_line in source.drawing_lines
135
+ )
136
+ if len(member_rows) < 2 and internal_drawing_count < 4:
137
+ continue
138
+ output.append(_tighten_form_image_bbox(source, bbox))
139
+ return sorted(output, key=lambda bbox: (bbox[1], bbox[0], bbox[3], bbox[2]))
140
+
141
+
142
+ def _build_form_image_blocks(
143
+ source: _PageSource,
144
+ form_bboxes: list[BBox],
145
+ claimed_line_indices: set[int],
146
+ ) -> tuple[list[dict[str, Any]], set[int]]:
147
+ """把 Form 及其完整内含文本输出为 image,并保持 source_index 唯一认领。"""
148
+
149
+ if not form_bboxes:
150
+ return [], set()
151
+ members_by_candidate: list[list[_LineItem]] = [[] for _ in form_bboxes]
152
+ claimed: set[int] = set()
153
+ for line in source.lines:
154
+ if line.source_index in claimed_line_indices:
155
+ continue
156
+ matching_indices = [
157
+ candidate_index
158
+ for candidate_index, bbox in enumerate(form_bboxes)
159
+ if _bbox_overlap_in_first(line.bbox, bbox) >= 0.9
160
+ ]
161
+ if not matching_indices:
162
+ continue
163
+ candidate_index = min(
164
+ matching_indices,
165
+ key=lambda index: (_bbox_area(form_bboxes[index]), index),
166
+ )
167
+ members_by_candidate[candidate_index].append(line)
168
+ claimed.add(line.source_index)
169
+
170
+ blocks = [
171
+ {
172
+ "type": "image",
173
+ "bbox": bbox,
174
+ "angle": 0,
175
+ "content": _image_members_to_content(members, source.page_size),
176
+ }
177
+ for bbox, members in zip(form_bboxes, members_by_candidate, strict=True)
178
+ ]
179
+ blocks.sort(key=lambda block: (block["bbox"][1], block["bbox"][0]))
180
+ return blocks, claimed
181
+
182
+
183
+ def _build_graphic_like_blocks(
184
+ source: _PageSource,
185
+ table_bboxes: list[BBox],
186
+ claimed_line_indices: set[int],
187
+ strong_core_bboxes: list[BBox] | None = None,
188
+ ) -> tuple[list[dict[str, Any]], set[int]]:
189
+ """在表格认领后把紧凑绘图组件及其短标签聚成内部图形文本块。"""
190
+
191
+ lines = [line for line in source.lines if line.source_index not in claimed_line_indices]
192
+ if strong_core_bboxes is None:
193
+ strong_core_bboxes = _detect_strong_graphic_bboxes(source)
194
+ if len(lines) < 2 or (len(source.drawing_lines) < 4 and not strong_core_bboxes):
195
+ return [], set()
196
+
197
+ effective_heights = [
198
+ max(
199
+ 0.1,
200
+ line.effective_height
201
+ or min(
202
+ max(0.1, line.bbox[2] - line.bbox[0]),
203
+ max(0.1, line.bbox[3] - line.bbox[1]),
204
+ ),
205
+ )
206
+ for line in lines
207
+ ]
208
+ median_height = statistics.median(effective_heights)
209
+ lanes = _infer_graphic_text_lanes(lines, source.page_size, median_height)
210
+ line_candidates = _detect_graphic_candidates(
211
+ source.drawing_lines,
212
+ source.page_size,
213
+ median_height,
214
+ lanes,
215
+ table_bboxes,
216
+ )
217
+ # 复杂 Path 或成对坐标轴形成的强图形核心优先于普通绘图线组件,
218
+ # 避免同一图表被拆成多个相互重叠的 image。
219
+ candidates = [
220
+ candidate
221
+ for candidate in line_candidates
222
+ if not any(_bbox_overlap_in_smaller(candidate.core_bbox, core_bbox) >= 0.5 for core_bbox in strong_core_bboxes)
223
+ ]
224
+ candidates.extend(
225
+ _GraphicCandidate(
226
+ core_bbox=core_bbox,
227
+ lane_index=_strong_graphic_lane_index(
228
+ core_bbox,
229
+ lanes,
230
+ median_height,
231
+ ),
232
+ label_margin_scale=(
233
+ 2.5
234
+ if any(
235
+ _bbox_overlap_in_smaller(candidate.core_bbox, core_bbox) >= 0.5
236
+ and _bbox_area(candidate.core_bbox) >= 0.8 * _bbox_area(core_bbox)
237
+ for candidate in line_candidates
238
+ )
239
+ else 1.0
240
+ ),
241
+ )
242
+ for core_bbox in strong_core_bboxes
243
+ if not any(_bbox_overlap_in_smaller(core_bbox, table_bbox) >= 0.5 for table_bbox in table_bboxes)
244
+ )
245
+ if not candidates:
246
+ return [], set()
247
+
248
+ row_groups: dict[tuple[int, int, int, int], list[_LineItem]] = {}
249
+ for line in lines:
250
+ lane_index = _graphic_lane_index(line.bbox, lanes)
251
+ if line.visual_row_id is None:
252
+ row_kind, row_identity = 1, line.source_index
253
+ else:
254
+ row_kind, row_identity = 0, line.visual_row_id
255
+ row_groups.setdefault(
256
+ (line.angle, row_kind, row_identity, lane_index),
257
+ [],
258
+ ).append(line)
259
+
260
+ protected_caption_indices = _graphic_caption_line_indices_to_preserve(
261
+ lines,
262
+ candidates,
263
+ median_height,
264
+ )
265
+ protected_body_tail_indices = _graphic_body_tail_line_indices_to_preserve(
266
+ lines,
267
+ candidates,
268
+ lanes,
269
+ median_height,
270
+ )
271
+
272
+ for row_lines in row_groups.values():
273
+ if any(line.source_index in protected_caption_indices | protected_body_tail_indices for line in row_lines):
274
+ continue
275
+ row_lane_index = _graphic_lane_index(row_lines[0].bbox, lanes)
276
+ matches: list[tuple[int, float, int]] = []
277
+ for candidate_index, candidate in enumerate(candidates):
278
+ if candidate.lane_index >= 0 and candidate.lane_index != row_lane_index:
279
+ continue
280
+ member_flags = [
281
+ _is_graphic_label_member(
282
+ line,
283
+ candidate.core_bbox,
284
+ median_height,
285
+ margin_scale=candidate.label_margin_scale,
286
+ )
287
+ for line in row_lines
288
+ ]
289
+ # 同一 pdftext 视觉行必须整体归属或整体保留,避免只吞掉 caption 的短碎片。
290
+ if not all(member_flags):
291
+ continue
292
+ inside_count = sum(
293
+ _point_in_bbox(
294
+ (_bbox_center_x(line.bbox), _bbox_center_y(line.bbox)),
295
+ candidate.core_bbox,
296
+ )
297
+ for line in row_lines
298
+ )
299
+ mean_distance = statistics.fmean(_bbox_distance(line.bbox, candidate.core_bbox) for line in row_lines)
300
+ matches.append((-inside_count, mean_distance, candidate_index))
301
+ if not matches:
302
+ continue
303
+ candidate_index = min(matches)[2]
304
+ candidates[candidate_index].line_indices.update(line.source_index for line in row_lines)
305
+
306
+ blocks: list[dict[str, Any]] = []
307
+ claimed: set[int] = set()
308
+ lines_by_index = {line.source_index: line for line in lines}
309
+ for candidate in candidates:
310
+ members = [
311
+ lines_by_index[source_index] for source_index in sorted(candidate.line_indices) if source_index in lines_by_index
312
+ ]
313
+ if len(members) < 2:
314
+ continue
315
+ block = _graphic_members_to_block(candidate, members, source.page_size)
316
+ if block is None:
317
+ continue
318
+ blocks.append(block)
319
+ claimed.update(line.source_index for line in members)
320
+
321
+ blocks.sort(key=lambda block: (block["bbox"][1], block["bbox"][0]))
322
+ return blocks, claimed
323
+
324
+
325
+ def _parallel_graphic_rule_pairs(
326
+ drawing_lines: list[_AxisLine],
327
+ image_bboxes: list[BBox],
328
+ table_bboxes: list[BBox],
329
+ page_size: tuple[float, float],
330
+ median_height: float,
331
+ ) -> list[tuple[BBox, BBox]]:
332
+ """筛选分别贴近两个并排图形上沿的同高长横线。"""
333
+
334
+ minimum_rule_width = max(8.0 * median_height, 0.18 * page_size[0])
335
+ long_rules = [
336
+ line.bbox
337
+ for line in drawing_lines
338
+ if line.orientation == "horizontal"
339
+ and line.bbox[2] - line.bbox[0] >= minimum_rule_width
340
+ and not any(
341
+ _point_in_bbox(
342
+ (_bbox_center_x(line.bbox), _bbox_center_y(line.bbox)),
343
+ table_bbox,
344
+ )
345
+ for table_bbox in table_bboxes
346
+ )
347
+ ]
348
+ ordered_images = sorted(image_bboxes, key=lambda bbox: (bbox[0], bbox[1]))
349
+ rule_pairs: list[tuple[BBox, BBox]] = []
350
+ seen_pairs: set[tuple[BBox, BBox]] = set()
351
+ for left_index, left_image in enumerate(ordered_images):
352
+ left_height = max(0.1, left_image[3] - left_image[1])
353
+ for right_image in ordered_images[left_index + 1 :]:
354
+ if left_image[2] >= right_image[0]:
355
+ continue
356
+ right_height = max(0.1, right_image[3] - right_image[1])
357
+ image_overlap = max(
358
+ 0.0,
359
+ min(left_image[3], right_image[3]) - max(left_image[1], right_image[1]),
360
+ )
361
+ if image_overlap < 0.7 * min(left_height, right_height):
362
+ continue
363
+ if any(
364
+ _bbox_overlap_in_smaller(image_bbox, table_bbox) >= 0.5
365
+ for image_bbox in (left_image, right_image)
366
+ for table_bbox in table_bboxes
367
+ ):
368
+ continue
369
+
370
+ left_rules = [
371
+ rule_bbox
372
+ for rule_bbox in long_rules
373
+ if _bbox_axis_overlap_ratio(rule_bbox, left_image, axis="x") >= 0.8
374
+ and -0.25 * median_height <= left_image[1] - rule_bbox[3] <= 3.0 * median_height
375
+ ]
376
+ right_rules = [
377
+ rule_bbox
378
+ for rule_bbox in long_rules
379
+ if _bbox_axis_overlap_ratio(rule_bbox, right_image, axis="x") >= 0.8
380
+ and -0.25 * median_height <= right_image[1] - rule_bbox[3] <= 3.0 * median_height
381
+ ]
382
+ for left_rule in left_rules:
383
+ for right_rule in right_rules:
384
+ if left_rule[2] >= right_rule[0]:
385
+ continue
386
+ if abs(_bbox_center_y(left_rule) - _bbox_center_y(right_rule)) > 0.5 * median_height:
387
+ continue
388
+ rule_gap = right_rule[0] - left_rule[2]
389
+ if not 0.5 * median_height <= rule_gap <= 5.0 * median_height:
390
+ continue
391
+ pair = (left_rule, right_rule)
392
+ if pair not in seen_pairs:
393
+ seen_pairs.add(pair)
394
+ rule_pairs.append(pair)
395
+ return rule_pairs
396
+
397
+
398
+ def _parallel_graphic_row_split_boundary(
399
+ members: list[_LineItem],
400
+ left_rule: BBox,
401
+ right_rule: BBox,
402
+ table_bboxes: list[BBox],
403
+ page_size: tuple[float, float],
404
+ median_height: float,
405
+ ) -> float | None:
406
+ """用横线栏沟和字符投影确认并排图形上方文本的安全切分点。"""
407
+
408
+ if not members or any(member.angle != 0 for member in members) or len({member.semantic_type for member in members}) != 1:
409
+ return None
410
+ row_bbox = _bbox_union_many([member.bbox for member in members])
411
+ if any(_bbox_intersects(row_bbox, table_bbox) for table_bbox in table_bboxes):
412
+ return None
413
+ rule_top = min(left_rule[1], right_rule[1])
414
+ if not -0.2 * median_height <= rule_top - row_bbox[3] <= 1.5 * median_height:
415
+ return None
416
+
417
+ glyph_bboxes = [
418
+ bbox
419
+ for member in members
420
+ for char in member.chars
421
+ if str(char.get("char") or "").isprintable()
422
+ and not str(char.get("char") or "").isspace()
423
+ and (bbox := _clip_bbox(_coerce_bbox(char.get("bbox")), page_size)) is not None
424
+ ]
425
+ if not glyph_bboxes:
426
+ return None
427
+ boundary = 0.5 * (left_rule[2] + right_rule[0])
428
+ if any(
429
+ _bbox_center_x(bbox) < left_rule[0] - median_height or _bbox_center_x(bbox) > right_rule[2] + median_height
430
+ for bbox in glyph_bboxes
431
+ ):
432
+ return None
433
+ left_glyphs = [bbox for bbox in glyph_bboxes if _bbox_center_x(bbox) < boundary]
434
+ right_glyphs = [bbox for bbox in glyph_bboxes if _bbox_center_x(bbox) > boundary]
435
+ if len(left_glyphs) < 3 or len(right_glyphs) < 3:
436
+ return None
437
+ left_width = max(bbox[2] for bbox in left_glyphs) - min(bbox[0] for bbox in left_glyphs)
438
+ right_width = max(bbox[2] for bbox in right_glyphs) - min(bbox[0] for bbox in right_glyphs)
439
+ if min(left_width, right_width) < 4.0 * median_height:
440
+ return None
441
+ left_edge = max(bbox[2] for bbox in left_glyphs)
442
+ right_edge = min(bbox[0] for bbox in right_glyphs)
443
+ if right_edge - left_edge < 0.75 * median_height:
444
+ return None
445
+ if not (left_edge <= left_rule[2] <= right_edge and left_edge <= right_rule[0] <= right_edge):
446
+ return None
447
+ return boundary
448
+
449
+
450
+ def _split_parallel_graphic_rule_rows(
451
+ lines: list[_LineItem],
452
+ drawing_lines: list[_AxisLine],
453
+ image_bboxes: list[BBox],
454
+ table_bboxes: list[BBox],
455
+ page_size: tuple[float, float],
456
+ *,
457
+ source_index_start: int | None = None,
458
+ ) -> list[_LineItem]:
459
+ """按成对图形、独立顶边横线和栏沟字符投影拆分并排图形上方文本。"""
460
+
461
+ horizontal_lines = [line for line in lines if line.angle == 0 and line.effective_height > 0]
462
+ if len(horizontal_lines) < 1 or len(image_bboxes) < 2:
463
+ return list(lines)
464
+ median_height = statistics.median(line.effective_height for line in horizontal_lines)
465
+ rule_pairs = _parallel_graphic_rule_pairs(
466
+ drawing_lines,
467
+ image_bboxes,
468
+ table_bboxes,
469
+ page_size,
470
+ median_height,
471
+ )
472
+ if not rule_pairs:
473
+ return list(lines)
474
+
475
+ row_groups: dict[tuple[int, int], list[_LineItem]] = {}
476
+ for line in horizontal_lines:
477
+ if line.visual_row_id is not None:
478
+ row_groups.setdefault((line.angle, line.visual_row_id), []).append(line)
479
+ boundaries_by_row: dict[tuple[int, int], list[float]] = {}
480
+ for row_key, members in row_groups.items():
481
+ for left_rule, right_rule in rule_pairs:
482
+ boundary = _parallel_graphic_row_split_boundary(
483
+ members,
484
+ left_rule,
485
+ right_rule,
486
+ table_bboxes,
487
+ page_size,
488
+ median_height,
489
+ )
490
+ if boundary is None:
491
+ continue
492
+ row_boundaries = boundaries_by_row.setdefault(row_key, [])
493
+ if not any(abs(boundary - existing) <= 0.5 * median_height for existing in row_boundaries):
494
+ row_boundaries.append(boundary)
495
+ if not boundaries_by_row:
496
+ return list(lines)
497
+
498
+ next_source_index = max(
499
+ max((line.source_index for line in lines), default=-1) + 1,
500
+ source_index_start or 0,
501
+ )
502
+ consumed_source_indices: set[int] = set()
503
+ split_lines: list[_LineItem] = []
504
+ for row_key, boundaries in boundaries_by_row.items():
505
+ members = sorted(
506
+ row_groups[row_key],
507
+ key=lambda line: (line.bbox[0], line.run_index, line.source_index),
508
+ )
509
+ ordered_chars = [char for member in members for char in member.chars]
510
+ split_indices: list[int] = []
511
+ for boundary in sorted(boundaries):
512
+ split_index = next(
513
+ (
514
+ index
515
+ for index, char in enumerate(ordered_chars)
516
+ if str(char.get("char") or "").isprintable()
517
+ and not str(char.get("char") or "").isspace()
518
+ and (
519
+ bbox := _clip_bbox(
520
+ _coerce_bbox(char.get("bbox")),
521
+ page_size,
522
+ )
523
+ )
524
+ is not None
525
+ and _bbox_center_x(bbox) > boundary
526
+ ),
527
+ None,
528
+ )
529
+ if split_index is not None and split_index not in split_indices:
530
+ split_indices.append(split_index)
531
+ if not split_indices:
532
+ continue
533
+ ranges: list[tuple[int, int]] = []
534
+ start = 0
535
+ for split_index in sorted(split_indices):
536
+ ranges.append((start, split_index))
537
+ start = split_index
538
+ ranges.append((start, len(ordered_chars)))
539
+
540
+ source_indices = [member.source_index for member in members]
541
+ rebuilt: list[_LineItem] = []
542
+ for run_index, (start, end) in enumerate(ranges):
543
+ run_chars = ordered_chars[start:end]
544
+ run_bboxes = [
545
+ bbox
546
+ for char in run_chars
547
+ if str(char.get("char") or "").isprintable()
548
+ and not str(char.get("char") or "").isspace()
549
+ and (
550
+ bbox := _clip_bbox(
551
+ _coerce_bbox(char.get("bbox")),
552
+ page_size,
553
+ )
554
+ )
555
+ is not None
556
+ ]
557
+ run_text = _normalize_native_run_text("".join(str(char.get("char") or "") for char in run_chars))
558
+ if not run_text or not run_bboxes:
559
+ continue
560
+ if run_index < len(source_indices):
561
+ source_index = source_indices[run_index]
562
+ else:
563
+ source_index = next_source_index
564
+ next_source_index += 1
565
+ template = members[min(run_index, len(members) - 1)]
566
+ rebuilt_line = replace(
567
+ template,
568
+ text=run_text,
569
+ bbox=_bbox_union_many(run_bboxes),
570
+ source_index=source_index,
571
+ chars=list(run_chars),
572
+ visual_row_id=row_key[1],
573
+ run_index=run_index,
574
+ split_from_row=True,
575
+ preserve_split_boundary=True,
576
+ )
577
+ _fill_native_typography(rebuilt_line, page_size)
578
+ rebuilt.append(rebuilt_line)
579
+ if len(rebuilt) < 2:
580
+ continue
581
+ consumed_source_indices.update(member.source_index for member in members)
582
+ split_lines.extend(rebuilt)
583
+
584
+ output = [line for line in lines if line.source_index not in consumed_source_indices]
585
+ output.extend(split_lines)
586
+ output.sort(key=lambda line: (line.angle, line.bbox[1], line.bbox[0], line.source_index))
587
+ return output
588
+
589
+
590
+ def _graphic_caption_line_indices_to_preserve(
591
+ lines: list[_LineItem],
592
+ candidates: list[_GraphicCandidate],
593
+ median_height: float,
594
+ ) -> set[int]:
595
+ """保护贴近图形下沿的图注及其同字体续行,避免末词被图片容器认领。"""
596
+
597
+ protected: set[int] = set()
598
+ ordered_lines = sorted(
599
+ (line for line in lines if line.angle == 0),
600
+ key=lambda line: (line.bbox[1], line.bbox[0], line.source_index),
601
+ )
602
+ for seed_index, seed in enumerate(ordered_lines):
603
+ if not _FIGURE_CAPTION_LINE_RE.match(seed.text):
604
+ continue
605
+ matching_candidates = [
606
+ candidate
607
+ for candidate in candidates
608
+ if _bbox_axis_overlap_ratio(
609
+ seed.bbox,
610
+ candidate.core_bbox,
611
+ axis="x",
612
+ )
613
+ >= 0.35
614
+ and candidate.core_bbox[3] - 2.5 * median_height
615
+ <= _bbox_center_y(seed.bbox)
616
+ <= candidate.core_bbox[3] + 2.5 * median_height
617
+ ]
618
+ if not matching_candidates:
619
+ continue
620
+ protected.add(seed.source_index)
621
+ previous = seed
622
+ for candidate_line in ordered_lines[seed_index + 1 :]:
623
+ if candidate_line.bbox[1] - previous.bbox[3] > 0.75 * median_height:
624
+ break
625
+ if _bbox_center_y(candidate_line.bbox) <= _bbox_center_y(previous.bbox):
626
+ continue
627
+ if abs(candidate_line.bbox[0] - seed.bbox[0]) > median_height or (
628
+ seed.font_signature is not None
629
+ and candidate_line.font_signature is not None
630
+ and seed.font_signature != candidate_line.font_signature
631
+ ):
632
+ continue
633
+ protected.add(candidate_line.source_index)
634
+ previous = candidate_line
635
+ if candidate_line.text.rstrip().endswith((".", "!", "?")):
636
+ break
637
+ return protected
638
+
639
+
640
+ def _graphic_body_tail_line_indices_to_preserve(
641
+ lines: list[_LineItem],
642
+ candidates: list[_GraphicCandidate],
643
+ lanes: list[_TextLane],
644
+ median_height: float,
645
+ ) -> set[int]:
646
+ """保护贴近图形上沿但延续上方满栏正文排版的短尾行。"""
647
+
648
+ protected: set[int] = set()
649
+ horizontal_lines = [line for line in lines if line.angle == 0]
650
+ for tail in horizontal_lines:
651
+ lane_index = _graphic_lane_index(tail.bbox, lanes)
652
+ lane = lanes[lane_index]
653
+ lane_width = max(0.1, lane.right - lane.left)
654
+ tail_width = tail.bbox[2] - tail.bbox[0]
655
+ if tail_width > 0.5 * lane_width:
656
+ continue
657
+
658
+ matching_candidates = [
659
+ candidate
660
+ for candidate in candidates
661
+ if (candidate.lane_index < 0 or candidate.lane_index == lane_index)
662
+ and tail.bbox[3] <= candidate.core_bbox[1] + 0.25 * median_height
663
+ and _is_graphic_label_member(
664
+ tail,
665
+ candidate.core_bbox,
666
+ median_height,
667
+ margin_scale=candidate.label_margin_scale,
668
+ )
669
+ ]
670
+ if not matching_candidates:
671
+ continue
672
+
673
+ tail_height = _line_effective_height(tail, tail.bbox)
674
+ for previous in horizontal_lines:
675
+ if previous.source_index == tail.source_index:
676
+ continue
677
+ if _graphic_lane_index(previous.bbox, lanes) != lane_index:
678
+ continue
679
+ vertical_gap = tail.bbox[1] - previous.bbox[3]
680
+ if not -0.25 * median_height <= vertical_gap <= 0.75 * median_height:
681
+ continue
682
+ if abs(previous.bbox[0] - tail.bbox[0]) > 0.75 * median_height:
683
+ continue
684
+
685
+ previous_width = previous.bbox[2] - previous.bbox[0]
686
+ if (
687
+ previous_width < 0.75 * lane_width
688
+ or lane.right - previous.bbox[2] > median_height
689
+ or previous_width < 1.5 * tail_width
690
+ ):
691
+ continue
692
+ previous_height = _line_effective_height(previous, previous.bbox)
693
+ if max(previous_height, tail_height) > 1.25 * min(previous_height, tail_height):
694
+ continue
695
+ if (
696
+ previous.font_signature is not None
697
+ and tail.font_signature is not None
698
+ and previous.font_signature != tail.font_signature
699
+ ):
700
+ continue
701
+ if any(
702
+ _is_graphic_label_member(
703
+ previous,
704
+ candidate.core_bbox,
705
+ median_height,
706
+ margin_scale=candidate.label_margin_scale,
707
+ )
708
+ for candidate in matching_candidates
709
+ ):
710
+ continue
711
+ protected.add(tail.source_index)
712
+ break
713
+ return protected
714
+
715
+
716
+ def _detect_strong_graphic_bboxes(source: _PageSource) -> list[BBox]:
717
+ """仅按复杂 Path、容器尺度与成对坐标轴识别高置信图形核心。"""
718
+
719
+ if not source.path_infos:
720
+ return []
721
+ effective_heights = [_line_effective_height(line, line.bbox) for line in source.lines if line.angle == 0]
722
+ median_height = statistics.median(effective_heights) if effective_heights else 1.0
723
+ candidates = [
724
+ *_detect_complex_path_containers(
725
+ source.path_infos,
726
+ source.page_size,
727
+ median_height,
728
+ ),
729
+ *_detect_axis_path_graphics(
730
+ source.path_infos,
731
+ source.page_size,
732
+ median_height,
733
+ ),
734
+ *_detect_complex_drawing_components(
735
+ source.drawing_lines,
736
+ source.path_infos,
737
+ source.page_size,
738
+ median_height,
739
+ ),
740
+ ]
741
+
742
+ output: list[BBox] = []
743
+ for bbox in sorted(candidates, key=_bbox_area, reverse=True):
744
+ if any(_bbox_overlap_in_first(bbox, accepted) >= 0.9 for accepted in output):
745
+ continue
746
+ output.append(bbox)
747
+ return sorted(output, key=lambda bbox: (bbox[1], bbox[0], bbox[3], bbox[2]))
748
+
749
+
750
+ def _detect_complex_path_containers(
751
+ path_infos: list[PDFPathInfo],
752
+ page_size: tuple[float, float],
753
+ median_height: float,
754
+ ) -> list[BBox]:
755
+ """筛选包含多个内部 Path 且至少含一个二维复杂轮廓的大容器。"""
756
+
757
+ page_area = max(0.1, page_size[0] * page_size[1])
758
+ output: list[BBox] = []
759
+ for path_info in path_infos:
760
+ bbox = path_info.bbox
761
+ width = bbox[2] - bbox[0]
762
+ height = bbox[3] - bbox[1]
763
+ area_ratio = _bbox_area(bbox) / page_area
764
+ if (
765
+ path_info.form_depth != 0
766
+ or not path_info.fill_visible
767
+ or not 0.005 <= area_ratio <= 0.5
768
+ or width < 4.0 * median_height
769
+ or height < 4.0 * median_height
770
+ ):
771
+ continue
772
+ inner_paths = [
773
+ item
774
+ for item in path_infos
775
+ if item.source_index != path_info.source_index
776
+ and _bbox_overlap_in_first(item.bbox, bbox) >= 0.9
777
+ and _bbox_area(item.bbox) < 0.95 * _bbox_area(bbox)
778
+ ]
779
+ if len(inner_paths) < 4:
780
+ continue
781
+ if not any(_is_two_dimensional_complex_path(item, median_height) for item in inner_paths):
782
+ continue
783
+ output.append(bbox)
784
+ return output
785
+
786
+
787
+ def _detect_axis_path_graphics(
788
+ path_infos: list[PDFPathInfo],
789
+ page_size: tuple[float, float],
790
+ median_height: float,
791
+ ) -> list[BBox]:
792
+ """用相交的长横纵轴和内部二维复杂路径补充无外框图表。"""
793
+
794
+ thin_limit = max(1.0, 0.5 * median_height)
795
+ minimum_axis_length = 6.0 * median_height
796
+ horizontal_axes = [
797
+ item
798
+ for item in path_infos
799
+ if item.form_depth == 0
800
+ and item.stroke_visible
801
+ and item.bbox[3] - item.bbox[1] <= thin_limit
802
+ and item.bbox[2] - item.bbox[0] >= minimum_axis_length
803
+ ]
804
+ vertical_axes = [
805
+ item
806
+ for item in path_infos
807
+ if item.form_depth == 0
808
+ and item.stroke_visible
809
+ and item.bbox[2] - item.bbox[0] <= thin_limit
810
+ and item.bbox[3] - item.bbox[1] >= minimum_axis_length
811
+ ]
812
+ tolerance = max(2.0, median_height)
813
+ output: list[BBox] = []
814
+ for horizontal in horizontal_axes:
815
+ horizontal_y = _bbox_center_y(horizontal.bbox)
816
+ for vertical in vertical_axes:
817
+ vertical_x = _bbox_center_x(vertical.bbox)
818
+ touches_x = (
819
+ min(
820
+ abs(vertical_x - horizontal.bbox[0]),
821
+ abs(vertical_x - horizontal.bbox[2]),
822
+ )
823
+ <= tolerance
824
+ )
825
+ touches_y = (
826
+ min(
827
+ abs(horizontal_y - vertical.bbox[1]),
828
+ abs(horizontal_y - vertical.bbox[3]),
829
+ )
830
+ <= tolerance
831
+ )
832
+ if not (touches_x and touches_y):
833
+ continue
834
+ plot_bbox = _bbox_union(horizontal.bbox, vertical.bbox)
835
+ width = plot_bbox[2] - plot_bbox[0]
836
+ height = plot_bbox[3] - plot_bbox[1]
837
+ if width > 0.65 * page_size[0] or height > 0.5 * page_size[1]:
838
+ continue
839
+ complex_paths = [
840
+ item
841
+ for item in path_infos
842
+ if _is_two_dimensional_complex_path(item, median_height)
843
+ and _bbox_overlap_in_smaller(item.bbox, plot_bbox) >= 0.2
844
+ ]
845
+ if not complex_paths:
846
+ continue
847
+ output.append(
848
+ _bbox_union(
849
+ plot_bbox,
850
+ _bbox_union_many([item.bbox for item in complex_paths]),
851
+ )
852
+ )
853
+ return output
854
+
855
+
856
+ def _is_two_dimensional_complex_path(
857
+ path_info: PDFPathInfo,
858
+ median_height: float,
859
+ ) -> bool:
860
+ """排除细轴线,只保留横纵均有尺寸且段数较多的图形轮廓。"""
861
+
862
+ width = path_info.bbox[2] - path_info.bbox[0]
863
+ height = path_info.bbox[3] - path_info.bbox[1]
864
+ return (
865
+ path_info.form_depth == 0
866
+ and path_info.segment_count >= 6
867
+ and width >= 1.5 * median_height
868
+ and height >= 1.5 * median_height
869
+ )
870
+
871
+
872
+ def _detect_complex_drawing_components(
873
+ drawing_lines: list[_AxisLine],
874
+ path_infos: list[PDFPathInfo],
875
+ page_size: tuple[float, float],
876
+ median_height: float,
877
+ ) -> list[BBox]:
878
+ """以横纵绘图线组件和内部二维复杂 Path 识别坐标图或嵌入式图表。"""
879
+
880
+ tolerance = max(2.0, 0.75 * median_height)
881
+ output: list[BBox] = []
882
+ for component in _connected_drawing_line_components(drawing_lines, tolerance):
883
+ horizontal_count = sum(line.orientation == "horizontal" for line in component)
884
+ vertical_count = len(component) - horizontal_count
885
+ core_bbox = _bbox_union_many([line.bbox for line in component])
886
+ width = core_bbox[2] - core_bbox[0]
887
+ height = core_bbox[3] - core_bbox[1]
888
+ if (
889
+ len(component) < 4
890
+ or horizontal_count < 2
891
+ or vertical_count < 2
892
+ or width < 4.0 * median_height
893
+ or height < 3.0 * median_height
894
+ or width > 0.65 * page_size[0]
895
+ or height > 0.5 * page_size[1]
896
+ ):
897
+ continue
898
+ complex_paths = [
899
+ path_info
900
+ for path_info in path_infos
901
+ if _is_two_dimensional_complex_path(path_info, median_height)
902
+ and _bbox_overlap_in_smaller(path_info.bbox, core_bbox) >= 0.2
903
+ ]
904
+ if not complex_paths:
905
+ continue
906
+ output.append(
907
+ _bbox_union(
908
+ core_bbox,
909
+ _bbox_union_many([path_info.bbox for path_info in complex_paths]),
910
+ )
911
+ )
912
+ return output
913
+
914
+
915
+ def _infer_graphic_text_lanes(
916
+ lines: list[_LineItem],
917
+ page_size: tuple[float, float],
918
+ median_height: float,
919
+ ) -> list[_TextLane]:
920
+ """用横排正文推断页内栏带,供不同角度的图形标签共享栏归属。"""
921
+
922
+ line_geometry = [(line, line.bbox) for line in lines if line.angle == 0]
923
+ if not line_geometry:
924
+ return [_TextLane(left=0.0, right=page_size[0])]
925
+ angle_heights = [_line_effective_height(line, bbox) for line, bbox in line_geometry]
926
+ angle_median_height = statistics.median(angle_heights) if angle_heights else median_height
927
+ lanes = [
928
+ lane
929
+ for lane in _infer_text_lanes(
930
+ line_geometry,
931
+ page_size[0],
932
+ angle_median_height,
933
+ )
934
+ if not lane.is_span
935
+ ]
936
+ return lanes or [_TextLane(left=0.0, right=page_size[0])]
937
+
938
+
939
+ def _graphic_lane_index(bbox: BBox, lanes: list[_TextLane]) -> int:
940
+ """按中心点、水平覆盖和距离为 bbox 选择唯一栏带。"""
941
+
942
+ center_x = _bbox_center_x(bbox)
943
+ best_index = 0
944
+ best_score = (-1, -1.0, -math.inf)
945
+ for lane_index, lane in enumerate(lanes):
946
+ inside = int(lane.left <= center_x <= lane.right)
947
+ overlap = max(0.0, min(bbox[2], lane.right) - max(bbox[0], lane.left))
948
+ if inside:
949
+ distance = 0.0
950
+ else:
951
+ distance = min(abs(center_x - lane.left), abs(center_x - lane.right))
952
+ score = (inside, overlap, -distance)
953
+ if score > best_score:
954
+ best_score = score
955
+ best_index = lane_index
956
+ return best_index
957
+
958
+
959
+ def _strong_graphic_lane_index(
960
+ core_bbox: BBox,
961
+ lanes: list[_TextLane],
962
+ median_height: float,
963
+ ) -> int:
964
+ """仅把几乎完整落入唯一栏带的强图形核心绑定到该栏。"""
965
+
966
+ core_width = max(0.1, core_bbox[2] - core_bbox[0])
967
+ tolerance = max(1.0, median_height)
968
+ matching_indices = []
969
+ for lane_index, lane in enumerate(lanes):
970
+ overlap = max(
971
+ 0.0,
972
+ min(core_bbox[2], lane.right) - max(core_bbox[0], lane.left),
973
+ )
974
+ if overlap / core_width >= 0.9 and core_bbox[0] >= lane.left - tolerance and core_bbox[2] <= lane.right + tolerance:
975
+ matching_indices.append(lane_index)
976
+ return matching_indices[0] if len(matching_indices) == 1 else -1
977
+
978
+
979
+ def _detect_graphic_candidates(
980
+ drawing_lines: list[_AxisLine],
981
+ page_size: tuple[float, float],
982
+ median_height: float,
983
+ lanes: list[_TextLane],
984
+ table_bboxes: list[BBox],
985
+ ) -> list[_GraphicCandidate]:
986
+ """从非表格绘图线连通分量中筛选尺寸受限的图形容器。"""
987
+
988
+ tolerance = max(2.0, 0.75 * median_height)
989
+ candidates: list[_GraphicCandidate] = []
990
+ for component in _connected_drawing_line_components(drawing_lines, tolerance):
991
+ horizontal_count = sum(line.orientation == "horizontal" for line in component)
992
+ vertical_count = len(component) - horizontal_count
993
+ core_bbox = _bbox_union_many([line.bbox for line in component])
994
+ width = core_bbox[2] - core_bbox[0]
995
+ height = core_bbox[3] - core_bbox[1]
996
+ if (
997
+ len(component) < 4
998
+ or horizontal_count < 2
999
+ or vertical_count < 2
1000
+ or width < 4.0 * median_height
1001
+ or height < 3.0 * median_height
1002
+ or width > 0.5 * page_size[0]
1003
+ or height > 0.5 * page_size[1]
1004
+ ):
1005
+ continue
1006
+ if any(_bbox_overlap_in_smaller(core_bbox, table_bbox) >= 0.5 for table_bbox in table_bboxes):
1007
+ continue
1008
+ candidates.append(
1009
+ _GraphicCandidate(
1010
+ core_bbox=core_bbox,
1011
+ lane_index=_graphic_lane_index(core_bbox, lanes),
1012
+ )
1013
+ )
1014
+ return candidates
1015
+
1016
+
1017
+ def _connected_drawing_line_components(
1018
+ drawing_lines: list[_AxisLine],
1019
+ tolerance: float,
1020
+ ) -> list[list[_AxisLine]]:
1021
+ """按 bbox 间距连接相邻绘图线,并返回互不重叠的连通分量。"""
1022
+
1023
+ parents = list(range(len(drawing_lines)))
1024
+
1025
+ def find(index: int) -> int:
1026
+ """查找绘图线连通分量的根节点。"""
1027
+
1028
+ while parents[index] != index:
1029
+ parents[index] = parents[parents[index]]
1030
+ index = parents[index]
1031
+ return index
1032
+
1033
+ def union(first_index: int, second_index: int) -> None:
1034
+ """合并两个距离满足条件的绘图线分量。"""
1035
+
1036
+ first_root = find(first_index)
1037
+ second_root = find(second_index)
1038
+ if first_root != second_root:
1039
+ parents[second_root] = first_root
1040
+
1041
+ for first_index, first in enumerate(drawing_lines):
1042
+ for second_index in range(first_index + 1, len(drawing_lines)):
1043
+ if _bbox_distance(first.bbox, drawing_lines[second_index].bbox) <= tolerance:
1044
+ union(first_index, second_index)
1045
+
1046
+ components: dict[int, list[_AxisLine]] = {}
1047
+ for line_index, line in enumerate(drawing_lines):
1048
+ components.setdefault(find(line_index), []).append(line)
1049
+ return list(components.values())
1050
+
1051
+
1052
+ def _is_graphic_label_member(
1053
+ line: _LineItem,
1054
+ core_bbox: BBox,
1055
+ median_height: float,
1056
+ *,
1057
+ margin_scale: float = 2.5,
1058
+ ) -> bool:
1059
+ """判断短文本是否位于图形核心内部或对应轴向的邻近标签区。"""
1060
+
1061
+ center = (_bbox_center_x(line.bbox), _bbox_center_y(line.bbox))
1062
+ if _point_in_bbox(center, core_bbox):
1063
+ return True
1064
+
1065
+ line_height = max(
1066
+ 0.1,
1067
+ line.effective_height
1068
+ or min(
1069
+ max(0.1, line.bbox[2] - line.bbox[0]),
1070
+ max(0.1, line.bbox[3] - line.bbox[1]),
1071
+ ),
1072
+ )
1073
+ if line.angle in {90, 270}:
1074
+ primary_length = line.bbox[3] - line.bbox[1]
1075
+ core_primary_length = core_bbox[3] - core_bbox[1]
1076
+ else:
1077
+ primary_length = line.bbox[2] - line.bbox[0]
1078
+ core_primary_length = core_bbox[2] - core_bbox[0]
1079
+
1080
+ horizontal_gap = max(core_bbox[0] - line.bbox[2], line.bbox[0] - core_bbox[2], 0.0)
1081
+ vertical_gap = max(core_bbox[1] - line.bbox[3], line.bbox[1] - core_bbox[3], 0.0)
1082
+ # 横排坐标轴标题允许比刻度标签略长,但必须与图宽、行高和上下间距同时相容。
1083
+ is_horizontal_axis_title = (
1084
+ line.angle in {0, 180}
1085
+ and primary_length <= 8.0 * line_height
1086
+ and primary_length <= 0.45 * core_primary_length
1087
+ and _bbox_axis_overlap_ratio(line.bbox, core_bbox, axis="x") >= 0.15
1088
+ and vertical_gap <= 2.5 * median_height
1089
+ )
1090
+ if is_horizontal_axis_title:
1091
+ return True
1092
+ if primary_length > min(5.0 * line_height, 0.5 * core_primary_length):
1093
+ return False
1094
+
1095
+ if _bbox_axis_overlap_ratio(line.bbox, core_bbox, axis="x") >= 0.15:
1096
+ return vertical_gap <= margin_scale * median_height
1097
+ if _bbox_axis_overlap_ratio(line.bbox, core_bbox, axis="y") >= 0.15:
1098
+ horizontal_limit = max(
1099
+ margin_scale * median_height,
1100
+ 0.2 * (core_bbox[2] - core_bbox[0]),
1101
+ )
1102
+ return horizontal_gap <= horizontal_limit
1103
+ corner_limit = min(margin_scale, 1.5) * median_height
1104
+ return (
1105
+ horizontal_gap <= corner_limit
1106
+ and vertical_gap <= corner_limit
1107
+ and math.hypot(horizontal_gap, vertical_gap) <= corner_limit
1108
+ )
1109
+
1110
+
1111
+ def _image_members_to_content(
1112
+ members: list[_LineItem],
1113
+ page_size: tuple[float, float],
1114
+ ) -> str:
1115
+ """按视觉行和页内位置生成图片内部文本,保留不同视觉行之间的换行。"""
1116
+
1117
+ row_groups: dict[tuple[int, int, int], list[_LineItem]] = {}
1118
+ for line in members:
1119
+ if line.visual_row_id is None:
1120
+ row_kind, row_identity = 1, line.source_index
1121
+ else:
1122
+ row_kind, row_identity = 0, line.visual_row_id
1123
+ row_groups.setdefault((line.angle, row_kind, row_identity), []).append(line)
1124
+
1125
+ rows: list[tuple[BBox, str]] = []
1126
+ for row_lines in row_groups.values():
1127
+ row_bbox = _bbox_union_many([line.bbox for line in row_lines])
1128
+ angle = row_lines[0].angle
1129
+ local_geometry = [(line, _rotate_bbox_to_upright(line.bbox, page_size, angle)) for line in row_lines]
1130
+ content = _join_formula_visual_row(local_geometry, page_size)
1131
+ if content:
1132
+ rows.append((row_bbox, content))
1133
+ rows.sort(key=lambda item: (item[0][1], item[0][0]))
1134
+ return _sanitize_pdf_control_text(
1135
+ "\n".join(row_content for _row_bbox, row_content in rows),
1136
+ preserve_newlines=True,
1137
+ ).strip()
1138
+
1139
+
1140
+ def _graphic_members_to_block(
1141
+ candidate: _GraphicCandidate,
1142
+ members: list[_LineItem],
1143
+ page_size: tuple[float, float],
1144
+ ) -> dict[str, Any] | None:
1145
+ """生成含内部文本的矢量图 image block,并合并绘图核心与标签 bbox。"""
1146
+
1147
+ content = _image_members_to_content(members, page_size)
1148
+ if not content:
1149
+ return None
1150
+ return {
1151
+ "type": "image",
1152
+ "bbox": _bbox_union(candidate.core_bbox, _bbox_union_many([line.bbox for line in members])),
1153
+ "angle": 0,
1154
+ "content": content,
1155
+ }
1156
+
1157
+
1158
+ def _inline_raster_gap_member(
1159
+ source: _PageSource,
1160
+ left_bbox: BBox,
1161
+ right_bbox: BBox,
1162
+ claimed_line_indices: set[int],
1163
+ median_height: float,
1164
+ ) -> _LineItem | None:
1165
+ """查找恰好填充两张同行图片间隙的唯一拆分文本 run。"""
1166
+
1167
+ left_height = max(0.1, left_bbox[3] - left_bbox[1])
1168
+ right_height = max(0.1, right_bbox[3] - right_bbox[1])
1169
+ vertical_overlap = max(
1170
+ 0.0,
1171
+ min(left_bbox[3], right_bbox[3]) - max(left_bbox[1], right_bbox[1]),
1172
+ )
1173
+ horizontal_gap = right_bbox[0] - left_bbox[2]
1174
+ if (
1175
+ max(left_height, right_height) / min(left_height, right_height) > 1.25
1176
+ or vertical_overlap / min(left_height, right_height) < 0.8
1177
+ or not 0.0 <= horizontal_gap <= 1.5 * median_height
1178
+ ):
1179
+ return None
1180
+
1181
+ edge_tolerance = max(0.5, 0.25 * median_height)
1182
+ band_top = max(left_bbox[1], right_bbox[1])
1183
+ band_bottom = min(left_bbox[3], right_bbox[3])
1184
+ gap_members = [
1185
+ line
1186
+ for line in source.lines
1187
+ if line.source_index not in claimed_line_indices
1188
+ and line.angle == 0
1189
+ and line.split_from_row
1190
+ and line.visual_row_id is not None
1191
+ and left_bbox[2] - edge_tolerance <= _bbox_center_x(line.bbox) <= right_bbox[0] + edge_tolerance
1192
+ and band_top - edge_tolerance <= _bbox_center_y(line.bbox) <= band_bottom + edge_tolerance
1193
+ ]
1194
+ if len(gap_members) != 1:
1195
+ return None
1196
+ member = gap_members[0]
1197
+ if abs(member.bbox[0] - left_bbox[2]) > edge_tolerance or abs(member.bbox[2] - right_bbox[0]) > edge_tolerance:
1198
+ return None
1199
+ return member
1200
+
1201
+
1202
+ def _inline_raster_group_has_only_expected_text(
1203
+ source: _PageSource,
1204
+ image_bboxes: list[BBox],
1205
+ gap_members: list[_LineItem],
1206
+ group_bbox: BBox,
1207
+ claimed_line_indices: set[int],
1208
+ ) -> bool:
1209
+ """确认复合图片框内没有图片内部文本和间隔符之外的正文。"""
1210
+
1211
+ gap_member_indices = {line.source_index for line in gap_members}
1212
+ for line in source.lines:
1213
+ if line.source_index in claimed_line_indices:
1214
+ continue
1215
+ center = (_bbox_center_x(line.bbox), _bbox_center_y(line.bbox))
1216
+ if not _point_in_bbox(center, group_bbox):
1217
+ continue
1218
+ if line.source_index in gap_member_indices:
1219
+ continue
1220
+ if any(_point_in_bbox(center, image_bbox) for image_bbox in image_bboxes):
1221
+ continue
1222
+ return False
1223
+ return True
1224
+
1225
+
1226
+ def _merge_inline_raster_image_candidates(
1227
+ source: _PageSource,
1228
+ candidate_bboxes: list[BBox],
1229
+ container_bboxes: list[BBox],
1230
+ claimed_line_indices: set[int],
1231
+ ) -> list[tuple[BBox, int | None]]:
1232
+ """把由同一视觉行间隔符连接的已准入图片合成为单一候选。"""
1233
+
1234
+ if len(candidate_bboxes) < 3:
1235
+ return [(bbox, None) for bbox in candidate_bboxes]
1236
+ effective_heights = [
1237
+ _line_effective_height(line, line.bbox)
1238
+ for line in source.lines
1239
+ if line.source_index not in claimed_line_indices and line.angle == 0
1240
+ ]
1241
+ median_height = statistics.median(effective_heights) if effective_heights else 1.0
1242
+
1243
+ adjacency: dict[int, set[int]] = {index: set() for index in range(len(candidate_bboxes))}
1244
+ gap_members: dict[tuple[int, int], _LineItem] = {}
1245
+ for first_index, first_bbox in enumerate(candidate_bboxes):
1246
+ for second_index, second_bbox in enumerate(candidate_bboxes):
1247
+ if first_index == second_index or first_bbox[0] >= second_bbox[0]:
1248
+ continue
1249
+ member = _inline_raster_gap_member(
1250
+ source,
1251
+ first_bbox,
1252
+ second_bbox,
1253
+ claimed_line_indices,
1254
+ median_height,
1255
+ )
1256
+ if member is None:
1257
+ continue
1258
+ adjacency[first_index].add(second_index)
1259
+ adjacency[second_index].add(first_index)
1260
+ gap_members[(first_index, second_index)] = member
1261
+
1262
+ components: list[list[int]] = []
1263
+ visited: set[int] = set()
1264
+ for start_index in range(len(candidate_bboxes)):
1265
+ if start_index in visited:
1266
+ continue
1267
+ component: list[int] = []
1268
+ pending = [start_index]
1269
+ while pending:
1270
+ current_index = pending.pop()
1271
+ if current_index in visited:
1272
+ continue
1273
+ visited.add(current_index)
1274
+ component.append(current_index)
1275
+ pending.extend(adjacency[current_index] - visited)
1276
+ components.append(component)
1277
+
1278
+ merged_specs: list[tuple[BBox, int | None]] = []
1279
+ consumed_indices: set[int] = set()
1280
+ for component in components:
1281
+ if len(component) < 3:
1282
+ continue
1283
+ ordered_indices = sorted(
1284
+ component,
1285
+ key=lambda index: candidate_bboxes[index][0],
1286
+ )
1287
+ ordered_pairs = list(zip(ordered_indices, ordered_indices[1:]))
1288
+ if not all(pair in gap_members for pair in ordered_pairs):
1289
+ continue
1290
+ members = [gap_members[pair] for pair in ordered_pairs]
1291
+ visual_row_ids = {member.visual_row_id for member in members}
1292
+ if len(visual_row_ids) != 1 or None in visual_row_ids:
1293
+ continue
1294
+ image_bboxes = [candidate_bboxes[index] for index in ordered_indices]
1295
+ group_bbox = _bbox_union_many(
1296
+ [*image_bboxes, *[member.bbox for member in members]],
1297
+ )
1298
+ if any(
1299
+ _bbox_overlap_in_smaller(group_bbox, container_bbox) >= _IMAGE_CONTAINER_OVERLAP_THRESHOLD
1300
+ for container_bbox in container_bboxes
1301
+ ):
1302
+ continue
1303
+ if not _inline_raster_group_has_only_expected_text(
1304
+ source,
1305
+ image_bboxes,
1306
+ members,
1307
+ group_bbox,
1308
+ claimed_line_indices,
1309
+ ):
1310
+ continue
1311
+ merged_specs.append((group_bbox, next(iter(visual_row_ids))))
1312
+ consumed_indices.update(ordered_indices)
1313
+
1314
+ merged_specs.extend((bbox, None) for index, bbox in enumerate(candidate_bboxes) if index not in consumed_indices)
1315
+ merged_specs.sort(key=lambda item: (item[0][1], item[0][0], item[0][3], item[0][2]))
1316
+ return merged_specs
1317
+
1318
+
1319
+ def _image_bboxes_are_near_equal(first: BBox, second: BBox) -> bool:
1320
+ """用亚 point 边界容差识别同一图片框,避免签名与点阵来源重复输出。"""
1321
+
1322
+ return all(
1323
+ abs(first_value - second_value) <= _SIGNATURE_IMAGE_BBOX_DEDUP_TOLERANCE
1324
+ for first_value, second_value in zip(first, second, strict=True)
1325
+ )
1326
+
1327
+
1328
+ def _merge_vertical_raster_tiles(
1329
+ bboxes: list[BBox],
1330
+ page_size: tuple[float, float],
1331
+ ) -> list[BBox]:
1332
+ """把同宽且纵向连续的点阵切片合成一张完整图片。"""
1333
+
1334
+ page_width, page_height = page_size
1335
+ page_area = max(0.0, page_width) * max(0.0, page_height)
1336
+ if len(bboxes) < 3 or page_area <= 0:
1337
+ return list(bboxes)
1338
+
1339
+ endpoint_tolerance = max(0.75, 0.002 * page_width)
1340
+ endpoint_groups: list[list[tuple[int, BBox]]] = []
1341
+ for index, bbox in sorted(
1342
+ enumerate(bboxes),
1343
+ key=lambda item: (item[1][0], item[1][2], item[1][1]),
1344
+ ):
1345
+ target = next(
1346
+ (
1347
+ group
1348
+ for group in endpoint_groups
1349
+ if abs(bbox[0] - statistics.median(item[1][0] for item in group)) <= endpoint_tolerance
1350
+ and abs(bbox[2] - statistics.median(item[1][2] for item in group)) <= endpoint_tolerance
1351
+ ),
1352
+ None,
1353
+ )
1354
+ if target is None:
1355
+ endpoint_groups.append([(index, bbox)])
1356
+ else:
1357
+ target.append((index, bbox))
1358
+
1359
+ merged: list[BBox] = []
1360
+ consumed: set[int] = set()
1361
+ for group in endpoint_groups:
1362
+ ordered = sorted(group, key=lambda item: (item[1][1], item[1][3]))
1363
+ segments: list[list[tuple[int, BBox]]] = []
1364
+ for item in ordered:
1365
+ if not segments:
1366
+ segments.append([item])
1367
+ continue
1368
+ previous_bbox = segments[-1][-1][1]
1369
+ current_bbox = item[1]
1370
+ previous_height = max(0.1, previous_bbox[3] - previous_bbox[1])
1371
+ current_height = max(0.1, current_bbox[3] - current_bbox[1])
1372
+ maximum_gap = max(
1373
+ 1.0,
1374
+ 0.5 * max(previous_height, current_height),
1375
+ )
1376
+ if current_bbox[1] - previous_bbox[3] <= maximum_gap:
1377
+ segments[-1].append(item)
1378
+ else:
1379
+ segments.append([item])
1380
+
1381
+ for segment in segments:
1382
+ if len(segment) < 3:
1383
+ continue
1384
+ segment_bboxes = [bbox for _index, bbox in segment]
1385
+ union_bbox = _bbox_union_many(segment_bboxes)
1386
+ union_height = max(0.1, union_bbox[3] - union_bbox[1])
1387
+ covered_height = sum(max(0.0, bbox[3] - bbox[1]) for bbox in segment_bboxes)
1388
+ if (
1389
+ _bbox_area(union_bbox) / page_area < _MIN_RASTER_IMAGE_PAGE_AREA_RATIO
1390
+ or union_bbox[2] - union_bbox[0] < 0.12 * page_width
1391
+ or union_bbox[2] - union_bbox[0] > 0.9 * page_width
1392
+ or covered_height / union_height < 0.9
1393
+ ):
1394
+ continue
1395
+ merged.append(union_bbox)
1396
+ consumed.update(index for index, _bbox in segment)
1397
+
1398
+ merged.extend(bbox for index, bbox in enumerate(bboxes) if index not in consumed)
1399
+ return sorted(
1400
+ merged,
1401
+ key=lambda bbox: (bbox[1], bbox[0], bbox[3], bbox[2]),
1402
+ )
1403
+
1404
+
1405
+ def _build_raster_image_blocks(
1406
+ source: _PageSource,
1407
+ container_blocks: list[dict[str, Any]],
1408
+ claimed_line_indices: set[int],
1409
+ ) -> tuple[list[dict[str, Any]], set[int]]:
1410
+ """过滤点阵图并接纳签名框,避让高优先级容器后唯一认领内部文本。"""
1411
+
1412
+ page_area = max(0.0, source.page_size[0]) * max(0.0, source.page_size[1])
1413
+ if page_area <= 0:
1414
+ return [], set()
1415
+
1416
+ container_bboxes = [bbox for block in container_blocks if (bbox := _coerce_bbox(block.get("bbox"))) is not None]
1417
+ signature_bboxes: list[BBox] = []
1418
+ for raw_bbox in source.signature_bboxes:
1419
+ bbox = _clip_bbox(_coerce_bbox(raw_bbox), source.page_size)
1420
+ if bbox is None:
1421
+ continue
1422
+ if any(
1423
+ _bbox_overlap_in_smaller(bbox, container_bbox) >= _IMAGE_CONTAINER_OVERLAP_THRESHOLD
1424
+ for container_bbox in container_bboxes
1425
+ ):
1426
+ continue
1427
+ if not any(_image_bboxes_are_near_equal(bbox, existing_bbox) for existing_bbox in signature_bboxes):
1428
+ # 已由注释可见性和 /AP 严格确认的签名不再套用普通点阵图面积门槛。
1429
+ signature_bboxes.append(bbox)
1430
+
1431
+ clipped_raster_bboxes = [
1432
+ bbox for raw_bbox in source.image_bboxes if (bbox := _clip_bbox(_coerce_bbox(raw_bbox), source.page_size)) is not None
1433
+ ]
1434
+ raster_bboxes: list[BBox] = []
1435
+ for bbox in _merge_vertical_raster_tiles(
1436
+ clipped_raster_bboxes,
1437
+ source.page_size,
1438
+ ):
1439
+ if _bbox_area(bbox) / page_area < _MIN_RASTER_IMAGE_PAGE_AREA_RATIO:
1440
+ continue
1441
+ if any(
1442
+ _bbox_overlap_in_smaller(bbox, container_bbox) >= _IMAGE_CONTAINER_OVERLAP_THRESHOLD
1443
+ for container_bbox in container_bboxes
1444
+ ):
1445
+ continue
1446
+ if any(_image_bboxes_are_near_equal(bbox, signature_bbox) for signature_bbox in signature_bboxes):
1447
+ continue
1448
+ raster_bboxes.append(bbox)
1449
+ if not raster_bboxes and not signature_bboxes:
1450
+ return [], set()
1451
+
1452
+ candidate_specs = (
1453
+ _merge_inline_raster_image_candidates(
1454
+ source,
1455
+ raster_bboxes,
1456
+ container_bboxes,
1457
+ claimed_line_indices,
1458
+ )
1459
+ if raster_bboxes
1460
+ else []
1461
+ )
1462
+ candidate_specs.extend((bbox, None) for bbox in signature_bboxes)
1463
+ candidate_specs.sort(key=lambda item: (item[0][1], item[0][0], item[0][3], item[0][2]))
1464
+ candidate_bboxes = [bbox for bbox, _row_id in candidate_specs]
1465
+
1466
+ members_by_candidate: list[list[_LineItem]] = [[] for _ in candidate_bboxes]
1467
+ claimed: set[int] = set()
1468
+ for line in source.lines:
1469
+ if line.source_index in claimed_line_indices:
1470
+ continue
1471
+ center = (_bbox_center_x(line.bbox), _bbox_center_y(line.bbox))
1472
+ matching_indices = [
1473
+ candidate_index for candidate_index, bbox in enumerate(candidate_bboxes) if _point_in_bbox(center, bbox)
1474
+ ]
1475
+ if not matching_indices:
1476
+ continue
1477
+ # 重叠点阵图共享内部文本时归属最小容器,避免 content 重复。
1478
+ candidate_index = min(
1479
+ matching_indices,
1480
+ key=lambda index: (_bbox_area(candidate_bboxes[index]), index),
1481
+ )
1482
+ members_by_candidate[candidate_index].append(line)
1483
+ claimed.add(line.source_index)
1484
+
1485
+ blocks: list[dict[str, Any]] = []
1486
+ for (bbox, visual_row_id), members in zip(
1487
+ candidate_specs,
1488
+ members_by_candidate,
1489
+ strict=True,
1490
+ ):
1491
+ block = {
1492
+ "type": "image",
1493
+ "bbox": bbox,
1494
+ "angle": 0,
1495
+ "content": _image_members_to_content(members, source.page_size),
1496
+ }
1497
+ if visual_row_id is not None:
1498
+ block["_inline_visual_row_id"] = visual_row_id
1499
+ blocks.append(block)
1500
+ blocks.sort(key=lambda block: (block["bbox"][1], block["bbox"][0]))
1501
+ return blocks, claimed