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,1122 @@
|
|
|
1
|
+
"""在既有候选全部失败后恢复多行少线表格结构。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import statistics
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .sparse_common import _LocalRule, _local_rules, cluster_members
|
|
12
|
+
from .candidate import GridCellSpec, build_candidate
|
|
13
|
+
from .contracts import NativeTableCandidate, NativeTableGlyph, NativeTableInput, NativeTableText, NativeTableTextRow
|
|
14
|
+
from .geometry import covered_interval_ratio, normalize_angle, normalize_bbox, page_bbox_to_table_local, table_local_size
|
|
15
|
+
|
|
16
|
+
MIN_MULTILINE_RELIABILITY = 0.98
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class _LocalRectangle:
|
|
21
|
+
"""保存正向表格局部坐标中的矩形证据。"""
|
|
22
|
+
|
|
23
|
+
bbox: tuple[float, float, float, float]
|
|
24
|
+
fill_visible: bool
|
|
25
|
+
stroke_visible: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class _ColumnHypothesis:
|
|
30
|
+
"""保存少线多行候选的一组列轨与来源证据。"""
|
|
31
|
+
|
|
32
|
+
evidence: str
|
|
33
|
+
x_tracks: tuple[float, ...]
|
|
34
|
+
physical_boundaries: frozenset[int]
|
|
35
|
+
filled_band_count: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class _LogicalRow:
|
|
40
|
+
"""保存由一条或多条视觉基线组成的逻辑正文行。"""
|
|
41
|
+
|
|
42
|
+
visual_indices: tuple[int, ...]
|
|
43
|
+
top: float
|
|
44
|
+
bottom: float
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _local_rectangles(
|
|
48
|
+
table_input: NativeTableInput,
|
|
49
|
+
width: float,
|
|
50
|
+
height: float,
|
|
51
|
+
) -> tuple[_LocalRectangle, ...]:
|
|
52
|
+
"""把相交矩形转换为局部坐标并裁剪到表格范围。"""
|
|
53
|
+
|
|
54
|
+
table_bbox = normalize_bbox(table_input.table_bbox)
|
|
55
|
+
if table_bbox is None:
|
|
56
|
+
return ()
|
|
57
|
+
angle = normalize_angle(table_input.angle)
|
|
58
|
+
output: list[_LocalRectangle] = []
|
|
59
|
+
for rectangle in table_input.rectangles:
|
|
60
|
+
if rectangle.segment_count != 5 or not (rectangle.fill_visible or rectangle.stroke_visible):
|
|
61
|
+
continue
|
|
62
|
+
bbox = normalize_bbox(rectangle.bbox)
|
|
63
|
+
if bbox is None:
|
|
64
|
+
continue
|
|
65
|
+
local_bbox = page_bbox_to_table_local(bbox, table_bbox, angle)
|
|
66
|
+
if local_bbox is None:
|
|
67
|
+
continue
|
|
68
|
+
clipped = (
|
|
69
|
+
max(0.0, local_bbox[0]),
|
|
70
|
+
max(0.0, local_bbox[1]),
|
|
71
|
+
min(width, local_bbox[2]),
|
|
72
|
+
min(height, local_bbox[3]),
|
|
73
|
+
)
|
|
74
|
+
if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]:
|
|
75
|
+
continue
|
|
76
|
+
output.append(
|
|
77
|
+
_LocalRectangle(
|
|
78
|
+
bbox=clipped,
|
|
79
|
+
fill_visible=rectangle.fill_visible,
|
|
80
|
+
stroke_visible=rectangle.stroke_visible,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return tuple(output)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _row_occupancy(
|
|
87
|
+
row: NativeTableTextRow,
|
|
88
|
+
glyph_by_id: dict[int, NativeTableGlyph],
|
|
89
|
+
x_tracks: tuple[float, ...],
|
|
90
|
+
) -> set[int]:
|
|
91
|
+
"""按字符中心统计一条视觉行占用的叶子列。"""
|
|
92
|
+
|
|
93
|
+
occupied: set[int] = set()
|
|
94
|
+
for glyph_id in row.glyph_ids:
|
|
95
|
+
glyph = glyph_by_id[glyph_id]
|
|
96
|
+
center = (glyph.bbox[0] + glyph.bbox[2]) / 2.0
|
|
97
|
+
for col, (left, right) in enumerate(zip(x_tracks, x_tracks[1:])):
|
|
98
|
+
if left <= center <= right:
|
|
99
|
+
occupied.add(col)
|
|
100
|
+
break
|
|
101
|
+
return occupied
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _infer_target_columns(text: NativeTableText) -> int | None:
|
|
105
|
+
"""从重复的最大 token 数推断叶子列数。"""
|
|
106
|
+
|
|
107
|
+
counts = Counter(len(row.tokens) for row in text.rows if 2 <= len(row.tokens) <= 20)
|
|
108
|
+
candidates = [count for count, occurrences in counts.items() if occurrences >= 2]
|
|
109
|
+
return max(candidates) if candidates else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _infer_text_tracks(
|
|
113
|
+
text: NativeTableText,
|
|
114
|
+
width: float,
|
|
115
|
+
target_cols: int,
|
|
116
|
+
) -> tuple[float, ...] | None:
|
|
117
|
+
"""从完整锚点行的相邻 token 空隙恢复文本列轨。"""
|
|
118
|
+
|
|
119
|
+
anchor_rows = [row for row in text.rows if len(row.tokens) == target_cols]
|
|
120
|
+
if len(anchor_rows) < 2:
|
|
121
|
+
return None
|
|
122
|
+
boundaries: list[float] = []
|
|
123
|
+
for col in range(target_cols - 1):
|
|
124
|
+
left_edges = [row.tokens[col].bbox[2] for row in anchor_rows]
|
|
125
|
+
right_edges = [row.tokens[col + 1].bbox[0] for row in anchor_rows]
|
|
126
|
+
global_left = max(left_edges)
|
|
127
|
+
global_right = min(right_edges)
|
|
128
|
+
if global_left < global_right:
|
|
129
|
+
boundary = (global_left + global_right) / 2.0
|
|
130
|
+
else:
|
|
131
|
+
midpoints = [(left + right) / 2.0 for left, right in zip(left_edges, right_edges, strict=True)]
|
|
132
|
+
boundary = float(statistics.median(midpoints))
|
|
133
|
+
if not 0.0 < boundary < width:
|
|
134
|
+
return None
|
|
135
|
+
boundaries.append(boundary)
|
|
136
|
+
tracks = _refine_text_tracks(
|
|
137
|
+
text,
|
|
138
|
+
(0.0, *boundaries, width),
|
|
139
|
+
)
|
|
140
|
+
minimum_width = max(1.0, 0.50 * text.median_glyph_width)
|
|
141
|
+
if any(current - previous < minimum_width for previous, current in zip(tracks, tracks[1:])):
|
|
142
|
+
return None
|
|
143
|
+
return tracks
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _refine_text_tracks(
|
|
147
|
+
text: NativeTableText,
|
|
148
|
+
tracks: tuple[float, ...],
|
|
149
|
+
) -> tuple[float, ...]:
|
|
150
|
+
"""用全部简单 token 的外缘扩展文本列间空白走廊。"""
|
|
151
|
+
|
|
152
|
+
refined = list(tracks)
|
|
153
|
+
for boundary_index in range(1, len(tracks) - 1):
|
|
154
|
+
boundary = refined[boundary_index]
|
|
155
|
+
left_limits: list[float] = []
|
|
156
|
+
right_limits: list[float] = []
|
|
157
|
+
for row in text.rows:
|
|
158
|
+
for token in row.tokens:
|
|
159
|
+
crossed_boundaries = sum(token.bbox[0] < item < token.bbox[2] for item in tracks[1:-1])
|
|
160
|
+
if crossed_boundaries > 1:
|
|
161
|
+
continue
|
|
162
|
+
center = (token.bbox[0] + token.bbox[2]) / 2.0
|
|
163
|
+
if center < boundary:
|
|
164
|
+
left_limits.append(token.bbox[2])
|
|
165
|
+
elif center > boundary:
|
|
166
|
+
right_limits.append(token.bbox[0])
|
|
167
|
+
if not left_limits or not right_limits:
|
|
168
|
+
continue
|
|
169
|
+
lower = max(left_limits)
|
|
170
|
+
upper = min(right_limits)
|
|
171
|
+
if refined[boundary_index - 1] < lower < upper < refined[boundary_index + 1]:
|
|
172
|
+
refined[boundary_index] = (lower + upper) / 2.0
|
|
173
|
+
return tuple(refined)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _rectangle_tracks(
|
|
177
|
+
rectangles: tuple[_LocalRectangle, ...],
|
|
178
|
+
width: float,
|
|
179
|
+
tolerance: float,
|
|
180
|
+
outer_tolerance: float,
|
|
181
|
+
) -> tuple[float, ...]:
|
|
182
|
+
"""从重复矩形端点恢复列轨并去除一次性装饰边缘。"""
|
|
183
|
+
|
|
184
|
+
edges = [coordinate for rectangle in rectangles for coordinate in (rectangle.bbox[0], rectangle.bbox[2])]
|
|
185
|
+
positions: list[float] = [0.0, width]
|
|
186
|
+
for coordinate, members in cluster_members(edges, tolerance):
|
|
187
|
+
if len(members) < 2:
|
|
188
|
+
continue
|
|
189
|
+
snapped = 0.0 if coordinate <= outer_tolerance else width if width - coordinate <= outer_tolerance else coordinate
|
|
190
|
+
positions.append(snapped)
|
|
191
|
+
tracks = tuple(sorted(set(positions)))
|
|
192
|
+
if any(current <= previous for previous, current in zip(tracks, tracks[1:])):
|
|
193
|
+
return ()
|
|
194
|
+
return tracks
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _filled_band_count(
|
|
198
|
+
rectangles: tuple[_LocalRectangle, ...],
|
|
199
|
+
width: float,
|
|
200
|
+
median_height: float,
|
|
201
|
+
tolerance: float,
|
|
202
|
+
) -> int:
|
|
203
|
+
"""统计能覆盖多列的重复填充行带数量。"""
|
|
204
|
+
|
|
205
|
+
bands: list[tuple[float, float]] = []
|
|
206
|
+
for rectangle in rectangles:
|
|
207
|
+
if not rectangle.fill_visible:
|
|
208
|
+
continue
|
|
209
|
+
left, top, right, bottom = rectangle.bbox
|
|
210
|
+
if bottom - top < 0.75 * median_height or right - left < 0.08 * width:
|
|
211
|
+
continue
|
|
212
|
+
bands.append((top, bottom))
|
|
213
|
+
clustered = cluster_members(
|
|
214
|
+
[(top + bottom) / 2.0 for top, bottom in bands],
|
|
215
|
+
max(tolerance, 0.50 * median_height),
|
|
216
|
+
)
|
|
217
|
+
return len(clustered)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _build_column_hypothesis(
|
|
221
|
+
text: NativeTableText,
|
|
222
|
+
width: float,
|
|
223
|
+
rules: tuple[_LocalRule, ...],
|
|
224
|
+
rectangles: tuple[_LocalRectangle, ...],
|
|
225
|
+
diagnostics: dict[str, Any] | None,
|
|
226
|
+
) -> _ColumnHypothesis | None:
|
|
227
|
+
"""融合矩形端点与文本空白选择唯一列轨假设。"""
|
|
228
|
+
|
|
229
|
+
target_cols = _infer_target_columns(text)
|
|
230
|
+
if target_cols is None:
|
|
231
|
+
if diagnostics is not None:
|
|
232
|
+
diagnostics["first_rejection_gate"] = "column_count"
|
|
233
|
+
return None
|
|
234
|
+
text_tracks = _infer_text_tracks(text, width, target_cols)
|
|
235
|
+
tolerance = max(1.0, 0.25 * text.median_glyph_height)
|
|
236
|
+
rect_tracks = _rectangle_tracks(
|
|
237
|
+
rectangles,
|
|
238
|
+
width,
|
|
239
|
+
tolerance,
|
|
240
|
+
max(tolerance, 1.50 * text.median_glyph_height),
|
|
241
|
+
)
|
|
242
|
+
physical_boundaries: frozenset[int] = frozenset()
|
|
243
|
+
tracks = text_tracks
|
|
244
|
+
if len(rect_tracks) == target_cols + 1:
|
|
245
|
+
tracks = rect_tracks
|
|
246
|
+
physical_boundaries = frozenset(range(1, target_cols))
|
|
247
|
+
if tracks is None:
|
|
248
|
+
if diagnostics is not None:
|
|
249
|
+
diagnostics["first_rejection_gate"] = "column_tracks"
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
band_count = _filled_band_count(
|
|
253
|
+
rectangles,
|
|
254
|
+
width,
|
|
255
|
+
text.median_glyph_height,
|
|
256
|
+
tolerance,
|
|
257
|
+
)
|
|
258
|
+
internal_full_rules = [
|
|
259
|
+
rule
|
|
260
|
+
for rule in rules
|
|
261
|
+
if rule.orientation == "horizontal"
|
|
262
|
+
and rule.end - rule.start >= 0.90 * width
|
|
263
|
+
and 0.01 * max((row.bbox[3] for row in text.rows), default=0.0)
|
|
264
|
+
< rule.coordinate
|
|
265
|
+
< 0.99 * max((row.bbox[3] for row in text.rows), default=0.0)
|
|
266
|
+
]
|
|
267
|
+
if band_count >= 2 and physical_boundaries:
|
|
268
|
+
evidence = "filled_record"
|
|
269
|
+
elif internal_full_rules:
|
|
270
|
+
evidence = "rule_band"
|
|
271
|
+
else:
|
|
272
|
+
evidence = "keyed_record"
|
|
273
|
+
if diagnostics is not None:
|
|
274
|
+
diagnostics.update(
|
|
275
|
+
{
|
|
276
|
+
"target_cols": target_cols,
|
|
277
|
+
"x_tracks": list(tracks),
|
|
278
|
+
"physical_boundaries": sorted(physical_boundaries),
|
|
279
|
+
"filled_band_count": band_count,
|
|
280
|
+
"evidence": evidence,
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
return _ColumnHypothesis(
|
|
284
|
+
evidence=evidence,
|
|
285
|
+
x_tracks=tracks,
|
|
286
|
+
physical_boundaries=physical_boundaries,
|
|
287
|
+
filled_band_count=band_count,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _internal_full_rules(
|
|
292
|
+
rules: tuple[_LocalRule, ...],
|
|
293
|
+
width: float,
|
|
294
|
+
height: float,
|
|
295
|
+
) -> tuple[_LocalRule, ...]:
|
|
296
|
+
"""返回排除上下外框后的长横线。"""
|
|
297
|
+
|
|
298
|
+
return tuple(
|
|
299
|
+
rule
|
|
300
|
+
for rule in rules
|
|
301
|
+
if rule.orientation == "horizontal"
|
|
302
|
+
and rule.end - rule.start >= 0.90 * width
|
|
303
|
+
and 0.01 * height < rule.coordinate < 0.99 * height
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _infer_header_boundary(
|
|
308
|
+
text: NativeTableText,
|
|
309
|
+
x_tracks: tuple[float, ...],
|
|
310
|
+
rules: tuple[_LocalRule, ...],
|
|
311
|
+
width: float,
|
|
312
|
+
height: float,
|
|
313
|
+
evidence: str,
|
|
314
|
+
) -> float | None:
|
|
315
|
+
"""用首条正文锚点和最后一条表头长线确定表头底边。"""
|
|
316
|
+
|
|
317
|
+
if len(text.rows) < 3:
|
|
318
|
+
return None
|
|
319
|
+
target_cols = len(x_tracks) - 1
|
|
320
|
+
body_hint = next(
|
|
321
|
+
(row for row in text.rows[1:] if len(row.tokens) == target_cols),
|
|
322
|
+
text.rows[1],
|
|
323
|
+
)
|
|
324
|
+
body_center = (body_hint.bbox[1] + body_hint.bbox[3]) / 2.0
|
|
325
|
+
candidates = [rule.coordinate for rule in _internal_full_rules(rules, width, height) if rule.coordinate < body_center]
|
|
326
|
+
if candidates:
|
|
327
|
+
boundary = max(candidates)
|
|
328
|
+
else:
|
|
329
|
+
if evidence in {"filled_record", "keyed_record"}:
|
|
330
|
+
first = text.rows[0]
|
|
331
|
+
second = text.rows[1]
|
|
332
|
+
return ((first.bbox[1] + first.bbox[3]) / 2.0 + (second.bbox[1] + second.bbox[3]) / 2.0) / 2.0
|
|
333
|
+
previous_rows = [row for row in text.rows if row.row_index < body_hint.row_index]
|
|
334
|
+
if not previous_rows:
|
|
335
|
+
return None
|
|
336
|
+
previous = previous_rows[-1]
|
|
337
|
+
boundary = ((previous.bbox[1] + previous.bbox[3]) / 2.0 + body_center) / 2.0
|
|
338
|
+
if not text.rows[0].bbox[3] - 0.5 <= boundary <= text.rows[-1].bbox[1] + 0.5:
|
|
339
|
+
return None
|
|
340
|
+
return boundary
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _choose_key_column(
|
|
344
|
+
occupancies: list[set[int]],
|
|
345
|
+
cols: int,
|
|
346
|
+
) -> tuple[int, int]:
|
|
347
|
+
"""选择能重复标记逻辑记录起点的最左稳定关键列。"""
|
|
348
|
+
|
|
349
|
+
stats: list[tuple[int, int, int]] = []
|
|
350
|
+
for col in range(cols):
|
|
351
|
+
flags = [col in occupancy for occupancy in occupancies]
|
|
352
|
+
runs = 0
|
|
353
|
+
previous = False
|
|
354
|
+
for flag in flags:
|
|
355
|
+
if flag and not previous:
|
|
356
|
+
runs += 1
|
|
357
|
+
previous = flag
|
|
358
|
+
occupied = sum(flags)
|
|
359
|
+
stats.append((col, runs, occupied))
|
|
360
|
+
if stats[0][2] >= math.ceil(0.80 * len(occupancies)):
|
|
361
|
+
return 0, stats[0][1]
|
|
362
|
+
repeated = [item for item in stats if item[1] >= 3]
|
|
363
|
+
if repeated:
|
|
364
|
+
maximum_occupied = max(item[2] for item in stats)
|
|
365
|
+
dense_repeated = [item for item in repeated if item[2] >= 0.60 * maximum_occupied]
|
|
366
|
+
if dense_repeated:
|
|
367
|
+
selected = min(dense_repeated, key=lambda item: item[0])
|
|
368
|
+
else:
|
|
369
|
+
selected = max(
|
|
370
|
+
repeated,
|
|
371
|
+
key=lambda item: (item[1], item[2], -item[0]),
|
|
372
|
+
)
|
|
373
|
+
return selected[0], selected[1]
|
|
374
|
+
if stats[0][2] >= 2:
|
|
375
|
+
return 0, stats[0][1]
|
|
376
|
+
selected = max(stats, key=lambda item: (item[2], -item[0]))
|
|
377
|
+
return selected[0], selected[1]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _rule_bands(
|
|
381
|
+
header_bottom: float,
|
|
382
|
+
rules: tuple[_LocalRule, ...],
|
|
383
|
+
width: float,
|
|
384
|
+
height: float,
|
|
385
|
+
) -> list[tuple[float, float]]:
|
|
386
|
+
"""用正文长横线切出有限物理行带。"""
|
|
387
|
+
|
|
388
|
+
boundaries = [header_bottom]
|
|
389
|
+
boundaries.extend(
|
|
390
|
+
rule.coordinate for rule in _internal_full_rules(rules, width, height) if rule.coordinate > header_bottom + 0.5
|
|
391
|
+
)
|
|
392
|
+
boundaries.append(height)
|
|
393
|
+
ordered = sorted(set(boundaries))
|
|
394
|
+
return [(top, bottom) for top, bottom in zip(ordered, ordered[1:]) if bottom - top > 0.5]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _split_rows_by_anchors(
|
|
398
|
+
rows: list[NativeTableTextRow],
|
|
399
|
+
key_col: int,
|
|
400
|
+
occupancies_by_index: dict[int, set[int]],
|
|
401
|
+
*,
|
|
402
|
+
group_short_key_runs: bool,
|
|
403
|
+
) -> list[tuple[int, ...]]:
|
|
404
|
+
"""按关键列锚点把视觉基线拆成逻辑记录组。"""
|
|
405
|
+
|
|
406
|
+
if not rows:
|
|
407
|
+
return []
|
|
408
|
+
anchor_positions = [index for index, row in enumerate(rows) if key_col in occupancies_by_index[row.row_index]]
|
|
409
|
+
if not anchor_positions:
|
|
410
|
+
return []
|
|
411
|
+
if group_short_key_runs:
|
|
412
|
+
runs: list[list[int]] = [[anchor_positions[0]]]
|
|
413
|
+
for position in anchor_positions[1:]:
|
|
414
|
+
if position == runs[-1][-1] + 1:
|
|
415
|
+
runs[-1].append(position)
|
|
416
|
+
else:
|
|
417
|
+
runs.append([position])
|
|
418
|
+
anchors = [run[0] for run in runs]
|
|
419
|
+
else:
|
|
420
|
+
anchors = anchor_positions
|
|
421
|
+
|
|
422
|
+
groups: list[list[int]] = [[] for _ in anchors]
|
|
423
|
+
for position, row in enumerate(rows):
|
|
424
|
+
owner = 0
|
|
425
|
+
for index, anchor in enumerate(anchors):
|
|
426
|
+
if anchor <= position:
|
|
427
|
+
owner = index
|
|
428
|
+
else:
|
|
429
|
+
break
|
|
430
|
+
if position < anchors[0]:
|
|
431
|
+
owner = 0
|
|
432
|
+
groups[owner].append(row.row_index)
|
|
433
|
+
return [tuple(group) for group in groups if group]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _logical_body_rows(
|
|
437
|
+
text: NativeTableText,
|
|
438
|
+
x_tracks: tuple[float, ...],
|
|
439
|
+
rules: tuple[_LocalRule, ...],
|
|
440
|
+
header_bottom: float,
|
|
441
|
+
width: float,
|
|
442
|
+
height: float,
|
|
443
|
+
evidence: str,
|
|
444
|
+
diagnostics: dict[str, Any] | None,
|
|
445
|
+
) -> tuple[list[_LogicalRow], int, list[set[int]]] | None:
|
|
446
|
+
"""结合物理行带和关键列锚点构造正文逻辑行。"""
|
|
447
|
+
|
|
448
|
+
glyph_by_id = {glyph.glyph_id: glyph for glyph in text.glyphs}
|
|
449
|
+
body_rows = [row for row in text.rows if (row.bbox[1] + row.bbox[3]) / 2.0 > header_bottom]
|
|
450
|
+
if len(body_rows) < 2:
|
|
451
|
+
return None
|
|
452
|
+
occupancies = [_row_occupancy(row, glyph_by_id, x_tracks) for row in body_rows]
|
|
453
|
+
occupancies_by_index = {row.row_index: occupancy for row, occupancy in zip(body_rows, occupancies, strict=True)}
|
|
454
|
+
body_internal_rules = [
|
|
455
|
+
rule
|
|
456
|
+
for rule in _internal_full_rules(rules, width, height)
|
|
457
|
+
if header_bottom + 0.5 < rule.coordinate < (body_rows[-1].bbox[1] + body_rows[-1].bbox[3]) / 2.0
|
|
458
|
+
]
|
|
459
|
+
if evidence == "rule_band" and not body_internal_rules:
|
|
460
|
+
seen_first_col = False
|
|
461
|
+
seen_first_col_gap = False
|
|
462
|
+
for occupancy in occupancies:
|
|
463
|
+
if 0 in occupancy:
|
|
464
|
+
if seen_first_col and seen_first_col_gap:
|
|
465
|
+
if diagnostics is not None:
|
|
466
|
+
diagnostics["first_rejection_gate"] = "ambiguous_body_rowspan"
|
|
467
|
+
return None
|
|
468
|
+
seen_first_col = True
|
|
469
|
+
elif seen_first_col and occupancy != {len(x_tracks) - 2}:
|
|
470
|
+
seen_first_col_gap = True
|
|
471
|
+
key_col, key_runs = _choose_key_column(occupancies, len(x_tracks) - 1)
|
|
472
|
+
|
|
473
|
+
flags = [key_col in occupancy for occupancy in occupancies]
|
|
474
|
+
run_lengths: list[int] = []
|
|
475
|
+
gap_lengths: list[int] = []
|
|
476
|
+
index = 0
|
|
477
|
+
while index < len(flags):
|
|
478
|
+
if flags[index]:
|
|
479
|
+
end = index
|
|
480
|
+
while end + 1 < len(flags) and flags[end + 1]:
|
|
481
|
+
end += 1
|
|
482
|
+
run_lengths.append(end - index + 1)
|
|
483
|
+
index = end + 1
|
|
484
|
+
else:
|
|
485
|
+
end = index
|
|
486
|
+
while end + 1 < len(flags) and not flags[end + 1]:
|
|
487
|
+
end += 1
|
|
488
|
+
gap_lengths.append(end - index + 1)
|
|
489
|
+
index = end + 1
|
|
490
|
+
group_short_runs = (
|
|
491
|
+
evidence == "keyed_record"
|
|
492
|
+
and key_runs >= 3
|
|
493
|
+
and run_lengths
|
|
494
|
+
and statistics.median(run_lengths) <= 2
|
|
495
|
+
and gap_lengths
|
|
496
|
+
and statistics.median(gap_lengths) >= 2
|
|
497
|
+
)
|
|
498
|
+
if evidence == "keyed_record" and not group_short_runs:
|
|
499
|
+
if diagnostics is not None:
|
|
500
|
+
diagnostics["first_rejection_gate"] = "record_key_support"
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
grouped_indices: list[tuple[int, ...]] = []
|
|
504
|
+
for top, bottom in _rule_bands(header_bottom, rules, width, height):
|
|
505
|
+
band_rows = [row for row in body_rows if top <= (row.bbox[1] + row.bbox[3]) / 2.0 <= bottom]
|
|
506
|
+
if not band_rows:
|
|
507
|
+
continue
|
|
508
|
+
grouped_indices.extend(
|
|
509
|
+
_split_rows_by_anchors(
|
|
510
|
+
band_rows,
|
|
511
|
+
key_col,
|
|
512
|
+
occupancies_by_index,
|
|
513
|
+
group_short_key_runs=group_short_runs,
|
|
514
|
+
)
|
|
515
|
+
)
|
|
516
|
+
if len(grouped_indices) < 2:
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
row_by_index = {row.row_index: row for row in body_rows}
|
|
520
|
+
raw_extents = [
|
|
521
|
+
(
|
|
522
|
+
min(row_by_index[index].bbox[1] for index in indices),
|
|
523
|
+
max(row_by_index[index].bbox[3] for index in indices),
|
|
524
|
+
)
|
|
525
|
+
for indices in grouped_indices
|
|
526
|
+
]
|
|
527
|
+
boundaries = [header_bottom]
|
|
528
|
+
for first, second in zip(raw_extents, raw_extents[1:]):
|
|
529
|
+
if first[1] <= second[0]:
|
|
530
|
+
boundary = (first[1] + second[0]) / 2.0
|
|
531
|
+
else:
|
|
532
|
+
boundary = ((first[0] + first[1]) / 2.0 + (second[0] + second[1]) / 2.0) / 2.0
|
|
533
|
+
boundaries.append(boundary)
|
|
534
|
+
boundaries.append(height)
|
|
535
|
+
if any(current <= previous for previous, current in zip(boundaries, boundaries[1:])):
|
|
536
|
+
return None
|
|
537
|
+
logical_rows = [
|
|
538
|
+
_LogicalRow(
|
|
539
|
+
visual_indices=indices,
|
|
540
|
+
top=boundaries[index],
|
|
541
|
+
bottom=boundaries[index + 1],
|
|
542
|
+
)
|
|
543
|
+
for index, indices in enumerate(grouped_indices)
|
|
544
|
+
]
|
|
545
|
+
logical_occupancies = [
|
|
546
|
+
set().union(*(occupancies_by_index[index] for index in logical.visual_indices)) for logical in logical_rows
|
|
547
|
+
]
|
|
548
|
+
if diagnostics is not None:
|
|
549
|
+
diagnostics.update(
|
|
550
|
+
{
|
|
551
|
+
"key_col": key_col,
|
|
552
|
+
"key_runs": key_runs,
|
|
553
|
+
"group_short_key_runs": group_short_runs,
|
|
554
|
+
"logical_body_rows": len(logical_rows),
|
|
555
|
+
"body_groups": [list(row.visual_indices) for row in logical_rows],
|
|
556
|
+
}
|
|
557
|
+
)
|
|
558
|
+
return logical_rows, key_col, logical_occupancies
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _separator_coverage(
|
|
562
|
+
rules: tuple[_LocalRule, ...],
|
|
563
|
+
y: float,
|
|
564
|
+
left: float,
|
|
565
|
+
right: float,
|
|
566
|
+
tolerance: float,
|
|
567
|
+
) -> float:
|
|
568
|
+
"""计算一条表头局部分隔在指定列带的覆盖率。"""
|
|
569
|
+
|
|
570
|
+
intervals = [
|
|
571
|
+
(rule.start, rule.end) for rule in rules if rule.orientation == "horizontal" and abs(rule.coordinate - y) <= tolerance
|
|
572
|
+
]
|
|
573
|
+
return covered_interval_ratio(intervals, left, right)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _header_separator(
|
|
577
|
+
text: NativeTableText,
|
|
578
|
+
rules: tuple[_LocalRule, ...],
|
|
579
|
+
header_bottom: float,
|
|
580
|
+
width: float,
|
|
581
|
+
) -> float | None:
|
|
582
|
+
"""选择表头内部唯一的完整或局部分隔线。"""
|
|
583
|
+
|
|
584
|
+
header_rows = [row for row in text.rows if (row.bbox[1] + row.bbox[3]) / 2.0 < header_bottom]
|
|
585
|
+
if len(header_rows) < 2:
|
|
586
|
+
return None
|
|
587
|
+
candidates = [
|
|
588
|
+
rule
|
|
589
|
+
for rule in rules
|
|
590
|
+
if rule.orientation == "horizontal"
|
|
591
|
+
and rule.end - rule.start >= 0.20 * width
|
|
592
|
+
and header_rows[0].bbox[3] - 0.5 < rule.coordinate < header_bottom - 0.5
|
|
593
|
+
]
|
|
594
|
+
if not candidates:
|
|
595
|
+
return None
|
|
596
|
+
candidates.sort(
|
|
597
|
+
key=lambda rule: (
|
|
598
|
+
rule.end - rule.start,
|
|
599
|
+
-abs(rule.coordinate - header_bottom / 2.0),
|
|
600
|
+
),
|
|
601
|
+
reverse=True,
|
|
602
|
+
)
|
|
603
|
+
return candidates[0].coordinate
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _header_layer_tokens(
|
|
607
|
+
text: NativeTableText,
|
|
608
|
+
top: float,
|
|
609
|
+
bottom: float,
|
|
610
|
+
) -> list[tuple[float, float, float]]:
|
|
611
|
+
"""收集一个表头层中 token 的水平区间和中心。"""
|
|
612
|
+
|
|
613
|
+
tokens: list[tuple[float, float, float]] = []
|
|
614
|
+
for row in text.rows:
|
|
615
|
+
center_y = (row.bbox[1] + row.bbox[3]) / 2.0
|
|
616
|
+
if not top <= center_y <= bottom:
|
|
617
|
+
continue
|
|
618
|
+
tokens.extend(
|
|
619
|
+
(
|
|
620
|
+
token.bbox[0],
|
|
621
|
+
token.bbox[2],
|
|
622
|
+
(token.bbox[0] + token.bbox[2]) / 2.0,
|
|
623
|
+
)
|
|
624
|
+
for token in row.tokens
|
|
625
|
+
)
|
|
626
|
+
return tokens
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _two_layer_header_specs(
|
|
630
|
+
text: NativeTableText,
|
|
631
|
+
x_tracks: tuple[float, ...],
|
|
632
|
+
header_bottom: float,
|
|
633
|
+
separator: float,
|
|
634
|
+
rules: tuple[_LocalRule, ...],
|
|
635
|
+
) -> tuple[GridCellSpec, ...] | None:
|
|
636
|
+
"""用表头局部分隔恢复两层表头合并格。"""
|
|
637
|
+
|
|
638
|
+
cols = len(x_tracks) - 1
|
|
639
|
+
tolerance = max(1.0, 0.25 * text.median_glyph_height)
|
|
640
|
+
coverages = [
|
|
641
|
+
_separator_coverage(
|
|
642
|
+
rules,
|
|
643
|
+
separator,
|
|
644
|
+
x_tracks[col],
|
|
645
|
+
x_tracks[col + 1],
|
|
646
|
+
tolerance,
|
|
647
|
+
)
|
|
648
|
+
for col in range(cols)
|
|
649
|
+
]
|
|
650
|
+
if any(0.20 < coverage < 0.80 for coverage in coverages):
|
|
651
|
+
return None
|
|
652
|
+
absent_cols = {col for col, coverage in enumerate(coverages) if coverage <= 0.20}
|
|
653
|
+
present_cols = set(range(cols)) - absent_cols
|
|
654
|
+
specs: list[GridCellSpec] = [
|
|
655
|
+
GridCellSpec(
|
|
656
|
+
row=0,
|
|
657
|
+
col=col,
|
|
658
|
+
rowspan=2,
|
|
659
|
+
colspan=1,
|
|
660
|
+
bbox=(x_tracks[col], 0.0, x_tracks[col + 1], header_bottom),
|
|
661
|
+
)
|
|
662
|
+
for col in sorted(absent_cols)
|
|
663
|
+
]
|
|
664
|
+
glyph_by_id = {glyph.glyph_id: glyph for glyph in text.glyphs}
|
|
665
|
+
bottom_occupied: set[int] = set()
|
|
666
|
+
for row in text.rows:
|
|
667
|
+
center_y = (row.bbox[1] + row.bbox[3]) / 2.0
|
|
668
|
+
if not separator < center_y < header_bottom:
|
|
669
|
+
continue
|
|
670
|
+
bottom_occupied.update(_row_occupancy(row, glyph_by_id, x_tracks))
|
|
671
|
+
top_tokens = _header_layer_tokens(text, 0.0, separator)
|
|
672
|
+
assignments: dict[int, list[int]] = {index: [] for index in range(len(top_tokens))}
|
|
673
|
+
for col in sorted(bottom_occupied.intersection(present_cols)):
|
|
674
|
+
if not top_tokens:
|
|
675
|
+
return None
|
|
676
|
+
col_center = (x_tracks[col] + x_tracks[col + 1]) / 2.0
|
|
677
|
+
owner = min(
|
|
678
|
+
range(len(top_tokens)),
|
|
679
|
+
key=lambda index: abs(col_center - top_tokens[index][2]),
|
|
680
|
+
)
|
|
681
|
+
assignments[owner].append(col)
|
|
682
|
+
|
|
683
|
+
covered: set[int] = set()
|
|
684
|
+
for index, (_left, _right, center) in enumerate(top_tokens):
|
|
685
|
+
group = assignments[index]
|
|
686
|
+
if not group:
|
|
687
|
+
continue
|
|
688
|
+
if group != list(range(group[0], group[-1] + 1)):
|
|
689
|
+
return None
|
|
690
|
+
if not x_tracks[group[0]] <= center <= x_tracks[group[-1] + 1]:
|
|
691
|
+
return None
|
|
692
|
+
specs.append(
|
|
693
|
+
GridCellSpec(
|
|
694
|
+
row=0,
|
|
695
|
+
col=group[0],
|
|
696
|
+
rowspan=1,
|
|
697
|
+
colspan=group[-1] - group[0] + 1,
|
|
698
|
+
bbox=(
|
|
699
|
+
x_tracks[group[0]],
|
|
700
|
+
0.0,
|
|
701
|
+
x_tracks[group[-1] + 1],
|
|
702
|
+
separator,
|
|
703
|
+
),
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
covered.update(group)
|
|
707
|
+
for col in sorted(present_cols - covered):
|
|
708
|
+
specs.append(
|
|
709
|
+
GridCellSpec(
|
|
710
|
+
row=0,
|
|
711
|
+
col=col,
|
|
712
|
+
rowspan=1,
|
|
713
|
+
colspan=1,
|
|
714
|
+
bbox=(x_tracks[col], 0.0, x_tracks[col + 1], separator),
|
|
715
|
+
)
|
|
716
|
+
)
|
|
717
|
+
for col in sorted(present_cols):
|
|
718
|
+
specs.append(
|
|
719
|
+
GridCellSpec(
|
|
720
|
+
row=1,
|
|
721
|
+
col=col,
|
|
722
|
+
rowspan=1,
|
|
723
|
+
colspan=1,
|
|
724
|
+
bbox=(x_tracks[col], separator, x_tracks[col + 1], header_bottom),
|
|
725
|
+
)
|
|
726
|
+
)
|
|
727
|
+
return tuple(specs)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _logical_cell_has_glyph(
|
|
731
|
+
text: NativeTableText,
|
|
732
|
+
logical_row: _LogicalRow,
|
|
733
|
+
col: int,
|
|
734
|
+
x_tracks: tuple[float, ...],
|
|
735
|
+
) -> bool:
|
|
736
|
+
"""判断一个逻辑正文格是否含有字符中心。"""
|
|
737
|
+
|
|
738
|
+
visual_indices = set(logical_row.visual_indices)
|
|
739
|
+
for glyph in text.glyphs:
|
|
740
|
+
if glyph.visual_row not in visual_indices:
|
|
741
|
+
continue
|
|
742
|
+
center_x = (glyph.bbox[0] + glyph.bbox[2]) / 2.0
|
|
743
|
+
if x_tracks[col] <= center_x <= x_tracks[col + 1]:
|
|
744
|
+
return True
|
|
745
|
+
return False
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _body_specs(
|
|
749
|
+
text: NativeTableText,
|
|
750
|
+
x_tracks: tuple[float, ...],
|
|
751
|
+
logical_rows: list[_LogicalRow],
|
|
752
|
+
row_offset: int,
|
|
753
|
+
evidence: str,
|
|
754
|
+
key_col: int,
|
|
755
|
+
) -> tuple[GridCellSpec, ...]:
|
|
756
|
+
"""构造正文网格,并仅在填充记录表中推断首列 rowspan。"""
|
|
757
|
+
|
|
758
|
+
cols = len(x_tracks) - 1
|
|
759
|
+
specs: list[GridCellSpec] = []
|
|
760
|
+
span_lengths: dict[int, int] = {}
|
|
761
|
+
if evidence == "filled_record" and key_col > 0:
|
|
762
|
+
occupied_rows = [
|
|
763
|
+
index for index, logical in enumerate(logical_rows) if _logical_cell_has_glyph(text, logical, 0, x_tracks)
|
|
764
|
+
]
|
|
765
|
+
for position, start in enumerate(occupied_rows):
|
|
766
|
+
end = occupied_rows[position + 1] if position + 1 < len(occupied_rows) else len(logical_rows)
|
|
767
|
+
span_lengths[start] = max(1, end - start)
|
|
768
|
+
|
|
769
|
+
covered_first_col: set[int] = set()
|
|
770
|
+
for body_row, logical in enumerate(logical_rows):
|
|
771
|
+
if body_row in span_lengths:
|
|
772
|
+
rowspan = span_lengths[body_row]
|
|
773
|
+
specs.append(
|
|
774
|
+
GridCellSpec(
|
|
775
|
+
row=row_offset + body_row,
|
|
776
|
+
col=0,
|
|
777
|
+
rowspan=rowspan,
|
|
778
|
+
colspan=1,
|
|
779
|
+
bbox=(
|
|
780
|
+
x_tracks[0],
|
|
781
|
+
logical.top,
|
|
782
|
+
x_tracks[1],
|
|
783
|
+
logical_rows[body_row + rowspan - 1].bottom,
|
|
784
|
+
),
|
|
785
|
+
)
|
|
786
|
+
)
|
|
787
|
+
covered_first_col.update(range(body_row, body_row + rowspan))
|
|
788
|
+
elif body_row not in covered_first_col:
|
|
789
|
+
specs.append(
|
|
790
|
+
GridCellSpec(
|
|
791
|
+
row=row_offset + body_row,
|
|
792
|
+
col=0,
|
|
793
|
+
rowspan=1,
|
|
794
|
+
colspan=1,
|
|
795
|
+
bbox=(x_tracks[0], logical.top, x_tracks[1], logical.bottom),
|
|
796
|
+
)
|
|
797
|
+
)
|
|
798
|
+
for col in range(1, cols):
|
|
799
|
+
specs.append(
|
|
800
|
+
GridCellSpec(
|
|
801
|
+
row=row_offset + body_row,
|
|
802
|
+
col=col,
|
|
803
|
+
rowspan=1,
|
|
804
|
+
colspan=1,
|
|
805
|
+
bbox=(
|
|
806
|
+
x_tracks[col],
|
|
807
|
+
logical.top,
|
|
808
|
+
x_tracks[col + 1],
|
|
809
|
+
logical.bottom,
|
|
810
|
+
),
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
return tuple(specs)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _stable_gutters(
|
|
817
|
+
text: NativeTableText,
|
|
818
|
+
x_tracks: tuple[float, ...],
|
|
819
|
+
physical_boundaries: frozenset[int],
|
|
820
|
+
header_bottom: float,
|
|
821
|
+
diagnostics: dict[str, Any] | None,
|
|
822
|
+
) -> bool:
|
|
823
|
+
"""校验每条文本列边界都由稳定空白走廊或物理边缘支持。"""
|
|
824
|
+
|
|
825
|
+
edge_tolerance = max(0.15, 0.03 * text.median_glyph_width)
|
|
826
|
+
supports: list[int] = []
|
|
827
|
+
glyphs_by_row: dict[int, list[NativeTableGlyph]] = {}
|
|
828
|
+
for glyph in text.glyphs:
|
|
829
|
+
glyphs_by_row.setdefault(glyph.visual_row, []).append(glyph)
|
|
830
|
+
body_glyphs = [
|
|
831
|
+
glyph
|
|
832
|
+
for glyph in text.glyphs
|
|
833
|
+
if (text.rows[glyph.visual_row].bbox[1] + text.rows[glyph.visual_row].bbox[3]) / 2.0 > header_bottom
|
|
834
|
+
]
|
|
835
|
+
for boundary_index, boundary in enumerate(x_tracks[1:-1], start=1):
|
|
836
|
+
if any(glyph.bbox[0] + edge_tolerance < boundary < glyph.bbox[2] - edge_tolerance for glyph in body_glyphs):
|
|
837
|
+
if diagnostics is not None:
|
|
838
|
+
diagnostics.update(
|
|
839
|
+
{
|
|
840
|
+
"failed_gutter_boundary": boundary_index,
|
|
841
|
+
"failed_gutter_reason": "glyph_crossing",
|
|
842
|
+
}
|
|
843
|
+
)
|
|
844
|
+
return False
|
|
845
|
+
if boundary_index in physical_boundaries:
|
|
846
|
+
supports.append(len(text.rows))
|
|
847
|
+
continue
|
|
848
|
+
comparable = 0
|
|
849
|
+
stable = 0
|
|
850
|
+
for row in text.rows:
|
|
851
|
+
if (row.bbox[1] + row.bbox[3]) / 2.0 <= header_bottom:
|
|
852
|
+
continue
|
|
853
|
+
row_glyphs = glyphs_by_row.get(row.row_index, [])
|
|
854
|
+
left_glyphs = [glyph for glyph in row_glyphs if glyph.bbox[2] <= boundary]
|
|
855
|
+
right_glyphs = [glyph for glyph in row_glyphs if glyph.bbox[0] >= boundary]
|
|
856
|
+
if not left_glyphs or not right_glyphs:
|
|
857
|
+
continue
|
|
858
|
+
comparable += 1
|
|
859
|
+
gap = min(glyph.bbox[0] for glyph in right_glyphs) - max(glyph.bbox[2] for glyph in left_glyphs)
|
|
860
|
+
if gap >= max(0.25, 0.10 * text.median_glyph_width):
|
|
861
|
+
stable += 1
|
|
862
|
+
if comparable < 3 or stable / comparable < 0.80:
|
|
863
|
+
if diagnostics is not None:
|
|
864
|
+
diagnostics.update(
|
|
865
|
+
{
|
|
866
|
+
"failed_gutter_boundary": boundary_index,
|
|
867
|
+
"failed_gutter_reason": "support",
|
|
868
|
+
"failed_gutter_comparable": comparable,
|
|
869
|
+
"failed_gutter_stable": stable,
|
|
870
|
+
}
|
|
871
|
+
)
|
|
872
|
+
return False
|
|
873
|
+
supports.append(stable)
|
|
874
|
+
if diagnostics is not None:
|
|
875
|
+
diagnostics["gutter_supports"] = supports
|
|
876
|
+
return True
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _has_overlapping_formula_rows(
|
|
880
|
+
text: NativeTableText,
|
|
881
|
+
header_bottom: float,
|
|
882
|
+
) -> bool:
|
|
883
|
+
"""识别高公式字符框跨越相邻逻辑行的危险表格。"""
|
|
884
|
+
|
|
885
|
+
body_rows = [row for row in text.rows if (row.bbox[1] + row.bbox[3]) / 2.0 > header_bottom]
|
|
886
|
+
if any(row.bbox[3] - row.bbox[1] > 3.0 * text.median_glyph_height for row in body_rows):
|
|
887
|
+
return True
|
|
888
|
+
return False
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _has_ambiguous_body_descriptor(
|
|
892
|
+
logical_occupancies: list[set[int]],
|
|
893
|
+
evidence: str,
|
|
894
|
+
) -> bool:
|
|
895
|
+
"""识别无线正文中首列空缺后再次出现而无法唯一确定 rowspan 的情况。"""
|
|
896
|
+
|
|
897
|
+
if evidence == "filled_record":
|
|
898
|
+
return False
|
|
899
|
+
seen_nonempty = False
|
|
900
|
+
seen_gap = False
|
|
901
|
+
for occupancy in logical_occupancies:
|
|
902
|
+
if 0 in occupancy:
|
|
903
|
+
if seen_nonempty and seen_gap:
|
|
904
|
+
return True
|
|
905
|
+
seen_nonempty = True
|
|
906
|
+
elif seen_nonempty:
|
|
907
|
+
seen_gap = True
|
|
908
|
+
return False
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _build_candidate(
|
|
912
|
+
table_input: NativeTableInput,
|
|
913
|
+
text: NativeTableText,
|
|
914
|
+
diagnostics: dict[str, Any] | None,
|
|
915
|
+
) -> NativeTableCandidate | None:
|
|
916
|
+
"""构造一个末级多行少线候选并执行高置信硬门。"""
|
|
917
|
+
|
|
918
|
+
table_bbox = normalize_bbox(table_input.table_bbox)
|
|
919
|
+
if table_bbox is None:
|
|
920
|
+
return None
|
|
921
|
+
width, height = table_local_size(
|
|
922
|
+
table_bbox,
|
|
923
|
+
normalize_angle(table_input.angle),
|
|
924
|
+
)
|
|
925
|
+
rules = _local_rules(table_input, width, height)
|
|
926
|
+
rectangles = _local_rectangles(table_input, width, height)
|
|
927
|
+
hypothesis = _build_column_hypothesis(
|
|
928
|
+
text,
|
|
929
|
+
width,
|
|
930
|
+
rules,
|
|
931
|
+
rectangles,
|
|
932
|
+
diagnostics,
|
|
933
|
+
)
|
|
934
|
+
if hypothesis is None:
|
|
935
|
+
return None
|
|
936
|
+
header_bottom = _infer_header_boundary(
|
|
937
|
+
text,
|
|
938
|
+
hypothesis.x_tracks,
|
|
939
|
+
rules,
|
|
940
|
+
width,
|
|
941
|
+
height,
|
|
942
|
+
hypothesis.evidence,
|
|
943
|
+
)
|
|
944
|
+
if header_bottom is None:
|
|
945
|
+
if diagnostics is not None:
|
|
946
|
+
diagnostics["first_rejection_gate"] = "header_boundary"
|
|
947
|
+
return None
|
|
948
|
+
if _has_overlapping_formula_rows(text, header_bottom):
|
|
949
|
+
if diagnostics is not None:
|
|
950
|
+
diagnostics["first_rejection_gate"] = "overlapping_formula_rows"
|
|
951
|
+
return None
|
|
952
|
+
body = _logical_body_rows(
|
|
953
|
+
text,
|
|
954
|
+
hypothesis.x_tracks,
|
|
955
|
+
rules,
|
|
956
|
+
header_bottom,
|
|
957
|
+
width,
|
|
958
|
+
height,
|
|
959
|
+
hypothesis.evidence,
|
|
960
|
+
diagnostics,
|
|
961
|
+
)
|
|
962
|
+
if body is None:
|
|
963
|
+
if diagnostics is not None and diagnostics.get("first_rejection_gate") is None:
|
|
964
|
+
diagnostics["first_rejection_gate"] = "logical_rows"
|
|
965
|
+
return None
|
|
966
|
+
logical_rows, key_col, logical_occupancies = body
|
|
967
|
+
if _has_ambiguous_body_descriptor(
|
|
968
|
+
logical_occupancies,
|
|
969
|
+
hypothesis.evidence,
|
|
970
|
+
):
|
|
971
|
+
if diagnostics is not None:
|
|
972
|
+
diagnostics["first_rejection_gate"] = "ambiguous_body_rowspan"
|
|
973
|
+
return None
|
|
974
|
+
if not _stable_gutters(
|
|
975
|
+
text,
|
|
976
|
+
hypothesis.x_tracks,
|
|
977
|
+
hypothesis.physical_boundaries,
|
|
978
|
+
header_bottom,
|
|
979
|
+
diagnostics,
|
|
980
|
+
):
|
|
981
|
+
if diagnostics is not None:
|
|
982
|
+
diagnostics["first_rejection_gate"] = "gutter_support"
|
|
983
|
+
return None
|
|
984
|
+
|
|
985
|
+
separator = _header_separator(text, rules, header_bottom, width)
|
|
986
|
+
if separator is None:
|
|
987
|
+
header_rows = 1
|
|
988
|
+
header_specs = tuple(
|
|
989
|
+
GridCellSpec(
|
|
990
|
+
row=0,
|
|
991
|
+
col=col,
|
|
992
|
+
rowspan=1,
|
|
993
|
+
colspan=1,
|
|
994
|
+
bbox=(
|
|
995
|
+
hypothesis.x_tracks[col],
|
|
996
|
+
0.0,
|
|
997
|
+
hypothesis.x_tracks[col + 1],
|
|
998
|
+
header_bottom,
|
|
999
|
+
),
|
|
1000
|
+
)
|
|
1001
|
+
for col in range(len(hypothesis.x_tracks) - 1)
|
|
1002
|
+
)
|
|
1003
|
+
else:
|
|
1004
|
+
header_rows = 2
|
|
1005
|
+
header_specs = _two_layer_header_specs(
|
|
1006
|
+
text,
|
|
1007
|
+
hypothesis.x_tracks,
|
|
1008
|
+
header_bottom,
|
|
1009
|
+
separator,
|
|
1010
|
+
rules,
|
|
1011
|
+
)
|
|
1012
|
+
if header_specs is None:
|
|
1013
|
+
if diagnostics is not None:
|
|
1014
|
+
diagnostics["first_rejection_gate"] = "header_topology"
|
|
1015
|
+
return None
|
|
1016
|
+
body_specs = _body_specs(
|
|
1017
|
+
text,
|
|
1018
|
+
hypothesis.x_tracks,
|
|
1019
|
+
logical_rows,
|
|
1020
|
+
header_rows,
|
|
1021
|
+
hypothesis.evidence,
|
|
1022
|
+
key_col,
|
|
1023
|
+
)
|
|
1024
|
+
candidate_diagnostics: dict[str, object] = {}
|
|
1025
|
+
candidate = build_candidate(
|
|
1026
|
+
source="sparse_multiline",
|
|
1027
|
+
rows=header_rows + len(logical_rows),
|
|
1028
|
+
cols=len(hypothesis.x_tracks) - 1,
|
|
1029
|
+
specs=(*header_specs, *body_specs),
|
|
1030
|
+
text=text,
|
|
1031
|
+
structure_support=1.0,
|
|
1032
|
+
row_stability=1.0,
|
|
1033
|
+
column_stability=1.0,
|
|
1034
|
+
issues=(
|
|
1035
|
+
f"evidence={hypothesis.evidence}",
|
|
1036
|
+
f"header_rows={header_rows}",
|
|
1037
|
+
f"logical_body_rows={len(logical_rows)}",
|
|
1038
|
+
f"key_col={key_col}",
|
|
1039
|
+
f"filled_band_count={hypothesis.filled_band_count}",
|
|
1040
|
+
),
|
|
1041
|
+
use_grid_index=True,
|
|
1042
|
+
diagnostics=candidate_diagnostics,
|
|
1043
|
+
)
|
|
1044
|
+
if candidate is None:
|
|
1045
|
+
if diagnostics is not None:
|
|
1046
|
+
diagnostics["first_rejection_gate"] = candidate_diagnostics.get(
|
|
1047
|
+
"candidate_rejection_gate",
|
|
1048
|
+
"candidate_hard_gate",
|
|
1049
|
+
)
|
|
1050
|
+
return None
|
|
1051
|
+
if diagnostics is not None:
|
|
1052
|
+
diagnostics["candidate_diagnostics"] = dict(candidate_diagnostics)
|
|
1053
|
+
diagnostics["candidate_components"] = {
|
|
1054
|
+
"text_capture": candidate.text_capture,
|
|
1055
|
+
"order_consistency": candidate.order_consistency,
|
|
1056
|
+
"score": candidate.score,
|
|
1057
|
+
"ambiguous_glyph_ratio": candidate_diagnostics.get(
|
|
1058
|
+
"ambiguous_glyph_ratio",
|
|
1059
|
+
1.0,
|
|
1060
|
+
),
|
|
1061
|
+
}
|
|
1062
|
+
ambiguous_ratio = float(candidate_diagnostics.get("ambiguous_glyph_ratio", 1.0))
|
|
1063
|
+
if (
|
|
1064
|
+
candidate.text_capture < 1.0
|
|
1065
|
+
or candidate.order_consistency < 1.0
|
|
1066
|
+
or ambiguous_ratio > 0.0
|
|
1067
|
+
or candidate.score < MIN_MULTILINE_RELIABILITY
|
|
1068
|
+
):
|
|
1069
|
+
if diagnostics is not None:
|
|
1070
|
+
diagnostics["first_rejection_gate"] = "verified_integrity"
|
|
1071
|
+
return None
|
|
1072
|
+
if hypothesis.evidence == "keyed_record" and len(logical_rows) < 3:
|
|
1073
|
+
if diagnostics is not None:
|
|
1074
|
+
diagnostics["first_rejection_gate"] = "record_count"
|
|
1075
|
+
return None
|
|
1076
|
+
if diagnostics is not None:
|
|
1077
|
+
diagnostics.update(
|
|
1078
|
+
{
|
|
1079
|
+
"first_rejection_gate": None,
|
|
1080
|
+
"grid": {"rows": candidate.rows, "cols": candidate.cols},
|
|
1081
|
+
"header_bottom": header_bottom,
|
|
1082
|
+
"header_rows": header_rows,
|
|
1083
|
+
"score": candidate.score,
|
|
1084
|
+
"token_split_count": candidate_diagnostics.get(
|
|
1085
|
+
"token_split_count",
|
|
1086
|
+
0,
|
|
1087
|
+
),
|
|
1088
|
+
}
|
|
1089
|
+
)
|
|
1090
|
+
return candidate
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def build_sparse_multiline_candidates(
|
|
1094
|
+
table_input: NativeTableInput,
|
|
1095
|
+
text: NativeTableText,
|
|
1096
|
+
diagnostics: list[dict[str, Any]] | None = None,
|
|
1097
|
+
) -> list[NativeTableCandidate]:
|
|
1098
|
+
"""生成仅在既有候选全部失败后运行的多行少线候选。"""
|
|
1099
|
+
|
|
1100
|
+
record: dict[str, Any] | None = {"source": "sparse_multiline"} if diagnostics is not None else None
|
|
1101
|
+
candidate = _build_candidate(table_input, text, record)
|
|
1102
|
+
if diagnostics is not None and record is not None:
|
|
1103
|
+
diagnostics.append(record)
|
|
1104
|
+
return [candidate] if candidate is not None else []
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def diagnose_sparse_multiline_candidate_builds(
|
|
1108
|
+
table_input: NativeTableInput,
|
|
1109
|
+
text: NativeTableText,
|
|
1110
|
+
) -> tuple[dict[str, Any], ...]:
|
|
1111
|
+
"""重放多行少线候选构造并返回私有诊断。"""
|
|
1112
|
+
|
|
1113
|
+
diagnostics: list[dict[str, Any]] = []
|
|
1114
|
+
build_sparse_multiline_candidates(
|
|
1115
|
+
table_input,
|
|
1116
|
+
text,
|
|
1117
|
+
diagnostics=diagnostics,
|
|
1118
|
+
)
|
|
1119
|
+
return tuple(diagnostics)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
__all__ = ["build_sparse_multiline_candidates"]
|