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.
- docvortex/__init__.py +21 -0
- docvortex/analyzers/__init__.py +3 -0
- docvortex/analyzers/native/__init__.py +37 -0
- docvortex/analyzers/native/_shared/__init__.py +3 -0
- docvortex/analyzers/native/_shared/hyperlink.py +16 -0
- docvortex/analyzers/native/_shared/image.py +8 -0
- docvortex/analyzers/native/_shared/markup/__init__.py +47 -0
- docvortex/analyzers/native/_shared/markup/anchors.py +30 -0
- docvortex/analyzers/native/_shared/markup/formula.py +37 -0
- docvortex/analyzers/native/_shared/markup/projector.py +57 -0
- docvortex/analyzers/native/_shared/markup/styles.py +19 -0
- docvortex/analyzers/native/_shared/mathml.py +17 -0
- docvortex/analyzers/native/_shared/names.py +7 -0
- docvortex/analyzers/native/_shared/xycut.py +414 -0
- docvortex/analyzers/native/contracts.py +44 -0
- docvortex/analyzers/native/csv.py +351 -0
- docvortex/analyzers/native/epub/__init__.py +18 -0
- docvortex/analyzers/native/epub/constants.py +45 -0
- docvortex/analyzers/native/epub/converter.py +80 -0
- docvortex/analyzers/native/epub/errors.py +20 -0
- docvortex/analyzers/native/epub/metadata.py +31 -0
- docvortex/analyzers/native/epub/package.py +649 -0
- docvortex/analyzers/native/epub/xhtml.py +306 -0
- docvortex/analyzers/native/html/__init__.py +6 -0
- docvortex/analyzers/native/html/anchors.py +271 -0
- docvortex/analyzers/native/html/constants.py +25 -0
- docvortex/analyzers/native/html/contracts.py +7 -0
- docvortex/analyzers/native/html/converter.py +117 -0
- docvortex/analyzers/native/html/document.py +389 -0
- docvortex/analyzers/native/html/errors.py +12 -0
- docvortex/analyzers/native/html/resources.py +365 -0
- docvortex/analyzers/native/html/selector.py +421 -0
- docvortex/analyzers/native/models.py +209 -0
- docvortex/analyzers/native/ofd/__init__.py +14 -0
- docvortex/analyzers/native/ofd/constants.py +59 -0
- docvortex/analyzers/native/ofd/converter.py +31 -0
- docvortex/analyzers/native/ofd/errors.py +16 -0
- docvortex/analyzers/native/ofd/geometry.py +215 -0
- docvortex/analyzers/native/ofd/images.py +124 -0
- docvortex/analyzers/native/ofd/metadata.py +53 -0
- docvortex/analyzers/native/ofd/models.py +170 -0
- docvortex/analyzers/native/ofd/package.py +346 -0
- docvortex/analyzers/native/ofd/path.py +201 -0
- docvortex/analyzers/native/ofd/reading_order.py +293 -0
- docvortex/analyzers/native/ofd/resources.py +122 -0
- docvortex/analyzers/native/ofd/scene.py +347 -0
- docvortex/analyzers/native/ofd/table.py +240 -0
- docvortex/analyzers/native/ofd/text.py +521 -0
- docvortex/analyzers/native/office/__init__.py +3 -0
- docvortex/analyzers/native/office/doc/__init__.py +3 -0
- docvortex/analyzers/native/office/doc/bookmarks.py +81 -0
- docvortex/analyzers/native/office/doc/doc_converter.py +573 -0
- docvortex/analyzers/native/office/doc/fib.py +261 -0
- docvortex/analyzers/native/office/doc/fields.py +101 -0
- docvortex/analyzers/native/office/doc/formatting.py +171 -0
- docvortex/analyzers/native/office/doc/images.py +162 -0
- docvortex/analyzers/native/office/doc/lists.py +356 -0
- docvortex/analyzers/native/office/doc/models.py +164 -0
- docvortex/analyzers/native/office/doc/parser.py +851 -0
- docvortex/analyzers/native/office/doc/pieces.py +256 -0
- docvortex/analyzers/native/office/doc/records.py +40 -0
- docvortex/analyzers/native/office/doc/sprm.py +269 -0
- docvortex/analyzers/native/office/doc/styles.py +215 -0
- docvortex/analyzers/native/office/docx/__init__.py +3 -0
- docvortex/analyzers/native/office/docx/context.py +71 -0
- docvortex/analyzers/native/office/docx/docx_converter.py +614 -0
- docvortex/analyzers/native/office/docx/equationxml.py +119 -0
- docvortex/analyzers/native/office/docx/fields.py +816 -0
- docvortex/analyzers/native/office/docx/formatting_types.py +23 -0
- docvortex/analyzers/native/office/docx/main.py +50 -0
- docvortex/analyzers/native/office/docx/numbering.py +491 -0
- docvortex/analyzers/native/office/docx/office_xml.py +57 -0
- docvortex/analyzers/native/office/docx/package_normalizer.py +248 -0
- docvortex/analyzers/native/office/docx/resources.py +526 -0
- docvortex/analyzers/native/office/docx/styles.py +693 -0
- docvortex/analyzers/native/office/docx/tables.py +515 -0
- docvortex/analyzers/native/office/equation/__init__.py +3 -0
- docvortex/analyzers/native/office/equation/image.py +470 -0
- docvortex/analyzers/native/office/equation/latex_dict.py +324 -0
- docvortex/analyzers/native/office/equation/mtef.py +885 -0
- docvortex/analyzers/native/office/equation/mtef_v5.py +941 -0
- docvortex/analyzers/native/office/equation/omml.py +561 -0
- docvortex/analyzers/native/office/equation/ooxml.py +62 -0
- docvortex/analyzers/native/office/errors.py +33 -0
- docvortex/analyzers/native/office/image.py +307 -0
- docvortex/analyzers/native/office/legacy/__init__.py +3 -0
- docvortex/analyzers/native/office/legacy/binary.py +43 -0
- docvortex/analyzers/native/office/legacy/officeart.py +362 -0
- docvortex/analyzers/native/office/legacy/ole.py +110 -0
- docvortex/analyzers/native/office/limits.py +12 -0
- docvortex/analyzers/native/office/odf/__init__.py +3 -0
- docvortex/analyzers/native/office/odf/chart.py +76 -0
- docvortex/analyzers/native/office/odf/constants.py +70 -0
- docvortex/analyzers/native/office/odf/converters.py +403 -0
- docvortex/analyzers/native/office/odf/errors.py +18 -0
- docvortex/analyzers/native/office/odf/metadata.py +91 -0
- docvortex/analyzers/native/office/odf/models.py +176 -0
- docvortex/analyzers/native/office/odf/package.py +270 -0
- docvortex/analyzers/native/office/odf/styles.py +329 -0
- docvortex/analyzers/native/office/odf/table.py +469 -0
- docvortex/analyzers/native/office/odf/text.py +1002 -0
- docvortex/analyzers/native/office/ooxml_chart.py +1016 -0
- docvortex/analyzers/native/office/opc.py +38 -0
- docvortex/analyzers/native/office/ppt/__init__.py +3 -0
- docvortex/analyzers/native/office/ppt/models.py +118 -0
- docvortex/analyzers/native/office/ppt/parser.py +1895 -0
- docvortex/analyzers/native/office/ppt/ppt_converter.py +292 -0
- docvortex/analyzers/native/office/ppt/records.py +131 -0
- docvortex/analyzers/native/office/ppt/style_text.py +247 -0
- docvortex/analyzers/native/office/pptx/__init__.py +3 -0
- docvortex/analyzers/native/office/pptx/context.py +113 -0
- docvortex/analyzers/native/office/pptx/lists.py +558 -0
- docvortex/analyzers/native/office/pptx/main.py +20 -0
- docvortex/analyzers/native/office/pptx/package_normalizer.py +321 -0
- docvortex/analyzers/native/office/pptx/pptx_converter.py +323 -0
- docvortex/analyzers/native/office/pptx/resources.py +329 -0
- docvortex/analyzers/native/office/pptx/shapes.py +393 -0
- docvortex/analyzers/native/office/pptx/text_styles.py +625 -0
- docvortex/analyzers/native/office/pptx/titles.py +178 -0
- docvortex/analyzers/native/office/rich_text.py +420 -0
- docvortex/analyzers/native/office/rtf/__init__.py +3 -0
- docvortex/analyzers/native/office/rtf/converter.py +708 -0
- docvortex/analyzers/native/office/rtf/lexer.py +213 -0
- docvortex/analyzers/native/office/rtf/math.py +339 -0
- docvortex/analyzers/native/office/rtf/models.py +185 -0
- docvortex/analyzers/native/office/rtf/parser.py +1553 -0
- docvortex/analyzers/native/office/spreadsheet/__init__.py +3 -0
- docvortex/analyzers/native/office/spreadsheet/html.py +81 -0
- docvortex/analyzers/native/office/spreadsheet/models.py +79 -0
- docvortex/analyzers/native/office/spreadsheet/projector.py +928 -0
- docvortex/analyzers/native/office/streams.py +18 -0
- docvortex/analyzers/native/office/xls/__init__.py +3 -0
- docvortex/analyzers/native/office/xls/chart.py +132 -0
- docvortex/analyzers/native/office/xls/embedded_chart.py +299 -0
- docvortex/analyzers/native/office/xls/models.py +109 -0
- docvortex/analyzers/native/office/xls/number_format.py +521 -0
- docvortex/analyzers/native/office/xls/parser.py +1145 -0
- docvortex/analyzers/native/office/xls/records.py +201 -0
- docvortex/analyzers/native/office/xls/strings.py +205 -0
- docvortex/analyzers/native/office/xls/xls_converter.py +354 -0
- docvortex/analyzers/native/office/xlsx/__init__.py +3 -0
- docvortex/analyzers/native/office/xlsx/main.py +20 -0
- docvortex/analyzers/native/office/xlsx/ooxml_ole.py +522 -0
- docvortex/analyzers/native/office/xlsx/package_normalizer.py +310 -0
- docvortex/analyzers/native/office/xlsx/xlsx_converter.py +716 -0
- docvortex/analyzers/native/pdf/__init__.py +3 -0
- docvortex/analyzers/native/pdf/auxiliary_text.py +1671 -0
- docvortex/analyzers/native/pdf/char_geometry.py +1662 -0
- docvortex/analyzers/native/pdf/code_blocks.py +535 -0
- docvortex/analyzers/native/pdf/formulas.py +1985 -0
- docvortex/analyzers/native/pdf/geometry.py +281 -0
- docvortex/analyzers/native/pdf/graphics.py +1501 -0
- docvortex/analyzers/native/pdf/index_blocks.py +268 -0
- docvortex/analyzers/native/pdf/inline/__init__.py +3 -0
- docvortex/analyzers/native/pdf/inline/common.py +112 -0
- docvortex/analyzers/native/pdf/inline/detection.py +590 -0
- docvortex/analyzers/native/pdf/inline/matching.py +1185 -0
- docvortex/analyzers/native/pdf/inline/materialize.py +457 -0
- docvortex/analyzers/native/pdf/inline/scripts.py +975 -0
- docvortex/analyzers/native/pdf/inline/types.py +385 -0
- docvortex/analyzers/native/pdf/line_layout.py +1106 -0
- docvortex/analyzers/native/pdf/line_merging.py +1223 -0
- docvortex/analyzers/native/pdf/models.py +246 -0
- docvortex/analyzers/native/pdf/native_text.py +1004 -0
- docvortex/analyzers/native/pdf/pipeline.py +1390 -0
- docvortex/analyzers/native/pdf/script_geometry.py +636 -0
- docvortex/analyzers/native/pdf/shared.py +30 -0
- docvortex/analyzers/native/pdf/spatial_text.py +383 -0
- docvortex/analyzers/native/pdf/table_annotations.py +446 -0
- docvortex/analyzers/native/pdf/table_constants.py +48 -0
- docvortex/analyzers/native/pdf/table_detection.py +227 -0
- docvortex/analyzers/native/pdf/table_filled_grid.py +218 -0
- docvortex/analyzers/native/pdf/table_geometry.py +40 -0
- docvortex/analyzers/native/pdf/table_materialization.py +444 -0
- docvortex/analyzers/native/pdf/table_recovery/__init__.py +15 -0
- docvortex/analyzers/native/pdf/table_recovery/candidate.py +356 -0
- docvortex/analyzers/native/pdf/table_recovery/contracts.py +154 -0
- docvortex/analyzers/native/pdf/table_recovery/engine.py +609 -0
- docvortex/analyzers/native/pdf/table_recovery/geometry.py +164 -0
- docvortex/analyzers/native/pdf/table_recovery/sparse_common.py +85 -0
- docvortex/analyzers/native/pdf/table_recovery/sparse_hybrid.py +804 -0
- docvortex/analyzers/native/pdf/table_recovery/sparse_multiline.py +1122 -0
- docvortex/analyzers/native/pdf/table_recovery/text.py +414 -0
- docvortex/analyzers/native/pdf/table_recovery/text_grid.py +618 -0
- docvortex/analyzers/native/pdf/table_recovery/vector.py +1931 -0
- docvortex/analyzers/native/pdf/table_rows.py +34 -0
- docvortex/analyzers/native/pdf/table_rules.py +1129 -0
- docvortex/analyzers/native/pdf/table_text_styles.py +283 -0
- docvortex/analyzers/native/pdf/tables.py +147 -0
- docvortex/analyzers/native/pdf/text_assembly/__init__.py +3 -0
- docvortex/analyzers/native/pdf/text_assembly/annotations.py +581 -0
- docvortex/analyzers/native/pdf/text_assembly/assembly.py +292 -0
- docvortex/analyzers/native/pdf/text_assembly/common.py +477 -0
- docvortex/analyzers/native/pdf/text_assembly/footnotes.py +394 -0
- docvortex/analyzers/native/pdf/text_assembly/merging.py +1274 -0
- docvortex/analyzers/native/pdf/text_assembly/rows.py +692 -0
- docvortex/analyzers/native/pdf/text_blocks.py +82 -0
- docvortex/analyzers/native/pdf/text_styles.py +55 -0
- docvortex/analyzers/native/pdf/title_analysis/__init__.py +3 -0
- docvortex/analyzers/native/pdf/title_analysis/body_profile.py +215 -0
- docvortex/analyzers/native/pdf/title_analysis/common.py +117 -0
- docvortex/analyzers/native/pdf/title_analysis/document_profile.py +164 -0
- docvortex/analyzers/native/pdf/title_analysis/lane_titles.py +758 -0
- docvortex/analyzers/native/pdf/title_analysis/page_titles.py +1024 -0
- docvortex/analyzers/native/pdf/title_analysis/prototype.py +194 -0
- docvortex/analyzers/native/pdf/title_analysis/structural.py +1081 -0
- docvortex/analyzers/native/pdf/titles.py +75 -0
- docvortex/analyzers/native/pdf/typography.py +19 -0
- docvortex/analyzers/native/pdf/visual_annotations.py +1262 -0
- docvortex/api.py +180 -0
- docvortex/assets/__init__.py +5 -0
- docvortex/assets/store.py +51 -0
- docvortex/cli.py +54 -0
- docvortex/codecs/__init__.py +3 -0
- docvortex/codecs/html/__init__.py +22 -0
- docvortex/codecs/html/contracts.py +236 -0
- docvortex/codecs/html/materializer.py +331 -0
- docvortex/codecs/html/parser.py +763 -0
- docvortex/codecs/html/resources.py +34 -0
- docvortex/codecs/json.py +17 -0
- docvortex/content/__init__.py +5 -0
- docvortex/content/inline.py +248 -0
- docvortex/content/markup/__init__.py +44 -0
- docvortex/content/markup/anchors.py +188 -0
- docvortex/content/markup/formula.py +280 -0
- docvortex/content/markup/projector.py +1237 -0
- docvortex/content/markup/styles.py +327 -0
- docvortex/content/mathml.py +167 -0
- docvortex/content/normalization.py +188 -0
- docvortex/content/spans.py +183 -0
- docvortex/content/table/__init__.py +18 -0
- docvortex/content/table/blocks.py +152 -0
- docvortex/content/table/content.py +425 -0
- docvortex/content/table/document.py +104 -0
- docvortex/content/table/html.py +399 -0
- docvortex/content/table/models.py +76 -0
- docvortex/content/table/rules.py +42 -0
- docvortex/content/table/structure.py +221 -0
- docvortex/content/tree.py +5 -0
- docvortex/document/__init__.py +3 -0
- docvortex/document/contracts.py +22 -0
- docvortex/document/detection.py +389 -0
- docvortex/document/filetypes.py +175 -0
- docvortex/document/page_range.py +167 -0
- docvortex/document/pdf/__init__.py +19 -0
- docvortex/document/pdf/classify.py +1138 -0
- docvortex/document/pdf/constants.py +132 -0
- docvortex/document/pdf/diagnostics.py +350 -0
- docvortex/document/pdf/document.py +582 -0
- docvortex/document/pdf/font_runtime.py +335 -0
- docvortex/document/pdf/geometry.py +31 -0
- docvortex/document/pdf/images.py +654 -0
- docvortex/document/pdf/native_annotations.py +367 -0
- docvortex/document/pdf/native_contracts.py +169 -0
- docvortex/document/pdf/native_coordinates.py +216 -0
- docvortex/document/pdf/native_lifecycle.py +16 -0
- docvortex/document/pdf/native_objects.py +902 -0
- docvortex/document/pdf/native_text_geometry.py +315 -0
- docvortex/document/pdf/pdfium.py +325 -0
- docvortex/document/pdf/raster.py +46 -0
- docvortex/document/pdf/text/__init__.py +62 -0
- docvortex/document/pdf/text/contracts.py +211 -0
- docvortex/document/pdf/text/extract.py +165 -0
- docvortex/document/pdf/text/geometry.py +16 -0
- docvortex/document/pdf/text/groups.py +162 -0
- docvortex/document/pdf/visual_geometry.py +201 -0
- docvortex/document/pdf/visuals.py +343 -0
- docvortex/document/source.py +92 -0
- docvortex/errors.py +21 -0
- docvortex/export/__init__.py +3 -0
- docvortex/export/bundle.py +98 -0
- docvortex/export/files.py +66 -0
- docvortex/export/middle.py +208 -0
- docvortex/foundation/__init__.py +3 -0
- docvortex/foundation/geometry.py +125 -0
- docvortex/foundation/hyperlink.py +65 -0
- docvortex/foundation/image.py +48 -0
- docvortex/foundation/image_encoding.py +30 -0
- docvortex/foundation/image_payload.py +280 -0
- docvortex/foundation/language.py +92 -0
- docvortex/foundation/platform.py +38 -0
- docvortex/foundation/text.py +153 -0
- docvortex/foundation/type_identity.py +20 -0
- docvortex/foundation/xml_names.py +20 -0
- docvortex/options.py +30 -0
- docvortex/postprocess/__init__.py +3 -0
- docvortex/postprocess/content.py +53 -0
- docvortex/postprocess/document.py +19 -0
- docvortex/postprocess/lists.py +236 -0
- docvortex/postprocess/page_blocks.py +214 -0
- docvortex/postprocess/pages.py +95 -0
- docvortex/postprocess/paragraphs.py +580 -0
- docvortex/postprocess/visual.py +715 -0
- docvortex/render/__init__.py +48 -0
- docvortex/render/_internal/__init__.py +3 -0
- docvortex/render/_internal/common/__init__.py +3 -0
- docvortex/render/_internal/common/context.py +43 -0
- docvortex/render/_internal/common/html_table.py +178 -0
- docvortex/render/_internal/common/index.py +33 -0
- docvortex/render/_internal/common/list_items.py +158 -0
- docvortex/render/_internal/common/planner.py +140 -0
- docvortex/render/_internal/docx/__init__.py +3 -0
- docvortex/render/_internal/docx/assets.py +202 -0
- docvortex/render/_internal/docx/inline.py +434 -0
- docvortex/render/_internal/docx/math.py +220 -0
- docvortex/render/_internal/docx/renderer.py +905 -0
- docvortex/render/_internal/docx/styles.py +195 -0
- docvortex/render/_internal/docx/table.py +442 -0
- docvortex/render/_internal/epub/__init__.py +5 -0
- docvortex/render/_internal/epub/assets.py +173 -0
- docvortex/render/_internal/epub/package.py +249 -0
- docvortex/render/_internal/epub/renderer.py +1156 -0
- docvortex/render/_internal/html/__init__.py +3 -0
- docvortex/render/_internal/html/inline.py +346 -0
- docvortex/render/_internal/html/renderer.py +1041 -0
- docvortex/render/_internal/html/sanitizer.py +478 -0
- docvortex/render/_internal/html/table.py +121 -0
- docvortex/render/_internal/latex/__init__.py +1 -0
- docvortex/render/_internal/latex/assets.py +85 -0
- docvortex/render/_internal/latex/inline.py +145 -0
- docvortex/render/_internal/latex/renderer.py +506 -0
- docvortex/render/_internal/latex/table.py +347 -0
- docvortex/render/_internal/markdown/__init__.py +3 -0
- docvortex/render/_internal/markdown/assets.py +78 -0
- docvortex/render/_internal/markdown/blocks.py +635 -0
- docvortex/render/_internal/markdown/escaping.py +51 -0
- docvortex/render/_internal/markdown/inline.py +260 -0
- docvortex/render/_internal/markdown/renderer.py +93 -0
- docvortex/render/_internal/markdown/table.py +281 -0
- docvortex/render/_internal/pdf/__init__.py +3 -0
- docvortex/render/_internal/pdf/assets.py +197 -0
- docvortex/render/_internal/pdf/formula.py +417 -0
- docvortex/render/_internal/pdf/inline.py +343 -0
- docvortex/render/_internal/pdf/renderer.py +734 -0
- docvortex/render/_internal/pdf/styles.py +206 -0
- docvortex/render/_internal/pdf/table.py +272 -0
- docvortex/render/_internal/structured_content/__init__.py +3 -0
- docvortex/render/_internal/structured_content/renderer.py +193 -0
- docvortex/render/api.py +199 -0
- docvortex/render/contracts.py +205 -0
- docvortex/render/docx.py +38 -0
- docvortex/render/epub.py +35 -0
- docvortex/render/fragments.py +70 -0
- docvortex/render/html.py +29 -0
- docvortex/render/latex.py +24 -0
- docvortex/render/markdown.py +50 -0
- docvortex/render/pdf.py +25 -0
- docvortex/render/structured_content.py +23 -0
- docvortex/resources/epub/docvortex.css +91 -0
- docvortex/resources/fasttext-langdetect/lid.176.ftz +0 -0
- docvortex/resources/fonts/DroidSansFallbackFull.ttf +0 -0
- docvortex/resources/fonts/NOTICE +190 -0
- docvortex/resources/fonts/manifest.json +9 -0
- docvortex/resources/html/docvortex.css +601 -0
- docvortex/resources/html/docvortex.min.css +1 -0
- docvortex/result.py +91 -0
- docvortex/schema.py +1141 -0
- docvortex/version.py +3 -0
- docvortex-0.2.1.dist-info/METADATA +193 -0
- docvortex-0.2.1.dist-info/RECORD +364 -0
- docvortex-0.2.1.dist-info/WHEEL +5 -0
- docvortex-0.2.1.dist-info/entry_points.txt +2 -0
- docvortex-0.2.1.dist-info/licenses/LICENSE.md +21 -0
- 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
|