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,804 @@
|
|
|
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, 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_COLUMN_SUPPORT = 0.60
|
|
17
|
+
MIN_OVERALL_ANCHOR_SUPPORT = 0.80
|
|
18
|
+
MIN_SPARSE_RELIABILITY = 0.98
|
|
19
|
+
MAX_SPARSE_HYPOTHESES = 8
|
|
20
|
+
MAX_HEADER_ROWS = 2
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class _TrackHypothesis:
|
|
25
|
+
"""保存一组少线表叶子列轨及其独立证据。"""
|
|
26
|
+
|
|
27
|
+
evidence: str
|
|
28
|
+
x_tracks: tuple[float, ...]
|
|
29
|
+
physical_boundaries: frozenset[int]
|
|
30
|
+
body_start: int
|
|
31
|
+
reliability: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class _DenseLayout:
|
|
36
|
+
"""保存正文稠密行推断出的列数和连续正文起点。"""
|
|
37
|
+
|
|
38
|
+
target_cols: int
|
|
39
|
+
body_start: int
|
|
40
|
+
dense_row_indices: tuple[int, ...]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _long_horizontal_rules(
|
|
44
|
+
rules: tuple[_LocalRule, ...],
|
|
45
|
+
width: float,
|
|
46
|
+
) -> tuple[_LocalRule, ...]:
|
|
47
|
+
"""筛选能够独立证明表带存在的长横线。"""
|
|
48
|
+
|
|
49
|
+
return tuple(rule for rule in rules if rule.orientation == "horizontal" and rule.end - rule.start >= 0.50 * width)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _vertical_track_evidence(
|
|
53
|
+
rules: tuple[_LocalRule, ...],
|
|
54
|
+
width: float,
|
|
55
|
+
height: float,
|
|
56
|
+
tolerance: float,
|
|
57
|
+
) -> tuple[tuple[float, ...], dict[float, float]]:
|
|
58
|
+
"""合并同 X 分段竖线并返回覆盖足够的物理列轨。"""
|
|
59
|
+
|
|
60
|
+
vertical_rules = [rule for rule in rules if rule.orientation == "vertical"]
|
|
61
|
+
clusters = cluster_members(
|
|
62
|
+
[rule.coordinate for rule in vertical_rules],
|
|
63
|
+
tolerance,
|
|
64
|
+
)
|
|
65
|
+
positions: list[float] = []
|
|
66
|
+
coverages: dict[float, float] = {}
|
|
67
|
+
for coordinate, _members in clusters:
|
|
68
|
+
intervals = [(rule.start, rule.end) for rule in vertical_rules if abs(rule.coordinate - coordinate) <= tolerance]
|
|
69
|
+
coverage = covered_interval_ratio(intervals, 0.0, height)
|
|
70
|
+
if coverage < 0.75:
|
|
71
|
+
continue
|
|
72
|
+
snapped = 0.0 if coordinate <= tolerance else width if width - coordinate <= tolerance else coordinate
|
|
73
|
+
positions.append(snapped)
|
|
74
|
+
coverages[snapped] = max(coverages.get(snapped, 0.0), coverage)
|
|
75
|
+
positions.extend([0.0, width])
|
|
76
|
+
return tuple(sorted(set(positions))), coverages
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _rectangle_edge_evidence(
|
|
80
|
+
table_input: NativeTableInput,
|
|
81
|
+
text: NativeTableText,
|
|
82
|
+
width: float,
|
|
83
|
+
height: float,
|
|
84
|
+
tolerance: float,
|
|
85
|
+
) -> tuple[float, ...]:
|
|
86
|
+
"""从表头单元格矩形和上下细条中提取可复现的列边界。"""
|
|
87
|
+
|
|
88
|
+
table_bbox = normalize_bbox(table_input.table_bbox)
|
|
89
|
+
if table_bbox is None:
|
|
90
|
+
return ()
|
|
91
|
+
angle = normalize_angle(table_input.angle)
|
|
92
|
+
raw_edges: list[float] = []
|
|
93
|
+
header_limit = min(0.30 * height, 3.0 * text.median_glyph_height)
|
|
94
|
+
thin_limit = max(1.5, 0.40 * text.median_glyph_height)
|
|
95
|
+
for rectangle in table_input.rectangles:
|
|
96
|
+
if rectangle.segment_count != 5 or not (rectangle.fill_visible or rectangle.stroke_visible):
|
|
97
|
+
continue
|
|
98
|
+
bbox = normalize_bbox(rectangle.bbox)
|
|
99
|
+
if bbox is None:
|
|
100
|
+
continue
|
|
101
|
+
local_bbox = page_bbox_to_table_local(bbox, table_bbox, angle)
|
|
102
|
+
if local_bbox is None:
|
|
103
|
+
continue
|
|
104
|
+
rect_width = local_bbox[2] - local_bbox[0]
|
|
105
|
+
rect_height = local_bbox[3] - local_bbox[1]
|
|
106
|
+
is_thin_rule = rect_height <= thin_limit and rect_width >= 2.0 * text.median_glyph_width
|
|
107
|
+
is_header_cell = (
|
|
108
|
+
local_bbox[1] <= header_limit
|
|
109
|
+
and rect_height <= 3.0 * text.median_glyph_height
|
|
110
|
+
and rect_width >= 2.0 * text.median_glyph_width
|
|
111
|
+
)
|
|
112
|
+
if not (is_thin_rule or is_header_cell):
|
|
113
|
+
continue
|
|
114
|
+
raw_edges.extend([max(0.0, local_bbox[0]), min(width, local_bbox[2])])
|
|
115
|
+
|
|
116
|
+
supported = [
|
|
117
|
+
coordinate
|
|
118
|
+
for coordinate, members in cluster_members(raw_edges, tolerance)
|
|
119
|
+
if len(members) >= 2 or coordinate <= tolerance or width - coordinate <= tolerance
|
|
120
|
+
]
|
|
121
|
+
return tuple(sorted({0.0, width, *supported}))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _canonical_edge_tracks(
|
|
125
|
+
positions: tuple[float, ...],
|
|
126
|
+
width: float,
|
|
127
|
+
tolerance: float,
|
|
128
|
+
) -> tuple[float, ...]:
|
|
129
|
+
"""折叠靠近表格外缘的重复矩形端点并返回严格递增轨道。"""
|
|
130
|
+
|
|
131
|
+
snapped = [0.0 if position <= tolerance else width if width - position <= tolerance else position for position in positions]
|
|
132
|
+
tracks = tuple(sorted(set(snapped)))
|
|
133
|
+
if any(current <= previous for previous, current in zip(tracks, tracks[1:])):
|
|
134
|
+
return ()
|
|
135
|
+
return tracks
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _longest_consecutive_run(indices: list[int]) -> tuple[int, ...]:
|
|
139
|
+
"""返回整数索引列表中最长的连续区间。"""
|
|
140
|
+
|
|
141
|
+
if not indices:
|
|
142
|
+
return ()
|
|
143
|
+
runs: list[list[int]] = [[indices[0]]]
|
|
144
|
+
for index in indices[1:]:
|
|
145
|
+
if index == runs[-1][-1] + 1:
|
|
146
|
+
runs[-1].append(index)
|
|
147
|
+
else:
|
|
148
|
+
runs.append([index])
|
|
149
|
+
return tuple(max(runs, key=lambda run: (len(run), run[-1])))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _infer_dense_layout(text: NativeTableText) -> _DenseLayout | None:
|
|
153
|
+
"""从正文重复 token 数选择叶子列数和首条正文行。"""
|
|
154
|
+
|
|
155
|
+
counts = Counter(len(row.tokens) for row in text.rows if len(row.tokens) >= 2)
|
|
156
|
+
hypotheses: list[tuple[int, int, int, tuple[int, ...]]] = []
|
|
157
|
+
for count, occurrences in counts.items():
|
|
158
|
+
indices = [row.row_index for row in text.rows if len(row.tokens) == count]
|
|
159
|
+
run = _longest_consecutive_run(indices)
|
|
160
|
+
if len(run) < 2:
|
|
161
|
+
continue
|
|
162
|
+
hypotheses.append((len(run), occurrences, count, run))
|
|
163
|
+
if not hypotheses:
|
|
164
|
+
return None
|
|
165
|
+
_run_length, _occurrences, target_cols, run = max(hypotheses)
|
|
166
|
+
return _DenseLayout(
|
|
167
|
+
target_cols=target_cols,
|
|
168
|
+
body_start=run[0],
|
|
169
|
+
dense_row_indices=run,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _infer_text_tracks(
|
|
174
|
+
text: NativeTableText,
|
|
175
|
+
width: float,
|
|
176
|
+
layout: _DenseLayout,
|
|
177
|
+
) -> tuple[float, ...] | None:
|
|
178
|
+
"""用正文相邻 token 空隙的中位位置推断叶子列边界。"""
|
|
179
|
+
|
|
180
|
+
dense_rows = [text.rows[index] for index in layout.dense_row_indices]
|
|
181
|
+
boundaries: list[float] = []
|
|
182
|
+
for col in range(layout.target_cols - 1):
|
|
183
|
+
midpoints = [(row.tokens[col].bbox[2] + row.tokens[col + 1].bbox[0]) / 2.0 for row in dense_rows]
|
|
184
|
+
boundary = float(statistics.median(midpoints))
|
|
185
|
+
if not 0.0 < boundary < width:
|
|
186
|
+
return None
|
|
187
|
+
boundaries.append(boundary)
|
|
188
|
+
tracks = (0.0, *boundaries, width)
|
|
189
|
+
if any(current <= previous for previous, current in zip(tracks, tracks[1:])):
|
|
190
|
+
return None
|
|
191
|
+
return tracks
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _row_glyph_occupancy(
|
|
195
|
+
row: NativeTableTextRow,
|
|
196
|
+
text: NativeTableText,
|
|
197
|
+
x_tracks: tuple[float, ...],
|
|
198
|
+
) -> set[int]:
|
|
199
|
+
"""按字符中心统计一条视觉行实际占用的叶子列。"""
|
|
200
|
+
|
|
201
|
+
glyph_by_id = {glyph.glyph_id: glyph for glyph in text.glyphs}
|
|
202
|
+
occupied: set[int] = set()
|
|
203
|
+
for glyph_id in row.glyph_ids:
|
|
204
|
+
glyph = glyph_by_id[glyph_id]
|
|
205
|
+
center = (glyph.bbox[0] + glyph.bbox[2]) / 2.0
|
|
206
|
+
col = next(
|
|
207
|
+
(index for index, (left, right) in enumerate(zip(x_tracks, x_tracks[1:])) if left <= center <= right),
|
|
208
|
+
None,
|
|
209
|
+
)
|
|
210
|
+
if col is not None:
|
|
211
|
+
occupied.add(col)
|
|
212
|
+
return occupied
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _track_support(
|
|
216
|
+
text: NativeTableText,
|
|
217
|
+
x_tracks: tuple[float, ...],
|
|
218
|
+
body_start: int,
|
|
219
|
+
) -> tuple[float, float, tuple[set[int], ...]] | None:
|
|
220
|
+
"""校验正文行、关键列和各叶子列的重复占用支持。"""
|
|
221
|
+
|
|
222
|
+
body_rows = text.rows[body_start:]
|
|
223
|
+
cols = len(x_tracks) - 1
|
|
224
|
+
if len(body_rows) < 2 or cols < 2:
|
|
225
|
+
return None
|
|
226
|
+
occupancies = tuple(_row_glyph_occupancy(row, text, x_tracks) for row in body_rows)
|
|
227
|
+
minimum_dense_cols = max(2, math.ceil(MIN_COLUMN_SUPPORT * cols))
|
|
228
|
+
if any(len(occupancy) < minimum_dense_cols for occupancy in occupancies):
|
|
229
|
+
return None
|
|
230
|
+
first_columns = [min(occupancy) for occupancy in occupancies if occupancy]
|
|
231
|
+
if not first_columns:
|
|
232
|
+
return None
|
|
233
|
+
key_col = Counter(first_columns).most_common(1)[0][0]
|
|
234
|
+
if sum(key_col in occupancy for occupancy in occupancies) / len(occupancies) < MIN_COLUMN_SUPPORT:
|
|
235
|
+
return None
|
|
236
|
+
supports = [sum(col in occupancy for occupancy in occupancies) / len(occupancies) for col in range(cols)]
|
|
237
|
+
minimum_support = min(supports)
|
|
238
|
+
overall_support = float(statistics.mean(supports))
|
|
239
|
+
if minimum_support < MIN_COLUMN_SUPPORT or overall_support < MIN_OVERALL_ANCHOR_SUPPORT:
|
|
240
|
+
return None
|
|
241
|
+
return minimum_support, overall_support, occupancies
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _nearest_physical_boundaries(
|
|
245
|
+
x_tracks: tuple[float, ...],
|
|
246
|
+
physical_positions: tuple[float, ...],
|
|
247
|
+
tolerance: float,
|
|
248
|
+
) -> frozenset[int]:
|
|
249
|
+
"""标记能被独立 drawing 或矩形边缘支持的内部列边界。"""
|
|
250
|
+
|
|
251
|
+
return frozenset(
|
|
252
|
+
index
|
|
253
|
+
for index, coordinate in enumerate(x_tracks[1:-1], start=1)
|
|
254
|
+
if any(abs(coordinate - physical) <= tolerance for physical in physical_positions)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _infer_y_tracks(
|
|
259
|
+
text: NativeTableText,
|
|
260
|
+
rules: tuple[_LocalRule, ...],
|
|
261
|
+
height: float,
|
|
262
|
+
) -> tuple[float, ...] | None:
|
|
263
|
+
"""以视觉行中心中点为基础并优先吸附相邻行间横线。"""
|
|
264
|
+
|
|
265
|
+
if len(text.rows) < 2:
|
|
266
|
+
return None
|
|
267
|
+
tracks: list[float] = [0.0]
|
|
268
|
+
horizontal_rules = [rule for rule in rules if rule.orientation == "horizontal"]
|
|
269
|
+
for previous, current in zip(text.rows, text.rows[1:]):
|
|
270
|
+
candidates = [rule for rule in horizontal_rules if previous.bbox[3] - 0.5 <= rule.coordinate <= current.bbox[1] + 0.5]
|
|
271
|
+
if candidates:
|
|
272
|
+
boundary = max(candidates, key=lambda rule: rule.end - rule.start).coordinate
|
|
273
|
+
else:
|
|
274
|
+
previous_center = (previous.bbox[1] + previous.bbox[3]) / 2.0
|
|
275
|
+
current_center = (current.bbox[1] + current.bbox[3]) / 2.0
|
|
276
|
+
boundary = (previous_center + current_center) / 2.0
|
|
277
|
+
tracks.append(boundary)
|
|
278
|
+
tracks.append(height)
|
|
279
|
+
if any(current <= previous for previous, current in zip(tracks, tracks[1:])):
|
|
280
|
+
return None
|
|
281
|
+
return tuple(tracks)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _horizontal_separator_coverage(
|
|
285
|
+
rules: tuple[_LocalRule, ...],
|
|
286
|
+
boundary: float,
|
|
287
|
+
left: float,
|
|
288
|
+
right: float,
|
|
289
|
+
tolerance: float,
|
|
290
|
+
) -> float:
|
|
291
|
+
"""计算指定表头行边界在一个叶子列范围内的横线覆盖率。"""
|
|
292
|
+
|
|
293
|
+
intervals = [
|
|
294
|
+
(rule.start, rule.end)
|
|
295
|
+
for rule in rules
|
|
296
|
+
if rule.orientation == "horizontal" and abs(rule.coordinate - boundary) <= tolerance
|
|
297
|
+
]
|
|
298
|
+
return covered_interval_ratio(intervals, left, right)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _row_token_columns(
|
|
302
|
+
row: NativeTableTextRow,
|
|
303
|
+
x_tracks: tuple[float, ...],
|
|
304
|
+
) -> list[int]:
|
|
305
|
+
"""把一行粗 token 的中心映射到叶子列。"""
|
|
306
|
+
|
|
307
|
+
output: list[int] = []
|
|
308
|
+
for token in row.tokens:
|
|
309
|
+
center = (token.bbox[0] + token.bbox[2]) / 2.0
|
|
310
|
+
col = next(
|
|
311
|
+
(index for index, (left, right) in enumerate(zip(x_tracks, x_tracks[1:])) if left <= center <= right),
|
|
312
|
+
None,
|
|
313
|
+
)
|
|
314
|
+
if col is not None:
|
|
315
|
+
output.append(col)
|
|
316
|
+
return output
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _two_level_header_specs(
|
|
320
|
+
text: NativeTableText,
|
|
321
|
+
x_tracks: tuple[float, ...],
|
|
322
|
+
y_tracks: tuple[float, ...],
|
|
323
|
+
rules: tuple[_LocalRule, ...],
|
|
324
|
+
tolerance: float,
|
|
325
|
+
) -> tuple[GridCellSpec, ...] | None:
|
|
326
|
+
"""用局部横线和上下层文本恢复两层表头的 rowspan/colspan。"""
|
|
327
|
+
|
|
328
|
+
cols = len(x_tracks) - 1
|
|
329
|
+
boundary = y_tracks[1]
|
|
330
|
+
coverages = [
|
|
331
|
+
_horizontal_separator_coverage(
|
|
332
|
+
rules,
|
|
333
|
+
boundary,
|
|
334
|
+
x_tracks[col],
|
|
335
|
+
x_tracks[col + 1],
|
|
336
|
+
tolerance,
|
|
337
|
+
)
|
|
338
|
+
for col in range(cols)
|
|
339
|
+
]
|
|
340
|
+
if any(0.20 < coverage < 0.80 for coverage in coverages):
|
|
341
|
+
return None
|
|
342
|
+
absent_cols = {col for col, coverage in enumerate(coverages) if coverage <= 0.20}
|
|
343
|
+
present_cols = set(range(cols)) - absent_cols
|
|
344
|
+
specs: list[GridCellSpec] = [
|
|
345
|
+
GridCellSpec(
|
|
346
|
+
row=0,
|
|
347
|
+
col=col,
|
|
348
|
+
rowspan=2,
|
|
349
|
+
colspan=1,
|
|
350
|
+
bbox=(x_tracks[col], y_tracks[0], x_tracks[col + 1], y_tracks[2]),
|
|
351
|
+
)
|
|
352
|
+
for col in sorted(absent_cols)
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
child_occupied = _row_glyph_occupancy(text.rows[1], text, x_tracks).intersection(present_cols)
|
|
356
|
+
top_tokens = [
|
|
357
|
+
(token, col)
|
|
358
|
+
for token, col in zip(
|
|
359
|
+
text.rows[0].tokens,
|
|
360
|
+
_row_token_columns(text.rows[0], x_tracks),
|
|
361
|
+
strict=False,
|
|
362
|
+
)
|
|
363
|
+
if col in present_cols
|
|
364
|
+
]
|
|
365
|
+
assigned_groups: dict[int, list[int]] = {index: [] for index in range(len(top_tokens))}
|
|
366
|
+
for col in sorted(child_occupied):
|
|
367
|
+
col_center = (x_tracks[col] + x_tracks[col + 1]) / 2.0
|
|
368
|
+
if not top_tokens:
|
|
369
|
+
return None
|
|
370
|
+
owner = min(
|
|
371
|
+
range(len(top_tokens)),
|
|
372
|
+
key=lambda index: abs(col_center - (top_tokens[index][0].bbox[0] + top_tokens[index][0].bbox[2]) / 2.0),
|
|
373
|
+
)
|
|
374
|
+
assigned_groups[owner].append(col)
|
|
375
|
+
|
|
376
|
+
top_covered: set[int] = set()
|
|
377
|
+
for index, (token, _token_col) in enumerate(top_tokens):
|
|
378
|
+
group = assigned_groups[index]
|
|
379
|
+
if not group or group != list(range(group[0], group[-1] + 1)):
|
|
380
|
+
return None
|
|
381
|
+
token_center = (token.bbox[0] + token.bbox[2]) / 2.0
|
|
382
|
+
if not x_tracks[group[0]] <= token_center <= x_tracks[group[-1] + 1]:
|
|
383
|
+
return None
|
|
384
|
+
specs.append(
|
|
385
|
+
GridCellSpec(
|
|
386
|
+
row=0,
|
|
387
|
+
col=group[0],
|
|
388
|
+
rowspan=1,
|
|
389
|
+
colspan=group[-1] - group[0] + 1,
|
|
390
|
+
bbox=(x_tracks[group[0]], y_tracks[0], x_tracks[group[-1] + 1], y_tracks[1]),
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
top_covered.update(group)
|
|
394
|
+
|
|
395
|
+
for col in sorted(present_cols - top_covered):
|
|
396
|
+
specs.append(
|
|
397
|
+
GridCellSpec(
|
|
398
|
+
row=0,
|
|
399
|
+
col=col,
|
|
400
|
+
rowspan=1,
|
|
401
|
+
colspan=1,
|
|
402
|
+
bbox=(x_tracks[col], y_tracks[0], x_tracks[col + 1], y_tracks[1]),
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
for col in sorted(present_cols):
|
|
406
|
+
specs.append(
|
|
407
|
+
GridCellSpec(
|
|
408
|
+
row=1,
|
|
409
|
+
col=col,
|
|
410
|
+
rowspan=1,
|
|
411
|
+
colspan=1,
|
|
412
|
+
bbox=(x_tracks[col], y_tracks[1], x_tracks[col + 1], y_tracks[2]),
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
return tuple(specs)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _build_sparse_specs(
|
|
419
|
+
text: NativeTableText,
|
|
420
|
+
x_tracks: tuple[float, ...],
|
|
421
|
+
y_tracks: tuple[float, ...],
|
|
422
|
+
body_start: int,
|
|
423
|
+
rules: tuple[_LocalRule, ...],
|
|
424
|
+
tolerance: float,
|
|
425
|
+
) -> tuple[GridCellSpec, ...] | None:
|
|
426
|
+
"""构造完整少线网格,并仅在两层表头中推断合并格。"""
|
|
427
|
+
|
|
428
|
+
rows = len(y_tracks) - 1
|
|
429
|
+
cols = len(x_tracks) - 1
|
|
430
|
+
if body_start > MAX_HEADER_ROWS:
|
|
431
|
+
return None
|
|
432
|
+
specs: list[GridCellSpec] = []
|
|
433
|
+
if body_start == 2:
|
|
434
|
+
header_specs = _two_level_header_specs(
|
|
435
|
+
text,
|
|
436
|
+
x_tracks,
|
|
437
|
+
y_tracks,
|
|
438
|
+
rules,
|
|
439
|
+
tolerance,
|
|
440
|
+
)
|
|
441
|
+
if header_specs is None:
|
|
442
|
+
return None
|
|
443
|
+
specs.extend(header_specs)
|
|
444
|
+
else:
|
|
445
|
+
for row in range(body_start):
|
|
446
|
+
for col in range(cols):
|
|
447
|
+
specs.append(
|
|
448
|
+
GridCellSpec(
|
|
449
|
+
row=row,
|
|
450
|
+
col=col,
|
|
451
|
+
rowspan=1,
|
|
452
|
+
colspan=1,
|
|
453
|
+
bbox=(x_tracks[col], y_tracks[row], x_tracks[col + 1], y_tracks[row + 1]),
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
for row in range(body_start, rows):
|
|
457
|
+
for col in range(cols):
|
|
458
|
+
specs.append(
|
|
459
|
+
GridCellSpec(
|
|
460
|
+
row=row,
|
|
461
|
+
col=col,
|
|
462
|
+
rowspan=1,
|
|
463
|
+
colspan=1,
|
|
464
|
+
bbox=(x_tracks[col], y_tracks[row], x_tracks[col + 1], y_tracks[row + 1]),
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
return tuple(specs)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _spec_owner_grid(
|
|
471
|
+
rows: int,
|
|
472
|
+
cols: int,
|
|
473
|
+
specs: tuple[GridCellSpec, ...],
|
|
474
|
+
) -> list[list[int]]:
|
|
475
|
+
"""把逻辑单元格展开为原子格到 spec 下标的映射。"""
|
|
476
|
+
|
|
477
|
+
owners = [[-1 for _ in range(cols)] for _ in range(rows)]
|
|
478
|
+
for index, spec in enumerate(specs):
|
|
479
|
+
for row in range(spec.row, spec.row + spec.rowspan):
|
|
480
|
+
for col in range(spec.col, spec.col + spec.colspan):
|
|
481
|
+
owners[row][col] = index
|
|
482
|
+
return owners
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _validate_token_splits(
|
|
486
|
+
text: NativeTableText,
|
|
487
|
+
x_tracks: tuple[float, ...],
|
|
488
|
+
specs: tuple[GridCellSpec, ...],
|
|
489
|
+
physical_boundaries: frozenset[int],
|
|
490
|
+
) -> tuple[bool, int]:
|
|
491
|
+
"""只允许被强物理边界证明且不横切字符的粗 token 跨格。"""
|
|
492
|
+
|
|
493
|
+
rows = len(text.rows)
|
|
494
|
+
cols = len(x_tracks) - 1
|
|
495
|
+
owners = _spec_owner_grid(rows, cols, specs)
|
|
496
|
+
glyph_by_id = {glyph.glyph_id: glyph for glyph in text.glyphs}
|
|
497
|
+
edge_tolerance = max(0.25, 0.05 * text.median_glyph_width)
|
|
498
|
+
justified_splits = 0
|
|
499
|
+
for row in text.rows:
|
|
500
|
+
for token in row.tokens:
|
|
501
|
+
for boundary_index, boundary in enumerate(x_tracks[1:-1], start=1):
|
|
502
|
+
if owners[row.row_index][boundary_index - 1] == owners[row.row_index][boundary_index]:
|
|
503
|
+
continue
|
|
504
|
+
if not token.bbox[0] + edge_tolerance < boundary < token.bbox[2] - edge_tolerance:
|
|
505
|
+
continue
|
|
506
|
+
if boundary_index not in physical_boundaries:
|
|
507
|
+
return False, justified_splits
|
|
508
|
+
if any(
|
|
509
|
+
glyph_by_id[glyph_id].bbox[0] + edge_tolerance < boundary < glyph_by_id[glyph_id].bbox[2] - edge_tolerance
|
|
510
|
+
for glyph_id in token.glyph_ids
|
|
511
|
+
):
|
|
512
|
+
return False, justified_splits
|
|
513
|
+
justified_splits += 1
|
|
514
|
+
return True, justified_splits
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _build_hypothesis_candidate(
|
|
518
|
+
table_input: NativeTableInput,
|
|
519
|
+
text: NativeTableText,
|
|
520
|
+
rules: tuple[_LocalRule, ...],
|
|
521
|
+
hypothesis: _TrackHypothesis,
|
|
522
|
+
height: float,
|
|
523
|
+
diagnostics: dict[str, Any] | None,
|
|
524
|
+
) -> NativeTableCandidate | None:
|
|
525
|
+
"""把一组少线轨道恢复为候选并执行全部高置信硬门。"""
|
|
526
|
+
|
|
527
|
+
x_tracks = hypothesis.x_tracks
|
|
528
|
+
y_tracks = _infer_y_tracks(text, rules, height)
|
|
529
|
+
if y_tracks is None:
|
|
530
|
+
if diagnostics is not None:
|
|
531
|
+
diagnostics["first_rejection_gate"] = "row_tracks"
|
|
532
|
+
return None
|
|
533
|
+
support = _track_support(text, x_tracks, hypothesis.body_start)
|
|
534
|
+
if support is None:
|
|
535
|
+
if diagnostics is not None:
|
|
536
|
+
diagnostics["first_rejection_gate"] = "anchor_support"
|
|
537
|
+
return None
|
|
538
|
+
minimum_support, overall_support, occupancies = support
|
|
539
|
+
tolerance = max(1.0, 0.25 * text.median_glyph_height)
|
|
540
|
+
specs = _build_sparse_specs(
|
|
541
|
+
text,
|
|
542
|
+
x_tracks,
|
|
543
|
+
y_tracks,
|
|
544
|
+
hypothesis.body_start,
|
|
545
|
+
rules,
|
|
546
|
+
tolerance,
|
|
547
|
+
)
|
|
548
|
+
if specs is None:
|
|
549
|
+
if diagnostics is not None:
|
|
550
|
+
diagnostics["first_rejection_gate"] = "header_topology"
|
|
551
|
+
return None
|
|
552
|
+
token_splits_valid, justified_splits = _validate_token_splits(
|
|
553
|
+
text,
|
|
554
|
+
x_tracks,
|
|
555
|
+
specs,
|
|
556
|
+
hypothesis.physical_boundaries,
|
|
557
|
+
)
|
|
558
|
+
if not token_splits_valid:
|
|
559
|
+
if diagnostics is not None:
|
|
560
|
+
diagnostics["first_rejection_gate"] = "token_split"
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
candidate_diagnostics: dict[str, object] = {}
|
|
564
|
+
candidate = build_candidate(
|
|
565
|
+
source="sparse_hybrid",
|
|
566
|
+
rows=len(y_tracks) - 1,
|
|
567
|
+
cols=len(x_tracks) - 1,
|
|
568
|
+
specs=specs,
|
|
569
|
+
text=text,
|
|
570
|
+
structure_support=hypothesis.reliability,
|
|
571
|
+
row_stability=1.0,
|
|
572
|
+
column_stability=min(1.0, overall_support),
|
|
573
|
+
issues=(
|
|
574
|
+
f"evidence={hypothesis.evidence}",
|
|
575
|
+
f"body_start={hypothesis.body_start}",
|
|
576
|
+
f"minimum_column_support={minimum_support:.4f}",
|
|
577
|
+
f"overall_anchor_support={overall_support:.4f}",
|
|
578
|
+
f"physical_boundaries={len(hypothesis.physical_boundaries)}",
|
|
579
|
+
f"physically_justified_token_splits={justified_splits}",
|
|
580
|
+
),
|
|
581
|
+
use_grid_index=True,
|
|
582
|
+
diagnostics=candidate_diagnostics,
|
|
583
|
+
)
|
|
584
|
+
if candidate is None:
|
|
585
|
+
if diagnostics is not None:
|
|
586
|
+
diagnostics["first_rejection_gate"] = candidate_diagnostics.get(
|
|
587
|
+
"candidate_rejection_gate",
|
|
588
|
+
"candidate_hard_gate",
|
|
589
|
+
)
|
|
590
|
+
return None
|
|
591
|
+
ambiguous_ratio = float(candidate_diagnostics.get("ambiguous_glyph_ratio", 1.0))
|
|
592
|
+
if (
|
|
593
|
+
candidate.text_capture < 1.0
|
|
594
|
+
or candidate.order_consistency < 1.0
|
|
595
|
+
or ambiguous_ratio > 0.0
|
|
596
|
+
or candidate.score < MIN_SPARSE_RELIABILITY
|
|
597
|
+
):
|
|
598
|
+
if diagnostics is not None:
|
|
599
|
+
diagnostics["first_rejection_gate"] = "verified_integrity"
|
|
600
|
+
return None
|
|
601
|
+
if diagnostics is not None:
|
|
602
|
+
diagnostics.update(
|
|
603
|
+
{
|
|
604
|
+
"first_rejection_gate": None,
|
|
605
|
+
"grid": {"rows": candidate.rows, "cols": candidate.cols},
|
|
606
|
+
"body_start": hypothesis.body_start,
|
|
607
|
+
"x_tracks": list(x_tracks),
|
|
608
|
+
"physical_boundaries": sorted(hypothesis.physical_boundaries),
|
|
609
|
+
"minimum_column_support": minimum_support,
|
|
610
|
+
"overall_anchor_support": overall_support,
|
|
611
|
+
"body_occupancies": [sorted(occupancy) for occupancy in occupancies],
|
|
612
|
+
"physically_justified_token_splits": justified_splits,
|
|
613
|
+
"score": candidate.score,
|
|
614
|
+
}
|
|
615
|
+
)
|
|
616
|
+
return candidate
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _build_track_hypotheses(
|
|
620
|
+
table_input: NativeTableInput,
|
|
621
|
+
text: NativeTableText,
|
|
622
|
+
rules: tuple[_LocalRule, ...],
|
|
623
|
+
width: float,
|
|
624
|
+
height: float,
|
|
625
|
+
) -> tuple[_TrackHypothesis, ...]:
|
|
626
|
+
"""构造有限的文本轨和强竖线轨假设并消除同拓扑重复。"""
|
|
627
|
+
|
|
628
|
+
layout = _infer_dense_layout(text)
|
|
629
|
+
if layout is None:
|
|
630
|
+
return ()
|
|
631
|
+
tolerance = max(1.0, 0.25 * text.median_glyph_height)
|
|
632
|
+
vertical_tracks, vertical_coverages = _vertical_track_evidence(
|
|
633
|
+
rules,
|
|
634
|
+
width,
|
|
635
|
+
height,
|
|
636
|
+
tolerance,
|
|
637
|
+
)
|
|
638
|
+
rect_positions = _rectangle_edge_evidence(
|
|
639
|
+
table_input,
|
|
640
|
+
text,
|
|
641
|
+
width,
|
|
642
|
+
height,
|
|
643
|
+
tolerance,
|
|
644
|
+
)
|
|
645
|
+
physical_positions = tuple(sorted({*vertical_tracks, *rect_positions}))
|
|
646
|
+
rect_tracks = _canonical_edge_tracks(rect_positions, width, tolerance)
|
|
647
|
+
text_tracks = _infer_text_tracks(text, width, layout)
|
|
648
|
+
hypotheses: list[_TrackHypothesis] = []
|
|
649
|
+
|
|
650
|
+
physical_cols = len(vertical_tracks) - 1
|
|
651
|
+
if (
|
|
652
|
+
physical_cols >= 2
|
|
653
|
+
and physical_cols in {layout.target_cols, layout.target_cols + 1}
|
|
654
|
+
and _track_support(text, vertical_tracks, layout.body_start) is not None
|
|
655
|
+
):
|
|
656
|
+
internal_coverages = [
|
|
657
|
+
coverage for coordinate, coverage in vertical_coverages.items() if tolerance < coordinate < width - tolerance
|
|
658
|
+
]
|
|
659
|
+
reliability = min(internal_coverages, default=1.0)
|
|
660
|
+
if physical_cols == layout.target_cols:
|
|
661
|
+
reliability = 1.0
|
|
662
|
+
hypotheses.append(
|
|
663
|
+
_TrackHypothesis(
|
|
664
|
+
evidence="vertical_text",
|
|
665
|
+
x_tracks=vertical_tracks,
|
|
666
|
+
physical_boundaries=frozenset(range(1, len(vertical_tracks) - 1)),
|
|
667
|
+
body_start=layout.body_start,
|
|
668
|
+
reliability=min(1.0, reliability),
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
rect_cols = len(rect_tracks) - 1
|
|
673
|
+
if rect_cols == layout.target_cols and rect_cols >= 2 and _track_support(text, rect_tracks, layout.body_start) is not None:
|
|
674
|
+
hypotheses.append(
|
|
675
|
+
_TrackHypothesis(
|
|
676
|
+
evidence="rect_text",
|
|
677
|
+
x_tracks=rect_tracks,
|
|
678
|
+
physical_boundaries=frozenset(range(1, len(rect_tracks) - 1)),
|
|
679
|
+
body_start=layout.body_start,
|
|
680
|
+
reliability=1.0,
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
prefer_physical = bool(hypotheses)
|
|
685
|
+
if text_tracks is not None and not prefer_physical:
|
|
686
|
+
hypotheses.append(
|
|
687
|
+
_TrackHypothesis(
|
|
688
|
+
evidence="text_network",
|
|
689
|
+
x_tracks=text_tracks,
|
|
690
|
+
physical_boundaries=_nearest_physical_boundaries(
|
|
691
|
+
text_tracks,
|
|
692
|
+
physical_positions,
|
|
693
|
+
tolerance,
|
|
694
|
+
),
|
|
695
|
+
body_start=layout.body_start,
|
|
696
|
+
reliability=1.0,
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
deduplicated: dict[tuple[int, tuple[int, ...]], _TrackHypothesis] = {}
|
|
701
|
+
for hypothesis in hypotheses[:MAX_SPARSE_HYPOTHESES]:
|
|
702
|
+
signature = (
|
|
703
|
+
len(hypothesis.x_tracks),
|
|
704
|
+
tuple(round(track / max(tolerance, 0.1)) for track in hypothesis.x_tracks),
|
|
705
|
+
)
|
|
706
|
+
existing = deduplicated.get(signature)
|
|
707
|
+
if existing is None or hypothesis.reliability > existing.reliability:
|
|
708
|
+
deduplicated[signature] = hypothesis
|
|
709
|
+
return tuple(deduplicated.values())
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def build_sparse_hybrid_candidates(
|
|
713
|
+
table_input: NativeTableInput,
|
|
714
|
+
text: NativeTableText,
|
|
715
|
+
diagnostics: list[dict[str, Any]] | None = None,
|
|
716
|
+
) -> list[NativeTableCandidate]:
|
|
717
|
+
"""生成只在矢量网格失败后参与仲裁的高置信少线候选。"""
|
|
718
|
+
|
|
719
|
+
table_bbox = normalize_bbox(table_input.table_bbox)
|
|
720
|
+
if table_bbox is None:
|
|
721
|
+
return []
|
|
722
|
+
width, height = table_local_size(table_bbox, normalize_angle(table_input.angle))
|
|
723
|
+
rules = _local_rules(table_input, width, height)
|
|
724
|
+
long_rules = _long_horizontal_rules(rules, width)
|
|
725
|
+
tolerance = max(1.0, 0.25 * text.median_glyph_height)
|
|
726
|
+
vertical_tracks, _coverages = _vertical_track_evidence(
|
|
727
|
+
rules,
|
|
728
|
+
width,
|
|
729
|
+
height,
|
|
730
|
+
tolerance,
|
|
731
|
+
)
|
|
732
|
+
if len(long_rules) < 2 and len(vertical_tracks) < 3:
|
|
733
|
+
if diagnostics is not None:
|
|
734
|
+
diagnostics.append(
|
|
735
|
+
{
|
|
736
|
+
"source": "sparse_hybrid",
|
|
737
|
+
"first_rejection_gate": "physical_sparse_evidence",
|
|
738
|
+
"long_horizontal_rules": len(long_rules),
|
|
739
|
+
"vertical_tracks": len(vertical_tracks),
|
|
740
|
+
}
|
|
741
|
+
)
|
|
742
|
+
return []
|
|
743
|
+
|
|
744
|
+
hypotheses = _build_track_hypotheses(
|
|
745
|
+
table_input,
|
|
746
|
+
text,
|
|
747
|
+
rules,
|
|
748
|
+
width,
|
|
749
|
+
height,
|
|
750
|
+
)
|
|
751
|
+
candidates: list[NativeTableCandidate] = []
|
|
752
|
+
for hypothesis in hypotheses:
|
|
753
|
+
record: dict[str, Any] | None = (
|
|
754
|
+
{
|
|
755
|
+
"source": "sparse_hybrid",
|
|
756
|
+
"evidence": hypothesis.evidence,
|
|
757
|
+
"long_horizontal_rules": len(long_rules),
|
|
758
|
+
}
|
|
759
|
+
if diagnostics is not None
|
|
760
|
+
else None
|
|
761
|
+
)
|
|
762
|
+
candidate = _build_hypothesis_candidate(
|
|
763
|
+
table_input,
|
|
764
|
+
text,
|
|
765
|
+
rules,
|
|
766
|
+
hypothesis,
|
|
767
|
+
height,
|
|
768
|
+
record,
|
|
769
|
+
)
|
|
770
|
+
if diagnostics is not None and record is not None:
|
|
771
|
+
diagnostics.append(record)
|
|
772
|
+
if candidate is not None:
|
|
773
|
+
candidates.append(candidate)
|
|
774
|
+
|
|
775
|
+
topologies = {candidate.topology for candidate in candidates}
|
|
776
|
+
if len(topologies) > 1:
|
|
777
|
+
if diagnostics is not None:
|
|
778
|
+
diagnostics.append(
|
|
779
|
+
{
|
|
780
|
+
"source": "sparse_hybrid",
|
|
781
|
+
"first_rejection_gate": "topology_ambiguity",
|
|
782
|
+
"topology_count": len(topologies),
|
|
783
|
+
}
|
|
784
|
+
)
|
|
785
|
+
return []
|
|
786
|
+
return candidates[:1]
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def diagnose_sparse_hybrid_candidate_builds(
|
|
790
|
+
table_input: NativeTableInput,
|
|
791
|
+
text: NativeTableText,
|
|
792
|
+
) -> tuple[dict[str, Any], ...]:
|
|
793
|
+
"""重放少线候选构造并返回不进入用户结果的诊断。"""
|
|
794
|
+
|
|
795
|
+
diagnostics: list[dict[str, Any]] = []
|
|
796
|
+
build_sparse_hybrid_candidates(
|
|
797
|
+
table_input,
|
|
798
|
+
text,
|
|
799
|
+
diagnostics=diagnostics,
|
|
800
|
+
)
|
|
801
|
+
return tuple(diagnostics)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
__all__ = ["build_sparse_hybrid_candidates"]
|