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,1129 @@
|
|
|
1
|
+
"""PDF 规则线与文本行候选;保留原有认领顺序与判定规则。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import statistics
|
|
5
|
+
from typing import Any
|
|
6
|
+
from ....schema import BBox
|
|
7
|
+
from ....document.pdf.document import PDFPathInfo
|
|
8
|
+
from .models import _Fragment, _LineItem, _LocalAxisLine, _PageSource, _TableCandidate, _VisualRow
|
|
9
|
+
from .geometry import (
|
|
10
|
+
_bbox_area,
|
|
11
|
+
_bbox_axis_overlap_ratio,
|
|
12
|
+
_bbox_center_x,
|
|
13
|
+
_bbox_center_y,
|
|
14
|
+
_bbox_overlap_in_smaller,
|
|
15
|
+
_bbox_union,
|
|
16
|
+
_bbox_union_many,
|
|
17
|
+
_point_in_bbox,
|
|
18
|
+
_rotate_bbox_from_upright,
|
|
19
|
+
_rotate_bbox_to_upright,
|
|
20
|
+
_transform_axis_lines,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .table_annotations import (
|
|
24
|
+
_build_table_annotation,
|
|
25
|
+
_collect_caption_rows,
|
|
26
|
+
_collect_footnote_rows,
|
|
27
|
+
_find_table_caption,
|
|
28
|
+
_merge_table_candidate_annotations,
|
|
29
|
+
)
|
|
30
|
+
from .table_rows import _clip_visual_row_to_corridor
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_fragments(
|
|
34
|
+
lines: list[_LineItem],
|
|
35
|
+
page_size: tuple[float, float],
|
|
36
|
+
) -> list[_Fragment]:
|
|
37
|
+
"""将精修后的原生 run 转换成表格单元候选。"""
|
|
38
|
+
|
|
39
|
+
fragments: list[_Fragment] = []
|
|
40
|
+
for line in lines:
|
|
41
|
+
local_bbox = _rotate_bbox_to_upright(line.bbox, page_size, line.angle)
|
|
42
|
+
fragments.append(
|
|
43
|
+
_Fragment(
|
|
44
|
+
text=line.text,
|
|
45
|
+
bbox=line.bbox,
|
|
46
|
+
local_bbox=local_bbox,
|
|
47
|
+
line_index=line.source_index,
|
|
48
|
+
# 复用原生粗行身份,避免同一字符行内不同 cell
|
|
49
|
+
# 因轻微基线差异被误拆成多行。
|
|
50
|
+
visual_row_id=line.visual_row_id,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return fragments
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cluster_fragment_rows(
|
|
57
|
+
fragments: list[_Fragment],
|
|
58
|
+
median_height: float,
|
|
59
|
+
) -> list[_VisualRow]:
|
|
60
|
+
"""优先复用原生视觉行身份,其余片段按中心线容差聚成表格行。"""
|
|
61
|
+
|
|
62
|
+
tolerance = max(2.0, median_height * 0.5)
|
|
63
|
+
native_groups: dict[int, list[_Fragment]] = {}
|
|
64
|
+
geometric_fragments: list[_Fragment] = []
|
|
65
|
+
for fragment in fragments:
|
|
66
|
+
if fragment.visual_row_id is None:
|
|
67
|
+
geometric_fragments.append(fragment)
|
|
68
|
+
else:
|
|
69
|
+
native_groups.setdefault(fragment.visual_row_id, []).append(fragment)
|
|
70
|
+
|
|
71
|
+
# 先锁定同一原生粗行拆出的 run,再允许不同粗行按基线几何合并;
|
|
72
|
+
# 旋转表格常把同一数据行的各 cell 分成多个 pdftext 粗行,不能只依赖 row id。
|
|
73
|
+
seed_groups = [*native_groups.values(), *[[fragment] for fragment in geometric_fragments]]
|
|
74
|
+
seed_groups.sort(
|
|
75
|
+
key=lambda group: (
|
|
76
|
+
statistics.fmean(_bbox_center_y(item.local_bbox) for item in group),
|
|
77
|
+
min(item.local_bbox[0] for item in group),
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
grouped: list[list[_Fragment]] = []
|
|
81
|
+
for seed_group in seed_groups:
|
|
82
|
+
center_y = statistics.fmean(_bbox_center_y(item.local_bbox) for item in seed_group)
|
|
83
|
+
target_group: list[_Fragment] | None = None
|
|
84
|
+
for group in grouped:
|
|
85
|
+
group_center = statistics.fmean(_bbox_center_y(item.local_bbox) for item in group)
|
|
86
|
+
if abs(center_y - group_center) <= tolerance:
|
|
87
|
+
target_group = group
|
|
88
|
+
break
|
|
89
|
+
if target_group is None:
|
|
90
|
+
grouped.append(list(seed_group))
|
|
91
|
+
else:
|
|
92
|
+
target_group.extend(seed_group)
|
|
93
|
+
|
|
94
|
+
rows: list[_VisualRow] = []
|
|
95
|
+
for group in grouped:
|
|
96
|
+
group.sort(key=lambda item: item.local_bbox[0])
|
|
97
|
+
bbox = _bbox_union_many([item.local_bbox for item in group])
|
|
98
|
+
visual_row_ids = {item.visual_row_id for item in group if item.visual_row_id is not None}
|
|
99
|
+
rows.append(
|
|
100
|
+
_VisualRow(
|
|
101
|
+
fragments=group,
|
|
102
|
+
center_y=sum(_bbox_center_y(item.local_bbox) for item in group) / len(group),
|
|
103
|
+
bbox=bbox,
|
|
104
|
+
visual_row_id=next(iter(visual_row_ids)) if len(visual_row_ids) == 1 else None,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
rows.sort(key=lambda row: row.center_y)
|
|
108
|
+
return rows
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_rule_table_candidates(
|
|
112
|
+
rows: list[_VisualRow],
|
|
113
|
+
lines: list[_LineItem],
|
|
114
|
+
page_size: tuple[float, float],
|
|
115
|
+
angle: int,
|
|
116
|
+
median_height: float,
|
|
117
|
+
axis_lines: list[_LocalAxisLine],
|
|
118
|
+
*,
|
|
119
|
+
path_infos: list[PDFPathInfo] | None = None,
|
|
120
|
+
excluded_bboxes: list[BBox] | None = None,
|
|
121
|
+
) -> list[_TableCandidate]:
|
|
122
|
+
"""枚举同跨度横线边界区间,再以连续多列文本分布确认表格。"""
|
|
123
|
+
|
|
124
|
+
candidates: list[_TableCandidate] = []
|
|
125
|
+
path_infos = path_infos or []
|
|
126
|
+
excluded_bboxes = excluded_bboxes or []
|
|
127
|
+
for rule_group in _group_long_horizontal_rules(axis_lines, median_height):
|
|
128
|
+
for first_index, top_rule in enumerate(rule_group[:-1]):
|
|
129
|
+
for bottom_index in range(first_index + 1, len(rule_group)):
|
|
130
|
+
bottom_rule = rule_group[bottom_index]
|
|
131
|
+
interval_rules = rule_group[first_index : bottom_index + 1]
|
|
132
|
+
boundary_rules = [top_rule, bottom_rule]
|
|
133
|
+
rule_bbox = _bbox_union_many([line.bbox for line in boundary_rules])
|
|
134
|
+
core_rows = _rows_inside_rule_interval(
|
|
135
|
+
rows,
|
|
136
|
+
rule_bbox,
|
|
137
|
+
excluded_bboxes,
|
|
138
|
+
)
|
|
139
|
+
caption_line = _find_table_caption(
|
|
140
|
+
lines,
|
|
141
|
+
rule_bbox,
|
|
142
|
+
page_size,
|
|
143
|
+
angle,
|
|
144
|
+
median_height,
|
|
145
|
+
)
|
|
146
|
+
caption_anchored_compact_grid = (
|
|
147
|
+
caption_line is not None
|
|
148
|
+
and len(interval_rules) >= 3
|
|
149
|
+
and rule_bbox[3] - rule_bbox[1] <= 0.15 * (page_size[0] if angle in {90, 270} else page_size[1])
|
|
150
|
+
)
|
|
151
|
+
if (
|
|
152
|
+
not _every_rule_interval_has_multi_cell_row(
|
|
153
|
+
core_rows,
|
|
154
|
+
interval_rules,
|
|
155
|
+
median_height,
|
|
156
|
+
)
|
|
157
|
+
and not caption_anchored_compact_grid
|
|
158
|
+
):
|
|
159
|
+
continue
|
|
160
|
+
if (
|
|
161
|
+
not _rule_intervals_are_column_compatible(
|
|
162
|
+
core_rows,
|
|
163
|
+
interval_rules,
|
|
164
|
+
median_height,
|
|
165
|
+
)
|
|
166
|
+
and not caption_anchored_compact_grid
|
|
167
|
+
):
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
fill_band_count = _count_repeated_fill_bands(
|
|
171
|
+
path_infos,
|
|
172
|
+
rule_bbox,
|
|
173
|
+
page_size,
|
|
174
|
+
angle,
|
|
175
|
+
median_height,
|
|
176
|
+
)
|
|
177
|
+
aligned_vertical_count = _count_aligned_vertical_rules(
|
|
178
|
+
axis_lines,
|
|
179
|
+
rule_bbox,
|
|
180
|
+
median_height,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
row_segments = _continuous_table_row_segments(core_rows, median_height)
|
|
184
|
+
accepted: tuple[list[_VisualRow], list[_VisualRow], int, float] | None = None
|
|
185
|
+
for row_segment in row_segments:
|
|
186
|
+
dense_rows = [row for row in row_segment if len(row.fragments) >= 2]
|
|
187
|
+
compact_grid_columns = (
|
|
188
|
+
_compact_fully_ruled_grid_column_count(
|
|
189
|
+
row_segment,
|
|
190
|
+
dense_rows,
|
|
191
|
+
interval_rules,
|
|
192
|
+
axis_lines,
|
|
193
|
+
rule_bbox,
|
|
194
|
+
median_height,
|
|
195
|
+
)
|
|
196
|
+
if len(dense_rows) == 2
|
|
197
|
+
else 0
|
|
198
|
+
)
|
|
199
|
+
stable_columns, column_coverage = _count_stable_columns(
|
|
200
|
+
dense_rows,
|
|
201
|
+
median_height,
|
|
202
|
+
)
|
|
203
|
+
if compact_grid_columns > 0:
|
|
204
|
+
# 两行样本容易把左右/中心锚点误算成不同稳定列,使用物理网格列数。
|
|
205
|
+
stable_columns = compact_grid_columns
|
|
206
|
+
caption_supported_compact_rows = (
|
|
207
|
+
caption_anchored_compact_grid
|
|
208
|
+
and len(dense_rows) >= 2
|
|
209
|
+
and stable_columns >= 3
|
|
210
|
+
and column_coverage >= 0.5
|
|
211
|
+
)
|
|
212
|
+
if len(dense_rows) < 3 and compact_grid_columns == 0 and not caption_supported_compact_rows:
|
|
213
|
+
continue
|
|
214
|
+
# 真表格的多单元行会在整个数据带内反复出现;少数图题、图例和
|
|
215
|
+
# 坐标刻度偶然形成的多列行不能支撑一大片正文区域。
|
|
216
|
+
if len(dense_rows) / len(row_segment) < 0.2:
|
|
217
|
+
continue
|
|
218
|
+
if stable_columns < 2 or column_coverage < 0.5:
|
|
219
|
+
continue
|
|
220
|
+
if _looks_like_page_column_prose(
|
|
221
|
+
row_segment,
|
|
222
|
+
dense_rows,
|
|
223
|
+
stable_columns,
|
|
224
|
+
fill_band_count,
|
|
225
|
+
aligned_vertical_count,
|
|
226
|
+
rule_bbox,
|
|
227
|
+
):
|
|
228
|
+
continue
|
|
229
|
+
if not _table_segment_reaches_boundaries(
|
|
230
|
+
row_segment,
|
|
231
|
+
rule_bbox,
|
|
232
|
+
median_height,
|
|
233
|
+
):
|
|
234
|
+
continue
|
|
235
|
+
if not _table_rows_align_with_rule_span(
|
|
236
|
+
row_segment,
|
|
237
|
+
rule_bbox,
|
|
238
|
+
median_height,
|
|
239
|
+
):
|
|
240
|
+
continue
|
|
241
|
+
result = (
|
|
242
|
+
row_segment,
|
|
243
|
+
dense_rows,
|
|
244
|
+
stable_columns,
|
|
245
|
+
column_coverage,
|
|
246
|
+
)
|
|
247
|
+
if accepted is None or (
|
|
248
|
+
len(row_segment),
|
|
249
|
+
len(dense_rows),
|
|
250
|
+
stable_columns,
|
|
251
|
+
column_coverage,
|
|
252
|
+
) > (
|
|
253
|
+
len(accepted[0]),
|
|
254
|
+
len(accepted[1]),
|
|
255
|
+
accepted[2],
|
|
256
|
+
accepted[3],
|
|
257
|
+
):
|
|
258
|
+
accepted = result
|
|
259
|
+
if accepted is None:
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
accepted_rows, dense_rows, stable_columns, _coverage = accepted
|
|
263
|
+
candidate = _expand_rule_table_candidate(
|
|
264
|
+
boundary_rules,
|
|
265
|
+
accepted_rows,
|
|
266
|
+
rows,
|
|
267
|
+
lines,
|
|
268
|
+
page_size,
|
|
269
|
+
angle,
|
|
270
|
+
median_height,
|
|
271
|
+
caption_line,
|
|
272
|
+
)
|
|
273
|
+
candidate.score = float(2 + len(dense_rows) + stable_columns + min(fill_band_count, 8))
|
|
274
|
+
candidates.append(candidate)
|
|
275
|
+
return _expand_candidates_to_connected_rule_grids(
|
|
276
|
+
candidates,
|
|
277
|
+
rows,
|
|
278
|
+
page_size,
|
|
279
|
+
angle,
|
|
280
|
+
median_height,
|
|
281
|
+
axis_lines,
|
|
282
|
+
excluded_bboxes,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _expand_candidates_to_connected_rule_grids(
|
|
287
|
+
candidates: list[_TableCandidate],
|
|
288
|
+
rows: list[_VisualRow],
|
|
289
|
+
page_size: tuple[float, float],
|
|
290
|
+
angle: int,
|
|
291
|
+
median_height: float,
|
|
292
|
+
axis_lines: list[_LocalAxisLine],
|
|
293
|
+
excluded_bboxes: list[BBox],
|
|
294
|
+
) -> list[_TableCandidate]:
|
|
295
|
+
"""把已确认候选沿连续横边界和贯穿竖轨扩展到完整物理网格。"""
|
|
296
|
+
|
|
297
|
+
grid_bboxes = [
|
|
298
|
+
grid_bbox
|
|
299
|
+
for grid_bbox in _connected_rule_grid_bboxes(axis_lines, median_height)
|
|
300
|
+
if not any(_bbox_overlap_in_smaller(grid_bbox, excluded_bbox) >= 0.5 for excluded_bbox in excluded_bboxes)
|
|
301
|
+
]
|
|
302
|
+
if not grid_bboxes:
|
|
303
|
+
return candidates
|
|
304
|
+
|
|
305
|
+
tolerance = max(2.0, median_height)
|
|
306
|
+
for candidate in candidates:
|
|
307
|
+
if candidate.core_bbox is None:
|
|
308
|
+
continue
|
|
309
|
+
core_local_bbox = _rotate_bbox_to_upright(
|
|
310
|
+
candidate.core_bbox,
|
|
311
|
+
page_size,
|
|
312
|
+
angle,
|
|
313
|
+
)
|
|
314
|
+
matches = [
|
|
315
|
+
grid_bbox
|
|
316
|
+
for grid_bbox in grid_bboxes
|
|
317
|
+
if _bbox_axis_overlap_ratio(
|
|
318
|
+
core_local_bbox,
|
|
319
|
+
grid_bbox,
|
|
320
|
+
axis="x",
|
|
321
|
+
)
|
|
322
|
+
>= 0.9
|
|
323
|
+
and core_local_bbox[3] >= grid_bbox[1] - tolerance
|
|
324
|
+
and core_local_bbox[1] <= grid_bbox[3] + tolerance
|
|
325
|
+
]
|
|
326
|
+
if not matches:
|
|
327
|
+
continue
|
|
328
|
+
grid_bbox = max(
|
|
329
|
+
matches,
|
|
330
|
+
key=lambda bbox: (
|
|
331
|
+
min(core_local_bbox[3], bbox[3]) - max(core_local_bbox[1], bbox[1]),
|
|
332
|
+
_bbox_area(bbox),
|
|
333
|
+
),
|
|
334
|
+
)
|
|
335
|
+
expanded_core_bbox = _bbox_union(core_local_bbox, grid_bbox)
|
|
336
|
+
candidate.local_bbox = _bbox_union(candidate.local_bbox, grid_bbox)
|
|
337
|
+
candidate.core_bbox = _rotate_bbox_from_upright(
|
|
338
|
+
expanded_core_bbox,
|
|
339
|
+
page_size,
|
|
340
|
+
angle,
|
|
341
|
+
)
|
|
342
|
+
candidate.bbox = _rotate_bbox_from_upright(
|
|
343
|
+
candidate.local_bbox,
|
|
344
|
+
page_size,
|
|
345
|
+
angle,
|
|
346
|
+
)
|
|
347
|
+
for row in rows:
|
|
348
|
+
if not expanded_core_bbox[1] <= row.center_y <= expanded_core_bbox[3]:
|
|
349
|
+
continue
|
|
350
|
+
candidate.line_indices.update(
|
|
351
|
+
fragment.line_index
|
|
352
|
+
for fragment in row.fragments
|
|
353
|
+
if _point_in_bbox(
|
|
354
|
+
(
|
|
355
|
+
_bbox_center_x(fragment.local_bbox),
|
|
356
|
+
_bbox_center_y(fragment.local_bbox),
|
|
357
|
+
),
|
|
358
|
+
expanded_core_bbox,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
for annotation in candidate.annotations:
|
|
362
|
+
candidate.line_indices.difference_update(annotation.line_indices)
|
|
363
|
+
return candidates
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _build_closed_rule_grid_candidates(
|
|
367
|
+
rows: list[_VisualRow],
|
|
368
|
+
lines: list[_LineItem],
|
|
369
|
+
page_size: tuple[float, float],
|
|
370
|
+
angle: int,
|
|
371
|
+
median_height: float,
|
|
372
|
+
axis_lines: list[_LocalAxisLine],
|
|
373
|
+
excluded_bboxes: list[BBox],
|
|
374
|
+
) -> list[_TableCandidate]:
|
|
375
|
+
"""用闭合物理网格接纳含空行或仅有表头文本的稀疏表格。"""
|
|
376
|
+
|
|
377
|
+
candidates: list[_TableCandidate] = []
|
|
378
|
+
for component in _connected_rule_grid_components(axis_lines, median_height):
|
|
379
|
+
grid_bbox = _bbox_union_many([rule.bbox for rule in component])
|
|
380
|
+
if any(_bbox_overlap_in_smaller(grid_bbox, excluded_bbox) >= 0.5 for excluded_bbox in excluded_bboxes):
|
|
381
|
+
continue
|
|
382
|
+
core_rows = _rows_inside_rule_interval(
|
|
383
|
+
rows,
|
|
384
|
+
grid_bbox,
|
|
385
|
+
excluded_bboxes,
|
|
386
|
+
)
|
|
387
|
+
if not core_rows:
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
vertical_positions = _closed_grid_vertical_track_positions(
|
|
391
|
+
component,
|
|
392
|
+
axis_lines,
|
|
393
|
+
median_height,
|
|
394
|
+
)
|
|
395
|
+
if len(vertical_positions) < 2:
|
|
396
|
+
continue
|
|
397
|
+
edge_tolerance = max(2.0, 0.25 * median_height)
|
|
398
|
+
if (
|
|
399
|
+
abs(vertical_positions[0] - grid_bbox[0]) > edge_tolerance
|
|
400
|
+
or abs(vertical_positions[-1] - grid_bbox[2]) > edge_tolerance
|
|
401
|
+
):
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
if len(component) == 2:
|
|
405
|
+
if len(vertical_positions) < 3:
|
|
406
|
+
continue
|
|
407
|
+
occupied_columns = _count_occupied_closed_grid_columns(
|
|
408
|
+
core_rows,
|
|
409
|
+
vertical_positions,
|
|
410
|
+
)
|
|
411
|
+
if occupied_columns < 2:
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
caption_line = _find_table_caption(
|
|
415
|
+
lines,
|
|
416
|
+
grid_bbox,
|
|
417
|
+
page_size,
|
|
418
|
+
angle,
|
|
419
|
+
median_height,
|
|
420
|
+
)
|
|
421
|
+
candidate = _expand_rule_table_candidate(
|
|
422
|
+
[component[0], component[-1]],
|
|
423
|
+
core_rows,
|
|
424
|
+
rows,
|
|
425
|
+
lines,
|
|
426
|
+
page_size,
|
|
427
|
+
angle,
|
|
428
|
+
median_height,
|
|
429
|
+
caption_line,
|
|
430
|
+
)
|
|
431
|
+
candidate.score = float(100 + len(component) + len(vertical_positions))
|
|
432
|
+
candidates.append(candidate)
|
|
433
|
+
return candidates
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _closed_grid_vertical_track_positions(
|
|
437
|
+
horizontal_rules: list[_LocalAxisLine],
|
|
438
|
+
axis_lines: list[_LocalAxisLine],
|
|
439
|
+
median_height: float,
|
|
440
|
+
) -> list[float]:
|
|
441
|
+
"""收集覆盖首末横边界中心跨度至少九成的竖轨并合并重复路径。"""
|
|
442
|
+
|
|
443
|
+
top = _bbox_center_y(horizontal_rules[0].bbox)
|
|
444
|
+
bottom = _bbox_center_y(horizontal_rules[-1].bbox)
|
|
445
|
+
grid_height = max(0.1, bottom - top)
|
|
446
|
+
left = min(rule.bbox[0] for rule in horizontal_rules)
|
|
447
|
+
right = max(rule.bbox[2] for rule in horizontal_rules)
|
|
448
|
+
edge_tolerance = max(2.0, 0.25 * median_height)
|
|
449
|
+
raw_positions = []
|
|
450
|
+
for line in axis_lines:
|
|
451
|
+
if line.orientation != "vertical":
|
|
452
|
+
continue
|
|
453
|
+
overlap = max(
|
|
454
|
+
0.0,
|
|
455
|
+
min(line.bbox[3], bottom) - max(line.bbox[1], top),
|
|
456
|
+
)
|
|
457
|
+
position = _bbox_center_x(line.bbox)
|
|
458
|
+
if overlap / grid_height >= 0.9 and left - edge_tolerance <= position <= right + edge_tolerance:
|
|
459
|
+
raw_positions.append(position)
|
|
460
|
+
|
|
461
|
+
position_tolerance = max(1.0, 0.1 * median_height)
|
|
462
|
+
position_groups: list[list[float]] = []
|
|
463
|
+
for position in sorted(raw_positions):
|
|
464
|
+
if position_groups and abs(position - statistics.mean(position_groups[-1])) <= position_tolerance:
|
|
465
|
+
position_groups[-1].append(position)
|
|
466
|
+
else:
|
|
467
|
+
position_groups.append([position])
|
|
468
|
+
return [statistics.mean(group) for group in position_groups]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _count_occupied_closed_grid_columns(
|
|
472
|
+
rows: list[_VisualRow],
|
|
473
|
+
vertical_positions: list[float],
|
|
474
|
+
) -> int:
|
|
475
|
+
"""按文本片段中心统计闭合网格中实际有文字的物理列数。"""
|
|
476
|
+
|
|
477
|
+
occupied_columns: set[int] = set()
|
|
478
|
+
for row in rows:
|
|
479
|
+
for fragment in row.fragments:
|
|
480
|
+
center_x = _bbox_center_x(fragment.local_bbox)
|
|
481
|
+
matching_columns = [
|
|
482
|
+
index
|
|
483
|
+
for index, (left, right) in enumerate(zip(vertical_positions, vertical_positions[1:]))
|
|
484
|
+
if left < center_x < right
|
|
485
|
+
]
|
|
486
|
+
if len(matching_columns) == 1:
|
|
487
|
+
occupied_columns.add(matching_columns[0])
|
|
488
|
+
return len(occupied_columns)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _connected_rule_grid_bboxes(
|
|
492
|
+
axis_lines: list[_LocalAxisLine],
|
|
493
|
+
median_height: float,
|
|
494
|
+
) -> list[BBox]:
|
|
495
|
+
"""把端点一致且由外轨或至少两条列轨贯穿的相邻横线组成网格框。"""
|
|
496
|
+
|
|
497
|
+
return [
|
|
498
|
+
_bbox_union_many([rule.bbox for rule in component])
|
|
499
|
+
for component in _connected_rule_grid_components(
|
|
500
|
+
axis_lines,
|
|
501
|
+
median_height,
|
|
502
|
+
)
|
|
503
|
+
]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _connected_rule_grid_components(
|
|
507
|
+
axis_lines: list[_LocalAxisLine],
|
|
508
|
+
median_height: float,
|
|
509
|
+
) -> list[list[_LocalAxisLine]]:
|
|
510
|
+
"""保留连续网格的横边界成员,供精确外轨和横边界数量校验。"""
|
|
511
|
+
|
|
512
|
+
output: list[list[_LocalAxisLine]] = []
|
|
513
|
+
for rule_group in _group_long_horizontal_rules(axis_lines, median_height):
|
|
514
|
+
components: list[list[_LocalAxisLine]] = []
|
|
515
|
+
for rule in rule_group:
|
|
516
|
+
if not components or not _rule_bands_share_grid_tracks(
|
|
517
|
+
components[-1][-1],
|
|
518
|
+
rule,
|
|
519
|
+
axis_lines,
|
|
520
|
+
median_height,
|
|
521
|
+
):
|
|
522
|
+
components.append([rule])
|
|
523
|
+
else:
|
|
524
|
+
components[-1].append(rule)
|
|
525
|
+
output.extend(component for component in components if len(component) >= 2)
|
|
526
|
+
return output
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _connected_horizontal_rule_bboxes(
|
|
530
|
+
source: _PageSource,
|
|
531
|
+
) -> set[BBox]:
|
|
532
|
+
"""返回参与常规横排闭合网格的原始水平线框,供上游避免删除真实表格边界。"""
|
|
533
|
+
|
|
534
|
+
angle_lines = [line for line in source.lines if line.angle == 0]
|
|
535
|
+
fragments = _build_fragments(angle_lines, source.page_size)
|
|
536
|
+
if not fragments:
|
|
537
|
+
return set()
|
|
538
|
+
local_axis_lines = _transform_axis_lines(
|
|
539
|
+
source.drawing_lines,
|
|
540
|
+
source.page_size,
|
|
541
|
+
0,
|
|
542
|
+
)
|
|
543
|
+
return {
|
|
544
|
+
rule.original_bbox
|
|
545
|
+
for component in _connected_rule_grid_components(
|
|
546
|
+
local_axis_lines,
|
|
547
|
+
_median_fragment_height(fragments),
|
|
548
|
+
)
|
|
549
|
+
for rule in component
|
|
550
|
+
if rule.orientation == "horizontal"
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _rule_bands_share_grid_tracks(
|
|
555
|
+
top_rule: _LocalAxisLine,
|
|
556
|
+
bottom_rule: _LocalAxisLine,
|
|
557
|
+
axis_lines: list[_LocalAxisLine],
|
|
558
|
+
median_height: float,
|
|
559
|
+
) -> bool:
|
|
560
|
+
"""校验相邻横边界的跨度,并确认其间存在连续外框或稳定列分隔线。"""
|
|
561
|
+
|
|
562
|
+
top_width = max(0.1, top_rule.bbox[2] - top_rule.bbox[0])
|
|
563
|
+
bottom_width = max(0.1, bottom_rule.bbox[2] - bottom_rule.bbox[0])
|
|
564
|
+
overlap_left = max(top_rule.bbox[0], bottom_rule.bbox[0])
|
|
565
|
+
overlap_right = min(top_rule.bbox[2], bottom_rule.bbox[2])
|
|
566
|
+
overlap_width = max(0.0, overlap_right - overlap_left)
|
|
567
|
+
endpoint_tolerance = max(4.0, 2.0 * median_height)
|
|
568
|
+
if (
|
|
569
|
+
overlap_width / max(top_width, bottom_width) < 0.9
|
|
570
|
+
or abs(top_rule.bbox[0] - bottom_rule.bbox[0]) > endpoint_tolerance
|
|
571
|
+
or abs(top_rule.bbox[2] - bottom_rule.bbox[2]) > endpoint_tolerance
|
|
572
|
+
):
|
|
573
|
+
return False
|
|
574
|
+
|
|
575
|
+
top_y = _bbox_center_y(top_rule.bbox)
|
|
576
|
+
bottom_y = _bbox_center_y(bottom_rule.bbox)
|
|
577
|
+
track_tolerance = max(1.0, 0.25 * median_height)
|
|
578
|
+
raw_positions = [
|
|
579
|
+
_bbox_center_x(line.bbox)
|
|
580
|
+
for line in axis_lines
|
|
581
|
+
if line.orientation == "vertical"
|
|
582
|
+
and line.bbox[1] <= top_y + track_tolerance
|
|
583
|
+
and line.bbox[3] >= bottom_y - track_tolerance
|
|
584
|
+
and overlap_left - track_tolerance <= _bbox_center_x(line.bbox) <= overlap_right + track_tolerance
|
|
585
|
+
]
|
|
586
|
+
position_groups: list[list[float]] = []
|
|
587
|
+
for position in sorted(raw_positions):
|
|
588
|
+
if position_groups and abs(position - statistics.mean(position_groups[-1])) <= track_tolerance:
|
|
589
|
+
position_groups[-1].append(position)
|
|
590
|
+
else:
|
|
591
|
+
position_groups.append([position])
|
|
592
|
+
positions = [statistics.mean(group) for group in position_groups]
|
|
593
|
+
has_outer_tracks = any(abs(position - overlap_left) <= endpoint_tolerance for position in positions) and any(
|
|
594
|
+
abs(position - overlap_right) <= endpoint_tolerance for position in positions
|
|
595
|
+
)
|
|
596
|
+
interior_tracks = [
|
|
597
|
+
position for position in positions if overlap_left + track_tolerance < position < overlap_right - track_tolerance
|
|
598
|
+
]
|
|
599
|
+
return has_outer_tracks or len(interior_tracks) >= 2
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _group_long_horizontal_rules(
|
|
603
|
+
axis_lines: list[_LocalAxisLine],
|
|
604
|
+
median_height: float,
|
|
605
|
+
) -> list[list[_LocalAxisLine]]:
|
|
606
|
+
"""按近似左右端点聚合长横线,并去除同位置重复路径。"""
|
|
607
|
+
|
|
608
|
+
minimum_length = max(40.0, 10.0 * median_height)
|
|
609
|
+
horizontal_lines = [
|
|
610
|
+
line for line in axis_lines if line.orientation == "horizontal" and line.bbox[2] - line.bbox[0] >= minimum_length
|
|
611
|
+
]
|
|
612
|
+
endpoint_tolerance = max(4.0, 2.0 * median_height)
|
|
613
|
+
span_groups: list[list[_LocalAxisLine]] = []
|
|
614
|
+
for line in sorted(horizontal_lines, key=lambda item: (item.bbox[0], item.bbox[2], item.bbox[1])):
|
|
615
|
+
target = next(
|
|
616
|
+
(
|
|
617
|
+
group
|
|
618
|
+
for group in span_groups
|
|
619
|
+
if abs(line.bbox[0] - group[0].bbox[0]) <= endpoint_tolerance
|
|
620
|
+
and abs(line.bbox[2] - group[0].bbox[2]) <= endpoint_tolerance
|
|
621
|
+
),
|
|
622
|
+
None,
|
|
623
|
+
)
|
|
624
|
+
if target is None:
|
|
625
|
+
span_groups.append([line])
|
|
626
|
+
else:
|
|
627
|
+
target.append(line)
|
|
628
|
+
|
|
629
|
+
output: list[list[_LocalAxisLine]] = []
|
|
630
|
+
for span_group in span_groups:
|
|
631
|
+
unique_lines: list[_LocalAxisLine] = []
|
|
632
|
+
for line in sorted(span_group, key=lambda item: _bbox_center_y(item.bbox)):
|
|
633
|
+
if any(abs(_bbox_center_y(line.bbox) - _bbox_center_y(item.bbox)) <= 1.0 for item in unique_lines):
|
|
634
|
+
continue
|
|
635
|
+
unique_lines.append(line)
|
|
636
|
+
if len(unique_lines) >= 2:
|
|
637
|
+
output.append(unique_lines)
|
|
638
|
+
return output
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _rows_inside_rule_interval(
|
|
642
|
+
rows: list[_VisualRow],
|
|
643
|
+
rule_bbox: BBox,
|
|
644
|
+
excluded_bboxes: list[BBox],
|
|
645
|
+
) -> list[_VisualRow]:
|
|
646
|
+
"""截取边界走廊内文本行,并移除已由强图形核心覆盖的片段。"""
|
|
647
|
+
|
|
648
|
+
output: list[_VisualRow] = []
|
|
649
|
+
for row in rows:
|
|
650
|
+
clipped_row = _clip_visual_row_to_corridor(row, rule_bbox, margin=0.0)
|
|
651
|
+
if clipped_row is None or not rule_bbox[1] <= clipped_row.center_y <= rule_bbox[3]:
|
|
652
|
+
continue
|
|
653
|
+
fragments = [
|
|
654
|
+
fragment
|
|
655
|
+
for fragment in clipped_row.fragments
|
|
656
|
+
if not any(
|
|
657
|
+
_point_in_bbox(
|
|
658
|
+
(
|
|
659
|
+
_bbox_center_x(fragment.local_bbox),
|
|
660
|
+
_bbox_center_y(fragment.local_bbox),
|
|
661
|
+
),
|
|
662
|
+
excluded_bbox,
|
|
663
|
+
)
|
|
664
|
+
for excluded_bbox in excluded_bboxes
|
|
665
|
+
)
|
|
666
|
+
]
|
|
667
|
+
if not fragments:
|
|
668
|
+
continue
|
|
669
|
+
output.append(
|
|
670
|
+
_VisualRow(
|
|
671
|
+
fragments=fragments,
|
|
672
|
+
center_y=sum(_bbox_center_y(fragment.local_bbox) for fragment in fragments) / len(fragments),
|
|
673
|
+
bbox=_bbox_union_many([fragment.local_bbox for fragment in fragments]),
|
|
674
|
+
visual_row_id=clipped_row.visual_row_id,
|
|
675
|
+
)
|
|
676
|
+
)
|
|
677
|
+
return output
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _every_rule_interval_has_multi_cell_row(
|
|
681
|
+
rows: list[_VisualRow],
|
|
682
|
+
rule_group: list[_LocalAxisLine],
|
|
683
|
+
median_height: float,
|
|
684
|
+
) -> bool:
|
|
685
|
+
"""要求候选跨过的每个相邻横线区间都存在至少一行多单元文本。"""
|
|
686
|
+
|
|
687
|
+
if len(rule_group) < 2:
|
|
688
|
+
return False
|
|
689
|
+
for interval_index, (top_rule, bottom_rule) in enumerate(zip(rule_group, rule_group[1:])):
|
|
690
|
+
top = _bbox_center_y(top_rule.bbox)
|
|
691
|
+
bottom = _bbox_center_y(bottom_rule.bbox)
|
|
692
|
+
interval_rows = [row for row in rows if top <= row.center_y <= bottom]
|
|
693
|
+
if any(len(row.fragments) >= 2 for row in interval_rows):
|
|
694
|
+
continue
|
|
695
|
+
# 紧邻顶边界的合并表头可能由 pdftext 输出为一个短 fragment;
|
|
696
|
+
# 只放宽高度很小的首区间,避免把远处章节标题接到表格上。
|
|
697
|
+
if interval_index == 0 and interval_rows and bottom - top <= 2.5 * median_height:
|
|
698
|
+
continue
|
|
699
|
+
return False
|
|
700
|
+
return True
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _rule_intervals_are_column_compatible(
|
|
704
|
+
rows: list[_VisualRow],
|
|
705
|
+
rule_group: list[_LocalAxisLine],
|
|
706
|
+
median_height: float,
|
|
707
|
+
) -> bool:
|
|
708
|
+
"""拒绝跨过长篇栏式正文、导致稳定列数明显塌缩的多表合并区间。"""
|
|
709
|
+
|
|
710
|
+
profiles: list[tuple[int, float, int, float]] = []
|
|
711
|
+
for top_rule, bottom_rule in zip(rule_group, rule_group[1:]):
|
|
712
|
+
top = _bbox_center_y(top_rule.bbox)
|
|
713
|
+
bottom = _bbox_center_y(bottom_rule.bbox)
|
|
714
|
+
interval_rows = [row for row in rows if top <= row.center_y <= bottom and len(row.fragments) >= 2]
|
|
715
|
+
stable_columns, column_coverage = _count_stable_columns(
|
|
716
|
+
interval_rows,
|
|
717
|
+
median_height,
|
|
718
|
+
)
|
|
719
|
+
profiles.append(
|
|
720
|
+
(
|
|
721
|
+
stable_columns,
|
|
722
|
+
bottom - top,
|
|
723
|
+
len(interval_rows),
|
|
724
|
+
column_coverage,
|
|
725
|
+
)
|
|
726
|
+
)
|
|
727
|
+
maximum_columns = max(
|
|
728
|
+
(columns for columns, _height, _row_count, _coverage in profiles),
|
|
729
|
+
default=0,
|
|
730
|
+
)
|
|
731
|
+
for interval_index, (columns, interval_height, row_count, coverage) in enumerate(profiles):
|
|
732
|
+
# 紧凑首区间可能只是跨列表头;一旦区间明显高于普通表头,
|
|
733
|
+
# 也必须具有连续多单元格行,不能无条件跨过正文连接两张表。
|
|
734
|
+
if interval_index == 0 and interval_height <= 2.5 * median_height:
|
|
735
|
+
continue
|
|
736
|
+
if interval_height <= 6.0 * median_height:
|
|
737
|
+
continue
|
|
738
|
+
minimum_rows = max(2, int(interval_height / max(8.0 * median_height, 0.1)))
|
|
739
|
+
if row_count < minimum_rows or coverage < 0.5:
|
|
740
|
+
return False
|
|
741
|
+
if columns < max(2, int(0.5 * maximum_columns)):
|
|
742
|
+
return False
|
|
743
|
+
return True
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _continuous_table_row_segments(
|
|
747
|
+
rows: list[_VisualRow],
|
|
748
|
+
median_height: float,
|
|
749
|
+
) -> list[list[_VisualRow]]:
|
|
750
|
+
"""按物理行距切分边界区间,保留单元格换行参与连续性判断。"""
|
|
751
|
+
|
|
752
|
+
segments: list[list[_VisualRow]] = []
|
|
753
|
+
for row in sorted(rows, key=lambda item: item.center_y):
|
|
754
|
+
if not segments or max(0.0, row.bbox[1] - segments[-1][-1].bbox[3]) > 3.0 * median_height:
|
|
755
|
+
segments.append([row])
|
|
756
|
+
else:
|
|
757
|
+
segments[-1].append(row)
|
|
758
|
+
return segments
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _table_segment_reaches_boundaries(
|
|
762
|
+
rows: list[_VisualRow],
|
|
763
|
+
rule_bbox: BBox,
|
|
764
|
+
median_height: float,
|
|
765
|
+
) -> bool:
|
|
766
|
+
"""要求数据行链分别贴近最近的上下边界,排除页眉线和远处章节标题。"""
|
|
767
|
+
|
|
768
|
+
if not rows:
|
|
769
|
+
return False
|
|
770
|
+
maximum_gap = 2.5 * median_height
|
|
771
|
+
top_gap = max(0.0, rows[0].bbox[1] - rule_bbox[1])
|
|
772
|
+
bottom_gap = max(0.0, rule_bbox[3] - rows[-1].bbox[3])
|
|
773
|
+
return top_gap <= maximum_gap and bottom_gap <= maximum_gap
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _table_rows_align_with_rule_span(
|
|
777
|
+
rows: list[_VisualRow],
|
|
778
|
+
rule_bbox: BBox,
|
|
779
|
+
median_height: float,
|
|
780
|
+
) -> bool:
|
|
781
|
+
"""校验数据行总体跨度与横线走廊重叠,拒绝仅在边缘偶遇的多列文本。"""
|
|
782
|
+
|
|
783
|
+
if not rows:
|
|
784
|
+
return False
|
|
785
|
+
rows_bbox = _bbox_union_many([row.bbox for row in rows])
|
|
786
|
+
rule_width = max(0.1, rule_bbox[2] - rule_bbox[0])
|
|
787
|
+
rows_width = max(0.1, rows_bbox[2] - rows_bbox[0])
|
|
788
|
+
overlap = max(
|
|
789
|
+
0.0,
|
|
790
|
+
min(rows_bbox[2], rule_bbox[2]) - max(rows_bbox[0], rule_bbox[0]),
|
|
791
|
+
)
|
|
792
|
+
return overlap / min(rule_width, rows_width) >= 0.9 and rows_width >= max(8.0 * median_height, 0.25 * rule_width)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _count_aligned_vertical_rules(
|
|
796
|
+
axis_lines: list[_LocalAxisLine],
|
|
797
|
+
rule_bbox: BBox,
|
|
798
|
+
median_height: float,
|
|
799
|
+
) -> int:
|
|
800
|
+
"""统计贯穿候选主要高度且位于横线跨度内的竖向分隔线。"""
|
|
801
|
+
|
|
802
|
+
required_height = max(4.0 * median_height, 0.5 * (rule_bbox[3] - rule_bbox[1]))
|
|
803
|
+
return sum(
|
|
804
|
+
line.orientation == "vertical"
|
|
805
|
+
and rule_bbox[0] - median_height <= _bbox_center_x(line.bbox) <= rule_bbox[2] + median_height
|
|
806
|
+
and line.bbox[3] - line.bbox[1] >= required_height
|
|
807
|
+
and _bbox_axis_overlap_ratio(line.bbox, rule_bbox, axis="y") >= 0.8
|
|
808
|
+
for line in axis_lines
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _compact_fully_ruled_grid_column_count(
|
|
813
|
+
row_segment: list[_VisualRow],
|
|
814
|
+
dense_rows: list[_VisualRow],
|
|
815
|
+
interval_rules: list[_LocalAxisLine],
|
|
816
|
+
axis_lines: list[_LocalAxisLine],
|
|
817
|
+
rule_bbox: BBox,
|
|
818
|
+
median_height: float,
|
|
819
|
+
) -> int:
|
|
820
|
+
"""以完整横竖边界确认两行紧凑网格,并返回物理列数,失败时返回零。"""
|
|
821
|
+
|
|
822
|
+
rule_height = max(0.1, rule_bbox[3] - rule_bbox[1])
|
|
823
|
+
if len(row_segment) != 2 or len(dense_rows) != 2 or len(interval_rules) < 3 or rule_height > 6.0 * median_height:
|
|
824
|
+
return 0
|
|
825
|
+
|
|
826
|
+
vertical_positions = _full_height_vertical_rule_positions(
|
|
827
|
+
axis_lines,
|
|
828
|
+
rule_bbox,
|
|
829
|
+
median_height,
|
|
830
|
+
)
|
|
831
|
+
if len(vertical_positions) < 3:
|
|
832
|
+
return 0
|
|
833
|
+
|
|
834
|
+
edge_tolerance = max(1.5, 0.25 * median_height)
|
|
835
|
+
left_boundary = min(
|
|
836
|
+
vertical_positions,
|
|
837
|
+
key=lambda position: abs(position - rule_bbox[0]),
|
|
838
|
+
)
|
|
839
|
+
right_boundary = min(
|
|
840
|
+
vertical_positions,
|
|
841
|
+
key=lambda position: abs(position - rule_bbox[2]),
|
|
842
|
+
)
|
|
843
|
+
if (
|
|
844
|
+
abs(left_boundary - rule_bbox[0]) > edge_tolerance
|
|
845
|
+
or abs(right_boundary - rule_bbox[2]) > edge_tolerance
|
|
846
|
+
or right_boundary <= left_boundary
|
|
847
|
+
):
|
|
848
|
+
return 0
|
|
849
|
+
|
|
850
|
+
grid_boundaries = [position for position in vertical_positions if left_boundary <= position <= right_boundary]
|
|
851
|
+
if len(grid_boundaries) < 3:
|
|
852
|
+
return 0
|
|
853
|
+
grid_intervals = list(zip(grid_boundaries, grid_boundaries[1:]))
|
|
854
|
+
|
|
855
|
+
occupied_columns: list[set[int]] = []
|
|
856
|
+
for row in dense_rows:
|
|
857
|
+
row_columns: list[int] = []
|
|
858
|
+
for fragment in row.fragments:
|
|
859
|
+
fragment_center = _bbox_center_x(fragment.local_bbox)
|
|
860
|
+
matching_columns = [index for index, (left, right) in enumerate(grid_intervals) if left <= fragment_center <= right]
|
|
861
|
+
if len(matching_columns) != 1:
|
|
862
|
+
return 0
|
|
863
|
+
column_index = matching_columns[0]
|
|
864
|
+
if column_index in row_columns:
|
|
865
|
+
return 0
|
|
866
|
+
row_columns.append(column_index)
|
|
867
|
+
if len(row_columns) < 2:
|
|
868
|
+
return 0
|
|
869
|
+
occupied_columns.append(set(row_columns))
|
|
870
|
+
|
|
871
|
+
if len(occupied_columns[0] & occupied_columns[1]) < 2:
|
|
872
|
+
return 0
|
|
873
|
+
return len(grid_intervals)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _full_height_vertical_rule_positions(
|
|
877
|
+
axis_lines: list[_LocalAxisLine],
|
|
878
|
+
rule_bbox: BBox,
|
|
879
|
+
median_height: float,
|
|
880
|
+
) -> list[float]:
|
|
881
|
+
"""收集覆盖紧凑候选主要高度的竖线中心,并合并同位置重复路径。"""
|
|
882
|
+
|
|
883
|
+
rule_height = max(0.1, rule_bbox[3] - rule_bbox[1])
|
|
884
|
+
raw_positions: list[float] = []
|
|
885
|
+
for line in axis_lines:
|
|
886
|
+
if line.orientation != "vertical":
|
|
887
|
+
continue
|
|
888
|
+
overlap = max(
|
|
889
|
+
0.0,
|
|
890
|
+
min(line.bbox[3], rule_bbox[3]) - max(line.bbox[1], rule_bbox[1]),
|
|
891
|
+
)
|
|
892
|
+
if (
|
|
893
|
+
overlap / rule_height < 0.8
|
|
894
|
+
or line.bbox[3] - line.bbox[1] < 0.8 * rule_height
|
|
895
|
+
or not rule_bbox[0] - median_height <= _bbox_center_x(line.bbox) <= rule_bbox[2] + median_height
|
|
896
|
+
):
|
|
897
|
+
continue
|
|
898
|
+
raw_positions.append(_bbox_center_x(line.bbox))
|
|
899
|
+
|
|
900
|
+
deduplicated: list[list[float]] = []
|
|
901
|
+
position_tolerance = max(1.0, 0.1 * median_height)
|
|
902
|
+
for position in sorted(raw_positions):
|
|
903
|
+
if deduplicated and abs(position - statistics.mean(deduplicated[-1])) <= position_tolerance:
|
|
904
|
+
deduplicated[-1].append(position)
|
|
905
|
+
else:
|
|
906
|
+
deduplicated.append([position])
|
|
907
|
+
return [statistics.mean(group) for group in deduplicated]
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _looks_like_page_column_prose(
|
|
911
|
+
rows: list[_VisualRow],
|
|
912
|
+
dense_rows: list[_VisualRow],
|
|
913
|
+
stable_columns: int,
|
|
914
|
+
fill_band_count: int,
|
|
915
|
+
aligned_vertical_count: int,
|
|
916
|
+
rule_bbox: BBox,
|
|
917
|
+
) -> bool:
|
|
918
|
+
"""用双栏占宽率识别夹在远横线间的普通并排正文。"""
|
|
919
|
+
|
|
920
|
+
if stable_columns != 2 or fill_band_count >= 2 or aligned_vertical_count > 0 or len(dense_rows) / len(rows) < 0.55:
|
|
921
|
+
return False
|
|
922
|
+
corridor_width = max(0.1, rule_bbox[2] - rule_bbox[0])
|
|
923
|
+
occupied_ratios = [
|
|
924
|
+
sum(fragment.local_bbox[2] - fragment.local_bbox[0] for fragment in row.fragments) / corridor_width
|
|
925
|
+
for row in dense_rows
|
|
926
|
+
]
|
|
927
|
+
return statistics.median(occupied_ratios) >= 0.75
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _count_repeated_fill_bands(
|
|
931
|
+
path_infos: list[PDFPathInfo],
|
|
932
|
+
rule_bbox: BBox,
|
|
933
|
+
page_size: tuple[float, float],
|
|
934
|
+
angle: int,
|
|
935
|
+
median_height: float,
|
|
936
|
+
) -> int:
|
|
937
|
+
"""统计区间内左右端点和高度重复的填充行带,并对重叠 Path 去重。"""
|
|
938
|
+
|
|
939
|
+
minimum_width = max(8.0 * median_height, 0.3 * (rule_bbox[2] - rule_bbox[0]))
|
|
940
|
+
candidates: list[BBox] = []
|
|
941
|
+
for path_info in path_infos:
|
|
942
|
+
if path_info.form_depth != 0 or not path_info.fill_visible:
|
|
943
|
+
continue
|
|
944
|
+
bbox = _rotate_bbox_to_upright(path_info.bbox, page_size, angle)
|
|
945
|
+
width = bbox[2] - bbox[0]
|
|
946
|
+
height = bbox[3] - bbox[1]
|
|
947
|
+
if (
|
|
948
|
+
width < minimum_width
|
|
949
|
+
or not 0.25 * median_height <= height <= 3.0 * median_height
|
|
950
|
+
or _bbox_center_y(bbox) < rule_bbox[1]
|
|
951
|
+
or _bbox_center_y(bbox) > rule_bbox[3]
|
|
952
|
+
or _bbox_axis_overlap_ratio(bbox, rule_bbox, axis="x") < 0.8
|
|
953
|
+
):
|
|
954
|
+
continue
|
|
955
|
+
if any(_bbox_overlap_in_smaller(bbox, item) >= 0.9 for item in candidates):
|
|
956
|
+
continue
|
|
957
|
+
candidates.append(bbox)
|
|
958
|
+
|
|
959
|
+
endpoint_tolerance = max(3.0, median_height)
|
|
960
|
+
groups: list[list[BBox]] = []
|
|
961
|
+
for bbox in candidates:
|
|
962
|
+
target = next(
|
|
963
|
+
(
|
|
964
|
+
group
|
|
965
|
+
for group in groups
|
|
966
|
+
if abs(bbox[0] - group[0][0]) <= endpoint_tolerance
|
|
967
|
+
and abs(bbox[2] - group[0][2]) <= endpoint_tolerance
|
|
968
|
+
and abs((bbox[3] - bbox[1]) - (group[0][3] - group[0][1])) <= endpoint_tolerance
|
|
969
|
+
),
|
|
970
|
+
None,
|
|
971
|
+
)
|
|
972
|
+
if target is None:
|
|
973
|
+
groups.append([bbox])
|
|
974
|
+
else:
|
|
975
|
+
target.append(bbox)
|
|
976
|
+
return max((len(group) for group in groups), default=0)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _longest_dense_multi_cell_rows(
|
|
980
|
+
rows: list[_VisualRow],
|
|
981
|
+
median_height: float,
|
|
982
|
+
) -> list[_VisualRow]:
|
|
983
|
+
"""返回行距不超过四倍行高的最长连续多单元格文本段。"""
|
|
984
|
+
|
|
985
|
+
segments: list[list[_VisualRow]] = []
|
|
986
|
+
for row in (item for item in rows if len(item.fragments) >= 2):
|
|
987
|
+
if not segments or row.center_y - segments[-1][-1].center_y > 4.0 * median_height:
|
|
988
|
+
segments.append([row])
|
|
989
|
+
else:
|
|
990
|
+
segments[-1].append(row)
|
|
991
|
+
return max(segments, key=len, default=[])
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _expand_rule_table_candidate(
|
|
995
|
+
rule_group: list[_LocalAxisLine],
|
|
996
|
+
core_rows: list[_VisualRow],
|
|
997
|
+
all_rows: list[_VisualRow],
|
|
998
|
+
all_lines: list[_LineItem],
|
|
999
|
+
page_size: tuple[float, float],
|
|
1000
|
+
angle: int,
|
|
1001
|
+
median_height: float,
|
|
1002
|
+
caption_line: _LineItem | None,
|
|
1003
|
+
) -> _TableCandidate:
|
|
1004
|
+
"""合并横线核心与上下注释,并保留注释的独立行身份。"""
|
|
1005
|
+
|
|
1006
|
+
rule_bbox = _bbox_union_many([line.bbox for line in rule_group])
|
|
1007
|
+
core_line_indices = {fragment.line_index for row in core_rows for fragment in row.fragments}
|
|
1008
|
+
caption_rows = _collect_caption_rows(all_rows, caption_line, rule_bbox, median_height)
|
|
1009
|
+
footnote_rows = _collect_footnote_rows(
|
|
1010
|
+
all_rows,
|
|
1011
|
+
all_lines,
|
|
1012
|
+
rule_bbox,
|
|
1013
|
+
median_height,
|
|
1014
|
+
core_line_indices,
|
|
1015
|
+
page_size,
|
|
1016
|
+
angle,
|
|
1017
|
+
)
|
|
1018
|
+
core_local_bbox = _bbox_union(rule_bbox, _bbox_union_many([row.bbox for row in core_rows]))
|
|
1019
|
+
caption_annotation = _build_table_annotation(
|
|
1020
|
+
"caption",
|
|
1021
|
+
caption_rows,
|
|
1022
|
+
excluded_line_indices=core_line_indices,
|
|
1023
|
+
excluded_local_bbox=core_local_bbox,
|
|
1024
|
+
)
|
|
1025
|
+
footnote_annotation = _build_table_annotation(
|
|
1026
|
+
"footnote",
|
|
1027
|
+
footnote_rows,
|
|
1028
|
+
excluded_line_indices=core_line_indices,
|
|
1029
|
+
)
|
|
1030
|
+
annotations = [annotation for annotation in (caption_annotation, footnote_annotation) if annotation is not None]
|
|
1031
|
+
annotation_line_indices = (
|
|
1032
|
+
set().union(
|
|
1033
|
+
*(annotation.line_indices for annotation in annotations),
|
|
1034
|
+
)
|
|
1035
|
+
if annotations
|
|
1036
|
+
else set()
|
|
1037
|
+
)
|
|
1038
|
+
included_rows = [*caption_rows, *core_rows, *footnote_rows]
|
|
1039
|
+
local_bbox = _bbox_union(core_local_bbox, _bbox_union_many([row.bbox for row in included_rows]))
|
|
1040
|
+
return _TableCandidate(
|
|
1041
|
+
bbox=_rotate_bbox_from_upright(local_bbox, page_size, angle),
|
|
1042
|
+
local_bbox=local_bbox,
|
|
1043
|
+
angle=angle,
|
|
1044
|
+
score=0.0,
|
|
1045
|
+
core_bbox=_rotate_bbox_from_upright(core_local_bbox, page_size, angle),
|
|
1046
|
+
# 表体成员与注释成员保持互斥;物化失败时会显式把无效注释放回表体投影。
|
|
1047
|
+
line_indices=core_line_indices - annotation_line_indices,
|
|
1048
|
+
annotations=annotations,
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _count_stable_columns(
|
|
1053
|
+
rows: list[_VisualRow],
|
|
1054
|
+
median_height: float,
|
|
1055
|
+
) -> tuple[int, float]:
|
|
1056
|
+
"""分别聚类片段左边界、中心和右边界,返回最稳定的列分布。"""
|
|
1057
|
+
|
|
1058
|
+
tolerance = max(3.0, median_height * 0.75)
|
|
1059
|
+
best_result = (0, 0.0)
|
|
1060
|
+
# 三种对齐方式分别聚类,避免把同一片段的不同锚点混算为多列。
|
|
1061
|
+
for alignment in ("left", "center", "right"):
|
|
1062
|
+
clusters: list[dict[str, Any]] = []
|
|
1063
|
+
for row_index, row in enumerate(rows):
|
|
1064
|
+
for fragment in row.fragments:
|
|
1065
|
+
left, _top, right, _bottom = fragment.local_bbox
|
|
1066
|
+
if alignment == "left":
|
|
1067
|
+
anchor = left
|
|
1068
|
+
elif alignment == "center":
|
|
1069
|
+
anchor = (left + right) / 2
|
|
1070
|
+
else:
|
|
1071
|
+
anchor = right
|
|
1072
|
+
cluster = next(
|
|
1073
|
+
(item for item in clusters if abs(anchor - float(item["mean"])) <= tolerance),
|
|
1074
|
+
None,
|
|
1075
|
+
)
|
|
1076
|
+
if cluster is None:
|
|
1077
|
+
clusters.append({"mean": anchor, "values": [anchor], "rows": {row_index}})
|
|
1078
|
+
else:
|
|
1079
|
+
cluster["values"].append(anchor)
|
|
1080
|
+
cluster["rows"].add(row_index)
|
|
1081
|
+
cluster["mean"] = sum(cluster["values"]) / len(cluster["values"])
|
|
1082
|
+
stable_coverages = [len(cluster["rows"]) / len(rows) for cluster in clusters if len(cluster["rows"]) / len(rows) >= 0.5]
|
|
1083
|
+
result = (
|
|
1084
|
+
len(stable_coverages),
|
|
1085
|
+
min(stable_coverages) if stable_coverages else 0.0,
|
|
1086
|
+
)
|
|
1087
|
+
# 仅在结果严格更优时更新,平局时保留既有的左对齐优先级。
|
|
1088
|
+
if result > best_result:
|
|
1089
|
+
best_result = result
|
|
1090
|
+
return best_result
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _merge_table_candidates(candidates: list[_TableCandidate]) -> list[_TableCandidate]:
|
|
1094
|
+
"""合并同方向且明显重叠的横线候选,避免同一表格重复输出。"""
|
|
1095
|
+
|
|
1096
|
+
merged: list[_TableCandidate] = []
|
|
1097
|
+
for candidate in sorted(candidates, key=lambda item: item.score, reverse=True):
|
|
1098
|
+
target = next(
|
|
1099
|
+
(
|
|
1100
|
+
item
|
|
1101
|
+
for item in merged
|
|
1102
|
+
if item.angle == candidate.angle and _bbox_overlap_in_smaller(candidate.bbox, item.bbox) >= 0.2
|
|
1103
|
+
),
|
|
1104
|
+
None,
|
|
1105
|
+
)
|
|
1106
|
+
if target is None:
|
|
1107
|
+
merged.append(candidate)
|
|
1108
|
+
continue
|
|
1109
|
+
target.bbox = _bbox_union(target.bbox, candidate.bbox)
|
|
1110
|
+
target.local_bbox = _bbox_union(target.local_bbox, candidate.local_bbox)
|
|
1111
|
+
if target.core_bbox is None:
|
|
1112
|
+
target.core_bbox = candidate.core_bbox
|
|
1113
|
+
elif candidate.core_bbox is not None:
|
|
1114
|
+
target.core_bbox = _bbox_union(target.core_bbox, candidate.core_bbox)
|
|
1115
|
+
target.line_indices.update(candidate.line_indices)
|
|
1116
|
+
_merge_table_candidate_annotations(target, candidate)
|
|
1117
|
+
target.score = max(target.score, candidate.score)
|
|
1118
|
+
return sorted(merged, key=lambda item: (item.bbox[1], item.bbox[0]))
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _median_fragment_height(fragments: list[_Fragment]) -> float:
|
|
1122
|
+
"""返回正向文本片段高度的中位数。"""
|
|
1123
|
+
|
|
1124
|
+
heights = [
|
|
1125
|
+
fragment.local_bbox[3] - fragment.local_bbox[1]
|
|
1126
|
+
for fragment in fragments
|
|
1127
|
+
if fragment.local_bbox[3] > fragment.local_bbox[1]
|
|
1128
|
+
]
|
|
1129
|
+
return max(0.1, float(statistics.median(heights)) if heights else 1.0)
|