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,1662 @@
1
+ """为 Flash 原生文本生成 loose/tight/origin 协商后的 canonical 几何。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ import statistics
8
+ import unicodedata
9
+ from collections import Counter, defaultdict
10
+ from dataclasses import dataclass, field
11
+ from functools import lru_cache
12
+ from typing import Any, Literal, Sequence, TypeAlias
13
+
14
+ from ....schema import BBox
15
+ from ....document.pdf.document import PDFPageTextGeometry
16
+ from .geometry import (
17
+ _clip_validated_bbox,
18
+ _bbox_axis_overlap_ratio,
19
+ _bbox_union_many,
20
+ _clip_bbox,
21
+ _coerce_bbox,
22
+ _rotate_bbox_from_upright,
23
+ _rotate_bbox_to_upright,
24
+ _rotate_origin_to_upright,
25
+ )
26
+ from .models import _LineItem
27
+
28
+
29
+ X_RELIABLE_PAIR_MIN = 30
30
+ X_STRONG_MEDIAN_RATIO = 1.30
31
+ X_STRONG_RATIO_THRESHOLD = 1.35
32
+ X_STRONG_RATIO_SHARE = 0.30
33
+ X_STRONG_NEXT_TIGHT_OVERLAP_SHARE = 0.30
34
+ X_SIBLING_PAIR_MIN = 10
35
+ X_SIBLING_MEDIAN_RATIO = 1.15
36
+ X_SIBLING_NEXT_TIGHT_OVERLAP_SHARE = 0.50
37
+ X_SIBLING_P90_RATIO = 1.50
38
+
39
+ ANCHOR_MIN_TIGHT_HEIGHT_RATIO = 0.38
40
+ Y_MIN_ANCHOR_COUNT = 4
41
+ Y_MIN_GEOMETRY_COVERAGE = 0.80
42
+ Y_DOMINANT_ROW_SHARE = 0.80
43
+ Y_NEIGHBOR_CORE_INTRUSION = 0.25
44
+ Y_MIN_REPEATED_LINES = 3
45
+ Y_MIN_REPEATED_SHARE = 0.50
46
+ Y_HEALTHY_LOOSE_SAMPLE_MIN = 20
47
+ Y_DOCUMENT_RISK_P95_RATIO = 2.20
48
+ STYLE_INFLATION_LOOSE_FONT_RATIO = 1.50
49
+ STYLE_INFLATION_LOOSE_TIGHT_RATIO = 1.80
50
+ STYLE_INFLATION_MIN_LINE_COUNT = 3
51
+ STYLE_INFLATION_MIN_LINE_SHARE = 0.50
52
+ STYLE_INFLATION_MIN_PAGE_COUNT = 2
53
+ STYLE_INFLATION_MIN_SCALE = 4.0
54
+ STYLE_TIER_MIN_MEMBER_COUNT = 2
55
+ STYLE_TIER_MIN_MEMBER_SHARE = 0.15
56
+ STYLE_TIER_GAP_RATIO = 1.35
57
+
58
+ RunKey: TypeAlias = tuple[str, float, int, int, int, str]
59
+ XRunStyleKey: TypeAlias = tuple[str, float, int, int, int]
60
+ LineKey: TypeAlias = tuple[int, int]
61
+ CharKey: TypeAlias = tuple[int, int]
62
+ LooseTierSample: TypeAlias = tuple[float, float, float, float]
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class CharLayoutGeometry:
67
+ """保存一个实际需要修复的字符几何及分轴状态。"""
68
+
69
+ source_bbox: BBox
70
+ tight_bbox: BBox
71
+ origin: tuple[float, float]
72
+ layout_bbox: BBox
73
+ ink_bbox: BBox
74
+ baseline: float
75
+ advance: float | None
76
+ em_height: float
77
+ x_state: Literal["healthy", "abnormal", "unknown"]
78
+ y_state: Literal["healthy", "abnormal", "unknown"]
79
+ confidence: float
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class LineGeometryRepair:
84
+ """保存一条 legacy line 的 canonical 修复和 shadow 诊断。"""
85
+
86
+ source_bbox: BBox
87
+ layout_bbox: BBox
88
+ ink_bbox: BBox | None
89
+ baseline: float | None
90
+ em_height: float
91
+ state: Literal["healthy", "repair_x", "trim_y", "repair_xy", "uncertain"] = "healthy"
92
+ confidence: float = 1.0
93
+ split_y_candidate: bool = False
94
+ repaired_char_count: int = 0
95
+ y_intrusion_ratio: float = 0.0
96
+ run_key: RunKey | None = None
97
+
98
+
99
+ @dataclass(slots=True)
100
+ class DocumentGeometryPlan:
101
+ """保存文档级 run 结论、局部字符修复和逐行 canonical 几何。"""
102
+
103
+ char_repairs: dict[CharKey, CharLayoutGeometry] = field(default_factory=dict)
104
+ line_repairs: dict[LineKey, LineGeometryRepair] = field(default_factory=dict)
105
+ line_style_scales: dict[LineKey, float] = field(default_factory=dict)
106
+ line_ink_bboxes: dict[LineKey, BBox] = field(default_factory=dict)
107
+ line_baselines: dict[LineKey, float] = field(default_factory=dict)
108
+ style_inflated_runs: set[RunKey] = field(default_factory=set)
109
+ run_diagnostics: list[dict[str, Any]] = field(default_factory=list)
110
+ document_style_anomaly: bool = False
111
+
112
+ def to_dict(self) -> dict[str, Any]:
113
+ """转换为 review 脚本可序列化的稳定诊断。"""
114
+
115
+ return {
116
+ "document_style_anomaly": self.document_style_anomaly,
117
+ "run_diagnostics": self.run_diagnostics,
118
+ "char_repairs": [
119
+ {
120
+ "page_index": page_index,
121
+ "char_idx": char_idx,
122
+ "source_bbox": list(repair.source_bbox),
123
+ "tight_bbox": list(repair.tight_bbox),
124
+ "origin": list(repair.origin),
125
+ "layout_bbox": list(repair.layout_bbox),
126
+ "advance": repair.advance,
127
+ "em_height": repair.em_height,
128
+ "x_state": repair.x_state,
129
+ "y_state": repair.y_state,
130
+ "confidence": repair.confidence,
131
+ }
132
+ for (page_index, char_idx), repair in sorted(self.char_repairs.items())
133
+ ],
134
+ "line_repairs": [
135
+ {
136
+ "page_index": page_index,
137
+ "source_index": source_index,
138
+ "source_bbox": list(repair.source_bbox),
139
+ "layout_bbox": list(repair.layout_bbox),
140
+ "ink_bbox": list(repair.ink_bbox) if repair.ink_bbox is not None else None,
141
+ "baseline": repair.baseline,
142
+ "em_height": repair.em_height,
143
+ "state": repair.state,
144
+ "confidence": repair.confidence,
145
+ "split_y_candidate": repair.split_y_candidate,
146
+ "repaired_char_count": repair.repaired_char_count,
147
+ "y_intrusion_ratio": repair.y_intrusion_ratio,
148
+ "run_key": list(repair.run_key) if repair.run_key is not None else None,
149
+ }
150
+ for (page_index, source_index), repair in sorted(self.line_repairs.items())
151
+ if repair.state != "healthy" or repair.split_y_candidate
152
+ ],
153
+ }
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class _DocumentGeometryRisk:
158
+ """区分需要完整字符几何的布局风险与仅需字号校准的样式风险。"""
159
+
160
+ layout: bool = False
161
+ style: bool = False
162
+
163
+ @property
164
+ def any(self) -> bool:
165
+ """返回当前文档是否需要进入完整字符样本收集。"""
166
+
167
+ return self.layout or self.style
168
+
169
+
170
+ @dataclass(slots=True)
171
+ class _CharSample:
172
+ """保存分析阶段使用的局部字符样本。"""
173
+
174
+ page_index: int
175
+ line: _LineItem
176
+ position: int
177
+ char_idx: int
178
+ text: str
179
+ source_bbox: BBox
180
+ tight_bbox: BBox
181
+ origin: tuple[float, float]
182
+ local_source_bbox: BBox
183
+ local_tight_bbox: BBox
184
+ local_origin: tuple[float, float]
185
+ run_key: RunKey
186
+ font_size: float
187
+ is_anchor: bool = False
188
+
189
+
190
+ @dataclass(slots=True)
191
+ class _RunStats:
192
+ """保存一个字体 run 的 origin-advance 与覆盖统计。"""
193
+
194
+ key: RunKey
195
+ pair_ratios: list[float] = field(default_factory=list)
196
+ pair_overlaps: list[bool] = field(default_factory=list)
197
+ advances: list[float] = field(default_factory=list)
198
+ tight_left_bearings: list[float] = field(default_factory=list)
199
+ samples: list[_CharSample] = field(default_factory=list)
200
+ strong_x_bad: bool = False
201
+ sibling_x_bad: bool = False
202
+ style_y_bad: bool = False
203
+ median_advance: float | None = None
204
+ median_tight_left_bearing: float = 0.0
205
+
206
+
207
+ @dataclass(slots=True)
208
+ class _LineAnalysis:
209
+ """保存 Y 轴 row 聚类和邻行侵入判定所需信息。"""
210
+
211
+ key: LineKey
212
+ line: _LineItem
213
+ samples: list[_CharSample]
214
+ anchors: list[_CharSample]
215
+ dominant: list[_CharSample]
216
+ baseline: float
217
+ tight_core: BBox
218
+ local_source_bbox: BBox
219
+ explicit_all_source_bbox: BBox
220
+ legacy_local_bbox: BBox
221
+ run_key: RunKey
222
+ geometry_coverage: float
223
+ dominant_share: float
224
+ split_y_candidate: bool
225
+ neighbors: list[_LineAnalysis] = field(default_factory=list)
226
+ intrusion_ratio: float = 0.0
227
+ y_candidate: bool = False
228
+
229
+
230
+ def _quantile(values: list[float], fraction: float) -> float:
231
+ """返回确定性的线性插值分位数。"""
232
+
233
+ if not values:
234
+ return 0.0
235
+ ordered = sorted(values)
236
+ position = max(0.0, min(1.0, fraction)) * (len(ordered) - 1)
237
+ lower = int(math.floor(position))
238
+ upper = int(math.ceil(position))
239
+ if lower == upper:
240
+ return ordered[lower]
241
+ weight = position - lower
242
+ return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
243
+
244
+
245
+ def _line_loose_tier_offsets(
246
+ samples: list[LooseTierSample],
247
+ em_height: float,
248
+ ) -> tuple[float, float] | None:
249
+ """把归一化 loose 高度分档,并返回最大异常档回缩到次档后的 ascent/descent。"""
250
+
251
+ if len(samples) < 2 * STYLE_TIER_MIN_MEMBER_COUNT or em_height <= 0:
252
+ return None
253
+ normalized = sorted(
254
+ (
255
+ (ascent + descent) / max(font_size, tight_height, 0.1),
256
+ ascent,
257
+ descent,
258
+ tight_height,
259
+ font_size,
260
+ )
261
+ for ascent, descent, tight_height, font_size in samples
262
+ )
263
+ tiers: list[list[tuple[float, float, float, float, float]]] = []
264
+ for item in normalized:
265
+ if not tiers or item[0] > STYLE_TIER_GAP_RATIO * statistics.median(member[0] for member in tiers[-1]):
266
+ tiers.append([item])
267
+ else:
268
+ tiers[-1].append(item)
269
+ if len(tiers) < 2:
270
+ return None
271
+ upper_tier = tiers[-1]
272
+ reference_tier = tiers[-2]
273
+ minimum_members = max(
274
+ STYLE_TIER_MIN_MEMBER_COUNT,
275
+ math.ceil(STYLE_TIER_MIN_MEMBER_SHARE * len(samples)),
276
+ )
277
+ if len(upper_tier) < minimum_members or len(reference_tier) < STYLE_TIER_MIN_MEMBER_COUNT:
278
+ return None
279
+ upper_loose_heights = [member[1] + member[2] for member in upper_tier]
280
+ upper_font_sizes = [member[4] for member in upper_tier if member[4] > 0]
281
+ upper_tight_heights = [member[3] for member in upper_tier]
282
+ if statistics.median(upper_loose_heights) <= STYLE_INFLATION_LOOSE_FONT_RATIO * (
283
+ statistics.median(upper_font_sizes) if upper_font_sizes else 0.0
284
+ ) or statistics.median(upper_loose_heights) <= STYLE_INFLATION_LOOSE_TIGHT_RATIO * max(
285
+ 0.1, _quantile(upper_tight_heights, 0.75)
286
+ ):
287
+ return None
288
+ reference_ascent_ratios = [member[1] / max(member[4], member[3], 0.1) for member in reference_tier]
289
+ reference_descent_ratios = [member[2] / max(member[4], member[3], 0.1) for member in reference_tier]
290
+ return (
291
+ statistics.median(reference_ascent_ratios) * em_height,
292
+ statistics.median(reference_descent_ratios) * em_height,
293
+ )
294
+
295
+
296
+ def _coerce_origin(value: Any) -> tuple[float, float] | None:
297
+ """把 side map 中的 origin 收敛为有限二维坐标。"""
298
+
299
+ try:
300
+ origin = (float(value[0]), float(value[1]))
301
+ except (IndexError, TypeError, ValueError):
302
+ return None
303
+ return origin if all(math.isfinite(item) for item in origin) else None
304
+
305
+
306
+ @lru_cache(maxsize=512)
307
+ def _normalized_font_family(name: str) -> str:
308
+ """移除 PDF 子集前缀并归一化字体族名称。"""
309
+
310
+ value = re.sub(r"^[A-Z]{6}\+", "", name)
311
+ return re.sub(r"[\s_-]+", "", value).casefold() or "<unknown>"
312
+
313
+
314
+ @lru_cache(maxsize=8192)
315
+ def _script_group(text: str) -> str:
316
+ """把字符归入字体 run 使用的宽粒度文字类别。"""
317
+
318
+ if text.isascii() and text.isalpha():
319
+ return "latin"
320
+ if text.isdigit():
321
+ return "digit"
322
+ codepoint = ord(text[0]) if text else 0
323
+ if (
324
+ 0x3400 <= codepoint <= 0x9FFF
325
+ or 0xF900 <= codepoint <= 0xFAFF
326
+ or 0x3040 <= codepoint <= 0x30FF
327
+ or 0xAC00 <= codepoint <= 0xD7AF
328
+ ):
329
+ return "cjk"
330
+ category = unicodedata.category(text[0]) if text else "Cn"
331
+ if category.startswith("L"):
332
+ return "letter"
333
+ if category.startswith("N"):
334
+ return "number"
335
+ return "other"
336
+
337
+
338
+ def _font_run_key(char: dict[str, Any], angle: int, text: str) -> tuple[RunKey, float]:
339
+ """构造字体族、字号、字重、方向和文字类别组成的 run key。"""
340
+
341
+ font = char.get("font") or {}
342
+ font_name = str(font.get("name") or "<unknown>")
343
+ try:
344
+ font_size = float(font.get("size") or 0.0)
345
+ except (TypeError, ValueError):
346
+ font_size = 0.0
347
+ try:
348
+ flags = int(font.get("flags") or 0)
349
+ except (TypeError, ValueError):
350
+ flags = 0
351
+ try:
352
+ weight = int(round(float(font.get("weight") or 0.0) / 100.0) * 100)
353
+ except (TypeError, ValueError):
354
+ weight = 0
355
+ return _cached_run_key(
356
+ font_name,
357
+ font_size,
358
+ flags,
359
+ weight,
360
+ angle,
361
+ _script_group(text),
362
+ ), font_size
363
+
364
+
365
+ @lru_cache(maxsize=4096)
366
+ def _cached_run_key(
367
+ font_name: str,
368
+ font_size: float,
369
+ flags: int,
370
+ weight: int,
371
+ angle: int,
372
+ script: str,
373
+ ) -> RunKey:
374
+ """缓存同字体样式反复出现的规范化 run key。"""
375
+
376
+ return (
377
+ _normalized_font_family(font_name),
378
+ round(font_size * 4.0) / 4.0,
379
+ flags,
380
+ weight,
381
+ angle,
382
+ script,
383
+ )
384
+
385
+
386
+ def _is_anchor_text(text: str) -> bool:
387
+ """仅让字母、数字和 CJK 等完整字形参与主要基线统计。"""
388
+
389
+ if not text or text.isspace() or not text.isprintable():
390
+ return False
391
+ category = unicodedata.category(text[0])
392
+ return category.startswith("L") or category.startswith("N")
393
+
394
+
395
+ def _source_bbox(
396
+ char: dict[str, Any],
397
+ geometry: PDFPageTextGeometry,
398
+ char_idx: int,
399
+ ) -> BBox | None:
400
+ """按提取契约读取 loose:零旋转使用 char bbox,旋转字符才接受 side-map。"""
401
+
402
+ raw_bbox = _coerce_bbox(char.get("bbox"))
403
+ try:
404
+ rotation = float(char.get("rotation") or 0.0)
405
+ except (TypeError, ValueError):
406
+ rotation = math.nan
407
+ if math.isfinite(rotation) and abs(rotation) <= 1e-9:
408
+ return raw_bbox
409
+ side_bbox = _coerce_bbox(geometry.loose_bboxes.get(char_idx))
410
+ if side_bbox is None or raw_bbox is None:
411
+ return side_bbox or raw_bbox
412
+ tight_bbox = _coerce_bbox(geometry.tight_bboxes.get(char_idx))
413
+ stable_width = max(
414
+ raw_bbox[2] - raw_bbox[0],
415
+ (tight_bbox[2] - tight_bbox[0]) if tight_bbox is not None else 0.0,
416
+ 0.1,
417
+ )
418
+ if side_bbox[2] - side_bbox[0] > 1.6 * stable_width:
419
+ return raw_bbox
420
+ return side_bbox
421
+
422
+
423
+ def _style_line_is_inflated(
424
+ source_height: float,
425
+ font_sizes: Sequence[float],
426
+ tight_heights: Sequence[float],
427
+ ) -> bool:
428
+ """按统一阈值判断一行 loose 高度是否显著偏离字号与 tight 字形。"""
429
+
430
+ if not tight_heights:
431
+ return False
432
+ style_scale = max(
433
+ statistics.median(font_sizes) if font_sizes else 0.0,
434
+ _quantile(tight_heights, 0.75),
435
+ )
436
+ if style_scale < STYLE_INFLATION_MIN_SCALE:
437
+ return False
438
+ tight_height = max(0.1, _quantile(tight_heights, 0.75))
439
+ return (
440
+ source_height > STYLE_INFLATION_LOOSE_FONT_RATIO * style_scale
441
+ and source_height > STYLE_INFLATION_LOOSE_TIGHT_RATIO * tight_height
442
+ )
443
+
444
+
445
+ def _document_requires_full_geometry(
446
+ lines_by_page: list[list[_LineItem]],
447
+ geometries: list[PDFPageTextGeometry],
448
+ page_sizes: list[tuple[float, float]],
449
+ ) -> _DocumentGeometryRisk:
450
+ """流式识别布局与样式风险,避免健康文档保留整本字符样本。"""
451
+
452
+ x_ratios: dict[RunKey, list[float]] = defaultdict(list)
453
+ x_overlaps: dict[RunKey, int] = defaultdict(int)
454
+ y_ratios: list[float] = []
455
+ y_extreme_runs: Counter[RunKey] = Counter()
456
+ style_line_counts: Counter[RunKey] = Counter()
457
+ style_inflated_lines: dict[RunKey, set[LineKey]] = defaultdict(set)
458
+ for page_index, (lines, geometry, page_size) in enumerate(
459
+ zip(lines_by_page, geometries, page_sizes, strict=True),
460
+ ):
461
+ for line in lines:
462
+ entries: list[tuple[int, str, BBox, BBox, tuple[float, float], RunKey, float]] = []
463
+ for position, char in enumerate(line.chars):
464
+ text = str(char.get("char") or "")
465
+ char_idx = char.get("char_idx")
466
+ if not _is_anchor_text(text) or isinstance(char_idx, bool) or not isinstance(char_idx, int):
467
+ continue
468
+ source = _clip_validated_bbox(_source_bbox(char, geometry, char_idx), page_size)
469
+ tight = _clip_validated_bbox(_coerce_bbox(geometry.tight_bboxes.get(char_idx)), page_size)
470
+ origin = _coerce_origin(geometry.origins.get(char_idx))
471
+ if source is None or tight is None or origin is None:
472
+ continue
473
+ run_key, font_size = _font_run_key(char, line.angle, text)
474
+ entries.append(
475
+ (
476
+ position,
477
+ text,
478
+ _rotate_bbox_to_upright(source, page_size, line.angle),
479
+ _rotate_bbox_to_upright(tight, page_size, line.angle),
480
+ _rotate_origin_to_upright(origin, page_size, line.angle),
481
+ run_key,
482
+ font_size,
483
+ )
484
+ )
485
+ if not entries:
486
+ continue
487
+ height_q75 = _quantile([entry[3][3] - entry[3][1] for entry in entries], 0.75)
488
+ anchors = [entry for entry in entries if entry[3][3] - entry[3][1] >= ANCHOR_MIN_TIGHT_HEIGHT_RATIO * height_q75]
489
+ for current, following in zip(anchors, anchors[1:]):
490
+ if current[5] != following[5]:
491
+ continue
492
+ tight_height = max(current[3][3] - current[3][1], following[3][3] - following[3][1])
493
+ if abs(current[4][1] - following[4][1]) > max(0.5, 0.25 * tight_height):
494
+ continue
495
+ advance = following[4][0] - current[4][0]
496
+ tight_width = max(current[3][2] - current[3][0], following[3][2] - following[3][0])
497
+ if not 0.1 < advance <= max(5.0 * max(current[6], 1.0), 8.0 * tight_width):
498
+ continue
499
+ ratio = (current[2][2] - current[2][0]) / advance
500
+ x_ratios[current[5]].append(ratio)
501
+ following_width = following[3][2] - following[3][0]
502
+ if current[2][2] - following[3][0] >= 0.05 * max(following_width, 0.1):
503
+ x_overlaps[current[5]] += 1
504
+
505
+ anchors_by_run: dict[RunKey, list[tuple[int, str, BBox, BBox, tuple[float, float], RunKey, float]]] = defaultdict(
506
+ list
507
+ )
508
+ for entry in anchors:
509
+ anchors_by_run[entry[5]].append(entry)
510
+ for run_key, run_entries in anchors_by_run.items():
511
+ style_line_counts[run_key] += 1
512
+ if _style_line_is_inflated(
513
+ line.effective_height,
514
+ [entry[6] for entry in run_entries if entry[6] > 0],
515
+ [entry[3][3] - entry[3][1] for entry in run_entries],
516
+ ):
517
+ style_inflated_lines[run_key].add(
518
+ (page_index, line.source_index),
519
+ )
520
+
521
+ # 四锚点门槛只约束 Y 分析;短行仍须为文档级 X 统计贡献相邻字符对。
522
+ if len(anchors) < Y_MIN_ANCHOR_COUNT:
523
+ continue
524
+ if line.angle != 0 or line.formula_candidate_only or line.restored_inline_cluster or line.compact_formula_cluster:
525
+ continue
526
+ baseline_entries = sorted(anchors, key=lambda entry: entry[4][1])
527
+ tolerance = max(0.5, 0.25 * height_q75)
528
+ clusters: list[list[tuple[int, str, BBox, BBox, tuple[float, float], RunKey, float]]] = []
529
+ for entry in baseline_entries:
530
+ if not clusters or abs(entry[4][1] - statistics.median(item[4][1] for item in clusters[-1])) > tolerance:
531
+ clusters.append([entry])
532
+ else:
533
+ clusters[-1].append(entry)
534
+ supported = [cluster for cluster in clusters if len(cluster) >= 3 and len(cluster) / len(anchors) >= 0.20]
535
+ if len(supported) >= 2:
536
+ return _DocumentGeometryRisk(layout=True)
537
+ dominant = max(clusters, key=len)
538
+ if len(dominant) / len(anchors) < Y_DOMINANT_ROW_SHARE:
539
+ continue
540
+ source_union = _bbox_union_many([entry[2] for entry in dominant])
541
+ tight_union = _bbox_union_many([entry[3] for entry in dominant])
542
+ tight_height = tight_union[3] - tight_union[1]
543
+ if tight_height <= 0:
544
+ continue
545
+ ratio = (source_union[3] - source_union[1]) / tight_height
546
+ y_ratios.append(ratio)
547
+ if ratio >= 3.0:
548
+ y_extreme_runs[Counter(entry[5] for entry in dominant).most_common(1)[0][0]] += 1
549
+
550
+ layout_risk = False
551
+ for run_key, ratios in x_ratios.items():
552
+ pair_count = len(ratios)
553
+ if pair_count < X_RELIABLE_PAIR_MIN:
554
+ continue
555
+ if (
556
+ statistics.median(ratios) >= X_STRONG_MEDIAN_RATIO
557
+ and sum(value > X_STRONG_RATIO_THRESHOLD for value in ratios) / pair_count >= X_STRONG_RATIO_SHARE
558
+ and x_overlaps[run_key] / pair_count >= X_STRONG_NEXT_TIGHT_OVERLAP_SHARE
559
+ ):
560
+ layout_risk = True
561
+ break
562
+ layout_risk = (
563
+ layout_risk
564
+ or bool(y_ratios)
565
+ and (
566
+ _quantile(y_ratios, 0.95) >= Y_DOCUMENT_RISK_P95_RATIO
567
+ or any(count >= Y_MIN_REPEATED_LINES for count in y_extreme_runs.values())
568
+ )
569
+ )
570
+ style_risk = any(
571
+ len(inflated_lines) >= STYLE_INFLATION_MIN_LINE_COUNT
572
+ and len(inflated_lines) / style_line_counts[run_key] >= STYLE_INFLATION_MIN_LINE_SHARE
573
+ and len({page_index for page_index, _source_index in inflated_lines}) >= STYLE_INFLATION_MIN_PAGE_COUNT
574
+ for run_key, inflated_lines in style_inflated_lines.items()
575
+ if style_line_counts[run_key] >= STYLE_INFLATION_MIN_LINE_COUNT
576
+ )
577
+ return _DocumentGeometryRisk(
578
+ layout=layout_risk,
579
+ style=style_risk,
580
+ )
581
+
582
+
583
+ def _collect_samples(
584
+ lines_by_page: list[list[_LineItem]],
585
+ geometries: list[PDFPageTextGeometry],
586
+ page_sizes: list[tuple[float, float]],
587
+ ) -> tuple[list[_CharSample], dict[LineKey, list[_CharSample]]]:
588
+ """收集具有合法 loose/tight/origin 的可见字符样本。"""
589
+
590
+ samples: list[_CharSample] = []
591
+ by_line: dict[LineKey, list[_CharSample]] = defaultdict(list)
592
+ for page_index, (lines, geometry, page_size) in enumerate(zip(lines_by_page, geometries, page_sizes, strict=True)):
593
+ for line in lines:
594
+ for position, char in enumerate(line.chars):
595
+ text = str(char.get("char") or "")
596
+ char_idx = char.get("char_idx")
597
+ if (
598
+ not text
599
+ or not text.isprintable()
600
+ or text.isspace()
601
+ or isinstance(char_idx, bool)
602
+ or not isinstance(char_idx, int)
603
+ ):
604
+ continue
605
+ source_bbox = _clip_validated_bbox(_source_bbox(char, geometry, char_idx), page_size)
606
+ tight_bbox = _clip_validated_bbox(_coerce_bbox(geometry.tight_bboxes.get(char_idx)), page_size)
607
+ origin = _coerce_origin(geometry.origins.get(char_idx))
608
+ if source_bbox is None or tight_bbox is None or origin is None:
609
+ continue
610
+ run_key, font_size = _font_run_key(char, line.angle, text)
611
+ sample = _CharSample(
612
+ page_index=page_index,
613
+ line=line,
614
+ position=position,
615
+ char_idx=char_idx,
616
+ text=text,
617
+ source_bbox=source_bbox,
618
+ tight_bbox=tight_bbox,
619
+ origin=origin,
620
+ local_source_bbox=_rotate_bbox_to_upright(source_bbox, page_size, line.angle),
621
+ local_tight_bbox=_rotate_bbox_to_upright(tight_bbox, page_size, line.angle),
622
+ local_origin=_rotate_origin_to_upright(origin, page_size, line.angle),
623
+ run_key=run_key,
624
+ font_size=font_size,
625
+ )
626
+ samples.append(sample)
627
+ by_line[(page_index, line.source_index)].append(sample)
628
+ # canonical sample 已持有所需 source bbox;后续表格、脚本和字符回填只读取
629
+ # tight/origin,逐页释放 loose side-map 可限制长文档峰值内存。
630
+ geometry.loose_bboxes.clear()
631
+
632
+ for line_samples in by_line.values():
633
+ anchor_heights = [
634
+ sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in line_samples if _is_anchor_text(sample.text)
635
+ ]
636
+ height_q75 = _quantile(anchor_heights, 0.75)
637
+ for sample in line_samples:
638
+ tight_height = sample.local_tight_bbox[3] - sample.local_tight_bbox[1]
639
+ sample.is_anchor = (
640
+ _is_anchor_text(sample.text) and height_q75 > 0 and tight_height >= ANCHOR_MIN_TIGHT_HEIGHT_RATIO * height_q75
641
+ )
642
+ return samples, by_line
643
+
644
+
645
+ def _build_run_stats(
646
+ samples: list[_CharSample],
647
+ by_line: dict[LineKey, list[_CharSample]],
648
+ ) -> dict[RunKey, _RunStats]:
649
+ """统计同 run 相邻 origin advance 与 loose 覆盖下一 tight 的比例。"""
650
+
651
+ runs = {key: _RunStats(key=key) for key in {sample.run_key for sample in samples}}
652
+ for sample in samples:
653
+ run = runs[sample.run_key]
654
+ run.samples.append(sample)
655
+ run.tight_left_bearings.append(sample.local_tight_bbox[0] - sample.local_origin[0])
656
+
657
+ for line_samples in by_line.values():
658
+ anchors = [sample for sample in line_samples if sample.is_anchor]
659
+ anchors.sort(key=lambda sample: sample.position)
660
+ for current, following in zip(anchors, anchors[1:]):
661
+ if current.run_key != following.run_key:
662
+ continue
663
+ tight_height = max(
664
+ current.local_tight_bbox[3] - current.local_tight_bbox[1],
665
+ following.local_tight_bbox[3] - following.local_tight_bbox[1],
666
+ )
667
+ if abs(current.local_origin[1] - following.local_origin[1]) > max(0.5, 0.25 * tight_height):
668
+ continue
669
+ advance = following.local_origin[0] - current.local_origin[0]
670
+ tight_width = max(
671
+ current.local_tight_bbox[2] - current.local_tight_bbox[0],
672
+ following.local_tight_bbox[2] - following.local_tight_bbox[0],
673
+ )
674
+ limit = max(5.0 * max(current.font_size, 1.0), 8.0 * tight_width)
675
+ if not 0.1 < advance <= limit:
676
+ continue
677
+ source_width = current.local_source_bbox[2] - current.local_source_bbox[0]
678
+ if source_width <= 0:
679
+ continue
680
+ overlap = current.local_source_bbox[2] - following.local_tight_bbox[0]
681
+ following_width = following.local_tight_bbox[2] - following.local_tight_bbox[0]
682
+ run = runs[current.run_key]
683
+ run.pair_ratios.append(source_width / advance)
684
+ run.pair_overlaps.append(overlap >= 0.05 * max(following_width, 0.1))
685
+ run.advances.append(advance)
686
+
687
+ for run in runs.values():
688
+ run.median_advance = statistics.median(run.advances) if run.advances else None
689
+ run.median_tight_left_bearing = statistics.median(run.tight_left_bearings) if run.tight_left_bearings else 0.0
690
+ pair_count = len(run.pair_ratios)
691
+ if pair_count < X_RELIABLE_PAIR_MIN:
692
+ continue
693
+ median_ratio = statistics.median(run.pair_ratios)
694
+ large_share = sum(value > X_STRONG_RATIO_THRESHOLD for value in run.pair_ratios) / pair_count
695
+ overlap_share = sum(run.pair_overlaps) / pair_count
696
+ run.strong_x_bad = (
697
+ median_ratio >= X_STRONG_MEDIAN_RATIO
698
+ and large_share >= X_STRONG_RATIO_SHARE
699
+ and overlap_share >= X_STRONG_NEXT_TIGHT_OVERLAP_SHARE
700
+ )
701
+
702
+ bad_families = {run.key[0] for run in runs.values() if run.strong_x_bad}
703
+ for run in runs.values():
704
+ if run.strong_x_bad or run.key[0] not in bad_families or len(run.pair_ratios) < X_SIBLING_PAIR_MIN:
705
+ continue
706
+ median_ratio = statistics.median(run.pair_ratios)
707
+ overlap_share = sum(run.pair_overlaps) / len(run.pair_overlaps)
708
+ run.sibling_x_bad = (
709
+ median_ratio >= X_SIBLING_MEDIAN_RATIO and overlap_share >= X_SIBLING_NEXT_TIGHT_OVERLAP_SHARE
710
+ ) or _quantile(run.pair_ratios, 0.9) >= X_SIBLING_P90_RATIO
711
+ return runs
712
+
713
+
714
+ def _mark_style_inflated_runs(
715
+ runs: dict[RunKey, _RunStats],
716
+ ) -> set[RunKey]:
717
+ """标记跨页重复出现且 loose 高度同时偏离字号与 tight 的字体 run。"""
718
+
719
+ output: set[RunKey] = set()
720
+ for run in runs.values():
721
+ samples_by_line: dict[LineKey, list[_CharSample]] = defaultdict(list)
722
+ for sample in run.samples:
723
+ if sample.is_anchor:
724
+ samples_by_line[(sample.page_index, sample.line.source_index)].append(sample)
725
+ if len(samples_by_line) < STYLE_INFLATION_MIN_LINE_COUNT:
726
+ continue
727
+ inflated_lines: list[LineKey] = []
728
+ for line_key, line_samples in samples_by_line.items():
729
+ if _style_line_is_inflated(
730
+ line_samples[0].line.effective_height,
731
+ [sample.font_size for sample in line_samples if sample.font_size > 0],
732
+ [sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in line_samples],
733
+ ):
734
+ inflated_lines.append(line_key)
735
+ if (
736
+ len(inflated_lines) >= STYLE_INFLATION_MIN_LINE_COUNT
737
+ and len(inflated_lines) / len(samples_by_line) >= STYLE_INFLATION_MIN_LINE_SHARE
738
+ and len({page_index for page_index, _source_index in inflated_lines}) >= STYLE_INFLATION_MIN_PAGE_COUNT
739
+ ):
740
+ run.style_y_bad = True
741
+ output.add(run.key)
742
+ return output
743
+
744
+
745
+ def _document_uses_global_style_calibration(
746
+ style_inflated_runs: set[RunKey],
747
+ ) -> bool:
748
+ """只要存在已通过跨页重复证据的异常 run,就启用全文统一字体尺度。"""
749
+
750
+ return bool(style_inflated_runs)
751
+
752
+
753
+ def _apply_style_scale_repairs(
754
+ plan: DocumentGeometryPlan,
755
+ style_inflated_runs: set[RunKey],
756
+ by_line: dict[LineKey, list[_CharSample]],
757
+ ) -> None:
758
+ """异常文档触发后统一写入逐行 canonical 字号,不改变公开来源 bbox。"""
759
+
760
+ if not style_inflated_runs:
761
+ return
762
+ for line_key, line_samples in by_line.items():
763
+ anchor_samples = [sample for sample in line_samples if sample.is_anchor]
764
+ if not anchor_samples:
765
+ continue
766
+ dominant_run = Counter(sample.run_key for sample in anchor_samples).most_common(1)[0][0]
767
+ style_samples = [sample for sample in anchor_samples if sample.run_key == dominant_run]
768
+ font_sizes = [sample.font_size for sample in style_samples if sample.font_size > 0]
769
+ tight_heights = [sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in style_samples]
770
+ style_scale = max(
771
+ statistics.median(font_sizes) if font_sizes else 0.0,
772
+ _quantile(tight_heights, 0.75),
773
+ 0.1,
774
+ )
775
+ if style_scale < STYLE_INFLATION_MIN_SCALE:
776
+ continue
777
+ plan.line_style_scales[line_key] = style_scale
778
+
779
+
780
+ def _line_uses_repaired_style_scale(
781
+ line: _LineItem,
782
+ style_inflated_runs: set[RunKey],
783
+ ) -> bool:
784
+ """按字体族、字号、flags 和方向把异常 run 证据投影到当前视觉行。"""
785
+
786
+ if line.font_signature is None or line.em_height <= 0:
787
+ return False
788
+ family = _normalized_font_family(line.font_signature[0])
789
+ style_size = round(line.em_height * 4.0) / 4.0
790
+ flags = line.font_signature[1]
791
+ return any(
792
+ family == run_key[0] and abs(style_size - run_key[1]) <= 0.25 and flags == run_key[2] and line.angle == run_key[4]
793
+ for run_key in style_inflated_runs
794
+ )
795
+
796
+
797
+ def _restore_stable_legacy_source_bboxes(
798
+ by_line: dict[LineKey, list[_CharSample]],
799
+ page_sizes: list[tuple[float, float]],
800
+ ) -> None:
801
+ """全文异常成立后改用原始字符 bbox,隔离仅存在于 loose side-map 的扰动。"""
802
+
803
+ for (page_index, _source_index), line_samples in by_line.items():
804
+ page_size = page_sizes[page_index]
805
+ for sample in line_samples:
806
+ raw_bbox = _clip_validated_bbox(
807
+ _coerce_bbox(sample.line.chars[sample.position].get("bbox")),
808
+ page_size,
809
+ )
810
+ if raw_bbox is None:
811
+ continue
812
+ sample.source_bbox = raw_bbox
813
+ sample.local_source_bbox = _rotate_bbox_to_upright(
814
+ raw_bbox,
815
+ page_size,
816
+ sample.line.angle,
817
+ )
818
+
819
+
820
+ def _next_compatible_sample(
821
+ current: _CharSample,
822
+ line_samples: list[_CharSample],
823
+ ) -> _CharSample | None:
824
+ """返回同 run、同基线且 origin 正向的下一可见字符。"""
825
+
826
+ for candidate in line_samples:
827
+ if candidate.position <= current.position or candidate.run_key != current.run_key:
828
+ continue
829
+ tight_height = max(
830
+ current.local_tight_bbox[3] - current.local_tight_bbox[1],
831
+ candidate.local_tight_bbox[3] - candidate.local_tight_bbox[1],
832
+ )
833
+ if abs(current.local_origin[1] - candidate.local_origin[1]) > max(0.5, 0.25 * tight_height):
834
+ continue
835
+ advance = candidate.local_origin[0] - current.local_origin[0]
836
+ if advance > 0.1:
837
+ return candidate
838
+ return None
839
+
840
+
841
+ def _x_run_style_key(run_key: RunKey) -> XRunStyleKey:
842
+ """移除文字类别,返回字体样式和方向一致的 X 修复分组键。"""
843
+
844
+ return run_key[:5]
845
+
846
+
847
+ def _samples_share_baseline(first: _CharSample, second: _CharSample) -> bool:
848
+ """按 tight 字形高度判断两个字符是否位于同一局部基线。"""
849
+
850
+ tight_height = max(
851
+ first.local_tight_bbox[3] - first.local_tight_bbox[1],
852
+ second.local_tight_bbox[3] - second.local_tight_bbox[1],
853
+ )
854
+ return abs(first.local_origin[1] - second.local_origin[1]) <= max(0.5, 0.25 * tight_height)
855
+
856
+
857
+ def _next_style_compatible_sample(
858
+ current: _CharSample,
859
+ line_samples: list[_CharSample],
860
+ ) -> _CharSample | None:
861
+ """返回同字体样式、同基线且 origin 正向的下一可见字符。"""
862
+
863
+ style_key = _x_run_style_key(current.run_key)
864
+ for candidate in line_samples:
865
+ if (
866
+ candidate.position <= current.position
867
+ or _x_run_style_key(candidate.run_key) != style_key
868
+ or not _samples_share_baseline(current, candidate)
869
+ ):
870
+ continue
871
+ if candidate.local_origin[0] - current.local_origin[0] > 0.1:
872
+ return candidate
873
+ return None
874
+
875
+
876
+ def _build_x_char_repair(
877
+ sample: _CharSample,
878
+ donor_run: _RunStats,
879
+ next_sample: _CharSample | None,
880
+ page_size: tuple[float, float],
881
+ *,
882
+ left_bearing: float,
883
+ use_donor_advance: bool = False,
884
+ ) -> CharLayoutGeometry | None:
885
+ """用可靠 run 的 advance 为单个字符构造包含 tight 字形的 X-only 修复。"""
886
+
887
+ advance = (
888
+ donor_run.median_advance
889
+ if use_donor_advance or next_sample is None
890
+ else next_sample.local_origin[0] - sample.local_origin[0]
891
+ )
892
+ if advance is None or advance <= 0:
893
+ return None
894
+ left = min(sample.local_tight_bbox[0], sample.local_origin[0] + left_bearing)
895
+ right = max(sample.local_tight_bbox[2], sample.local_origin[0] + advance)
896
+ if next_sample is not None:
897
+ right = min(right, max(sample.local_tight_bbox[2], next_sample.local_origin[0]))
898
+ if right <= left:
899
+ return None
900
+ layout_bbox = _clip_bbox(
901
+ _rotate_bbox_from_upright(
902
+ (
903
+ left,
904
+ sample.local_source_bbox[1],
905
+ right,
906
+ sample.local_source_bbox[3],
907
+ ),
908
+ page_size,
909
+ sample.line.angle,
910
+ ),
911
+ page_size,
912
+ )
913
+ if layout_bbox is None:
914
+ return None
915
+ return CharLayoutGeometry(
916
+ source_bbox=sample.source_bbox,
917
+ tight_bbox=sample.tight_bbox,
918
+ origin=sample.origin,
919
+ layout_bbox=layout_bbox,
920
+ ink_bbox=sample.tight_bbox,
921
+ baseline=sample.local_origin[1],
922
+ advance=advance,
923
+ em_height=max(
924
+ sample.font_size,
925
+ sample.local_tight_bbox[3] - sample.local_tight_bbox[1],
926
+ ),
927
+ x_state="abnormal",
928
+ y_state="healthy",
929
+ confidence=min(1.0, len(donor_run.pair_ratios) / X_RELIABLE_PAIR_MIN),
930
+ )
931
+
932
+
933
+ def _x_repair_donors(
934
+ runs: dict[RunKey, _RunStats],
935
+ ) -> dict[XRunStyleKey, _RunStats]:
936
+ """按强异常、pair 数和完整键稳定选择各字体样式的 X 修复 donor。"""
937
+
938
+ donors: dict[XRunStyleKey, _RunStats] = {}
939
+ candidates = [
940
+ run
941
+ for run in runs.values()
942
+ if (run.strong_x_bad or run.sibling_x_bad) and run.median_advance is not None and run.median_advance > 0
943
+ ]
944
+ for run in sorted(
945
+ candidates,
946
+ key=lambda item: (
947
+ not item.strong_x_bad,
948
+ -len(item.pair_ratios),
949
+ item.key,
950
+ ),
951
+ ):
952
+ donors.setdefault(_x_run_style_key(run.key), run)
953
+ return donors
954
+
955
+
956
+ def _has_adjacent_repaired_style_sample(
957
+ sample: _CharSample,
958
+ line_samples: list[_CharSample],
959
+ page_index: int,
960
+ plan: DocumentGeometryPlan,
961
+ ) -> bool:
962
+ """确认稀疏字符紧邻同字体样式、同基线且已完成 X 修复的字符。"""
963
+
964
+ style_key = _x_run_style_key(sample.run_key)
965
+ return any(
966
+ abs(candidate.position - sample.position) == 1
967
+ and _x_run_style_key(candidate.run_key) == style_key
968
+ and _samples_share_baseline(sample, candidate)
969
+ and (page_index, candidate.char_idx) in plan.char_repairs
970
+ for candidate in line_samples
971
+ )
972
+
973
+
974
+ def _sparse_x_repair_affects_layout(
975
+ sample: _CharSample,
976
+ line_samples: list[_CharSample],
977
+ donor_run: _RunStats,
978
+ page_size: tuple[float, float],
979
+ ) -> bool:
980
+ """仅接纳撑开行尾或遮蔽跨栏硬间隙的稀疏字符 X 异常。"""
981
+
982
+ donor_advance = donor_run.median_advance
983
+ if donor_advance is None or donor_advance <= 0:
984
+ return False
985
+ tight_width = max(
986
+ 0.1,
987
+ sample.local_tight_bbox[2] - sample.local_tight_bbox[0],
988
+ )
989
+ canonical_width = max(donor_advance, tight_width)
990
+ source_width = sample.local_source_bbox[2] - sample.local_source_bbox[0]
991
+ canonical_right = max(
992
+ sample.local_tight_bbox[2],
993
+ sample.local_origin[0] + donor_advance,
994
+ )
995
+ local_page_width = page_size[1] if sample.line.angle in {90, 270} else page_size[0]
996
+ forward_overhang = sample.local_source_bbox[2] - canonical_right
997
+ if source_width <= X_STRONG_RATIO_THRESHOLD * canonical_width or forward_overhang < max(
998
+ 2.0 * canonical_width, 0.01 * local_page_width
999
+ ):
1000
+ return False
1001
+
1002
+ baseline_samples = sorted(
1003
+ (
1004
+ candidate
1005
+ for candidate in line_samples
1006
+ if candidate.position != sample.position and _samples_share_baseline(sample, candidate)
1007
+ ),
1008
+ key=lambda candidate: candidate.position,
1009
+ )
1010
+ following = next(
1011
+ (candidate for candidate in baseline_samples if candidate.position > sample.position),
1012
+ None,
1013
+ )
1014
+ if following is None:
1015
+ right_edge = max(candidate.local_source_bbox[2] for candidate in [sample, *baseline_samples])
1016
+ return abs(sample.local_source_bbox[2] - right_edge) <= 0.25
1017
+
1018
+ median_tight_width = statistics.median(
1019
+ max(
1020
+ 0.1,
1021
+ candidate.local_tight_bbox[2] - candidate.local_tight_bbox[0],
1022
+ )
1023
+ for candidate in [sample, *baseline_samples]
1024
+ )
1025
+ hard_gap_threshold = max(
1026
+ 15.0,
1027
+ 3.0 * median_tight_width,
1028
+ 0.02 * local_page_width,
1029
+ )
1030
+ return (
1031
+ following.local_tight_bbox[0] - canonical_right >= hard_gap_threshold
1032
+ and sample.local_source_bbox[2] >= following.local_origin[0]
1033
+ )
1034
+
1035
+
1036
+ def _repair_x_chars(
1037
+ plan: DocumentGeometryPlan,
1038
+ runs: dict[RunKey, _RunStats],
1039
+ by_line: dict[LineKey, list[_CharSample]],
1040
+ page_sizes: list[tuple[float, float]],
1041
+ ) -> None:
1042
+ """对强异常 run 及其同样式稀疏邻接字符重建前进方向 cell。"""
1043
+
1044
+ donors = _x_repair_donors(runs)
1045
+ for line_key, line_samples in by_line.items():
1046
+ page_index, _source_index = line_key
1047
+ page_size = page_sizes[page_index]
1048
+ for sample in line_samples:
1049
+ run = runs[sample.run_key]
1050
+ if not (run.strong_x_bad or run.sibling_x_bad):
1051
+ continue
1052
+ next_sample = _next_compatible_sample(sample, line_samples)
1053
+ repair = _build_x_char_repair(
1054
+ sample,
1055
+ run,
1056
+ next_sample,
1057
+ page_size,
1058
+ left_bearing=run.median_tight_left_bearing,
1059
+ )
1060
+ if repair is None:
1061
+ continue
1062
+ plan.char_repairs[(page_index, sample.char_idx)] = repair
1063
+
1064
+ for sample in line_samples:
1065
+ char_key = (page_index, sample.char_idx)
1066
+ run = runs[sample.run_key]
1067
+ donor = donors.get(_x_run_style_key(sample.run_key))
1068
+ if (
1069
+ char_key in plan.char_repairs
1070
+ or donor is None
1071
+ or len(run.pair_ratios) >= X_SIBLING_PAIR_MIN
1072
+ or not _has_adjacent_repaired_style_sample(
1073
+ sample,
1074
+ line_samples,
1075
+ page_index,
1076
+ plan,
1077
+ )
1078
+ or not _sparse_x_repair_affects_layout(
1079
+ sample,
1080
+ line_samples,
1081
+ donor,
1082
+ page_size,
1083
+ )
1084
+ ):
1085
+ continue
1086
+ repair = _build_x_char_repair(
1087
+ sample,
1088
+ donor,
1089
+ _next_style_compatible_sample(sample, line_samples),
1090
+ page_size,
1091
+ left_bearing=sample.local_tight_bbox[0] - sample.local_origin[0],
1092
+ use_donor_advance=True,
1093
+ )
1094
+ if repair is not None:
1095
+ plan.char_repairs[char_key] = repair
1096
+
1097
+
1098
+ def _baseline_clusters(samples: list[_CharSample]) -> tuple[list[list[_CharSample]], float]:
1099
+ """按 origin-v 将 anchor 聚为独立视觉基线。"""
1100
+
1101
+ tight_heights = [sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in samples]
1102
+ tolerance = max(0.5, 0.25 * _quantile(tight_heights, 0.75))
1103
+ clusters: list[list[_CharSample]] = []
1104
+ for sample in sorted(samples, key=lambda item: item.local_origin[1]):
1105
+ target = next(
1106
+ (
1107
+ cluster
1108
+ for cluster in clusters
1109
+ if abs(sample.local_origin[1] - statistics.median(item.local_origin[1] for item in cluster)) <= tolerance
1110
+ ),
1111
+ None,
1112
+ )
1113
+ if target is None:
1114
+ clusters.append([sample])
1115
+ else:
1116
+ target.append(sample)
1117
+ return clusters, tolerance
1118
+
1119
+
1120
+ def _record_line_canonical_metrics(
1121
+ plan: DocumentGeometryPlan,
1122
+ by_line: dict[LineKey, list[_CharSample]],
1123
+ ) -> None:
1124
+ """为无需改写 bbox 的普通行保存 tight 字形并集和 dominant origin 基线。"""
1125
+
1126
+ for line_key, line_samples in by_line.items():
1127
+ if not line_samples:
1128
+ continue
1129
+ line = line_samples[0].line
1130
+ plan.line_ink_bboxes[line_key] = _bbox_union_many(
1131
+ [sample.tight_bbox for sample in line_samples],
1132
+ )
1133
+ if line.formula_candidate_only or line.compact_formula_cluster:
1134
+ continue
1135
+ anchors = [sample for sample in line_samples if sample.is_anchor]
1136
+ if len(anchors) < 2:
1137
+ continue
1138
+ clusters, _tolerance = _baseline_clusters(anchors)
1139
+ dominant = max(
1140
+ clusters,
1141
+ key=lambda cluster: (
1142
+ sum(sample.local_tight_bbox[2] - sample.local_tight_bbox[0] for sample in cluster),
1143
+ len(cluster),
1144
+ ),
1145
+ )
1146
+ if len(dominant) / len(anchors) < 0.5:
1147
+ continue
1148
+ plan.line_baselines[line_key] = statistics.median(sample.local_origin[1] for sample in dominant)
1149
+
1150
+
1151
+ def _analyze_lines(
1152
+ lines_by_page: list[list[_LineItem]],
1153
+ by_line: dict[LineKey, list[_CharSample]],
1154
+ page_sizes: list[tuple[float, float]],
1155
+ ) -> dict[LineKey, _LineAnalysis]:
1156
+ """分析 legacy line 的 dominant anchor row 与 split shadow 候选。"""
1157
+
1158
+ analyses: dict[LineKey, _LineAnalysis] = {}
1159
+ for page_index, lines in enumerate(lines_by_page):
1160
+ page_size = page_sizes[page_index]
1161
+ for line in lines:
1162
+ if line.angle != 0 or line.formula_candidate_only or line.restored_inline_cluster or line.compact_formula_cluster:
1163
+ continue
1164
+ line_key = (page_index, line.source_index)
1165
+ line_samples = by_line.get(line_key, [])
1166
+ visible_count = sum(
1167
+ 1
1168
+ for char in line.chars
1169
+ if str(char.get("char") or "").isprintable() and not str(char.get("char") or "").isspace()
1170
+ )
1171
+ anchors = [sample for sample in line_samples if sample.is_anchor]
1172
+ if visible_count == 0 or len(anchors) < Y_MIN_ANCHOR_COUNT:
1173
+ continue
1174
+ clusters, _tolerance = _baseline_clusters(anchors)
1175
+ dominant = max(
1176
+ clusters,
1177
+ key=lambda cluster: (
1178
+ sum(item.local_tight_bbox[2] - item.local_tight_bbox[0] for item in cluster),
1179
+ len(cluster),
1180
+ ),
1181
+ )
1182
+ dominant_share = len(dominant) / len(anchors)
1183
+ geometry_coverage = len(line_samples) / visible_count
1184
+ supported_clusters = [cluster for cluster in clusters if len(cluster) >= 3 and len(cluster) / len(anchors) >= 0.20]
1185
+ baseline = statistics.median(sample.local_origin[1] for sample in dominant)
1186
+ tight_core = _bbox_union_many([sample.local_tight_bbox for sample in dominant])
1187
+ source_envelope = _bbox_union_many([sample.local_source_bbox for sample in anchors])
1188
+ all_source_envelope = _bbox_union_many([sample.local_source_bbox for sample in line_samples])
1189
+ run_key = Counter(sample.run_key for sample in dominant).most_common(1)[0][0]
1190
+ analyses[line_key] = _LineAnalysis(
1191
+ key=line_key,
1192
+ line=line,
1193
+ samples=line_samples,
1194
+ anchors=anchors,
1195
+ dominant=dominant,
1196
+ baseline=baseline,
1197
+ tight_core=tight_core,
1198
+ local_source_bbox=source_envelope,
1199
+ explicit_all_source_bbox=all_source_envelope,
1200
+ legacy_local_bbox=_rotate_bbox_to_upright(line.bbox, page_size, line.angle),
1201
+ run_key=run_key,
1202
+ geometry_coverage=geometry_coverage,
1203
+ dominant_share=dominant_share,
1204
+ split_y_candidate=len(supported_clusters) >= 2,
1205
+ )
1206
+ return analyses
1207
+
1208
+
1209
+ def _has_document_y_risk(by_line: dict[LineKey, list[_CharSample]]) -> bool:
1210
+ """用稳定单基线行的尾部分布实现正常文档 Y 分析快速否决。"""
1211
+
1212
+ ratios: list[float] = []
1213
+ extreme_by_run: Counter[RunKey] = Counter()
1214
+ for line_samples in by_line.values():
1215
+ line = line_samples[0].line if line_samples else None
1216
+ if (
1217
+ line is None
1218
+ or line.angle != 0
1219
+ or line.formula_candidate_only
1220
+ or line.restored_inline_cluster
1221
+ or line.compact_formula_cluster
1222
+ ):
1223
+ continue
1224
+ anchors = [sample for sample in line_samples if sample.is_anchor]
1225
+ if len(anchors) < Y_MIN_ANCHOR_COUNT:
1226
+ continue
1227
+ clusters, _tolerance = _baseline_clusters(anchors)
1228
+ supported_clusters = [cluster for cluster in clusters if len(cluster) >= 3 and len(cluster) / len(anchors) >= 0.20]
1229
+ if len(supported_clusters) >= 2:
1230
+ return True
1231
+ dominant = max(clusters, key=len)
1232
+ if len(dominant) / len(anchors) < Y_DOMINANT_ROW_SHARE:
1233
+ continue
1234
+ source_union = _bbox_union_many([sample.local_source_bbox for sample in dominant])
1235
+ tight_union = _bbox_union_many([sample.local_tight_bbox for sample in dominant])
1236
+ tight_height = tight_union[3] - tight_union[1]
1237
+ if tight_height <= 0:
1238
+ continue
1239
+ ratio = (source_union[3] - source_union[1]) / tight_height
1240
+ ratios.append(ratio)
1241
+ if ratio >= 3.0:
1242
+ run_key = Counter(sample.run_key for sample in dominant).most_common(1)[0][0]
1243
+ extreme_by_run[run_key] += 1
1244
+ return bool(ratios) and (
1245
+ _quantile(ratios, 0.95) >= Y_DOCUMENT_RISK_P95_RATIO
1246
+ or any(count >= Y_MIN_REPEATED_LINES for count in extreme_by_run.values())
1247
+ )
1248
+
1249
+
1250
+ def _same_lane(first: _LineAnalysis, second: _LineAnalysis) -> bool:
1251
+ """用水平覆盖和左边缘近似判断两行是否属于同一栏。"""
1252
+
1253
+ height = max(
1254
+ first.tight_core[3] - first.tight_core[1],
1255
+ second.tight_core[3] - second.tight_core[1],
1256
+ )
1257
+ return _bbox_axis_overlap_ratio(first.local_source_bbox, second.local_source_bbox, axis="x") >= 0.35 or abs(
1258
+ first.local_source_bbox[0] - second.local_source_bbox[0]
1259
+ ) <= 2.0 * max(height, 1.0)
1260
+
1261
+
1262
+ def _assign_neighbors(analyses: dict[LineKey, _LineAnalysis]) -> None:
1263
+ """为每条 eligible line 选择上下最近的同栏正文行。"""
1264
+
1265
+ by_page: dict[int, list[_LineAnalysis]] = defaultdict(list)
1266
+ for analysis in analyses.values():
1267
+ by_page[analysis.key[0]].append(analysis)
1268
+ for page_analyses in by_page.values():
1269
+ for current in page_analyses:
1270
+ candidates = [
1271
+ other
1272
+ for other in page_analyses
1273
+ if other is not current
1274
+ and _same_lane(current, other)
1275
+ and abs(other.baseline - current.baseline) >= max(2.0, 0.6 * (current.tight_core[3] - current.tight_core[1]))
1276
+ ]
1277
+ above = [other for other in candidates if other.baseline < current.baseline]
1278
+ below = [other for other in candidates if other.baseline > current.baseline]
1279
+ if above:
1280
+ current.neighbors.append(max(above, key=lambda other: other.baseline))
1281
+ if below:
1282
+ current.neighbors.append(min(below, key=lambda other: other.baseline))
1283
+
1284
+
1285
+ def _intrusion_ratio(source_bbox: BBox, tight_core: BBox) -> float:
1286
+ """计算 loose envelope 侵入邻行 tight core 的纵向比例。"""
1287
+
1288
+ overlap = max(0.0, min(source_bbox[3], tight_core[3]) - max(source_bbox[1], tight_core[1]))
1289
+ height = tight_core[3] - tight_core[1]
1290
+ return overlap / height if height > 0 else 0.0
1291
+
1292
+
1293
+ def _mark_y_candidates(analyses: dict[LineKey, _LineAnalysis]) -> set[RunKey]:
1294
+ """按局部侵入和文档级重复支持确认允许 trim_y 的 run。"""
1295
+
1296
+ _assign_neighbors(analyses)
1297
+ eligible_by_run: dict[RunKey, list[_LineAnalysis]] = defaultdict(list)
1298
+ for analysis in analyses.values():
1299
+ if (
1300
+ len(analysis.anchors) < Y_MIN_ANCHOR_COUNT
1301
+ or analysis.geometry_coverage < Y_MIN_GEOMETRY_COVERAGE
1302
+ or analysis.dominant_share < Y_DOMINANT_ROW_SHARE
1303
+ ):
1304
+ continue
1305
+ analysis.intrusion_ratio = max(
1306
+ (_intrusion_ratio(analysis.local_source_bbox, neighbor.tight_core) for neighbor in analysis.neighbors),
1307
+ default=0.0,
1308
+ )
1309
+ analysis.y_candidate = analysis.intrusion_ratio >= Y_NEIGHBOR_CORE_INTRUSION
1310
+ eligible_by_run[analysis.run_key].append(analysis)
1311
+
1312
+ confirmed: set[RunKey] = set()
1313
+ for run_key, members in eligible_by_run.items():
1314
+ candidates = [member for member in members if member.y_candidate]
1315
+ if len(candidates) >= Y_MIN_REPEATED_LINES and len(candidates) / len(members) >= Y_MIN_REPEATED_SHARE:
1316
+ confirmed.add(run_key)
1317
+ return confirmed
1318
+
1319
+
1320
+ def _healthy_loose_offsets_by_run(
1321
+ analyses: dict[LineKey, _LineAnalysis],
1322
+ confirmed_runs: set[RunKey],
1323
+ ) -> dict[RunKey, tuple[list[float], list[float]]]:
1324
+ """单次遍历收集各确认 run 的健康 loose ascent/descent。"""
1325
+
1326
+ values: dict[RunKey, tuple[list[float], list[float]]] = {run_key: ([], []) for run_key in confirmed_runs}
1327
+ for analysis in analyses.values():
1328
+ if analysis.run_key not in confirmed_runs or analysis.y_candidate:
1329
+ continue
1330
+ ascents, descents = values[analysis.run_key]
1331
+ for sample in analysis.dominant:
1332
+ ascents.append(max(0.0, sample.local_origin[1] - sample.local_source_bbox[1]))
1333
+ descents.append(max(0.0, sample.local_source_bbox[3] - sample.local_origin[1]))
1334
+ return values
1335
+
1336
+
1337
+ def _repair_y_lines(
1338
+ plan: DocumentGeometryPlan,
1339
+ analyses: dict[LineKey, _LineAnalysis],
1340
+ confirmed_runs: set[RunKey],
1341
+ x_bad_runs: set[RunKey],
1342
+ page_sizes: list[tuple[float, float]],
1343
+ ) -> None:
1344
+ """为确认异常的单基线行生成 baseline 锚定的 Y envelope。"""
1345
+
1346
+ healthy_offsets = _healthy_loose_offsets_by_run(analyses, confirmed_runs)
1347
+ for line_key, analysis in analyses.items():
1348
+ current = plan.line_repairs.get(line_key)
1349
+ source_bbox = analysis.line.source_bbox or analysis.line.bbox
1350
+ should_trim = (
1351
+ analysis.run_key in confirmed_runs
1352
+ and (analysis.y_candidate or analysis.run_key in x_bad_runs)
1353
+ and len(analysis.anchors) >= Y_MIN_ANCHOR_COUNT
1354
+ and analysis.geometry_coverage >= Y_MIN_GEOMETRY_COVERAGE
1355
+ and analysis.dominant_share >= Y_DOMINANT_ROW_SHARE
1356
+ )
1357
+ if current is None and not analysis.split_y_candidate and not should_trim:
1358
+ continue
1359
+ if current is None:
1360
+ current = LineGeometryRepair(
1361
+ source_bbox=source_bbox,
1362
+ layout_bbox=analysis.line.bbox,
1363
+ ink_bbox=_bbox_union_many([sample.tight_bbox for sample in analysis.samples]) if analysis.samples else None,
1364
+ baseline=analysis.baseline,
1365
+ em_height=analysis.line.effective_height,
1366
+ split_y_candidate=analysis.split_y_candidate,
1367
+ run_key=analysis.run_key,
1368
+ )
1369
+ plan.line_repairs[line_key] = current
1370
+ else:
1371
+ current.split_y_candidate = analysis.split_y_candidate
1372
+ current.baseline = analysis.baseline
1373
+ current.run_key = analysis.run_key
1374
+ if not should_trim:
1375
+ continue
1376
+
1377
+ local_tight_boxes = [sample.local_tight_bbox for sample in analysis.samples]
1378
+ tight_union = _bbox_union_many(local_tight_boxes)
1379
+ font_sizes = [sample.font_size for sample in analysis.dominant if sample.font_size > 0]
1380
+ tight_heights = [sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in analysis.dominant]
1381
+ em_height = max(
1382
+ statistics.median(font_sizes) if font_sizes else 0.0,
1383
+ _quantile(tight_heights, 0.75),
1384
+ 0.1,
1385
+ )
1386
+ healthy_ascents, healthy_descents = healthy_offsets[analysis.run_key]
1387
+ healthy_loose_height = (
1388
+ statistics.median(healthy_ascents) + statistics.median(healthy_descents)
1389
+ if healthy_ascents and healthy_descents
1390
+ else math.inf
1391
+ )
1392
+ tier_offsets = _line_loose_tier_offsets(
1393
+ [
1394
+ (
1395
+ max(
1396
+ 0.0,
1397
+ sample.local_origin[1] - sample.local_source_bbox[1],
1398
+ ),
1399
+ max(
1400
+ 0.0,
1401
+ sample.local_source_bbox[3] - sample.local_origin[1],
1402
+ ),
1403
+ sample.local_tight_bbox[3] - sample.local_tight_bbox[1],
1404
+ sample.font_size,
1405
+ )
1406
+ for sample in analysis.dominant
1407
+ if sample.run_key == analysis.run_key
1408
+ ],
1409
+ em_height,
1410
+ )
1411
+ if tier_offsets is not None:
1412
+ ascent, descent = tier_offsets
1413
+ elif len(healthy_ascents) >= Y_HEALTHY_LOOSE_SAMPLE_MIN and healthy_loose_height <= 1.5 * em_height:
1414
+ ascent = statistics.median(healthy_ascents)
1415
+ descent = statistics.median(healthy_descents)
1416
+ else:
1417
+ ascent = _quantile(
1418
+ [max(0.0, sample.local_origin[1] - sample.local_tight_bbox[1]) for sample in analysis.dominant],
1419
+ 0.9,
1420
+ )
1421
+ descent = _quantile(
1422
+ [max(0.0, sample.local_tight_bbox[3] - sample.local_origin[1]) for sample in analysis.dominant],
1423
+ 0.9,
1424
+ )
1425
+ padding = max(0.5, 0.08 * em_height)
1426
+ ascent += padding
1427
+ descent += padding
1428
+
1429
+ top = min(analysis.baseline - ascent, tight_union[1])
1430
+ bottom = max(analysis.baseline + descent, tight_union[3])
1431
+ for neighbor in analysis.neighbors:
1432
+ midpoint = (analysis.baseline + neighbor.baseline) / 2.0
1433
+ if neighbor.baseline < analysis.baseline:
1434
+ top = max(top, min(midpoint, tight_union[1]))
1435
+ else:
1436
+ bottom = min(bottom, max(midpoint, tight_union[3]))
1437
+ legacy_height = analysis.legacy_local_bbox[3] - analysis.legacy_local_bbox[1]
1438
+ provenance_tolerance = max(1.05, 0.05 * legacy_height)
1439
+ preserve_legacy_typography = (
1440
+ abs(analysis.explicit_all_source_bbox[1] - analysis.legacy_local_bbox[1]) > provenance_tolerance
1441
+ or abs(analysis.explicit_all_source_bbox[3] - analysis.legacy_local_bbox[3]) > provenance_tolerance
1442
+ )
1443
+ if preserve_legacy_typography:
1444
+ # explicit loose 与 legacy char bbox 来源不同时保留已经健康的 legacy envelope;
1445
+ # 该分支也让 review 扰动只验证 side-map 修复,不重写既有 line membership。
1446
+ top = analysis.legacy_local_bbox[1]
1447
+ bottom = analysis.legacy_local_bbox[3]
1448
+ page_size = page_sizes[line_key[0]]
1449
+ local_current = _rotate_bbox_to_upright(current.layout_bbox, page_size, analysis.line.angle)
1450
+ layout_bbox = _clip_bbox(
1451
+ _rotate_bbox_from_upright(
1452
+ (local_current[0], top, local_current[2], bottom),
1453
+ page_size,
1454
+ analysis.line.angle,
1455
+ ),
1456
+ page_size,
1457
+ )
1458
+ if layout_bbox is None:
1459
+ continue
1460
+ current.layout_bbox = layout_bbox
1461
+ current.ink_bbox = _bbox_union_many([sample.tight_bbox for sample in analysis.samples])
1462
+ current.em_height = analysis.line.effective_height if preserve_legacy_typography else max(0.1, bottom - top)
1463
+ current.state = "repair_xy" if current.state == "repair_x" else "trim_y"
1464
+ current.confidence = min(analysis.geometry_coverage, analysis.dominant_share)
1465
+ current.y_intrusion_ratio = analysis.intrusion_ratio
1466
+
1467
+
1468
+ def _build_line_repairs_from_x(
1469
+ plan: DocumentGeometryPlan,
1470
+ lines_by_page: list[list[_LineItem]],
1471
+ by_line: dict[LineKey, list[_CharSample]],
1472
+ page_sizes: list[tuple[float, float]],
1473
+ ) -> None:
1474
+ """把已修复字符并集投影为只改变 X 的 canonical line。"""
1475
+
1476
+ for page_index, lines in enumerate(lines_by_page):
1477
+ page_size = page_sizes[page_index]
1478
+ for line in lines:
1479
+ line_key = (page_index, line.source_index)
1480
+ line_samples = by_line.get(line_key, [])
1481
+ repaired = [
1482
+ plan.char_repairs[(page_index, sample.char_idx)]
1483
+ for sample in line_samples
1484
+ if (page_index, sample.char_idx) in plan.char_repairs
1485
+ ]
1486
+ if not repaired:
1487
+ continue
1488
+ local_source = _rotate_bbox_to_upright(line.bbox, page_size, line.angle)
1489
+ explicit_source = _bbox_union_many([sample.local_source_bbox for sample in line_samples])
1490
+ if abs(explicit_source[0] - local_source[0]) > 0.25 or abs(explicit_source[2] - local_source[2]) > 0.25:
1491
+ # 旋转字符或 shadow 扰动的 explicit loose 与 legacy bbox 来源不同,
1492
+ # 首版保留 legacy X,避免把诊断 side-map 变化误写入公开输出。
1493
+ continue
1494
+ local_boxes = []
1495
+ for sample in line_samples:
1496
+ repair = plan.char_repairs.get((page_index, sample.char_idx))
1497
+ page_bbox = repair.layout_bbox if repair is not None else sample.source_bbox
1498
+ local_boxes.append(_rotate_bbox_to_upright(page_bbox, page_size, line.angle))
1499
+ local_union = _bbox_union_many(local_boxes)
1500
+ layout_bbox = _clip_bbox(
1501
+ _rotate_bbox_from_upright(
1502
+ (local_union[0], local_source[1], local_union[2], local_source[3]),
1503
+ page_size,
1504
+ line.angle,
1505
+ ),
1506
+ page_size,
1507
+ )
1508
+ if layout_bbox is None:
1509
+ continue
1510
+ ink_bbox = _bbox_union_many([sample.tight_bbox for sample in line_samples]) if line_samples else None
1511
+ plan.line_repairs[line_key] = LineGeometryRepair(
1512
+ source_bbox=line.bbox,
1513
+ layout_bbox=layout_bbox,
1514
+ ink_bbox=ink_bbox,
1515
+ baseline=None,
1516
+ em_height=line.effective_height,
1517
+ state="repair_x",
1518
+ confidence=min(repair.confidence for repair in repaired),
1519
+ repaired_char_count=len(repaired),
1520
+ )
1521
+
1522
+
1523
+ def _run_diagnostics(runs: dict[RunKey, _RunStats]) -> list[dict[str, Any]]:
1524
+ """生成不依赖字符对象的 run 级可序列化诊断。"""
1525
+
1526
+ output = []
1527
+ for run in sorted(runs.values(), key=lambda item: item.key):
1528
+ pair_count = len(run.pair_ratios)
1529
+ output.append(
1530
+ {
1531
+ "run_key": list(run.key),
1532
+ "sample_count": len(run.samples),
1533
+ "reliable_pair_count": pair_count,
1534
+ "median_ratio": statistics.median(run.pair_ratios) if pair_count else None,
1535
+ "p90_ratio": _quantile(run.pair_ratios, 0.9) if pair_count else None,
1536
+ "ratio_gt_1_35_share": (
1537
+ sum(value > X_STRONG_RATIO_THRESHOLD for value in run.pair_ratios) / pair_count if pair_count else 0.0
1538
+ ),
1539
+ "next_tight_overlap_share": sum(run.pair_overlaps) / pair_count if pair_count else 0.0,
1540
+ "strong_x_bad": run.strong_x_bad,
1541
+ "sibling_x_bad": run.sibling_x_bad,
1542
+ "style_y_bad": run.style_y_bad,
1543
+ }
1544
+ )
1545
+ return output
1546
+
1547
+
1548
+ def build_document_geometry_plan(
1549
+ lines_by_page: list[list[_LineItem]],
1550
+ geometries: list[PDFPageTextGeometry],
1551
+ page_sizes: list[tuple[float, float]],
1552
+ ) -> DocumentGeometryPlan:
1553
+ """构建文档级 X 修复、Y trim 与 split shadow 计划。"""
1554
+
1555
+ plan = DocumentGeometryPlan()
1556
+ if not any(geometry.tight_bboxes and geometry.origins for geometry in geometries):
1557
+ return plan
1558
+ risk = _document_requires_full_geometry(
1559
+ lines_by_page,
1560
+ geometries,
1561
+ page_sizes,
1562
+ )
1563
+ if not risk.any:
1564
+ for geometry in geometries:
1565
+ geometry.loose_bboxes.clear()
1566
+ return plan
1567
+ samples, by_line = _collect_samples(lines_by_page, geometries, page_sizes)
1568
+ if risk.layout:
1569
+ # 只有布局风险才记录公开输出候选;仅样式异常时只校准内部字号。
1570
+ _record_line_canonical_metrics(plan, by_line)
1571
+ runs = _build_run_stats(samples, by_line)
1572
+ style_inflated_runs = _mark_style_inflated_runs(runs)
1573
+ plan.style_inflated_runs = style_inflated_runs
1574
+ plan.document_style_anomaly = _document_uses_global_style_calibration(
1575
+ style_inflated_runs,
1576
+ )
1577
+ _apply_style_scale_repairs(
1578
+ plan,
1579
+ style_inflated_runs if plan.document_style_anomaly else set(),
1580
+ by_line,
1581
+ )
1582
+ if plan.document_style_anomaly and risk.layout:
1583
+ _restore_stable_legacy_source_bboxes(by_line, page_sizes)
1584
+ runs = _build_run_stats(samples, by_line)
1585
+ for run in runs.values():
1586
+ run.style_y_bad = run.key in style_inflated_runs
1587
+ plan.run_diagnostics = _run_diagnostics(runs)
1588
+ if not risk.layout:
1589
+ return plan
1590
+ x_bad_runs = {run.key for run in runs.values() if run.strong_x_bad or run.sibling_x_bad}
1591
+ if x_bad_runs:
1592
+ _repair_x_chars(plan, runs, by_line, page_sizes)
1593
+ _build_line_repairs_from_x(plan, lines_by_page, by_line, page_sizes)
1594
+ if not _has_document_y_risk(by_line):
1595
+ return plan
1596
+ analyses = _analyze_lines(lines_by_page, by_line, page_sizes)
1597
+ confirmed_runs = _mark_y_candidates(analyses)
1598
+ _repair_y_lines(plan, analyses, confirmed_runs, x_bad_runs, page_sizes)
1599
+ return plan
1600
+
1601
+
1602
+ def apply_line_geometry_repairs(
1603
+ lines: list[_LineItem],
1604
+ *,
1605
+ page_index: int,
1606
+ plan: DocumentGeometryPlan,
1607
+ allow_y_trim: bool,
1608
+ ) -> None:
1609
+ """把文档计划应用到当前仍可参与 Flash 布局的行。"""
1610
+
1611
+ for line in lines:
1612
+ canonical_ink_bbox = plan.line_ink_bboxes.get(
1613
+ (page_index, line.source_index),
1614
+ )
1615
+ if canonical_ink_bbox is not None:
1616
+ line.ink_bbox = canonical_ink_bbox
1617
+ canonical_baseline = plan.line_baselines.get(
1618
+ (page_index, line.source_index),
1619
+ )
1620
+ if canonical_baseline is not None:
1621
+ line.baseline = canonical_baseline
1622
+ style_scale = plan.line_style_scales.get(
1623
+ (page_index, line.source_index),
1624
+ )
1625
+ if style_scale is not None:
1626
+ line.em_height = style_scale
1627
+ line.style_scale_repaired = _line_uses_repaired_style_scale(
1628
+ line,
1629
+ plan.style_inflated_runs,
1630
+ )
1631
+ repair = plan.line_repairs.get((page_index, line.source_index))
1632
+ if repair is None:
1633
+ continue
1634
+ if repair.state in {"trim_y", "repair_xy"} and not allow_y_trim:
1635
+ if repair.state == "trim_y":
1636
+ continue
1637
+ local_state = "repair_x"
1638
+ source = repair.source_bbox
1639
+ layout = (repair.layout_bbox[0], source[1], repair.layout_bbox[2], source[3])
1640
+ else:
1641
+ local_state = repair.state
1642
+ layout = repair.layout_bbox
1643
+ line.source_bbox = repair.source_bbox
1644
+ line.ink_bbox = repair.ink_bbox
1645
+ if repair.baseline is not None:
1646
+ line.baseline = repair.baseline
1647
+ line.geometry_state = local_state
1648
+ line.geometry_confidence = repair.confidence
1649
+ line.split_y_candidate = repair.split_y_candidate
1650
+ line.bbox = layout
1651
+ line.em_height = style_scale if style_scale is not None else repair.em_height
1652
+ if allow_y_trim and repair.state in {"trim_y", "repair_xy"}:
1653
+ line.effective_height = repair.em_height
1654
+
1655
+
1656
+ __all__ = [
1657
+ "CharLayoutGeometry",
1658
+ "DocumentGeometryPlan",
1659
+ "LineGeometryRepair",
1660
+ "apply_line_geometry_repairs",
1661
+ "build_document_geometry_plan",
1662
+ ]