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,1223 @@
1
+ """提供同基线文本和拆分视觉行的几何合并。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import statistics
7
+ import unicodedata
8
+
9
+
10
+ from ....schema import BBox
11
+
12
+ from .models import _LineItem, _TextLane
13
+ from .geometry import (
14
+ _bbox_axis_overlap_ratio,
15
+ _bbox_center_y,
16
+ _bbox_intersects,
17
+ _bbox_union_many,
18
+ _horizontal_bbox_gap,
19
+ _rotate_bbox_to_upright,
20
+ )
21
+ from .native_text import _fill_native_typography, _median_native_glyph_width
22
+ from .line_layout import _connection_crosses_table, _font_signatures_share_family, _infer_text_lanes, _line_effective_height
23
+
24
+
25
+ def _merge_same_baseline_text_lines(
26
+ lines: list[_LineItem],
27
+ page_size: tuple[float, float],
28
+ table_bboxes: list[BBox],
29
+ ) -> list[_LineItem]:
30
+ """在表格认领后合并同基线、同字体且水平邻近的正文 run。"""
31
+
32
+ if len(lines) < 2:
33
+ return list(lines)
34
+ local_bboxes = [_rotate_bbox_to_upright(line.bbox, page_size, line.angle) for line in lines]
35
+ parents = list(range(len(lines)))
36
+
37
+ def find(index: int) -> int:
38
+ """查找同行合并并查集的根节点。"""
39
+
40
+ while parents[index] != index:
41
+ parents[index] = parents[parents[index]]
42
+ index = parents[index]
43
+ return index
44
+
45
+ def union(left_index: int, right_index: int) -> None:
46
+ """合并两个满足同行条件的文本 run。"""
47
+
48
+ left_root = find(left_index)
49
+ right_root = find(right_index)
50
+ if left_root != right_root:
51
+ parents[right_root] = left_root
52
+
53
+ # 只枚举满足必要分类条件的行对,组内仍按原索引递增,保持并查集认领顺序。
54
+ compatible_indices: dict[tuple[int, bool, str | None], list[int]] = {}
55
+ for index, line in enumerate(lines):
56
+ compatible_indices.setdefault((line.angle, line.formula_candidate_only, line.semantic_type), []).append(index)
57
+ for left_index, left_line in enumerate(lines):
58
+ for right_index in compatible_indices[(left_line.angle, left_line.formula_candidate_only, left_line.semantic_type)]:
59
+ if right_index <= left_index:
60
+ continue
61
+ right_line = lines[right_index]
62
+ if _can_merge_same_baseline_pair(
63
+ left_line,
64
+ local_bboxes[left_index],
65
+ right_line,
66
+ local_bboxes[right_index],
67
+ table_bboxes,
68
+ ):
69
+ union(left_index, right_index)
70
+
71
+ groups: dict[int, list[int]] = {}
72
+ for index in range(len(lines)):
73
+ groups.setdefault(find(index), []).append(index)
74
+
75
+ output: list[_LineItem] = []
76
+ for indices in groups.values():
77
+ if len(indices) == 1:
78
+ output.append(lines[indices[0]])
79
+ continue
80
+ indices.sort(key=lambda index: (local_bboxes[index][0], local_bboxes[index][1], lines[index].source_index))
81
+ output.append(_merge_same_baseline_group(indices, lines, local_bboxes, page_size))
82
+ output.sort(
83
+ key=lambda line: (
84
+ line.angle,
85
+ _rotate_bbox_to_upright(line.bbox, page_size, line.angle)[1],
86
+ _rotate_bbox_to_upright(line.bbox, page_size, line.angle)[0],
87
+ line.source_index,
88
+ )
89
+ )
90
+ return output
91
+
92
+
93
+ def _merge_overlapping_inline_text_clusters(
94
+ lines: list[_LineItem],
95
+ page_size: tuple[float, float],
96
+ table_bboxes: list[BBox],
97
+ ) -> list[_LineItem]:
98
+ """在容器认领后恢复由分子、分母和上下标拆成的二维物理文本行。"""
99
+
100
+ if len(lines) < 2:
101
+ return list(lines)
102
+
103
+ consumed_source_indices: set[int] = set()
104
+ merged_lines: list[_LineItem] = []
105
+ for angle in sorted({line.angle for line in lines}):
106
+ angle_geometry = [(line, _rotate_bbox_to_upright(line.bbox, page_size, angle)) for line in lines if line.angle == angle]
107
+ if len(angle_geometry) < 2:
108
+ continue
109
+ angle_median_height = statistics.median(_line_effective_height(line, bbox) for line, bbox in angle_geometry)
110
+ local_page_width = page_size[1] if angle in {90, 270} else page_size[0]
111
+ lanes = _infer_text_lanes(
112
+ angle_geometry,
113
+ local_page_width,
114
+ angle_median_height,
115
+ )
116
+ for lane in lanes:
117
+ if lane.is_span or len(lane.lines) < 2:
118
+ continue
119
+ lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
120
+ lane_median_height = statistics.median(_line_effective_height(line, bbox) for line, bbox in lane.lines)
121
+ parents = list(range(len(lane.lines)))
122
+
123
+ def find(index: int) -> int:
124
+ """查找当前栏二维文本簇并查集的根节点。"""
125
+
126
+ while parents[index] != index:
127
+ parents[index] = parents[parents[index]]
128
+ index = parents[index]
129
+ return index
130
+
131
+ def union(first_index: int, second_index: int) -> None:
132
+ """合并两个满足二维物理行邻接条件的成员。"""
133
+
134
+ first_root = find(first_index)
135
+ second_root = find(second_index)
136
+ if first_root != second_root:
137
+ parents[second_root] = first_root
138
+
139
+ for first_index, first in enumerate(lane.lines):
140
+ for second_index in range(first_index + 1, len(lane.lines)):
141
+ second = lane.lines[second_index]
142
+ if _overlapping_inline_cluster_pair_is_connected(
143
+ first,
144
+ second,
145
+ lane_median_height,
146
+ table_bboxes,
147
+ local_page_width=local_page_width,
148
+ ):
149
+ union(first_index, second_index)
150
+
151
+ groups: dict[int, list[tuple[_LineItem, BBox]]] = {}
152
+ for index, item in enumerate(lane.lines):
153
+ groups.setdefault(find(index), []).append(item)
154
+ for members in groups.values():
155
+ cluster_kind = _classify_overlapping_inline_cluster(
156
+ members,
157
+ lane,
158
+ lane_median_height,
159
+ table_bboxes,
160
+ )
161
+ if cluster_kind is None:
162
+ continue
163
+ merged_lines.append(
164
+ _merge_overlapping_inline_cluster(
165
+ members,
166
+ page_size,
167
+ lane_median_height,
168
+ compact_formula_cluster=cluster_kind == "formula",
169
+ )
170
+ )
171
+ consumed_source_indices.update(line.source_index for line, _bbox in members)
172
+
173
+ output = [line for line in lines if line.source_index not in consumed_source_indices]
174
+ output.extend(merged_lines)
175
+ output.sort(
176
+ key=lambda line: (
177
+ line.angle,
178
+ _rotate_bbox_to_upright(
179
+ line.bbox,
180
+ page_size,
181
+ line.angle,
182
+ )[1],
183
+ _rotate_bbox_to_upright(
184
+ line.bbox,
185
+ page_size,
186
+ line.angle,
187
+ )[0],
188
+ line.source_index,
189
+ )
190
+ )
191
+ return output
192
+
193
+
194
+ def _overlapping_inline_cluster_pair_is_connected(
195
+ first: tuple[_LineItem, BBox],
196
+ second: tuple[_LineItem, BBox],
197
+ median_height: float,
198
+ table_bboxes: list[BBox],
199
+ *,
200
+ local_page_width: float | None = None,
201
+ ) -> bool:
202
+ """判断两个同栏成员是否为同一二维物理行中的重叠片段。"""
203
+
204
+ first_line, first_bbox = first
205
+ second_line, second_bbox = second
206
+ if first_line.angle != second_line.angle:
207
+ return False
208
+ if first_line.formula_candidate_only != second_line.formula_candidate_only:
209
+ return False
210
+ if first_line.semantic_type != second_line.semantic_type:
211
+ return False
212
+ if _connection_crosses_table(
213
+ first_line.bbox,
214
+ second_line.bbox,
215
+ table_bboxes,
216
+ ):
217
+ return False
218
+ if _bbox_axis_overlap_ratio(first_bbox, second_bbox, axis="y") < 0.55:
219
+ return False
220
+
221
+ first_guard_bbox = first_line.source_bbox or first_bbox
222
+ second_guard_bbox = second_line.source_bbox or second_bbox
223
+ left_bbox, right_bbox = sorted(
224
+ (first_guard_bbox, second_guard_bbox),
225
+ key=lambda bbox: bbox[0],
226
+ )
227
+ if (
228
+ local_page_width is not None
229
+ and (first_line.style_scale_repaired or second_line.style_scale_repaired)
230
+ and left_bbox[2] <= 0.5 * local_page_width
231
+ and right_bbox[0] >= 0.5 * local_page_width
232
+ and right_bbox[0] - left_bbox[2] >= 0.02 * local_page_width
233
+ ):
234
+ return False
235
+
236
+ horizontal_gap = _horizontal_bbox_gap(first_bbox, second_bbox)
237
+ if first_line.visual_row_id == second_line.visual_row_id and (first_line.split_from_row or second_line.split_from_row):
238
+ return horizontal_gap <= 3.0 * median_height
239
+ pair_height = max(
240
+ _line_effective_height(first_line, first_bbox),
241
+ _line_effective_height(second_line, second_bbox),
242
+ )
243
+ return _bbox_axis_overlap_ratio(first_bbox, second_bbox, axis="x") > 0.0 or horizontal_gap <= 1.5 * pair_height
244
+
245
+
246
+ def _classify_overlapping_inline_cluster(
247
+ members: list[tuple[_LineItem, BBox]],
248
+ lane: _TextLane,
249
+ median_height: float,
250
+ table_bboxes: list[BBox],
251
+ ) -> str | None:
252
+ """按正文宿主和紧凑程度区分行内文本簇与独立公式簇。"""
253
+
254
+ if len(members) < 2:
255
+ return None
256
+ visual_row_ids = {line.visual_row_id for line, _bbox in members if line.visual_row_id is not None}
257
+ if len(visual_row_ids) < 2:
258
+ return None
259
+ if any(_bbox_intersects(line.bbox, table_bbox) for line, _bbox in members for table_bbox in table_bboxes):
260
+ return None
261
+
262
+ union_bbox = _bbox_union_many([bbox for _line, bbox in members])
263
+ if union_bbox[3] - union_bbox[1] > 3.0 * median_height:
264
+ return None
265
+ lane_width = max(0.1, lane.right - lane.left)
266
+ has_fragment = any(
267
+ _line_effective_height(line, bbox) <= 0.88 * median_height or line.font_coverage < 0.75 for line, bbox in members
268
+ )
269
+ if not has_fragment:
270
+ return None
271
+
272
+ has_body_host = any(
273
+ bbox[2] - bbox[0] >= max(4.0 * median_height, 0.35 * lane_width)
274
+ and _line_effective_height(line, bbox) >= 0.8 * median_height
275
+ and line.font_coverage >= 0.75
276
+ for line, bbox in members
277
+ )
278
+ if has_body_host:
279
+ return "inline"
280
+ if len(members) >= 3 and len(visual_row_ids) >= 3 and union_bbox[2] - union_bbox[0] <= 0.6 * lane_width:
281
+ return "formula"
282
+ return None
283
+
284
+
285
+ def _select_overlapping_inline_cluster_host(
286
+ members: list[tuple[_LineItem, BBox]],
287
+ median_height: float,
288
+ ) -> _LineItem:
289
+ """选择与其他成员纵向重叠最多且最接近正文尺度的宿主行。"""
290
+
291
+ union_bbox = _bbox_union_many([bbox for _line, bbox in members])
292
+ union_center_y = _bbox_center_y(union_bbox)
293
+
294
+ def host_score(item: tuple[_LineItem, BBox]) -> tuple[float, ...]:
295
+ """生成宿主候选的正文尺度、同行支持和中心距离评分。"""
296
+
297
+ line, bbox = item
298
+ vertical_support = sum(
299
+ max(0.0, min(bbox[3], other_bbox[3]) - max(bbox[1], other_bbox[1]))
300
+ for other_line, other_bbox in members
301
+ if other_line is not line
302
+ )
303
+ same_row_support = sum(
304
+ line.visual_row_id is not None and line.visual_row_id == other_line.visual_row_id
305
+ for other_line, _other_bbox in members
306
+ if other_line is not line
307
+ )
308
+ body_like = float(_line_effective_height(line, bbox) >= 0.8 * median_height and line.font_coverage >= 0.75)
309
+ return (
310
+ body_like,
311
+ float(same_row_support),
312
+ vertical_support,
313
+ bbox[2] - bbox[0],
314
+ -abs(_bbox_center_y(bbox) - union_center_y),
315
+ -float(line.source_index),
316
+ )
317
+
318
+ return max(members, key=host_score)[0]
319
+
320
+
321
+ def _merge_overlapping_inline_cluster(
322
+ members: list[tuple[_LineItem, BBox]],
323
+ page_size: tuple[float, float],
324
+ median_height: float,
325
+ *,
326
+ compact_formula_cluster: bool,
327
+ ) -> _LineItem:
328
+ """按来源顺序合并二维文本簇,并保留字符与宿主排版信息。"""
329
+
330
+ ordered_members = sorted(
331
+ (line for line, _bbox in members),
332
+ key=lambda line: line.source_index,
333
+ )
334
+ host = _select_overlapping_inline_cluster_host(members, median_height)
335
+ detected_regions = (
336
+ [line.bbox for line in ordered_members]
337
+ if compact_formula_cluster
338
+ else [line.bbox for line in ordered_members if line is not host]
339
+ )
340
+ merged = _LineItem(
341
+ text=" ".join(text for line in ordered_members if (text := line.text.strip())),
342
+ bbox=_bbox_union_many([line.bbox for line in ordered_members]),
343
+ angle=host.angle,
344
+ source_index=min(line.source_index for line in ordered_members),
345
+ source_bbox=_bbox_union_many(
346
+ [line.source_bbox or line.bbox for line in ordered_members],
347
+ ),
348
+ ink_bbox=(
349
+ _bbox_union_many(
350
+ [line.ink_bbox for line in ordered_members if line.ink_bbox is not None],
351
+ )
352
+ if any(line.ink_bbox is not None for line in ordered_members)
353
+ else None
354
+ ),
355
+ baseline=host.baseline,
356
+ chars=[char for line in ordered_members for char in line.chars],
357
+ visual_row_id=host.visual_row_id,
358
+ run_index=host.run_index,
359
+ effective_height=host.effective_height,
360
+ em_height=host.em_height or host.effective_height,
361
+ font_signature=host.font_signature,
362
+ font_coverage=host.font_coverage,
363
+ dominant_font_weight=host.dominant_font_weight,
364
+ median_glyph_width=host.median_glyph_width,
365
+ leading_emphasis_width=ordered_members[0].leading_emphasis_width,
366
+ leading_typography_width=ordered_members[0].leading_typography_width,
367
+ paragraph_formula_context=any(line.paragraph_formula_context for line in ordered_members),
368
+ split_from_row=any(line.split_from_row for line in ordered_members),
369
+ preserve_split_boundary=any(line.preserve_split_boundary for line in ordered_members),
370
+ semantic_type=host.semantic_type,
371
+ restored_inline_cluster=True,
372
+ compact_formula_cluster=compact_formula_cluster,
373
+ formula_candidate_only=all(line.formula_candidate_only for line in ordered_members),
374
+ style_scale_repaired=any(line.style_scale_repaired for line in ordered_members),
375
+ inline_math_regions=[
376
+ *(region for line in ordered_members for region in line.inline_math_regions),
377
+ *detected_regions,
378
+ ],
379
+ )
380
+ if merged.chars:
381
+ _fill_native_typography(merged, page_size)
382
+ return merged
383
+
384
+
385
+ def _merge_post_semantic_text_runs(
386
+ lines: list[_LineItem],
387
+ page_size: tuple[float, float],
388
+ table_bboxes: list[BBox],
389
+ ) -> list[_LineItem]:
390
+ """在容器、公式和标题结束后合并紧贴同基线的普通混合字体 run。"""
391
+
392
+ if len(lines) < 2:
393
+ return list(lines)
394
+ local_bboxes = [_rotate_bbox_to_upright(line.bbox, page_size, line.angle) for line in lines]
395
+ parents = list(range(len(lines)))
396
+
397
+ def find(index: int) -> int:
398
+ """查找后处理同行合并分量的根节点。"""
399
+
400
+ while parents[index] != index:
401
+ parents[index] = parents[parents[index]]
402
+ index = parents[index]
403
+ return index
404
+
405
+ def union(first_index: int, second_index: int) -> None:
406
+ """合并两个后处理同行分量。"""
407
+
408
+ first_root = find(first_index)
409
+ second_root = find(second_index)
410
+ if first_root != second_root:
411
+ parents[second_root] = first_root
412
+
413
+ for first_index, first_line in enumerate(lines):
414
+ if first_line.semantic_type is not None:
415
+ continue
416
+ first_bbox = local_bboxes[first_index]
417
+ first_height = _line_effective_height(first_line, first_bbox)
418
+ for second_index in range(first_index + 1, len(lines)):
419
+ second_line = lines[second_index]
420
+ if second_line.semantic_type is not None or first_line.angle != second_line.angle:
421
+ continue
422
+ if first_line.formula_candidate_only != second_line.formula_candidate_only:
423
+ continue
424
+ if _connection_crosses_table(
425
+ first_line.bbox,
426
+ second_line.bbox,
427
+ table_bboxes,
428
+ ):
429
+ continue
430
+ second_bbox = local_bboxes[second_index]
431
+ second_height = _line_effective_height(second_line, second_bbox)
432
+ if _post_semantic_same_baseline_geometry(
433
+ first_bbox,
434
+ first_height,
435
+ second_bbox,
436
+ second_height,
437
+ ):
438
+ union(first_index, second_index)
439
+
440
+ groups: dict[int, list[int]] = {}
441
+ for index in range(len(lines)):
442
+ groups.setdefault(find(index), []).append(index)
443
+ output: list[_LineItem] = []
444
+ for indices in groups.values():
445
+ if len(indices) == 1:
446
+ output.append(lines[indices[0]])
447
+ continue
448
+ indices.sort(
449
+ key=lambda index: (
450
+ local_bboxes[index][0],
451
+ local_bboxes[index][1],
452
+ lines[index].source_index,
453
+ )
454
+ )
455
+ output.append(
456
+ _merge_same_baseline_group(
457
+ indices,
458
+ lines,
459
+ local_bboxes,
460
+ page_size,
461
+ )
462
+ )
463
+ output.sort(
464
+ key=lambda line: (
465
+ line.angle,
466
+ _rotate_bbox_to_upright(line.bbox, page_size, line.angle)[1],
467
+ _rotate_bbox_to_upright(line.bbox, page_size, line.angle)[0],
468
+ line.source_index,
469
+ )
470
+ )
471
+ return output
472
+
473
+
474
+ def _post_semantic_same_baseline_geometry(
475
+ first_bbox: BBox,
476
+ first_height: float,
477
+ second_bbox: BBox,
478
+ second_height: float,
479
+ ) -> bool:
480
+ """放宽上下标字号差异,仅合并水平紧贴且垂直充分交叠的普通 run。"""
481
+
482
+ pair_height = max(first_height, second_height)
483
+ if (
484
+ min(first_height, second_height) <= 0
485
+ or pair_height
486
+ / min(
487
+ first_height,
488
+ second_height,
489
+ )
490
+ > 1.6
491
+ ):
492
+ return False
493
+ y_overlap = max(
494
+ 0.0,
495
+ min(first_bbox[3], second_bbox[3]) - max(first_bbox[1], second_bbox[1]),
496
+ )
497
+ shorter_bbox_height = max(
498
+ 0.1,
499
+ min(first_bbox[3] - first_bbox[1], second_bbox[3] - second_bbox[1]),
500
+ )
501
+ if y_overlap / shorter_bbox_height < 0.7:
502
+ return False
503
+ left_bbox, right_bbox = sorted((first_bbox, second_bbox), key=lambda bbox: bbox[0])
504
+ horizontal_gap = right_bbox[0] - left_bbox[2]
505
+ return -0.2 * pair_height <= horizontal_gap <= max(2.0, 0.35 * pair_height)
506
+
507
+
508
+ def _can_merge_same_baseline_pair(
509
+ first: _LineItem,
510
+ first_bbox: BBox,
511
+ second: _LineItem,
512
+ second_bbox: BBox,
513
+ table_bboxes: list[BBox],
514
+ ) -> bool:
515
+ """判断两个剩余文本 run 是否属于同一条物理基线。"""
516
+
517
+ if first.angle != second.angle:
518
+ return False
519
+ if first.formula_candidate_only != second.formula_candidate_only:
520
+ return False
521
+ if first.semantic_type != second.semantic_type:
522
+ return False
523
+ if first.visual_row_id == second.visual_row_id and (first.split_from_row or second.split_from_row):
524
+ return False
525
+ if _consecutive_source_row(first, second):
526
+ return not _connection_crosses_table(first.bbox, second.bbox, table_bboxes)
527
+ first_height = _line_effective_height(first, first_bbox)
528
+ second_height = _line_effective_height(second, second_bbox)
529
+ has_compatible_dominant_font = not (
530
+ first.font_signature is None
531
+ or second.font_signature is None
532
+ or first.font_coverage < 0.75
533
+ or second.font_coverage < 0.75
534
+ or first.font_signature != second.font_signature
535
+ )
536
+ if has_compatible_dominant_font and _same_baseline_geometry(
537
+ first_bbox,
538
+ first_height,
539
+ second_bbox,
540
+ second_height,
541
+ ):
542
+ return not _connection_crosses_table(first.bbox, second.bbox, table_bboxes)
543
+ return _touching_same_baseline_geometry(
544
+ first_bbox,
545
+ first_height,
546
+ second_bbox,
547
+ second_height,
548
+ ) and not _connection_crosses_table(first.bbox, second.bbox, table_bboxes)
549
+
550
+
551
+ def _consecutive_source_row(first: _LineItem, second: _LineItem) -> bool:
552
+ """以连续源字符和原始行框验证同行,避免替代字形的 ink 修复扩大字体间隙。"""
553
+ if first.angle != 0 or second.angle != 0 or first.baseline is None or second.baseline is None:
554
+ return False
555
+ if first.preserve_split_boundary or second.preserve_split_boundary or not first.chars or not second.chars:
556
+ return False
557
+ first_box, second_box = first.source_bbox, second.source_bbox
558
+ if first_box is None or second_box is None:
559
+ return False
560
+ first_height, second_height = first_box[3] - first_box[1], second_box[3] - second_box[1]
561
+ # 原始大框可能覆盖整条分式,仍须有相近基线才能按普通同行处理。
562
+ if abs(first.baseline - second.baseline) > 0.25 * min(first_height, second_height):
563
+ return False
564
+ if not _same_baseline_geometry(first_box, first_height, second_box, second_height):
565
+ return False
566
+ # 源框与基线已包含文本矩阵的缩放;名义字号比不能代表实际显示大小。
567
+ left, right = sorted((first, second), key=lambda line: line.bbox[0])
568
+ left_indices = [index for char in left.chars for index in char.get("source_indices", (char.get("char_idx"),))]
569
+ right_indices = [index for char in right.chars for index in char.get("source_indices", (char.get("char_idx"),))]
570
+ # 最多容许一个 PDFium 空格;跨行或跨阅读顺序的 run 不以字形距离强行合并。
571
+ if not left_indices or not right_indices or not all(isinstance(index, int) for index in left_indices + right_indices):
572
+ return False
573
+ return 1 <= min(right_indices) - max(left_indices) <= 2
574
+
575
+
576
+ def _touching_same_baseline_geometry(
577
+ first_bbox: BBox,
578
+ first_height: float,
579
+ second_bbox: BBox,
580
+ second_height: float,
581
+ ) -> bool:
582
+ """为低字体覆盖率 run 提供严格的紧贴同基线几何兜底。"""
583
+
584
+ pair_height = max(first_height, second_height)
585
+ y_overlap = max(0.0, min(first_bbox[3], second_bbox[3]) - max(first_bbox[1], second_bbox[1]))
586
+ smaller_bbox_height = max(
587
+ 0.1,
588
+ min(first_bbox[3] - first_bbox[1], second_bbox[3] - second_bbox[1]),
589
+ )
590
+ if y_overlap / smaller_bbox_height < 0.7:
591
+ return False
592
+ if abs(_bbox_center_y(first_bbox) - _bbox_center_y(second_bbox)) > 0.5 * pair_height:
593
+ return False
594
+ left_bbox, right_bbox = sorted((first_bbox, second_bbox), key=lambda bbox: bbox[0])
595
+ signed_gap = right_bbox[0] - left_bbox[2]
596
+ return -0.15 * pair_height <= signed_gap <= 0.75
597
+
598
+
599
+ def _same_baseline_geometry(
600
+ first_bbox: BBox,
601
+ first_height: float,
602
+ second_bbox: BBox,
603
+ second_height: float,
604
+ *,
605
+ maximum_gap: float | None = None,
606
+ ) -> bool:
607
+ """仅依据行高、垂直交叠和水平净空判断两个局部 bbox 是否同基线相邻。"""
608
+
609
+ pair_height = max(first_height, second_height)
610
+ if min(first_height, second_height) <= 0 or pair_height / min(first_height, second_height) > 1.35:
611
+ return False
612
+ y_overlap = max(0.0, min(first_bbox[3], second_bbox[3]) - max(first_bbox[1], second_bbox[1]))
613
+ shorter_bbox_height = max(
614
+ 0.1,
615
+ min(first_bbox[3] - first_bbox[1], second_bbox[3] - second_bbox[1]),
616
+ )
617
+ if y_overlap / shorter_bbox_height < 0.7 and abs(first_bbox[3] - second_bbox[3]) > 0.25 * pair_height:
618
+ return False
619
+ left_bbox, right_bbox = sorted((first_bbox, second_bbox), key=lambda bbox: bbox[0])
620
+ signed_gap = right_bbox[0] - left_bbox[2]
621
+ gap_limit = max(3.0, 0.75 * pair_height) if maximum_gap is None else maximum_gap
622
+ return -0.25 * pair_height <= signed_gap <= gap_limit
623
+
624
+
625
+ def _merge_same_baseline_group(
626
+ indices: list[int],
627
+ lines: list[_LineItem],
628
+ local_bboxes: list[BBox],
629
+ page_size: tuple[float, float],
630
+ ) -> _LineItem:
631
+ """按局部 x 顺序合并一个同基线分量,并保留全部字符与几何信息。"""
632
+
633
+ members = [lines[index] for index in indices]
634
+ content_parts = [members[0].text.strip()]
635
+ for previous_index, current_index in zip(indices, indices[1:]):
636
+ previous_bbox = local_bboxes[previous_index]
637
+ current_bbox = local_bboxes[current_index]
638
+ signed_gap = current_bbox[0] - previous_bbox[2]
639
+ glyph_width = statistics.median(
640
+ [
641
+ width
642
+ for member in (lines[previous_index], lines[current_index])
643
+ if (width := _median_native_glyph_width(member, page_size)) is not None
644
+ ]
645
+ or [1.0]
646
+ )
647
+ separator = "" if signed_gap <= max(0.5, 0.25 * glyph_width) else " "
648
+ content_parts.extend([separator, lines[current_index].text.strip()])
649
+
650
+ merged = _LineItem(
651
+ text="".join(content_parts).strip(),
652
+ bbox=_bbox_union_many([member.bbox for member in members]),
653
+ angle=members[0].angle,
654
+ source_index=min(member.source_index for member in members),
655
+ source_bbox=_bbox_union_many(
656
+ [member.source_bbox or member.bbox for member in members],
657
+ ),
658
+ ink_bbox=(
659
+ _bbox_union_many(
660
+ [member.ink_bbox for member in members if member.ink_bbox is not None],
661
+ )
662
+ if any(member.ink_bbox is not None for member in members)
663
+ else None
664
+ ),
665
+ baseline=(
666
+ statistics.median(member.baseline for member in members if member.baseline is not None)
667
+ if any(member.baseline is not None for member in members)
668
+ else None
669
+ ),
670
+ chars=[char for member in members for char in member.chars],
671
+ visual_row_id=min(
672
+ (member.visual_row_id for member in members if member.visual_row_id is not None),
673
+ default=None,
674
+ ),
675
+ run_index=min(member.run_index for member in members),
676
+ effective_height=statistics.median(member.effective_height for member in members),
677
+ em_height=statistics.median(member.em_height or member.effective_height for member in members),
678
+ font_signature=members[0].font_signature,
679
+ font_coverage=min(member.font_coverage for member in members),
680
+ dominant_font_weight=statistics.median(
681
+ member.dominant_font_weight for member in members if member.dominant_font_weight is not None
682
+ )
683
+ if any(member.dominant_font_weight is not None for member in members)
684
+ else None,
685
+ median_glyph_width=statistics.median(
686
+ member.median_glyph_width for member in members if member.median_glyph_width is not None
687
+ )
688
+ if any(member.median_glyph_width is not None for member in members)
689
+ else None,
690
+ leading_emphasis_width=members[0].leading_emphasis_width,
691
+ leading_typography_width=members[0].leading_typography_width,
692
+ paragraph_formula_context=any(member.paragraph_formula_context for member in members),
693
+ split_from_row=any(member.split_from_row for member in members),
694
+ preserve_split_boundary=any(member.preserve_split_boundary for member in members),
695
+ semantic_type=members[0].semantic_type,
696
+ restored_inline_cluster=any(member.restored_inline_cluster for member in members),
697
+ compact_formula_cluster=any(member.compact_formula_cluster for member in members),
698
+ formula_candidate_only=all(member.formula_candidate_only for member in members),
699
+ style_scale_repaired=any(member.style_scale_repaired for member in members),
700
+ inline_math_regions=[region for member in members for region in member.inline_math_regions],
701
+ )
702
+ if merged.chars:
703
+ _fill_native_typography(merged, page_size)
704
+ return merged
705
+
706
+
707
+ def _join_formula_visual_row(
708
+ row: list[tuple[_LineItem, BBox]],
709
+ page_size: tuple[float, float],
710
+ ) -> str:
711
+ """将一个公式视觉行按局部 x 排序,并按字宽估计几何空格。"""
712
+
713
+ ordered = sorted(row, key=lambda item: (item[1][0], item[1][1], item[0].source_index))
714
+ if not ordered:
715
+ return ""
716
+ parts = [ordered[0][0].text.strip()]
717
+ for previous, current in zip(ordered, ordered[1:]):
718
+ previous_line, previous_bbox = previous
719
+ current_line, current_bbox = current
720
+ gap = current_bbox[0] - previous_bbox[2]
721
+ pair_height = max(
722
+ _line_effective_height(previous_line, previous_bbox),
723
+ _line_effective_height(current_line, current_bbox),
724
+ )
725
+ glyph_widths = [
726
+ width
727
+ for line in (previous_line, current_line)
728
+ if (width := _median_native_glyph_width(line, page_size)) is not None
729
+ ]
730
+ glyph_width = statistics.median(glyph_widths) if glyph_widths else max(1.0, 0.5 * pair_height)
731
+ if gap <= max(0.5, 0.2 * pair_height):
732
+ separator = ""
733
+ else:
734
+ separator = " " * max(1, min(8, int(round(gap / glyph_width))))
735
+ parts.extend([separator, current_line.text.strip()])
736
+ return "".join(parts).strip()
737
+
738
+
739
+ def _restore_dense_split_visual_rows(
740
+ lines: list[_LineItem],
741
+ page_size: tuple[float, float],
742
+ table_bboxes: list[BBox],
743
+ ) -> list[_LineItem]:
744
+ """在公式认领后恢复同一栏带内被均匀大空格拆开的密集原生视觉行。"""
745
+
746
+ if len(lines) < 3:
747
+ return list(lines)
748
+ lane_keys: dict[int, tuple[int, int]] = {}
749
+ for angle in sorted({line.angle for line in lines}):
750
+ line_geometry = [(line, _rotate_bbox_to_upright(line.bbox, page_size, angle)) for line in lines if line.angle == angle]
751
+ if not line_geometry:
752
+ continue
753
+ median_height = statistics.median(_line_effective_height(line, bbox) for line, bbox in line_geometry)
754
+ local_page_width = page_size[1] if angle in {90, 270} else page_size[0]
755
+ lanes = _infer_text_lanes(line_geometry, local_page_width, median_height)
756
+ for lane_index, lane in enumerate(lanes):
757
+ if lane.is_span:
758
+ continue
759
+ for line, _bbox in lane.lines:
760
+ lane_keys[line.source_index] = (angle, lane_index)
761
+
762
+ row_groups: dict[tuple[int, int], list[_LineItem]] = {}
763
+ for line in lines:
764
+ if line.visual_row_id is None:
765
+ continue
766
+ row_groups.setdefault((line.angle, line.visual_row_id), []).append(line)
767
+
768
+ consumed_source_indices: set[int] = set()
769
+ restored_lines: list[_LineItem] = []
770
+ for members in row_groups.values():
771
+ if not _can_restore_dense_split_visual_row(
772
+ members,
773
+ page_size,
774
+ table_bboxes,
775
+ lane_keys,
776
+ ):
777
+ continue
778
+ restored_lines.append(_merge_dense_split_visual_row(members, page_size))
779
+ consumed_source_indices.update(member.source_index for member in members)
780
+
781
+ output = [line for line in lines if line.source_index not in consumed_source_indices]
782
+ output.extend(restored_lines)
783
+ output.sort(
784
+ key=lambda line: (
785
+ line.angle,
786
+ _rotate_bbox_to_upright(line.bbox, page_size, line.angle)[1],
787
+ _rotate_bbox_to_upright(line.bbox, page_size, line.angle)[0],
788
+ line.source_index,
789
+ )
790
+ )
791
+ return output
792
+
793
+
794
+ def _merge_title_resolved_visual_rows(
795
+ lines: list[_LineItem],
796
+ page_size: tuple[float, float],
797
+ ) -> list[_LineItem]:
798
+ """在标题判定后合并同行标题或已降级的混合字体正文 run。"""
799
+
800
+ row_groups: dict[tuple[int, int], list[_LineItem]] = {}
801
+ for line in lines:
802
+ if line.visual_row_id is None:
803
+ continue
804
+ row_groups.setdefault((line.angle, line.visual_row_id), []).append(line)
805
+
806
+ consumed_source_indices: set[int] = set()
807
+ merged_rows: list[_LineItem] = []
808
+ for members in row_groups.values():
809
+ if len(members) < 2 or not all(member.split_from_row for member in members):
810
+ continue
811
+ if any(member.preserve_split_boundary for member in members):
812
+ continue
813
+ semantic_types = {member.semantic_type for member in members}
814
+ if len(semantic_types) != 1:
815
+ continue
816
+ semantic_type = next(iter(semantic_types))
817
+ font_signatures = {member.font_signature for member in members}
818
+ dense_same_font_text = _is_dense_same_font_two_run_row(
819
+ members,
820
+ page_size,
821
+ )
822
+ sparse_short_prefix_text = _is_sparse_short_prefix_two_run_row(
823
+ members,
824
+ page_size,
825
+ )
826
+ if semantic_type != "paragraph_title" and not (
827
+ semantic_type is None and (len(font_signatures) > 1 or dense_same_font_text or sparse_short_prefix_text)
828
+ ):
829
+ continue
830
+ local_geometry = [
831
+ (
832
+ member,
833
+ _rotate_bbox_to_upright(member.bbox, page_size, member.angle),
834
+ )
835
+ for member in members
836
+ ]
837
+ local_geometry.sort(key=lambda item: (item[1][0], item[1][1], item[0].source_index))
838
+ if any(
839
+ not _same_baseline_geometry(
840
+ previous[1],
841
+ _line_effective_height(*previous),
842
+ current[1],
843
+ _line_effective_height(*current),
844
+ maximum_gap=(5.0 if sparse_short_prefix_text else 3.0)
845
+ * max(
846
+ _line_effective_height(*previous),
847
+ _line_effective_height(*current),
848
+ ),
849
+ )
850
+ for previous, current in zip(local_geometry, local_geometry[1:])
851
+ ):
852
+ continue
853
+ merged_rows.append(_merge_dense_split_visual_row(members, page_size))
854
+ consumed_source_indices.update(member.source_index for member in members)
855
+
856
+ output = [line for line in lines if line.source_index not in consumed_source_indices]
857
+ output.extend(merged_rows)
858
+ output.sort(
859
+ key=lambda line: (
860
+ line.angle,
861
+ _rotate_bbox_to_upright(line.bbox, page_size, line.angle)[1],
862
+ _rotate_bbox_to_upright(line.bbox, page_size, line.angle)[0],
863
+ line.source_index,
864
+ )
865
+ )
866
+ return _merge_numbered_title_fragments(
867
+ output,
868
+ page_size,
869
+ )
870
+
871
+
872
+ def _merge_numbered_title_fragments(
873
+ lines: list[_LineItem],
874
+ page_size: tuple[float, float],
875
+ ) -> list[_LineItem]:
876
+ """合并 PDF 字体分割导致的章节编号与同基线标题文字。"""
877
+
878
+ marker_re = re.compile(
879
+ r"^\d+(?:\s*\.\s*\d+)*\.?$",
880
+ )
881
+ geometry = [
882
+ (
883
+ line,
884
+ _rotate_bbox_to_upright(
885
+ line.bbox,
886
+ page_size,
887
+ line.angle,
888
+ ),
889
+ )
890
+ for line in lines
891
+ if line.semantic_type == "paragraph_title"
892
+ ]
893
+ consumed: set[int] = set()
894
+ merged: list[_LineItem] = []
895
+ for marker, marker_bbox in geometry:
896
+ if marker.source_index in consumed:
897
+ continue
898
+ normalized = re.sub(
899
+ r"\s+",
900
+ " ",
901
+ unicodedata.normalize("NFKC", marker.text),
902
+ ).strip()
903
+ if marker_re.match(normalized) is None:
904
+ continue
905
+ marker_height = _line_effective_height(
906
+ marker,
907
+ marker_bbox,
908
+ )
909
+ companions = [
910
+ (candidate, candidate_bbox)
911
+ for candidate, candidate_bbox in geometry
912
+ if candidate is not marker
913
+ and candidate.source_index not in consumed
914
+ and candidate.angle == marker.angle
915
+ and candidate_bbox[0] >= marker_bbox[2]
916
+ and candidate_bbox[0] - marker_bbox[2] <= 4.0 * marker_height
917
+ and _bbox_axis_overlap_ratio(
918
+ marker_bbox,
919
+ candidate_bbox,
920
+ axis="y",
921
+ )
922
+ >= 0.5
923
+ ]
924
+ if not companions:
925
+ continue
926
+ companion, _companion_bbox = min(
927
+ companions,
928
+ key=lambda item: (
929
+ item[1][0] - marker_bbox[2],
930
+ item[1][1],
931
+ ),
932
+ )
933
+ merged.append(
934
+ _merge_dense_split_visual_row(
935
+ [marker, companion],
936
+ page_size,
937
+ )
938
+ )
939
+ consumed.update({marker.source_index, companion.source_index})
940
+ output = [line for line in lines if line.source_index not in consumed]
941
+ output.extend(merged)
942
+ output.sort(
943
+ key=lambda line: (
944
+ line.angle,
945
+ _rotate_bbox_to_upright(
946
+ line.bbox,
947
+ page_size,
948
+ line.angle,
949
+ )[1],
950
+ _rotate_bbox_to_upright(
951
+ line.bbox,
952
+ page_size,
953
+ line.angle,
954
+ )[0],
955
+ line.source_index,
956
+ )
957
+ )
958
+ return output
959
+
960
+
961
+ def _is_dense_same_font_two_run_row(
962
+ members: list[_LineItem],
963
+ page_size: tuple[float, float],
964
+ ) -> bool:
965
+ """检查两个普通文本 run 是否为同字体且占用充分的完整视觉行。"""
966
+
967
+ if (
968
+ len(members) != 2
969
+ or not all(member.split_from_row for member in members)
970
+ or any(member.preserve_split_boundary for member in members)
971
+ or any(member.semantic_type is not None for member in members)
972
+ or any(member.font_signature is None for member in members)
973
+ or any(member.font_coverage < 0.75 for member in members)
974
+ ):
975
+ return False
976
+ ordered = sorted(members, key=lambda member: member.run_index)
977
+ if [member.run_index for member in ordered] != [0, 1]:
978
+ return False
979
+ if ordered[0].font_signature != ordered[1].font_signature:
980
+ return False
981
+
982
+ local_geometry = [
983
+ (
984
+ member,
985
+ _rotate_bbox_to_upright(member.bbox, page_size, member.angle),
986
+ )
987
+ for member in ordered
988
+ ]
989
+ local_geometry.sort(key=lambda item: (item[1][0], item[1][1], item[0].source_index))
990
+ first, second = local_geometry
991
+ pair_height = max(
992
+ _line_effective_height(*first),
993
+ _line_effective_height(*second),
994
+ )
995
+ if not _same_baseline_geometry(
996
+ first[1],
997
+ _line_effective_height(*first),
998
+ second[1],
999
+ _line_effective_height(*second),
1000
+ maximum_gap=3.0 * pair_height,
1001
+ ):
1002
+ return False
1003
+
1004
+ union_bbox = _bbox_union_many([bbox for _member, bbox in local_geometry])
1005
+ occupied_width = sum(bbox[2] - bbox[0] for _member, bbox in local_geometry)
1006
+ return occupied_width / max(0.1, union_bbox[2] - union_bbox[0]) >= 0.85
1007
+
1008
+
1009
+ def _is_sparse_short_prefix_two_run_row(
1010
+ members: list[_LineItem],
1011
+ page_size: tuple[float, float],
1012
+ ) -> bool:
1013
+ """识别同视觉行中被宽空白拆开的短前缀与宽正文。"""
1014
+
1015
+ if (
1016
+ len(members) != 2
1017
+ or not all(member.split_from_row for member in members)
1018
+ or any(member.preserve_split_boundary for member in members)
1019
+ or any(member.semantic_type is not None for member in members)
1020
+ or any(member.font_signature is None for member in members)
1021
+ or any(member.font_coverage < 0.7 for member in members)
1022
+ ):
1023
+ return False
1024
+ ordered = sorted(members, key=lambda member: member.run_index)
1025
+ if [member.run_index for member in ordered] != [0, 1]:
1026
+ return False
1027
+ if not _font_signatures_share_family(
1028
+ ordered[0].font_signature,
1029
+ ordered[1].font_signature,
1030
+ ):
1031
+ return False
1032
+
1033
+ local_geometry = [
1034
+ (
1035
+ member,
1036
+ _rotate_bbox_to_upright(
1037
+ member.bbox,
1038
+ page_size,
1039
+ member.angle,
1040
+ ),
1041
+ )
1042
+ for member in ordered
1043
+ ]
1044
+ local_geometry.sort(
1045
+ key=lambda item: (
1046
+ item[1][0],
1047
+ item[1][1],
1048
+ item[0].source_index,
1049
+ )
1050
+ )
1051
+ first, second = local_geometry
1052
+ pair_height = max(
1053
+ _line_effective_height(*first),
1054
+ _line_effective_height(*second),
1055
+ )
1056
+ local_page_width = page_size[1] if ordered[0].angle in {90, 270} else page_size[0]
1057
+ horizontal_gap = second[1][0] - first[1][2]
1058
+ return (
1059
+ first[1][2] - first[1][0] <= 2.0 * pair_height
1060
+ and second[1][2] - second[1][0] >= 0.3 * local_page_width
1061
+ and 3.0 * pair_height < horizontal_gap <= 5.0 * pair_height
1062
+ and _same_baseline_geometry(
1063
+ first[1],
1064
+ _line_effective_height(*first),
1065
+ second[1],
1066
+ _line_effective_height(*second),
1067
+ maximum_gap=5.0 * pair_height,
1068
+ )
1069
+ )
1070
+
1071
+
1072
+ def _can_restore_dense_split_visual_row(
1073
+ members: list[_LineItem],
1074
+ page_size: tuple[float, float],
1075
+ table_bboxes: list[BBox],
1076
+ lane_keys: dict[int, tuple[int, int]],
1077
+ ) -> bool:
1078
+ """检查 hard-split run 是否构成同字体且占用充分的完整视觉行。"""
1079
+
1080
+ if (
1081
+ len(members) < 2
1082
+ or not all(member.split_from_row for member in members)
1083
+ or any(member.preserve_split_boundary for member in members)
1084
+ ):
1085
+ return False
1086
+ if len({member.semantic_type for member in members}) != 1:
1087
+ return False
1088
+ if len({member.formula_candidate_only for member in members}) != 1:
1089
+ return False
1090
+ ordered = sorted(members, key=lambda member: member.run_index)
1091
+ if [member.run_index for member in ordered] != list(range(len(ordered))):
1092
+ return False
1093
+ member_lane_keys = {lane_keys.get(member.source_index) for member in ordered}
1094
+ same_inferred_lane = None not in member_lane_keys and len(member_lane_keys) == 1
1095
+ font_signatures = {member.font_signature for member in ordered}
1096
+ if len(font_signatures) != 1:
1097
+ return False
1098
+ if any(_bbox_intersects(member.bbox, table_bbox) for member in ordered for table_bbox in table_bboxes):
1099
+ return False
1100
+
1101
+ local_geometry = [
1102
+ (
1103
+ member,
1104
+ _rotate_bbox_to_upright(member.bbox, page_size, member.angle),
1105
+ )
1106
+ for member in ordered
1107
+ ]
1108
+ local_geometry.sort(key=lambda item: (item[1][0], item[1][1], item[0].source_index))
1109
+ if not same_inferred_lane:
1110
+ member_widths = [bbox[2] - bbox[0] for _member, bbox in local_geometry]
1111
+ if len(members) == 2 and min(member_widths) > 0.35 * max(member_widths):
1112
+ return False
1113
+ if 3 <= len(members) <= 6:
1114
+ return False
1115
+ heights = [_line_effective_height(member, bbox) for member, bbox in local_geometry]
1116
+ median_height = statistics.median(heights)
1117
+ glyph_widths = [
1118
+ width for member, _bbox in local_geometry if (width := _median_native_glyph_width(member, page_size)) is not None
1119
+ ]
1120
+ median_glyph_width = statistics.median(glyph_widths) if glyph_widths else 0.0
1121
+ gap_limit = (
1122
+ max(12.0, 1.75 * median_height, 3.0 * median_glyph_width)
1123
+ if same_inferred_lane
1124
+ else max(8.0, 2.0 * median_height, 2.5 * median_glyph_width)
1125
+ )
1126
+ for previous, current in zip(local_geometry, local_geometry[1:]):
1127
+ if not _same_baseline_geometry(
1128
+ previous[1],
1129
+ _line_effective_height(*previous),
1130
+ current[1],
1131
+ _line_effective_height(*current),
1132
+ maximum_gap=gap_limit,
1133
+ ):
1134
+ return False
1135
+
1136
+ union_bbox = _bbox_union_many([bbox for _member, bbox in local_geometry])
1137
+ occupied_width = sum(bbox[2] - bbox[0] for _member, bbox in local_geometry)
1138
+ minimum_occupancy = (
1139
+ 0.8
1140
+ if len(members) == 2 and same_inferred_lane
1141
+ else 0.85
1142
+ if len(members) == 2
1143
+ else 0.65
1144
+ if not same_inferred_lane
1145
+ else 0.65
1146
+ )
1147
+ return occupied_width / max(0.1, union_bbox[2] - union_bbox[0]) >= minimum_occupancy
1148
+
1149
+
1150
+ def _merge_dense_split_visual_row(
1151
+ members: list[_LineItem],
1152
+ page_size: tuple[float, float],
1153
+ ) -> _LineItem:
1154
+ """按局部 x 顺序恢复密集视觉行,正净空使用单空格,重叠片段直接连接。"""
1155
+
1156
+ ordered_geometry = sorted(
1157
+ (
1158
+ (
1159
+ member,
1160
+ _rotate_bbox_to_upright(member.bbox, page_size, member.angle),
1161
+ )
1162
+ for member in members
1163
+ ),
1164
+ key=lambda item: (item[1][0], item[1][1], item[0].source_index),
1165
+ )
1166
+ content_parts = [ordered_geometry[0][0].text.strip()]
1167
+ for previous, current in zip(ordered_geometry, ordered_geometry[1:]):
1168
+ separator = "" if current[1][0] <= previous[1][2] else " "
1169
+ content_parts.extend([separator, current[0].text.strip()])
1170
+
1171
+ ordered_members = [member for member, _bbox in ordered_geometry]
1172
+ merged = _LineItem(
1173
+ text="".join(content_parts).strip(),
1174
+ bbox=_bbox_union_many([member.bbox for member in ordered_members]),
1175
+ angle=ordered_members[0].angle,
1176
+ source_index=min(member.source_index for member in ordered_members),
1177
+ source_bbox=_bbox_union_many(
1178
+ [member.source_bbox or member.bbox for member in ordered_members],
1179
+ ),
1180
+ ink_bbox=(
1181
+ _bbox_union_many(
1182
+ [member.ink_bbox for member in ordered_members if member.ink_bbox is not None],
1183
+ )
1184
+ if any(member.ink_bbox is not None for member in ordered_members)
1185
+ else None
1186
+ ),
1187
+ baseline=(
1188
+ statistics.median(member.baseline for member in ordered_members if member.baseline is not None)
1189
+ if any(member.baseline is not None for member in ordered_members)
1190
+ else None
1191
+ ),
1192
+ chars=[char for member in ordered_members for char in member.chars],
1193
+ visual_row_id=ordered_members[0].visual_row_id,
1194
+ run_index=0,
1195
+ effective_height=statistics.median(_line_effective_height(member, bbox) for member, bbox in ordered_geometry),
1196
+ em_height=statistics.median(_line_effective_height(member, bbox) for member, bbox in ordered_geometry),
1197
+ font_signature=ordered_members[0].font_signature,
1198
+ font_coverage=min(member.font_coverage for member in ordered_members),
1199
+ dominant_font_weight=statistics.median(
1200
+ member.dominant_font_weight for member in ordered_members if member.dominant_font_weight is not None
1201
+ )
1202
+ if any(member.dominant_font_weight is not None for member in ordered_members)
1203
+ else None,
1204
+ median_glyph_width=statistics.median(
1205
+ member.median_glyph_width for member in ordered_members if member.median_glyph_width is not None
1206
+ )
1207
+ if any(member.median_glyph_width is not None for member in ordered_members)
1208
+ else None,
1209
+ leading_emphasis_width=ordered_members[0].leading_emphasis_width,
1210
+ leading_typography_width=ordered_members[0].leading_typography_width,
1211
+ paragraph_formula_context=any(member.paragraph_formula_context for member in ordered_members),
1212
+ split_from_row=False,
1213
+ preserve_split_boundary=any(member.preserve_split_boundary for member in ordered_members),
1214
+ semantic_type=ordered_members[0].semantic_type,
1215
+ restored_inline_cluster=any(member.restored_inline_cluster for member in ordered_members),
1216
+ compact_formula_cluster=any(member.compact_formula_cluster for member in ordered_members),
1217
+ formula_candidate_only=all(member.formula_candidate_only for member in ordered_members),
1218
+ style_scale_repaired=any(member.style_scale_repaired for member in ordered_members),
1219
+ inline_math_regions=[region for member in ordered_members for region in member.inline_math_regions],
1220
+ )
1221
+ if merged.chars:
1222
+ _fill_native_typography(merged, page_size)
1223
+ return merged