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,1985 @@
1
+ """按空间关系检测并物化原生 PDF 公式块。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import statistics
7
+ import unicodedata
8
+ from dataclasses import dataclass, replace
9
+ from typing import Any
10
+
11
+
12
+ from ....schema import BBox
13
+ from ....document.pdf.document import PDFPathInfo
14
+ from ....foundation.text import build_tagged_formula_content
15
+
16
+ from .models import _AxisLine, _FormulaAnchor, _LineItem, _PageSource, _TextLane
17
+ from .geometry import (
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
+ _expand_bbox,
30
+ _rotate_bbox_to_upright,
31
+ _transform_axis_lines,
32
+ )
33
+ from .native_text import _sanitize_pdf_control_text
34
+ from .line_layout import (
35
+ _connection_crosses_table,
36
+ _infer_text_lanes,
37
+ _line_effective_height,
38
+ _line_style_scale,
39
+ _line_tight_output_bbox,
40
+ _lines_tight_output_bbox,
41
+ )
42
+ from .line_merging import _join_formula_visual_row, _merge_overlapping_inline_cluster
43
+
44
+
45
+ _FORMULA_NUMBER_SUFFIX_RE = re.compile(r"^(?P<prefix>.*?)(?P<marker>[((﹙][^()()﹙﹚\r\n]+[))﹚])\s*$")
46
+ _FORMULA_NUMBER_MARKER_RE = re.compile(
47
+ r"^[((﹙]\s*(?:[A-Za-z]?\d+(?:[.\-]\d+)*)\s*[))﹚]$",
48
+ )
49
+ _FORMULA_OPERATOR_CHARS = frozenset("=∑∫√±×÷")
50
+
51
+
52
+ _FORMULA_PAGE_MARGIN_RATIO = 0.05
53
+ _VECTOR_FORMULA_COMPLEX_SEGMENTS = 8
54
+ _VECTOR_FORMULA_MIN_PATHS = 5
55
+ _VECTOR_FORMULA_MIN_COMPLEX_PATHS = 5
56
+ _VECTOR_FORMULA_MIN_COMPLEX_RATIO = 0.5
57
+ _VECTOR_FORMULA_NUMBER_MIN_PATHS = 3
58
+ _VECTOR_FORMULA_NUMBER_MAX_PATHS = 6
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class _VectorPathComponent:
63
+ """保存同栏邻接 Path 形成的矢量组件。"""
64
+
65
+ lane_index: int
66
+ path_infos: list[PDFPathInfo]
67
+ bbox: BBox
68
+
69
+
70
+ @dataclass(slots=True)
71
+ class _VectorFormulaCandidate:
72
+ """保存已通过主体校验、等待吸收横线和编号的矢量公式。"""
73
+
74
+ lane_index: int
75
+ bbox: BBox
76
+ path_source_indices: set[int]
77
+ has_number: bool = False
78
+
79
+
80
+ def _build_vector_formula_blocks(
81
+ source: _PageSource,
82
+ container_blocks: list[dict[str, Any]],
83
+ claimed_line_indices: set[int],
84
+ ) -> tuple[list[dict[str, Any]], set[int]]:
85
+ """从根层填充 Path 构建空内容公式,并唯一认领可提取的公式编号。"""
86
+
87
+ available_lines = [line for line in source.lines if line.angle == 0 and line.source_index not in claimed_line_indices]
88
+ if len(available_lines) < 3 or not source.path_infos:
89
+ return [], set()
90
+
91
+ line_geometry = [(line, line.bbox) for line in available_lines]
92
+ effective_heights = [_line_effective_height(line, bbox) for line, bbox in line_geometry]
93
+ median_height = statistics.median(effective_heights) if effective_heights else 0.0
94
+ if median_height <= 0:
95
+ return [], set()
96
+ lanes = [
97
+ lane
98
+ for lane in _infer_text_lanes(line_geometry, source.page_size[0], median_height)
99
+ if not lane.is_span and len(lane.lines) >= 3
100
+ ]
101
+ if not lanes:
102
+ return [], set()
103
+
104
+ components = _build_vector_path_components(
105
+ source.path_infos,
106
+ lanes,
107
+ median_height,
108
+ )
109
+ container_bboxes = [bbox for block in container_blocks if (bbox := _coerce_bbox(block.get("bbox"))) is not None]
110
+ candidates = [
111
+ _VectorFormulaCandidate(
112
+ lane_index=component.lane_index,
113
+ bbox=component.bbox,
114
+ path_source_indices={item.source_index for item in component.path_infos},
115
+ )
116
+ for component in components
117
+ if _is_vector_formula_core(
118
+ component,
119
+ lanes[component.lane_index],
120
+ median_height,
121
+ source.page_size,
122
+ container_bboxes,
123
+ )
124
+ ]
125
+ if not candidates:
126
+ return [], set()
127
+
128
+ _attach_vector_formula_rules(candidates, components, median_height)
129
+ _attach_vector_formula_path_numbers(candidates, components, lanes, median_height)
130
+ claimed_number_indices = _attach_vector_formula_text_numbers(
131
+ candidates,
132
+ lanes,
133
+ median_height,
134
+ claimed_line_indices,
135
+ )
136
+
137
+ padding = min(1.5, 0.1 * median_height)
138
+ blocks: list[dict[str, Any]] = []
139
+ for candidate in sorted(candidates, key=lambda item: (item.bbox[1], item.bbox[0])):
140
+ padded_bbox = _clip_bbox(
141
+ (
142
+ candidate.bbox[0] - padding,
143
+ candidate.bbox[1] - padding,
144
+ candidate.bbox[2] + padding,
145
+ candidate.bbox[3] + padding,
146
+ ),
147
+ source.page_size,
148
+ )
149
+ if padded_bbox is None:
150
+ continue
151
+ blocks.append(
152
+ {
153
+ "type": "equation",
154
+ "bbox": padded_bbox,
155
+ "angle": 0,
156
+ "content": "",
157
+ }
158
+ )
159
+ return blocks, claimed_number_indices
160
+
161
+
162
+ def _build_vector_path_components(
163
+ path_infos: list[PDFPathInfo],
164
+ lanes: list[_TextLane],
165
+ median_height: float,
166
+ ) -> list[_VectorPathComponent]:
167
+ """按文本栏带筛选矢量字形,并用空间网格生成局部连通组件。"""
168
+
169
+ members_by_lane: dict[int, list[PDFPathInfo]] = {}
170
+ for path_info in path_infos:
171
+ if path_info.form_depth != 0 or not path_info.fill_visible or path_info.stroke_visible:
172
+ continue
173
+ lane_index = _assign_vector_path_lane(path_info.bbox, lanes, median_height)
174
+ if lane_index is None:
175
+ continue
176
+ if not _is_vector_formula_path_member(
177
+ path_info.bbox,
178
+ lanes[lane_index],
179
+ median_height,
180
+ ):
181
+ continue
182
+ members_by_lane.setdefault(lane_index, []).append(path_info)
183
+
184
+ components: list[_VectorPathComponent] = []
185
+ for lane_index, members in members_by_lane.items():
186
+ components.extend(
187
+ _connect_vector_path_members(
188
+ members,
189
+ lane_index,
190
+ median_height,
191
+ )
192
+ )
193
+ return sorted(
194
+ components,
195
+ key=lambda item: (item.bbox[1], item.bbox[0], item.path_infos[0].source_index),
196
+ )
197
+
198
+
199
+ def _assign_vector_path_lane(
200
+ bbox: BBox,
201
+ lanes: list[_TextLane],
202
+ median_height: float,
203
+ ) -> int | None:
204
+ """按中心点和水平覆盖率把 Path 唯一分配给一个正文栏带。"""
205
+
206
+ center_x = _bbox_center_x(bbox)
207
+ path_width = max(0.1, bbox[2] - bbox[0])
208
+ tolerance = 0.75 * median_height
209
+ matches: list[tuple[float, float, int]] = []
210
+ for lane_index, lane in enumerate(lanes):
211
+ if not lane.left - tolerance <= center_x <= lane.right + tolerance:
212
+ continue
213
+ overlap = max(0.0, min(bbox[2], lane.right) - max(bbox[0], lane.left))
214
+ coverage = overlap / path_width
215
+ lane_center = (lane.left + lane.right) / 2.0
216
+ matches.append((-coverage, abs(center_x - lane_center), lane_index))
217
+ return min(matches)[2] if matches else None
218
+
219
+
220
+ def _is_vector_formula_path_member(
221
+ bbox: BBox,
222
+ lane: _TextLane,
223
+ median_height: float,
224
+ ) -> bool:
225
+ """保留小字形轮廓和细横线,过滤跨栏或过大的普通矢量对象。"""
226
+
227
+ width = bbox[2] - bbox[0]
228
+ height = bbox[3] - bbox[1]
229
+ lane_width = max(0.1, lane.right - lane.left)
230
+ is_glyph = width <= 3.0 * median_height and height <= 3.0 * median_height
231
+ is_formula_rule = height <= 0.2 * median_height and width <= lane_width + median_height
232
+ return is_glyph or is_formula_rule
233
+
234
+
235
+ def _connect_vector_path_members(
236
+ members: list[PDFPathInfo],
237
+ lane_index: int,
238
+ median_height: float,
239
+ ) -> list[_VectorPathComponent]:
240
+ """用扩张 bbox 的网格邻接和并查集连接同栏 Path,避免全量两两比较。"""
241
+
242
+ if not members:
243
+ return []
244
+ ordered = sorted(members, key=lambda item: item.source_index)
245
+ parents = list(range(len(ordered)))
246
+
247
+ def find(index: int) -> int:
248
+ """查找并压缩一个 Path 的并查集根节点。"""
249
+
250
+ while parents[index] != index:
251
+ parents[index] = parents[parents[index]]
252
+ index = parents[index]
253
+ return index
254
+
255
+ def merge(first: int, second: int) -> None:
256
+ """合并两个相交扩张框所属的连通分量。"""
257
+
258
+ first_root = find(first)
259
+ second_root = find(second)
260
+ if first_root != second_root:
261
+ parents[second_root] = first_root
262
+
263
+ margin = 0.5 * median_height
264
+ cell_size = max(1.0, median_height)
265
+ expanded_bboxes = [_expand_bbox(item.bbox, margin) for item in ordered]
266
+ grid: dict[tuple[int, int], list[int]] = {}
267
+ seen_pairs: set[tuple[int, int]] = set()
268
+ for index, bbox in enumerate(expanded_bboxes):
269
+ start_x = int(bbox[0] // cell_size)
270
+ end_x = int(bbox[2] // cell_size)
271
+ start_y = int(bbox[1] // cell_size)
272
+ end_y = int(bbox[3] // cell_size)
273
+ for cell_x in range(start_x, end_x + 1):
274
+ for cell_y in range(start_y, end_y + 1):
275
+ cell = (cell_x, cell_y)
276
+ for other_index in grid.get(cell, []):
277
+ pair = (other_index, index)
278
+ if pair in seen_pairs:
279
+ continue
280
+ seen_pairs.add(pair)
281
+ if _bbox_intersects(bbox, expanded_bboxes[other_index]):
282
+ merge(index, other_index)
283
+ grid.setdefault(cell, []).append(index)
284
+
285
+ grouped: dict[int, list[PDFPathInfo]] = {}
286
+ for index, path_info in enumerate(ordered):
287
+ grouped.setdefault(find(index), []).append(path_info)
288
+ return [
289
+ _VectorPathComponent(
290
+ lane_index=lane_index,
291
+ path_infos=group,
292
+ bbox=_bbox_union_many([item.bbox for item in group]),
293
+ )
294
+ for group in grouped.values()
295
+ ]
296
+
297
+
298
+ def _is_vector_formula_core(
299
+ component: _VectorPathComponent,
300
+ lane: _TextLane,
301
+ median_height: float,
302
+ page_size: tuple[float, float],
303
+ container_bboxes: list[BBox],
304
+ ) -> bool:
305
+ """按复杂度、尺寸、正文碰撞和容器优先级校验公式主体组件。"""
306
+
307
+ path_count = len(component.path_infos)
308
+ complex_count = sum(item.segment_count >= _VECTOR_FORMULA_COMPLEX_SEGMENTS for item in component.path_infos)
309
+ if (
310
+ path_count < _VECTOR_FORMULA_MIN_PATHS
311
+ or complex_count < _VECTOR_FORMULA_MIN_COMPLEX_PATHS
312
+ or complex_count / path_count < _VECTOR_FORMULA_MIN_COMPLEX_RATIO
313
+ ):
314
+ return False
315
+
316
+ bbox = component.bbox
317
+ width = bbox[2] - bbox[0]
318
+ height = bbox[3] - bbox[1]
319
+ if not (width >= 2.5 * median_height and 0.9 * median_height <= height <= 8.0 * median_height and width >= 1.4 * height):
320
+ return False
321
+ if _is_formula_component_in_page_margin(bbox, page_size[1]):
322
+ return False
323
+ if any(_bbox_overlap_in_smaller(bbox, container_bbox) >= 0.5 for container_bbox in container_bboxes):
324
+ return False
325
+ return not any(_vector_formula_collides_with_text(bbox, line, line_bbox, median_height) for line, line_bbox in lane.lines)
326
+
327
+
328
+ def _is_formula_component_in_page_margin(bbox: BBox, page_height: float) -> bool:
329
+ """仅当公式组件完全落在页面顶部或底部边缘带时排除。"""
330
+
331
+ margin = _FORMULA_PAGE_MARGIN_RATIO * page_height
332
+ return bbox[3] <= margin or bbox[1] >= page_height - margin
333
+
334
+
335
+ def _vector_formula_collides_with_text(
336
+ formula_bbox: BBox,
337
+ line: _LineItem,
338
+ line_bbox: BBox,
339
+ median_height: float,
340
+ ) -> bool:
341
+ """排除覆盖正文或紧贴正文同行的 Path 组件,独立公式编号除外。"""
342
+
343
+ if _standalone_formula_number_marker(line.text) is not None:
344
+ return False
345
+ if _bbox_overlap_in_first(formula_bbox, line_bbox) >= 0.2:
346
+ return True
347
+ horizontal_gap = max(
348
+ formula_bbox[0] - line_bbox[2],
349
+ line_bbox[0] - formula_bbox[2],
350
+ 0.0,
351
+ )
352
+ return _bbox_axis_overlap_ratio(formula_bbox, line_bbox, axis="y") >= 0.5 and horizontal_gap <= median_height
353
+
354
+
355
+ def _attach_vector_formula_rules(
356
+ candidates: list[_VectorFormulaCandidate],
357
+ components: list[_VectorPathComponent],
358
+ median_height: float,
359
+ ) -> None:
360
+ """把靠近公式主体且横向覆盖充分的孤立细横线唯一并入主体。"""
361
+
362
+ used_sources = {source_index for candidate in candidates for source_index in candidate.path_source_indices}
363
+ for component in components:
364
+ component_sources = {item.source_index for item in component.path_infos}
365
+ if component_sources & used_sources or not all(
366
+ item.bbox[3] - item.bbox[1] <= 0.2 * median_height for item in component.path_infos
367
+ ):
368
+ continue
369
+ matches = [
370
+ (
371
+ _bbox_distance(candidate.bbox, component.bbox),
372
+ abs(_bbox_center_y(candidate.bbox) - _bbox_center_y(component.bbox)),
373
+ candidate_index,
374
+ )
375
+ for candidate_index, candidate in enumerate(candidates)
376
+ if candidate.lane_index == component.lane_index
377
+ and _bbox_distance(candidate.bbox, component.bbox) <= 0.5 * median_height
378
+ and _bbox_axis_overlap_ratio(candidate.bbox, component.bbox, axis="x") >= 0.5
379
+ ]
380
+ if not matches:
381
+ continue
382
+ candidate = candidates[min(matches)[2]]
383
+ candidate.bbox = _bbox_union(candidate.bbox, component.bbox)
384
+ candidate.path_source_indices.update(component_sources)
385
+ used_sources.update(component_sources)
386
+
387
+
388
+ def _attach_vector_formula_path_numbers(
389
+ candidates: list[_VectorFormulaCandidate],
390
+ components: list[_VectorPathComponent],
391
+ lanes: list[_TextLane],
392
+ median_height: float,
393
+ ) -> None:
394
+ """把栏右缘的小型复杂 Path 组件作为公式编号并入唯一主体。"""
395
+
396
+ used_sources = {source_index for candidate in candidates for source_index in candidate.path_source_indices}
397
+ for component in components:
398
+ component_sources = {item.source_index for item in component.path_infos}
399
+ if component_sources & used_sources or not _is_vector_formula_number_component(
400
+ component,
401
+ lanes[component.lane_index],
402
+ median_height,
403
+ ):
404
+ continue
405
+ matches = _vector_formula_number_matches(
406
+ component.bbox,
407
+ component.lane_index,
408
+ candidates,
409
+ median_height,
410
+ )
411
+ if not matches:
412
+ continue
413
+ candidate = candidates[min(matches)[3]]
414
+ candidate.bbox = _bbox_union(candidate.bbox, component.bbox)
415
+ candidate.path_source_indices.update(component_sources)
416
+ candidate.has_number = True
417
+ used_sources.update(component_sources)
418
+
419
+
420
+ def _is_vector_formula_number_component(
421
+ component: _VectorPathComponent,
422
+ lane: _TextLane,
423
+ median_height: float,
424
+ ) -> bool:
425
+ """识别位于栏右缘、尺寸接近正文行高的全复杂路径编号组件。"""
426
+
427
+ path_count = len(component.path_infos)
428
+ bbox = component.bbox
429
+ width = bbox[2] - bbox[0]
430
+ height = bbox[3] - bbox[1]
431
+ return (
432
+ _VECTOR_FORMULA_NUMBER_MIN_PATHS <= path_count <= _VECTOR_FORMULA_NUMBER_MAX_PATHS
433
+ and all(item.segment_count >= _VECTOR_FORMULA_COMPLEX_SEGMENTS for item in component.path_infos)
434
+ and 0.5 * median_height <= width <= 2.0 * median_height
435
+ and 0.6 * median_height <= height <= 1.4 * median_height
436
+ and abs(lane.right - bbox[2]) <= 1.5 * median_height
437
+ )
438
+
439
+
440
+ def _vector_formula_number_matches(
441
+ number_bbox: BBox,
442
+ lane_index: int,
443
+ candidates: list[_VectorFormulaCandidate],
444
+ median_height: float,
445
+ ) -> list[tuple[float, float, float, int]]:
446
+ """返回编号可关联的公式主体及稳定排序分值。"""
447
+
448
+ matches: list[tuple[float, float, float, int]] = []
449
+ for candidate_index, candidate in enumerate(candidates):
450
+ if candidate.has_number or candidate.lane_index != lane_index:
451
+ continue
452
+ vertical_overlap = _bbox_axis_overlap_ratio(candidate.bbox, number_bbox, axis="y")
453
+ if number_bbox[0] < candidate.bbox[2] or vertical_overlap < 0.6:
454
+ continue
455
+ center_distance = abs(_bbox_center_y(candidate.bbox) - _bbox_center_y(number_bbox))
456
+ horizontal_gap = max(0.0, number_bbox[0] - candidate.bbox[2])
457
+ matches.append((-vertical_overlap, center_distance, horizontal_gap / max(0.1, median_height), candidate_index))
458
+ return matches
459
+
460
+
461
+ def _attach_vector_formula_text_numbers(
462
+ candidates: list[_VectorFormulaCandidate],
463
+ lanes: list[_TextLane],
464
+ median_height: float,
465
+ claimed_line_indices: set[int],
466
+ ) -> set[int]:
467
+ """关联可提取的独立公式编号并认领其文本身份,防止重复输出。"""
468
+
469
+ claimed: set[int] = set()
470
+ for lane_index, lane in enumerate(lanes):
471
+ for line, bbox in sorted(lane.lines, key=lambda item: (item[1][1], item[1][0])):
472
+ if line.source_index in claimed_line_indices or _standalone_formula_number_marker(line.text) is None:
473
+ continue
474
+ width = bbox[2] - bbox[0]
475
+ height = bbox[3] - bbox[1]
476
+ if not (
477
+ 0.5 * median_height <= width <= 2.0 * median_height
478
+ and 0.6 * median_height <= height <= 1.4 * median_height
479
+ and abs(lane.right - bbox[2]) <= 1.5 * median_height
480
+ ):
481
+ continue
482
+ matches = _vector_formula_number_matches(
483
+ bbox,
484
+ lane_index,
485
+ candidates,
486
+ median_height,
487
+ )
488
+ if not matches:
489
+ continue
490
+ candidate = candidates[min(matches)[3]]
491
+ candidate.bbox = _bbox_union(candidate.bbox, bbox)
492
+ candidate.has_number = True
493
+ claimed.add(line.source_index)
494
+ return claimed
495
+
496
+
497
+ def _standalone_formula_number_marker(text: str) -> str | None:
498
+ """仅接受整行由圆括号公式编号构成的文本,不接纳带正文前缀的后缀。"""
499
+
500
+ parts = _split_trailing_formula_number(text)
501
+ if parts is None:
502
+ return None
503
+ prefix, marker = parts
504
+ return marker if not prefix else None
505
+
506
+
507
+ def _build_formula_like_blocks(
508
+ lines: list[_LineItem],
509
+ table_bboxes: list[BBox],
510
+ page_size: tuple[float, float],
511
+ *,
512
+ drawing_lines: list[_AxisLine] | None = None,
513
+ ) -> tuple[list[dict[str, Any]], list[_LineItem]]:
514
+ """仅依据栏带、右侧短锚点和空间连通关系聚合公式状区域。"""
515
+
516
+ blocks, claimed_source_indices = _build_split_visual_row_formula_blocks(
517
+ lines,
518
+ table_bboxes,
519
+ page_size,
520
+ )
521
+ paragraph_lines: list[_LineItem] = []
522
+ for angle in sorted({line.angle for line in lines}):
523
+ angle_geometry = [
524
+ (line, _rotate_bbox_to_upright(line.bbox, page_size, angle))
525
+ for line in lines
526
+ if line.angle == angle and line.source_index not in claimed_source_indices
527
+ ]
528
+ if len(angle_geometry) < 2:
529
+ continue
530
+ effective_heights = [_line_effective_height(line, bbox) for line, bbox in angle_geometry]
531
+ median_height = statistics.median(effective_heights) if effective_heights else 1.0
532
+ local_page_width = page_size[1] if angle in {90, 270} else page_size[0]
533
+ local_page_height = page_size[0] if angle in {90, 270} else page_size[1]
534
+ local_horizontal_rules = [
535
+ rule.bbox
536
+ for rule in _transform_axis_lines(
537
+ drawing_lines or [],
538
+ page_size,
539
+ angle,
540
+ )
541
+ if rule.orientation == "horizontal"
542
+ ]
543
+ lanes = _infer_text_lanes(angle_geometry, local_page_width, median_height)
544
+ for lane in lanes:
545
+ if lane.is_span:
546
+ continue
547
+ lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
548
+ dominant_body_font = _infer_formula_body_font(
549
+ lane,
550
+ median_height,
551
+ )
552
+ for line, bbox in list(lane.lines):
553
+ if not _is_single_line_numbered_formula(
554
+ (line, bbox),
555
+ lane,
556
+ median_height,
557
+ ):
558
+ continue
559
+ members = _expand_single_line_numbered_formula_members(
560
+ (line, bbox),
561
+ lane,
562
+ claimed_source_indices,
563
+ table_bboxes,
564
+ dominant_body_font,
565
+ median_height,
566
+ )
567
+ block = _formula_members_to_block(
568
+ members,
569
+ page_size,
570
+ angle,
571
+ anchor_source_index=line.source_index,
572
+ )
573
+ if block is None:
574
+ continue
575
+ blocks.append(block)
576
+ claimed_source_indices.update(member_line.source_index for member_line, _member_bbox in members)
577
+ lane.lines = [item for item in lane.lines if item[0].source_index not in claimed_source_indices]
578
+ for line, bbox in list(lane.lines):
579
+ if (
580
+ (
581
+ line.compact_formula_cluster
582
+ and not _compact_cluster_has_nearby_number_anchor(
583
+ (line, bbox),
584
+ lane,
585
+ median_height,
586
+ )
587
+ and _is_isolated_compact_formula_cluster(
588
+ (line, bbox),
589
+ lane,
590
+ median_height,
591
+ )
592
+ )
593
+ or _is_isolated_unnumbered_formula_line(
594
+ (line, bbox),
595
+ lane,
596
+ median_height,
597
+ dominant_body_font,
598
+ )
599
+ ) and not _is_formula_component_in_page_margin(
600
+ bbox,
601
+ local_page_height,
602
+ ):
603
+ content = _sanitize_pdf_control_text(
604
+ line.text,
605
+ preserve_newlines=False,
606
+ ).strip()
607
+ if not content:
608
+ continue
609
+ block = {
610
+ "type": "equation",
611
+ "bbox": line.bbox,
612
+ "angle": angle,
613
+ "content": content,
614
+ }
615
+ tight_output_bbox = _line_tight_output_bbox(
616
+ line,
617
+ page_size,
618
+ )
619
+ if tight_output_bbox is not None:
620
+ block["_tight_output_bbox"] = tight_output_bbox
621
+ blocks.append(block)
622
+ claimed_source_indices.add(line.source_index)
623
+ lane.lines = [item for item in lane.lines if item[0].source_index not in claimed_source_indices]
624
+ if len(lane.lines) < 2:
625
+ continue
626
+ anchors = _find_formula_spatial_anchors(
627
+ lane,
628
+ median_height,
629
+ dominant_body_font,
630
+ )
631
+ if not anchors:
632
+ continue
633
+ anchor_centers = [_bbox_center_y(anchor.bbox) for anchor in anchors]
634
+ lane_top = min(bbox[1] for _line, bbox in lane.lines)
635
+ lane_bottom = max(bbox[3] for _line, bbox in lane.lines)
636
+ for anchor_index, anchor in enumerate(anchors):
637
+ anchor_line = anchor.line
638
+ if anchor_line.source_index in claimed_source_indices:
639
+ continue
640
+ band_top = lane_top
641
+ band_bottom = lane_bottom
642
+ if anchor_index > 0:
643
+ band_top = max(
644
+ band_top,
645
+ (anchor_centers[anchor_index - 1] + anchor_centers[anchor_index]) / 2.0,
646
+ )
647
+ if anchor_index + 1 < len(anchors):
648
+ band_bottom = min(
649
+ band_bottom,
650
+ (anchor_centers[anchor_index] + anchor_centers[anchor_index + 1]) / 2.0,
651
+ )
652
+ members = _grow_formula_spatial_component(
653
+ lane,
654
+ anchor,
655
+ band_top,
656
+ band_bottom,
657
+ claimed_source_indices,
658
+ table_bboxes,
659
+ dominant_body_font,
660
+ median_height,
661
+ )
662
+ has_isolated_numbered_fraction = _formula_component_has_isolated_numbered_fraction(
663
+ members,
664
+ lane,
665
+ median_height,
666
+ local_horizontal_rules,
667
+ )
668
+ if (
669
+ _formula_component_has_left_prose(
670
+ members,
671
+ lane,
672
+ median_height,
673
+ )
674
+ and not has_isolated_numbered_fraction
675
+ ):
676
+ for member_line, _member_bbox in members:
677
+ member_line.paragraph_formula_context = True
678
+ if _fragmented_left_prose(members, lane, median_height):
679
+ paragraph_lines.append(_merge_paragraph_formula_members(members, page_size, median_height))
680
+ claimed_source_indices.update(line.source_index for line, _bbox in members)
681
+ continue
682
+ if len(members) < 2:
683
+ continue
684
+ if (
685
+ len(members) == 2
686
+ and _bbox_axis_overlap_ratio(
687
+ members[0][1],
688
+ members[1][1],
689
+ axis="y",
690
+ )
691
+ < 0.2
692
+ and not any(
693
+ _is_wide_tagged_formula_member(
694
+ anchor_line,
695
+ member_line,
696
+ member_bbox,
697
+ max(0.1, lane.right - lane.left),
698
+ )
699
+ for member_line, member_bbox in members
700
+ if member_line is not anchor_line
701
+ )
702
+ ):
703
+ continue
704
+ component_bbox = _bbox_union_many([member_bbox for _member_line, member_bbox in members])
705
+ if _is_formula_component_in_page_margin(
706
+ component_bbox,
707
+ local_page_height,
708
+ ):
709
+ continue
710
+ block = _formula_members_to_block(
711
+ members,
712
+ page_size,
713
+ angle,
714
+ anchor_source_index=anchor_line.source_index,
715
+ )
716
+ if block is None:
717
+ continue
718
+ blocks.append(block)
719
+ claimed_source_indices.update(line.source_index for line, _bbox in members)
720
+
721
+ remaining_lines = [
722
+ line
723
+ for line in lines
724
+ if line.source_index not in claimed_source_indices
725
+ and (not line.formula_candidate_only or line.paragraph_formula_context)
726
+ ]
727
+ return blocks, remaining_lines + paragraph_lines
728
+
729
+
730
+ def _formula_line_has_math_operator(text: str) -> bool:
731
+ """检查文本行是否具有独立公式常见的数学运算符。"""
732
+
733
+ return any(character in _FORMULA_OPERATOR_CHARS for character in text)
734
+
735
+
736
+ def _formula_prefix_has_prose(prefix: str) -> bool:
737
+ """用通用文字数量识别公式前的正文片段,不依赖特定引导词或标点。"""
738
+
739
+ prose_prefix = re.sub(
740
+ r"[({\[([【{][^)}\])]】}]*[)}\])]】}]",
741
+ " ",
742
+ prefix,
743
+ )
744
+ if len(re.findall(r"[\u3400-\u9fff]", prose_prefix)) >= 2:
745
+ return True
746
+ latin_word_count = sum(re.search(r"[A-Za-z]{2,}", token) is not None for token in prose_prefix.split())
747
+ return latin_word_count >= 2
748
+
749
+
750
+ def _formula_component_has_left_prose(
751
+ members: list[tuple[_LineItem, BBox]],
752
+ lane: _TextLane,
753
+ median_height: float,
754
+ ) -> bool:
755
+ """识别贴栏左缘且在首个运算符前带同行正文的伪行间公式。"""
756
+
757
+ for line, bbox in members:
758
+ normalized = unicodedata.normalize("NFKC", line.text).strip()
759
+ operator_positions = [index for index, character in enumerate(normalized) if character in _FORMULA_OPERATOR_CHARS]
760
+ if not operator_positions:
761
+ continue
762
+ prefix = normalized[: min(operator_positions)]
763
+ if not _formula_prefix_has_prose(prefix):
764
+ continue
765
+ if abs(bbox[0] - lane.left) <= 0.75 * median_height:
766
+ return True
767
+ return _fragmented_left_prose(members, lane, median_height)
768
+
769
+
770
+ def _fragmented_left_prose(members: list[tuple[_LineItem, BBox]], lane: _TextLane, median_height: float) -> bool:
771
+ """运算符前的正文被拆成多个 run 时,沿同行邻接找到栏左缘的正文证据。"""
772
+ for line, bbox in members:
773
+ normalized = unicodedata.normalize("NFKC", line.text).strip()
774
+ positions = [index for index, character in enumerate(normalized) if character in _FORMULA_OPERATOR_CHARS]
775
+ if not positions or not _formula_prefix_has_prose(normalized[: min(positions)]):
776
+ continue
777
+ if any(
778
+ other is not line
779
+ and abs(other_bbox[0] - lane.left) <= 0.75 * median_height
780
+ and 0 <= bbox[0] - other_bbox[2] <= 0.75 * median_height
781
+ and _bbox_axis_overlap_ratio(bbox, other_bbox, axis="y") >= 0.7
782
+ for other, other_bbox in members
783
+ ):
784
+ return True
785
+ return False
786
+
787
+
788
+ def _merge_paragraph_formula_members(
789
+ members: list[tuple[_LineItem, BBox]], page_size: tuple[float, float], median_height: float
790
+ ) -> _LineItem:
791
+ """把已确认的行内分式按重叠视觉行恢复顺序,避免字形高度差把正文前缀排到分子之后。"""
792
+ rows: list[list[tuple[_LineItem, BBox]]] = []
793
+ for item in sorted(members, key=lambda item: (item[1][1], item[1][0])):
794
+ match = next(
795
+ (row for row in rows if any(_bbox_axis_overlap_ratio(item[1], other[1], axis="y") >= 0.55 for other in row)),
796
+ None,
797
+ )
798
+ if match is None:
799
+ rows.append([item])
800
+ else:
801
+ match.append(item)
802
+ merged = _merge_overlapping_inline_cluster(members, page_size, median_height, compact_formula_cluster=False)
803
+ merged.text = " ".join(_join_formula_visual_row(row, page_size) for row in rows)
804
+ merged.paragraph_formula_context = True
805
+ merged.formula_candidate_only = False
806
+ return merged
807
+
808
+
809
+ def _formula_component_has_isolated_numbered_fraction(
810
+ members: list[tuple[_LineItem, BBox]],
811
+ lane: _TextLane,
812
+ median_height: float,
813
+ horizontal_rules: list[BBox],
814
+ ) -> bool:
815
+ """用右侧编号、内部分数线和上下留白确认独立多层公式。"""
816
+
817
+ if len(members) < 3 or not horizontal_rules:
818
+ return False
819
+ markers = [(line, bbox) for line, bbox in members if _standalone_formula_number_marker(line.text) is not None]
820
+ if len(markers) != 1:
821
+ return False
822
+ marker_line, marker_bbox = markers[0]
823
+ lane_width = max(0.1, lane.right - lane.left)
824
+ if marker_bbox[2] < lane.right - max(3.0, 0.08 * lane_width) or marker_bbox[2] - marker_bbox[0] > 0.12 * lane_width:
825
+ return False
826
+ body_members = [(line, bbox) for line, bbox in members if line is not marker_line]
827
+ if len(body_members) < 2:
828
+ return False
829
+ body_bbox = _bbox_union_many(
830
+ [bbox for _line, bbox in body_members],
831
+ )
832
+ member_sources = {line.source_index for line, _bbox in members}
833
+ rows_above = [bbox for line, bbox in lane.lines if line.source_index not in member_sources and bbox[3] <= body_bbox[1]]
834
+ rows_below = [bbox for line, bbox in lane.lines if line.source_index not in member_sources and bbox[1] >= body_bbox[3]]
835
+ if not rows_above or not rows_below:
836
+ return False
837
+ gap_above = body_bbox[1] - max(bbox[3] for bbox in rows_above)
838
+ gap_below = min(bbox[1] for bbox in rows_below) - body_bbox[3]
839
+ if min(gap_above, gap_below) < 0.75 * median_height:
840
+ return False
841
+
842
+ body_centers = [_bbox_center_y(bbox) for _line, bbox in body_members]
843
+ for rule_bbox in horizontal_rules:
844
+ rule_width = rule_bbox[2] - rule_bbox[0]
845
+ if not (3.0 * median_height <= rule_width <= 0.75 * lane_width):
846
+ continue
847
+ horizontal_overlap = max(
848
+ 0.0,
849
+ min(rule_bbox[2], body_bbox[2]) - max(rule_bbox[0], body_bbox[0]),
850
+ )
851
+ if horizontal_overlap < 0.6 * rule_width:
852
+ continue
853
+ rule_center = _bbox_center_y(rule_bbox)
854
+ if any(center <= rule_center - 0.1 * median_height for center in body_centers) and any(
855
+ center >= rule_center + 0.1 * median_height for center in body_centers
856
+ ):
857
+ return True
858
+ return False
859
+
860
+
861
+ def _is_wide_tagged_formula_member(
862
+ anchor_line: _LineItem,
863
+ member_line: _LineItem,
864
+ member_bbox: BBox,
865
+ lane_width: float,
866
+ ) -> bool:
867
+ """判断独立编号左侧是否为接近满栏的单行公式主体。"""
868
+
869
+ member_width = member_bbox[2] - member_bbox[0]
870
+ marker = _standalone_formula_number_marker(anchor_line.text)
871
+ return (
872
+ anchor_line.style_scale_repaired
873
+ and marker is not None
874
+ and _FORMULA_NUMBER_MARKER_RE.fullmatch(marker)
875
+ and 0.75 * lane_width < member_width <= 0.95 * lane_width
876
+ and _formula_line_has_math_operator(member_line.text)
877
+ )
878
+
879
+
880
+ def _is_single_line_numbered_formula(
881
+ candidate: tuple[_LineItem, BBox],
882
+ lane: _TextLane,
883
+ median_height: float,
884
+ ) -> bool:
885
+ """识别公式主体与右侧编号已落在同一原生文本行的情形。"""
886
+
887
+ line, bbox = candidate
888
+ if not line.style_scale_repaired:
889
+ return False
890
+ parts = _split_trailing_formula_number(line.text)
891
+ if parts is None:
892
+ return False
893
+ prefix, marker = parts
894
+ if not prefix or not _FORMULA_NUMBER_MARKER_RE.fullmatch(marker) or not _formula_line_has_math_operator(prefix):
895
+ return False
896
+ lane_width = max(0.1, lane.right - lane.left)
897
+ width_ratio = (bbox[2] - bbox[0]) / lane_width
898
+ if not 0.12 <= width_ratio <= 0.98:
899
+ return False
900
+ if abs(_bbox_center_x(bbox) - 0.5 * (lane.left + lane.right)) > 0.2 * lane_width:
901
+ return False
902
+ if bbox[3] - bbox[1] > 4.0 * median_height:
903
+ return False
904
+ return not _is_hanging_indent_tail_line(
905
+ candidate,
906
+ lane,
907
+ median_height,
908
+ )
909
+
910
+
911
+ def _expand_single_line_numbered_formula_members(
912
+ core: tuple[_LineItem, BBox],
913
+ lane: _TextLane,
914
+ claimed_source_indices: set[int],
915
+ table_bboxes: list[BBox],
916
+ dominant_body_font: tuple[str, int] | None,
917
+ median_height: float,
918
+ ) -> list[tuple[_LineItem, BBox]]:
919
+ """为已带编号的公式核心吸收同栏连通的等号前缀和窄分式碎片。"""
920
+
921
+ core_line, core_bbox = core
922
+ lane_width = max(0.1, lane.right - lane.left)
923
+ candidates = []
924
+ for candidate_line, candidate_bbox in lane.lines:
925
+ if (
926
+ candidate_line.source_index == core_line.source_index
927
+ or candidate_line.source_index in claimed_source_indices
928
+ or candidate_bbox[2] - candidate_bbox[0] > 0.35 * lane_width
929
+ or _is_formula_body_barrier(
930
+ (candidate_line, candidate_bbox),
931
+ lane,
932
+ dominant_body_font,
933
+ median_height,
934
+ )
935
+ or _is_formula_title_barrier(
936
+ (candidate_line, candidate_bbox),
937
+ lane,
938
+ dominant_body_font,
939
+ median_height,
940
+ )
941
+ ):
942
+ continue
943
+ narrow_fragment = (
944
+ candidate_bbox[2] - candidate_bbox[0] <= 1.5 * median_height
945
+ and core_bbox[0] - median_height <= _bbox_center_x(candidate_bbox) <= core_bbox[2] + median_height
946
+ )
947
+ if not (
948
+ _formula_line_has_math_operator(candidate_line.text)
949
+ or candidate_line.compact_formula_cluster
950
+ or candidate_line.formula_candidate_only
951
+ or narrow_fragment
952
+ ):
953
+ continue
954
+ if not _formula_lines_are_connected(
955
+ core_line,
956
+ core_bbox,
957
+ candidate_line,
958
+ candidate_bbox,
959
+ table_bboxes,
960
+ ):
961
+ continue
962
+ candidates.append(
963
+ (candidate_line, candidate_bbox),
964
+ )
965
+
966
+ members = [core, *candidates]
967
+ member_sources = {line.source_index for line, _bbox in members}
968
+ changed = True
969
+ while changed:
970
+ changed = False
971
+ for candidate in lane.lines:
972
+ candidate_line, candidate_bbox = candidate
973
+ if (
974
+ candidate_line.source_index in member_sources
975
+ or candidate_line.source_index in claimed_source_indices
976
+ or candidate_bbox[2] - candidate_bbox[0] > 0.35 * lane_width
977
+ or _is_formula_body_barrier(
978
+ candidate,
979
+ lane,
980
+ dominant_body_font,
981
+ median_height,
982
+ )
983
+ or _is_formula_title_barrier(
984
+ candidate,
985
+ lane,
986
+ dominant_body_font,
987
+ median_height,
988
+ )
989
+ ):
990
+ continue
991
+ if any(
992
+ _formula_lines_are_connected(
993
+ member_line,
994
+ member_bbox,
995
+ candidate_line,
996
+ candidate_bbox,
997
+ table_bboxes,
998
+ )
999
+ for member_line, member_bbox in members
1000
+ ) and (
1001
+ _formula_line_has_math_operator(candidate_line.text)
1002
+ or candidate_line.compact_formula_cluster
1003
+ or candidate_line.formula_candidate_only
1004
+ or candidate_bbox[2] - candidate_bbox[0] <= 1.5 * median_height
1005
+ ):
1006
+ members.append(candidate)
1007
+ member_sources.add(candidate_line.source_index)
1008
+ changed = True
1009
+ return members
1010
+
1011
+
1012
+ def _build_split_visual_row_formula_blocks(
1013
+ lines: list[_LineItem],
1014
+ table_bboxes: list[BBox],
1015
+ page_size: tuple[float, float],
1016
+ ) -> tuple[list[dict[str, Any]], set[int]]:
1017
+ """在栏带推断前恢复同一视觉行中带右侧编号的多字体公式。"""
1018
+
1019
+ row_groups: dict[tuple[int, int], list[_LineItem]] = {}
1020
+ for line in lines:
1021
+ if line.visual_row_id is None or not line.split_from_row:
1022
+ continue
1023
+ row_groups.setdefault((line.angle, line.visual_row_id), []).append(line)
1024
+
1025
+ blocks: list[dict[str, Any]] = []
1026
+ claimed: set[int] = set()
1027
+ for (angle, _row_id), members in row_groups.items():
1028
+ if len(members) < 3:
1029
+ continue
1030
+ markers = [member for member in members if _standalone_formula_number_marker(member.text) is not None]
1031
+ if len(markers) != 1:
1032
+ continue
1033
+ marker = markers[0]
1034
+ if any(_bbox_intersects(member.bbox, table_bbox) for member in members for table_bbox in table_bboxes):
1035
+ continue
1036
+ local_members = [
1037
+ (
1038
+ member,
1039
+ _rotate_bbox_to_upright(
1040
+ member.bbox,
1041
+ page_size,
1042
+ angle,
1043
+ ),
1044
+ )
1045
+ for member in members
1046
+ ]
1047
+ marker_bbox = next(bbox for member, bbox in local_members if member is marker)
1048
+ body_members = [(member, bbox) for member, bbox in local_members if member is not marker]
1049
+ if not body_members or marker_bbox[0] <= max(_bbox_center_x(bbox) for _member, bbox in body_members):
1050
+ continue
1051
+ median_height = statistics.median(_line_effective_height(member, bbox) for member, bbox in local_members)
1052
+ row_center = statistics.median(_bbox_center_y(bbox) for _member, bbox in local_members)
1053
+ if any(abs(_bbox_center_y(bbox) - row_center) > 0.75 * median_height for _member, bbox in local_members):
1054
+ continue
1055
+ body_fonts = {member.font_signature for member, _bbox in body_members if member.font_signature is not None}
1056
+ has_math_typography = len(body_fonts) >= 2 or any(
1057
+ member.compact_formula_cluster or member.font_coverage < 0.8 for member, _bbox in body_members
1058
+ )
1059
+ if not has_math_typography:
1060
+ continue
1061
+ body_bbox = _bbox_union_many([bbox for _member, bbox in body_members])
1062
+ body_width = max(0.1, body_bbox[2] - body_bbox[0])
1063
+ # 同行成员可能只是分式尾部;窄尾部不能压低外部公式片段的宽度容差,
1064
+ # 否则会提前认领分母、右括号和编号,使左侧公式主体落回普通文本。
1065
+ nearby_fragment_width_limit = max(
1066
+ 0.65 * body_width,
1067
+ 3.0 * median_height,
1068
+ )
1069
+ member_ids = {id(member) for member in members}
1070
+ has_nearby_formula_fragment = False
1071
+ for other in lines:
1072
+ if id(other) in member_ids or other.angle != angle:
1073
+ continue
1074
+ other_bbox = _rotate_bbox_to_upright(
1075
+ other.bbox,
1076
+ page_size,
1077
+ angle,
1078
+ )
1079
+ vertical_gap = max(
1080
+ 0.0,
1081
+ max(other_bbox[1], body_bbox[1]) - min(other_bbox[3], body_bbox[3]),
1082
+ )
1083
+ if (
1084
+ vertical_gap <= 0.75 * median_height
1085
+ and other_bbox[2] - other_bbox[0] <= nearby_fragment_width_limit
1086
+ and max(
1087
+ 0.0,
1088
+ max(other_bbox[0], body_bbox[0]) - min(other_bbox[2], body_bbox[2]),
1089
+ )
1090
+ <= median_height
1091
+ ):
1092
+ has_nearby_formula_fragment = True
1093
+ break
1094
+ if has_nearby_formula_fragment:
1095
+ continue
1096
+ block = _formula_members_to_block(
1097
+ local_members,
1098
+ page_size,
1099
+ angle,
1100
+ anchor_source_index=marker.source_index,
1101
+ )
1102
+ if block is None:
1103
+ continue
1104
+ local_bbox = _bbox_union_many([bbox for _member, bbox in local_members])
1105
+ local_page_height = page_size[0] if angle in {90, 270} else page_size[1]
1106
+ if _is_formula_component_in_page_margin(
1107
+ local_bbox,
1108
+ local_page_height,
1109
+ ):
1110
+ continue
1111
+ blocks.append(block)
1112
+ claimed.update(member.source_index for member in members)
1113
+ return blocks, claimed
1114
+
1115
+
1116
+ def _is_isolated_compact_formula_cluster(
1117
+ candidate: tuple[_LineItem, BBox],
1118
+ lane: _TextLane,
1119
+ median_height: float,
1120
+ ) -> bool:
1121
+ """用上下正文邻行确认紧凑二维文本簇是独立行间公式。"""
1122
+
1123
+ line, bbox = candidate
1124
+ if not line.compact_formula_cluster:
1125
+ return False
1126
+ lane_width = max(0.1, lane.right - lane.left)
1127
+ if bbox[2] - bbox[0] > 0.6 * lane_width:
1128
+ return False
1129
+ if bbox[3] - bbox[1] > 3.0 * median_height:
1130
+ return False
1131
+ center_delta_ratio = abs(_bbox_center_x(bbox) - 0.5 * (lane.left + lane.right)) / lane_width
1132
+ left_indent_ratio = (bbox[0] - lane.left) / lane_width
1133
+ right_blank_ratio = (lane.right - bbox[2]) / lane_width
1134
+ # 部分期刊把独立公式按固定左缩进排版;同时要求右侧大留白,排除贴栏正文。
1135
+ deliberately_left_indented = 0.03 <= left_indent_ratio <= 0.25 and right_blank_ratio >= 0.35
1136
+ if center_delta_ratio > 0.2 and not deliberately_left_indented:
1137
+ return False
1138
+
1139
+ candidate_center = _bbox_center_y(bbox)
1140
+ body_rows = [
1141
+ item
1142
+ for item in lane.lines
1143
+ if item[0].source_index != line.source_index
1144
+ and item[1][2] - item[1][0] >= 0.45 * lane_width
1145
+ and 0.8 * median_height <= _line_effective_height(*item) <= 1.25 * median_height
1146
+ ]
1147
+ rows_above = [item for item in body_rows if _bbox_center_y(item[1]) < candidate_center]
1148
+ rows_below = [item for item in body_rows if _bbox_center_y(item[1]) > candidate_center]
1149
+ if not rows_above or not rows_below:
1150
+ return False
1151
+ previous = max(rows_above, key=lambda item: _bbox_center_y(item[1]))
1152
+ following = min(rows_below, key=lambda item: _bbox_center_y(item[1]))
1153
+ return (
1154
+ candidate_center - _bbox_center_y(previous[1]) <= 8.0 * median_height
1155
+ and _bbox_center_y(following[1]) - candidate_center <= 8.0 * median_height
1156
+ )
1157
+
1158
+
1159
+ def _compact_cluster_has_nearby_number_anchor(
1160
+ candidate: tuple[_LineItem, BBox],
1161
+ lane: _TextLane,
1162
+ median_height: float,
1163
+ ) -> bool:
1164
+ """检测紧凑公式右侧的独立编号,保留给既有空间锚点统一扩张。"""
1165
+
1166
+ line, bbox = candidate
1167
+ return any(
1168
+ other_line.source_index != line.source_index
1169
+ and _standalone_formula_number_marker(other_line.text) is not None
1170
+ and other_bbox[0] > _bbox_center_x(bbox)
1171
+ and abs(_bbox_center_y(other_bbox) - _bbox_center_y(bbox)) <= 2.5 * median_height
1172
+ for other_line, other_bbox in lane.lines
1173
+ )
1174
+
1175
+
1176
+ def _is_isolated_unnumbered_formula_line(
1177
+ candidate: tuple[_LineItem, BBox],
1178
+ lane: _TextLane,
1179
+ median_height: float,
1180
+ dominant_body_font: tuple[str, int] | None,
1181
+ ) -> bool:
1182
+ """用低正文覆盖的数学排版和上下正文邻接识别无编号行间公式。"""
1183
+
1184
+ line, bbox = candidate
1185
+ if (
1186
+ line.compact_formula_cluster
1187
+ or dominant_body_font is None
1188
+ or line.font_signature is None
1189
+ or line.font_signature == dominant_body_font
1190
+ or line.font_coverage >= 0.75
1191
+ ):
1192
+ return False
1193
+ lane_width = max(0.1, lane.right - lane.left)
1194
+ line_width = bbox[2] - bbox[0]
1195
+ if not 0.15 * lane_width <= line_width <= 0.8 * lane_width:
1196
+ return False
1197
+ if abs(_bbox_center_x(bbox) - 0.5 * (lane.left + lane.right)) > 0.08 * lane_width:
1198
+ return False
1199
+ if bbox[3] - bbox[1] > 1.8 * median_height:
1200
+ return False
1201
+ if _is_hanging_indent_tail_line(candidate, lane, median_height):
1202
+ return False
1203
+ candidate_center = _bbox_center_y(bbox)
1204
+ if any(
1205
+ other_line.source_index != line.source_index
1206
+ and _standalone_formula_number_marker(other_line.text) is not None
1207
+ and abs(_bbox_center_y(other_bbox) - candidate_center) <= 4.0 * median_height
1208
+ for other_line, other_bbox in lane.lines
1209
+ ) or _has_nearby_punctuated_formula_number_anchor(
1210
+ candidate,
1211
+ lane,
1212
+ median_height,
1213
+ ):
1214
+ return False
1215
+ body_rows = [
1216
+ item
1217
+ for item in lane.lines
1218
+ if item[0].source_index != line.source_index
1219
+ and item[0].font_signature == dominant_body_font
1220
+ and item[0].font_coverage >= 0.75
1221
+ and item[1][2] - item[1][0] >= 0.45 * lane_width
1222
+ and 0.8 * median_height <= _line_effective_height(*item) <= 1.25 * median_height
1223
+ ]
1224
+ rows_above = [item for item in body_rows if _bbox_center_y(item[1]) < candidate_center]
1225
+ rows_below = [item for item in body_rows if _bbox_center_y(item[1]) > candidate_center]
1226
+ if not rows_above or not rows_below:
1227
+ return False
1228
+ previous = max(rows_above, key=lambda item: _bbox_center_y(item[1]))
1229
+ following = min(rows_below, key=lambda item: _bbox_center_y(item[1]))
1230
+ return (
1231
+ candidate_center - _bbox_center_y(previous[1]) <= 4.0 * median_height
1232
+ and _bbox_center_y(following[1]) - candidate_center <= 4.0 * median_height
1233
+ )
1234
+
1235
+
1236
+ def _is_hanging_indent_tail_line(
1237
+ candidate: tuple[_LineItem, BBox],
1238
+ lane: _TextLane,
1239
+ median_height: float,
1240
+ ) -> bool:
1241
+ """用相邻行缩进、字体和节奏识别参考条目的悬挂缩进尾行。"""
1242
+
1243
+ line, bbox = candidate
1244
+ if line.font_signature is None:
1245
+ return False
1246
+ candidate_center = _bbox_center_y(bbox)
1247
+ rows_above = [
1248
+ item for item in lane.lines if item[0].source_index != line.source_index and _bbox_center_y(item[1]) < candidate_center
1249
+ ]
1250
+ rows_below = [
1251
+ item for item in lane.lines if item[0].source_index != line.source_index and _bbox_center_y(item[1]) > candidate_center
1252
+ ]
1253
+ if not rows_above or not rows_below:
1254
+ return False
1255
+ previous = max(rows_above, key=lambda item: _bbox_center_y(item[1]))
1256
+ following = min(rows_below, key=lambda item: _bbox_center_y(item[1]))
1257
+ previous_line, previous_bbox = previous
1258
+ _following_line, following_bbox = following
1259
+ previous_pitch = candidate_center - _bbox_center_y(previous_bbox)
1260
+ following_pitch = _bbox_center_y(following_bbox) - candidate_center
1261
+ lane_width = max(0.1, lane.right - lane.left)
1262
+ candidate_width = bbox[2] - bbox[0]
1263
+ previous_width = previous_bbox[2] - previous_bbox[0]
1264
+ return (
1265
+ previous_line.font_signature == line.font_signature
1266
+ and abs(previous_bbox[0] - bbox[0]) <= 0.5 * median_height
1267
+ and candidate_width <= 0.9 * previous_width
1268
+ and 0.65 * median_height <= previous_pitch <= 1.6 * median_height
1269
+ and 0.65 * median_height <= following_pitch <= 1.6 * median_height
1270
+ and following_bbox[0] <= bbox[0] - 1.5 * median_height
1271
+ and following_bbox[2] - following_bbox[0] >= 0.75 * lane_width
1272
+ )
1273
+
1274
+
1275
+ def _has_nearby_punctuated_formula_number_anchor(
1276
+ candidate: tuple[_LineItem, BBox],
1277
+ lane: _TextLane,
1278
+ median_height: float,
1279
+ ) -> bool:
1280
+ """识别同一公式带右侧仅带标点前缀的编号,避免分式上下行被提前认领。"""
1281
+
1282
+ line, bbox = candidate
1283
+ for other_line, other_bbox in lane.lines:
1284
+ if other_line.source_index == line.source_index:
1285
+ continue
1286
+ parts = _split_trailing_formula_number(other_line.text)
1287
+ if parts is None:
1288
+ continue
1289
+ prefix, _marker = parts
1290
+ compact_prefix = prefix.strip()
1291
+ if not compact_prefix or len(compact_prefix) > 3 or any(character.isalnum() for character in compact_prefix):
1292
+ continue
1293
+ vertical_gap = max(
1294
+ 0.0,
1295
+ max(other_bbox[1], bbox[1]) - min(other_bbox[3], bbox[3]),
1296
+ )
1297
+ horizontal_gap = max(0.0, other_bbox[0] - bbox[2])
1298
+ if (
1299
+ other_bbox[0] >= bbox[2] - 0.5 * median_height
1300
+ and vertical_gap <= 0.75 * median_height
1301
+ and horizontal_gap <= 4.0 * median_height
1302
+ ):
1303
+ return True
1304
+ return False
1305
+
1306
+
1307
+ def _find_repeated_formula_number_anchors(
1308
+ lane: _TextLane,
1309
+ median_height: float,
1310
+ body_interval: tuple[float, float] | None,
1311
+ ) -> list[_FormulaAnchor]:
1312
+ """用栏右缘重复编号恢复正文区间之外的行间公式锚点。"""
1313
+ lane_width = max(0.1, lane.right - lane.left)
1314
+ markers = [
1315
+ (line, bbox)
1316
+ for line, bbox in lane.lines
1317
+ if (parts := _split_trailing_formula_number(line.text)) is not None
1318
+ and not parts[0]
1319
+ and abs(lane.right - bbox[2]) <= max(3.0, 0.02 * lane_width)
1320
+ ]
1321
+ output: list[_FormulaAnchor] = []
1322
+ for line, bbox in markers:
1323
+ if not any(
1324
+ other_line.source_index != line.source_index
1325
+ and abs(_bbox_center_y(other_bbox) - _bbox_center_y(bbox)) <= 6.0 * median_height
1326
+ for other_line, other_bbox in markers
1327
+ ):
1328
+ continue
1329
+ line_height = _line_effective_height(line, bbox)
1330
+ left_peers = [
1331
+ (other_line, other_bbox)
1332
+ for other_line, other_bbox in lane.lines
1333
+ if other_line.source_index != line.source_index
1334
+ and _bbox_center_x(other_bbox) < bbox[0]
1335
+ and _formula_detached_seed_vertical_match(
1336
+ bbox,
1337
+ line_height,
1338
+ other_bbox,
1339
+ _line_effective_height(other_line, other_bbox),
1340
+ )
1341
+ ]
1342
+ if len(left_peers) < 2 or not any(
1343
+ peer.formula_candidate_only or peer.compact_formula_cluster or peer.font_coverage < 0.75
1344
+ for peer, _peer_bbox in left_peers
1345
+ ):
1346
+ continue
1347
+ center_y = _bbox_center_y(bbox)
1348
+ detached_above = body_interval is None or center_y < body_interval[0]
1349
+ detached_below = body_interval is not None and center_y > body_interval[1]
1350
+ output.append(
1351
+ _FormulaAnchor(
1352
+ line=line,
1353
+ bbox=bbox,
1354
+ detached_below_body=detached_below,
1355
+ detached_above_body=detached_above,
1356
+ repeated_number_band=True,
1357
+ )
1358
+ )
1359
+ return output
1360
+
1361
+
1362
+ def _find_formula_spatial_anchors(
1363
+ lane: _TextLane,
1364
+ median_height: float,
1365
+ dominant_body_font: tuple[str, int] | None = None,
1366
+ ) -> list[_FormulaAnchor]:
1367
+ """查找栏带右缘短块或带编号后缀的非正文字体公式锚点。"""
1368
+
1369
+ lane_width = max(0.1, lane.right - lane.left)
1370
+ body_interval = _formula_lane_body_interval(lane, median_height)
1371
+ repeated_anchors = _find_repeated_formula_number_anchors(
1372
+ lane,
1373
+ median_height,
1374
+ body_interval,
1375
+ )
1376
+ if body_interval is None:
1377
+ return _deduplicate_formula_anchors(repeated_anchors, median_height)
1378
+ body_top, body_bottom = body_interval
1379
+ anchors: list[_FormulaAnchor] = list(repeated_anchors)
1380
+ repeated_sources = {anchor.line.source_index for anchor in repeated_anchors}
1381
+ for line, bbox in lane.lines:
1382
+ if line.source_index in repeated_sources:
1383
+ continue
1384
+ line_height = _line_effective_height(line, bbox)
1385
+ line_width = bbox[2] - bbox[0]
1386
+ is_short_right_anchor = line_width <= max(4.0 * line_height, 0.12 * lane_width)
1387
+ has_formula_number_suffix = _split_trailing_formula_number(line.text) is not None
1388
+ is_wide_numbered_anchor = (
1389
+ has_formula_number_suffix
1390
+ and line_width <= 0.75 * lane_width
1391
+ and dominant_body_font is not None
1392
+ and (line.font_signature != dominant_body_font or line.font_coverage < 0.75)
1393
+ )
1394
+ if not is_short_right_anchor and not is_wide_numbered_anchor:
1395
+ continue
1396
+ same_row_fragments = [
1397
+ other_line
1398
+ for other_line, _other_bbox in lane.lines
1399
+ if line.visual_row_id is not None
1400
+ and other_line.visual_row_id == line.visual_row_id
1401
+ and (line.split_from_row or other_line.split_from_row)
1402
+ ]
1403
+ if len(same_row_fragments) >= 3 and not any(
1404
+ other_line.font_coverage < 0.75
1405
+ or (dominant_body_font is not None and other_line.font_signature != dominant_body_font)
1406
+ for other_line in same_row_fragments
1407
+ if other_line.source_index != line.source_index
1408
+ ):
1409
+ # 一条粗行被多个大空格拆成密集词组时更像普通排版行,不能把末词当作公式编号锚点。
1410
+ continue
1411
+ if _split_visual_row_has_prose_continuation(
1412
+ lane,
1413
+ line,
1414
+ same_row_fragments,
1415
+ median_height,
1416
+ ):
1417
+ continue
1418
+ if abs(lane.right - bbox[2]) > max(3.0, 0.02 * lane_width):
1419
+ continue
1420
+ center_y = _bbox_center_y(bbox)
1421
+ detached_below_body = body_bottom < center_y <= body_bottom + 6.0 * median_height
1422
+ detached_above_body = body_top - 6.0 * median_height <= center_y < body_top
1423
+ if not body_top <= center_y <= body_bottom and not detached_below_body and not detached_above_body:
1424
+ continue
1425
+ left_peers = [
1426
+ (other_line, other_bbox)
1427
+ for other_line, other_bbox in lane.lines
1428
+ if other_line.source_index != line.source_index
1429
+ and (
1430
+ other_bbox[2] - other_bbox[0] <= 0.75 * lane_width
1431
+ or _is_wide_tagged_formula_member(
1432
+ line,
1433
+ other_line,
1434
+ other_bbox,
1435
+ lane_width,
1436
+ )
1437
+ )
1438
+ and _bbox_center_x(other_bbox) < bbox[0]
1439
+ and (
1440
+ _formula_detached_seed_vertical_match(
1441
+ bbox,
1442
+ line_height,
1443
+ other_bbox,
1444
+ _line_effective_height(other_line, other_bbox),
1445
+ )
1446
+ if detached_below_body
1447
+ or detached_above_body
1448
+ or _is_wide_tagged_formula_member(
1449
+ line,
1450
+ other_line,
1451
+ other_bbox,
1452
+ lane_width,
1453
+ )
1454
+ else _formula_seed_vertical_match(
1455
+ bbox,
1456
+ line_height,
1457
+ other_bbox,
1458
+ _line_effective_height(other_line, other_bbox),
1459
+ )
1460
+ )
1461
+ ]
1462
+ if is_short_right_anchor and not has_formula_number_suffix:
1463
+ # 非编号短锚点必须与左侧主体真正分离;分母字符与正文横向重叠时不能扩张成公式。
1464
+ if any(_bbox_axis_overlap_ratio(bbox, other_bbox, axis="x") >= 0.5 for _other_line, other_bbox in left_peers):
1465
+ continue
1466
+ minimum_gap = max(0.5, 0.1 * line_height)
1467
+ if not any(bbox[0] - other_bbox[2] >= minimum_gap for _other_line, other_bbox in left_peers):
1468
+ continue
1469
+ if left_peers:
1470
+ anchors.append(
1471
+ _FormulaAnchor(
1472
+ line=line,
1473
+ bbox=bbox,
1474
+ detached_below_body=detached_below_body,
1475
+ detached_above_body=detached_above_body,
1476
+ )
1477
+ )
1478
+ return _deduplicate_formula_anchors(anchors, median_height)
1479
+
1480
+
1481
+ def _split_visual_row_has_prose_continuation(
1482
+ lane: _TextLane,
1483
+ anchor_line: _LineItem,
1484
+ same_row_fragments: list[_LineItem],
1485
+ median_height: float,
1486
+ ) -> bool:
1487
+ """识别覆盖大部分栏宽且紧接下一正文行的同行拆分文本。"""
1488
+
1489
+ if len(same_row_fragments) < 3 or anchor_line.visual_row_id is None:
1490
+ return False
1491
+ fragment_sources = {line.source_index for line in same_row_fragments}
1492
+ fragment_geometry = [(line, bbox) for line, bbox in lane.lines if line.source_index in fragment_sources]
1493
+ if len(fragment_geometry) < 3:
1494
+ return False
1495
+ lane_width = max(0.1, lane.right - lane.left)
1496
+ row_bbox = _bbox_union_many([bbox for _line, bbox in fragment_geometry])
1497
+ if row_bbox[2] - row_bbox[0] < 0.75 * lane_width:
1498
+ return False
1499
+ row_center = statistics.median(_bbox_center_y(bbox) for _line, bbox in fragment_geometry)
1500
+ if any(
1501
+ abs(_bbox_center_y(bbox) - row_center) > 0.25 * median_height
1502
+ or not 0.7 * median_height <= _line_effective_height(line, bbox) <= 1.3 * median_height
1503
+ for line, bbox in fragment_geometry
1504
+ ):
1505
+ return False
1506
+ following_rows = [
1507
+ (line, bbox)
1508
+ for line, bbox in lane.lines
1509
+ if line.source_index not in fragment_sources and _bbox_center_y(bbox) > row_center + 0.5 * median_height
1510
+ ]
1511
+ if not following_rows:
1512
+ return False
1513
+ following_line, following_bbox = min(
1514
+ following_rows,
1515
+ key=lambda item: (_bbox_center_y(item[1]), item[1][0]),
1516
+ )
1517
+ following_height = _line_effective_height(
1518
+ following_line,
1519
+ following_bbox,
1520
+ )
1521
+ return (
1522
+ following_bbox[2] - following_bbox[0] >= 0.6 * lane_width
1523
+ and abs(following_bbox[0] - lane.left) <= 0.75 * median_height
1524
+ and 0.75 * median_height <= following_height <= 1.3 * median_height
1525
+ and following_bbox[1] - row_bbox[3] <= 1.25 * median_height
1526
+ )
1527
+
1528
+
1529
+ def _infer_formula_body_font(
1530
+ lane: _TextLane,
1531
+ median_height: float,
1532
+ ) -> tuple[str, int] | None:
1533
+ """从栏内常规宽正文行推断 dominant font,供公式扩张排除正文前缀。"""
1534
+
1535
+ lane_width = max(0.1, lane.right - lane.left)
1536
+ font_counts: dict[tuple[str, int], int] = {}
1537
+ for line, bbox in lane.lines:
1538
+ line_height = _line_effective_height(line, bbox)
1539
+ if bbox[2] - bbox[0] < 0.35 * lane_width:
1540
+ continue
1541
+ if not 0.8 * median_height <= line_height <= 1.25 * median_height:
1542
+ continue
1543
+ if line.font_signature is None or line.font_coverage < 0.75:
1544
+ continue
1545
+ font_counts[line.font_signature] = font_counts.get(line.font_signature, 0) + 1
1546
+ if not font_counts:
1547
+ return None
1548
+ return max(font_counts.items(), key=lambda item: (item[1], item[0]))[0]
1549
+
1550
+
1551
+ def _formula_lane_body_interval(
1552
+ lane: _TextLane,
1553
+ median_height: float,
1554
+ ) -> tuple[float, float] | None:
1555
+ """用连续出现的常规宽行确定栏带正文纵向范围,排除孤立页眉。"""
1556
+
1557
+ lane_width = max(0.1, lane.right - lane.left)
1558
+ body_lines = sorted(
1559
+ (item for item in lane.lines if item[1][2] - item[1][0] >= max(4.0 * _line_effective_height(*item), 0.35 * lane_width)),
1560
+ key=lambda item: (item[1][1], item[1][0]),
1561
+ )
1562
+ if len(body_lines) < 3:
1563
+ return None
1564
+ dense_lines: list[tuple[_LineItem, BBox]] = []
1565
+ for index, item in enumerate(body_lines):
1566
+ has_close_previous = index > 0 and item[1][1] - body_lines[index - 1][1][3] <= 1.5 * median_height
1567
+ has_close_next = index + 1 < len(body_lines) and body_lines[index + 1][1][1] - item[1][3] <= 1.5 * median_height
1568
+ if has_close_previous or has_close_next:
1569
+ dense_lines.append(item)
1570
+ if len(dense_lines) < 3:
1571
+ return None
1572
+ return (
1573
+ min(bbox[1] for _line, bbox in dense_lines),
1574
+ max(bbox[3] for _line, bbox in dense_lines),
1575
+ )
1576
+
1577
+
1578
+ def _deduplicate_formula_anchors(
1579
+ anchors: list[_FormulaAnchor],
1580
+ median_height: float,
1581
+ ) -> list[_FormulaAnchor]:
1582
+ """同一高度出现多个右缘短块时只保留最靠右的空间锚点。"""
1583
+
1584
+ if not anchors:
1585
+ return []
1586
+ output: list[_FormulaAnchor] = []
1587
+ tolerance = max(1.5, 0.35 * median_height)
1588
+ for anchor in sorted(anchors, key=lambda item: (_bbox_center_y(item.bbox), -item.bbox[2])):
1589
+ if output and abs(_bbox_center_y(anchor.bbox) - _bbox_center_y(output[-1].bbox)) <= tolerance:
1590
+ if (anchor.bbox[2], -anchor.bbox[0]) > (output[-1].bbox[2], -output[-1].bbox[0]):
1591
+ output[-1] = anchor
1592
+ continue
1593
+ output.append(anchor)
1594
+ return output
1595
+
1596
+
1597
+ def _grow_formula_spatial_component(
1598
+ lane: _TextLane,
1599
+ anchor: _FormulaAnchor,
1600
+ band_top: float,
1601
+ band_bottom: float,
1602
+ claimed_source_indices: set[int],
1603
+ table_bboxes: list[BBox],
1604
+ dominant_body_font: tuple[str, int] | None,
1605
+ median_height: float,
1606
+ ) -> list[tuple[_LineItem, BBox]]:
1607
+ """从右缘锚点的左侧首批成员出发,按二维邻接扩展公式分量。"""
1608
+
1609
+ anchor_line, anchor_bbox = anchor.line, anchor.bbox
1610
+ anchor_geometry = (anchor_line, anchor_bbox)
1611
+ lane_width = max(0.1, lane.right - lane.left)
1612
+ candidates = [
1613
+ item
1614
+ for item in lane.lines
1615
+ if item[0].source_index not in claimed_source_indices
1616
+ and (
1617
+ item[1][2] - item[1][0] <= 0.8 * lane_width
1618
+ or _is_wide_tagged_formula_member(
1619
+ anchor_line,
1620
+ item[0],
1621
+ item[1],
1622
+ lane_width,
1623
+ )
1624
+ )
1625
+ and band_top <= _bbox_center_y(item[1]) <= band_bottom
1626
+ and not _is_formula_body_barrier(
1627
+ item,
1628
+ lane,
1629
+ dominant_body_font,
1630
+ median_height,
1631
+ )
1632
+ and not _is_formula_title_barrier(
1633
+ item,
1634
+ lane,
1635
+ dominant_body_font,
1636
+ median_height,
1637
+ )
1638
+ and not _is_formula_body_prefix(
1639
+ item,
1640
+ lane,
1641
+ anchor_geometry,
1642
+ dominant_body_font,
1643
+ median_height,
1644
+ minimum_font_coverage=0.5 if anchor.repeated_number_band else 0.75,
1645
+ )
1646
+ ]
1647
+ seeds = [
1648
+ item
1649
+ for item in candidates
1650
+ if item[0].source_index != anchor_line.source_index
1651
+ and _bbox_center_x(item[1]) < anchor_bbox[0]
1652
+ and (
1653
+ _formula_detached_seed_vertical_match(
1654
+ anchor_bbox,
1655
+ _line_effective_height(anchor_line, anchor_bbox),
1656
+ item[1],
1657
+ _line_effective_height(*item),
1658
+ )
1659
+ if anchor.detached_below_body
1660
+ or anchor.detached_above_body
1661
+ or _is_wide_tagged_formula_member(
1662
+ anchor_line,
1663
+ item[0],
1664
+ item[1],
1665
+ lane_width,
1666
+ )
1667
+ else _formula_seed_vertical_match(
1668
+ anchor_bbox,
1669
+ _line_effective_height(anchor_line, anchor_bbox),
1670
+ item[1],
1671
+ _line_effective_height(*item),
1672
+ )
1673
+ )
1674
+ and not _connection_crosses_table(anchor_line.bbox, item[0].bbox, table_bboxes)
1675
+ ]
1676
+ if not seeds:
1677
+ return []
1678
+
1679
+ members = [anchor_geometry, *seeds]
1680
+ member_sources = {line.source_index for line, _bbox in members}
1681
+ changed = True
1682
+ while changed:
1683
+ changed = False
1684
+ for candidate in candidates:
1685
+ candidate_line, candidate_bbox = candidate
1686
+ if candidate_line.source_index in member_sources:
1687
+ continue
1688
+ if any(
1689
+ _formula_lines_are_connected(
1690
+ member_line,
1691
+ member_bbox,
1692
+ candidate_line,
1693
+ candidate_bbox,
1694
+ table_bboxes,
1695
+ )
1696
+ for member_line, member_bbox in members
1697
+ ):
1698
+ members.append(candidate)
1699
+ member_sources.add(candidate_line.source_index)
1700
+ changed = True
1701
+ return members
1702
+
1703
+
1704
+ def _is_formula_body_barrier(
1705
+ candidate: tuple[_LineItem, BBox],
1706
+ lane: _TextLane,
1707
+ dominant_body_font: tuple[str, int] | None,
1708
+ median_height: float,
1709
+ ) -> bool:
1710
+ """识别具有稳定正文排版的行,阻止公式分量吸收正文尾行。"""
1711
+
1712
+ line, bbox = candidate
1713
+ if not line.style_scale_repaired:
1714
+ if dominant_body_font is None:
1715
+ return False
1716
+ line_height = _line_effective_height(line, bbox)
1717
+ lane_width = max(0.1, lane.right - lane.left)
1718
+ return (
1719
+ line.font_signature == dominant_body_font
1720
+ and line.font_coverage >= 0.75
1721
+ and bbox[2] - bbox[0] >= 0.3 * lane_width
1722
+ and 0.8 * median_height <= line_height <= 1.25 * median_height
1723
+ )
1724
+ lane_width = max(0.1, lane.right - lane.left)
1725
+ body_style_scales = [
1726
+ _line_style_scale(other_line, other_bbox)
1727
+ for other_line, other_bbox in lane.lines
1728
+ if other_bbox[2] - other_bbox[0] >= 0.35 * lane_width and not _formula_line_has_math_operator(other_line.text)
1729
+ ]
1730
+ body_scale = statistics.median(body_style_scales) if body_style_scales else median_height
1731
+ line_scale = _line_style_scale(line, bbox)
1732
+ line_width = bbox[2] - bbox[0]
1733
+ left_aligned = abs(bbox[0] - lane.left) <= max(3.0, 0.75 * body_scale)
1734
+ return (
1735
+ not _formula_line_has_math_operator(line.text)
1736
+ and (line_width >= 0.3 * lane_width or (left_aligned and line_width >= 0.08 * lane_width))
1737
+ and 0.75 * body_scale <= line_scale <= 1.35 * body_scale
1738
+ and (
1739
+ (dominant_body_font is not None and line.font_signature == dominant_body_font and line.font_coverage >= 0.75)
1740
+ or left_aligned
1741
+ )
1742
+ )
1743
+
1744
+
1745
+ def _is_formula_title_barrier(
1746
+ candidate: tuple[_LineItem, BBox],
1747
+ lane: _TextLane,
1748
+ dominant_body_font: tuple[str, int] | None,
1749
+ median_height: float,
1750
+ ) -> bool:
1751
+ """用左对齐、字号突变和字体变化隔离公式下方的章节标题。"""
1752
+
1753
+ if dominant_body_font is None:
1754
+ return False
1755
+ line, bbox = candidate
1756
+ lane_width = max(0.1, lane.right - lane.left)
1757
+ line_height = _line_effective_height(line, bbox)
1758
+ return (
1759
+ line.font_signature is not None
1760
+ and line.font_signature != dominant_body_font
1761
+ and line.font_coverage >= 0.75
1762
+ and 1.1 * median_height <= line_height <= 1.6 * median_height
1763
+ and bbox[2] - bbox[0] >= 0.25 * lane_width
1764
+ and abs(bbox[0] - lane.left) <= median_height
1765
+ )
1766
+
1767
+
1768
+ def _is_formula_body_prefix(
1769
+ candidate: tuple[_LineItem, BBox],
1770
+ lane: _TextLane,
1771
+ anchor: tuple[_LineItem, BBox],
1772
+ dominant_body_font: tuple[str, int] | None,
1773
+ median_height: float,
1774
+ *,
1775
+ minimum_font_coverage: float = 0.75,
1776
+ ) -> bool:
1777
+ """识别锚点上方左对齐的常规正文行,防止公式空间扩张越界认领。"""
1778
+
1779
+ line, bbox = candidate
1780
+ anchor_line, anchor_bbox = anchor
1781
+ if line.formula_candidate_only:
1782
+ return False
1783
+ line_height = _line_effective_height(line, bbox)
1784
+ anchor_height = _line_effective_height(anchor_line, anchor_bbox)
1785
+ if _bbox_center_y(bbox) > _bbox_center_y(anchor_bbox) - 0.2 * max(line_height, anchor_height):
1786
+ return False
1787
+ if abs(bbox[0] - lane.left) > max(3.0, 0.75 * median_height):
1788
+ return False
1789
+ if not line.style_scale_repaired and not anchor_line.style_scale_repaired:
1790
+ return (
1791
+ dominant_body_font is not None
1792
+ and line.font_signature == dominant_body_font
1793
+ and line.font_coverage >= minimum_font_coverage
1794
+ and 0.8 * median_height <= line_height <= 1.25 * median_height
1795
+ )
1796
+ lane_width = max(0.1, lane.right - lane.left)
1797
+ if bbox[2] - bbox[0] < 0.08 * lane_width:
1798
+ return False
1799
+ if _formula_line_has_math_operator(line.text):
1800
+ return False
1801
+ body_style_scales = [
1802
+ _line_style_scale(other_line, other_bbox)
1803
+ for other_line, other_bbox in lane.lines
1804
+ if other_bbox[2] - other_bbox[0] >= 0.35 * lane_width
1805
+ and abs(other_bbox[0] - lane.left) <= max(3.0, 0.75 * median_height)
1806
+ and not _formula_line_has_math_operator(other_line.text)
1807
+ ]
1808
+ body_scale = statistics.median(body_style_scales) if body_style_scales else median_height
1809
+ line_scale = _line_style_scale(line, bbox)
1810
+ return 0.75 * body_scale <= line_scale <= 1.35 * body_scale and (
1811
+ dominant_body_font is None or line.font_signature == dominant_body_font or line.font_coverage <= minimum_font_coverage
1812
+ )
1813
+
1814
+
1815
+ def _formula_detached_seed_vertical_match(
1816
+ anchor_bbox: BBox,
1817
+ anchor_height: float,
1818
+ candidate_bbox: BBox,
1819
+ candidate_height: float,
1820
+ ) -> bool:
1821
+ """放宽正文密集区下方锚点的同高匹配,以接纳多行分段公式底部。"""
1822
+
1823
+ has_vertical_overlap = min(anchor_bbox[3], candidate_bbox[3]) > max(anchor_bbox[1], candidate_bbox[1])
1824
+ center_difference = abs(_bbox_center_y(anchor_bbox) - _bbox_center_y(candidate_bbox))
1825
+ return has_vertical_overlap or center_difference <= max(anchor_height, candidate_height)
1826
+
1827
+
1828
+ def _formula_seed_vertical_match(
1829
+ anchor_bbox: BBox,
1830
+ anchor_height: float,
1831
+ candidate_bbox: BBox,
1832
+ candidate_height: float,
1833
+ ) -> bool:
1834
+ """判断左侧短行是否与右缘锚点处在同一公式高度带。"""
1835
+
1836
+ overlap_ratio = _bbox_axis_overlap_ratio(anchor_bbox, candidate_bbox, axis="y")
1837
+ center_difference = abs(_bbox_center_y(anchor_bbox) - _bbox_center_y(candidate_bbox))
1838
+ return overlap_ratio >= 0.3 or center_difference <= 0.6 * max(anchor_height, candidate_height)
1839
+
1840
+
1841
+ def _formula_lines_are_connected(
1842
+ first_line: _LineItem,
1843
+ first_bbox: BBox,
1844
+ second_line: _LineItem,
1845
+ second_bbox: BBox,
1846
+ table_bboxes: list[BBox],
1847
+ ) -> bool:
1848
+ """按垂直接近和水平覆盖判断两个公式成员是否空间连通。"""
1849
+
1850
+ if first_line.angle != second_line.angle:
1851
+ return False
1852
+ if _connection_crosses_table(first_line.bbox, second_line.bbox, table_bboxes):
1853
+ return False
1854
+ first_height = _line_effective_height(first_line, first_bbox)
1855
+ second_height = _line_effective_height(second_line, second_bbox)
1856
+ pair_height = max(first_height, second_height)
1857
+ vertical_overlap = _bbox_axis_overlap_ratio(first_bbox, second_bbox, axis="y")
1858
+ vertical_gap = max(first_bbox[1] - second_bbox[3], second_bbox[1] - first_bbox[3], 0.0)
1859
+ if vertical_overlap < 0.2 and vertical_gap > 0.6 * pair_height:
1860
+ return False
1861
+ horizontal_overlap = _bbox_axis_overlap_ratio(first_bbox, second_bbox, axis="x")
1862
+ horizontal_gap = max(first_bbox[0] - second_bbox[2], second_bbox[0] - first_bbox[2], 0.0)
1863
+ return horizontal_overlap > 0.0 or horizontal_gap <= 1.5 * pair_height
1864
+
1865
+
1866
+ def _is_detached_formula_sidecar(
1867
+ anchor: tuple[_LineItem, BBox],
1868
+ members: list[tuple[_LineItem, BBox]],
1869
+ median_height: float,
1870
+ ) -> bool:
1871
+ """仅依据 bbox 判断右侧锚点是否为与公式主体分离的窄幅 sidecar。"""
1872
+
1873
+ anchor_line, anchor_bbox = anchor
1874
+ body_bboxes = [bbox for line, bbox in members if line.source_index != anchor_line.source_index]
1875
+ if not body_bboxes:
1876
+ return False
1877
+
1878
+ body_bbox = _bbox_union_many(body_bboxes)
1879
+ component_bbox = _bbox_union(body_bbox, anchor_bbox)
1880
+ effective_height = max(0.1, median_height)
1881
+ anchor_width = max(0.0, anchor_bbox[2] - anchor_bbox[0])
1882
+ component_width = max(0.1, component_bbox[2] - component_bbox[0])
1883
+ horizontal_gap = anchor_bbox[0] - body_bbox[2]
1884
+ right_tolerance = max(0.5, 0.1 * effective_height)
1885
+ minimum_gap = max(2.5 * effective_height, 0.08 * component_width)
1886
+
1887
+ return (
1888
+ anchor_bbox[0] >= body_bbox[2]
1889
+ and anchor_bbox[2] >= component_bbox[2] - right_tolerance
1890
+ and anchor_width <= 2.0 * effective_height
1891
+ and horizontal_gap > minimum_gap
1892
+ )
1893
+
1894
+
1895
+ def _split_trailing_formula_number(text: str) -> tuple[str, str] | None:
1896
+ """拆出右缘文本末尾的圆括号公式序号,并保留序号前的标点或正文。"""
1897
+
1898
+ match = _FORMULA_NUMBER_SUFFIX_RE.fullmatch(str(text or "").strip())
1899
+ if match is None:
1900
+ return None
1901
+ return match.group("prefix").rstrip(), match.group("marker").strip()
1902
+
1903
+
1904
+ def _formula_members_to_block(
1905
+ members: list[tuple[_LineItem, BBox]],
1906
+ page_size: tuple[float, float],
1907
+ angle: int,
1908
+ *,
1909
+ anchor_source_index: int,
1910
+ ) -> dict[str, Any] | None:
1911
+ """把公式空间分量按视觉行聚类,将编号序列化为 tag 并后置其他 sidecar。"""
1912
+
1913
+ anchor_line = next(
1914
+ (line for line, _bbox in members if line.source_index == anchor_source_index),
1915
+ None,
1916
+ )
1917
+ anchor_formula_number_parts = _split_trailing_formula_number(anchor_line.text) if anchor_line is not None else None
1918
+ heights = [_line_effective_height(line, bbox) for line, bbox in members]
1919
+ median_height = statistics.median(heights) if heights else 1.0
1920
+ row_tolerance = max(1.5, 0.35 * median_height)
1921
+ rows: list[list[tuple[_LineItem, BBox]]] = []
1922
+ for member in sorted(members, key=lambda item: (_bbox_center_y(item[1]), item[1][0], item[0].source_index)):
1923
+ if not rows:
1924
+ rows.append([member])
1925
+ continue
1926
+ row_center = statistics.median(_bbox_center_y(bbox) for _line, bbox in rows[-1])
1927
+ if abs(_bbox_center_y(member[1]) - row_center) <= row_tolerance:
1928
+ rows[-1].append(member)
1929
+ else:
1930
+ rows.append([member])
1931
+
1932
+ trailing_sidecar_content: str | None = None
1933
+ # 右侧 sidecar 按视觉 y 常落在分式中部;仅在其后仍有公式行时转为逻辑末行。
1934
+ for row_index, row in enumerate(rows[:-1]):
1935
+ anchor_member = next(
1936
+ (member for member in row if member[0].source_index == anchor_source_index),
1937
+ None,
1938
+ )
1939
+ if anchor_member is None:
1940
+ continue
1941
+ formula_number_parts = _split_trailing_formula_number(anchor_member[0].text)
1942
+ if formula_number_parts is not None:
1943
+ prefix, marker = formula_number_parts
1944
+ rows[row_index] = [
1945
+ (
1946
+ (replace(member[0], text=prefix), member[1])
1947
+ if member[0].source_index == anchor_source_index and prefix
1948
+ else member
1949
+ )
1950
+ for member in row
1951
+ if member[0].source_index != anchor_source_index or prefix
1952
+ ]
1953
+ trailing_sidecar_content = marker
1954
+ elif _is_detached_formula_sidecar(anchor_member, members, median_height):
1955
+ rows[row_index] = [member for member in row if member[0].source_index != anchor_source_index]
1956
+ trailing_sidecar_content = anchor_member[0].text.strip()
1957
+ break
1958
+
1959
+ row_contents = [_join_formula_visual_row(row, page_size) for row in rows if row]
1960
+ if trailing_sidecar_content is not None:
1961
+ row_contents.append(trailing_sidecar_content)
1962
+ content = _sanitize_pdf_control_text("\n".join(filter(None, row_contents)), preserve_newlines=True)
1963
+ if anchor_formula_number_parts is not None:
1964
+ _anchor_prefix, tag_content = anchor_formula_number_parts
1965
+ stripped_content = content.rstrip()
1966
+ if stripped_content.endswith(tag_content):
1967
+ formula_content = stripped_content[: -len(tag_content)].rstrip()
1968
+ tagged_content = build_tagged_formula_content(formula_content, tag_content)
1969
+ if tagged_content is not None:
1970
+ content = tagged_content
1971
+ if not content.strip():
1972
+ return None
1973
+ block = {
1974
+ "type": "equation",
1975
+ "bbox": _bbox_union_many([line.bbox for line, _bbox in members]),
1976
+ "angle": angle,
1977
+ "content": content,
1978
+ }
1979
+ tight_output_bbox = _lines_tight_output_bbox(
1980
+ [line for line, _bbox in members],
1981
+ page_size,
1982
+ )
1983
+ if tight_output_bbox is not None:
1984
+ block["_tight_output_bbox"] = tight_output_bbox
1985
+ return block