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,1501 @@
|
|
|
1
|
+
"""检测 Form、矢量图形和栅格图片并认领内部文本。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
import statistics
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from ....schema import BBox
|
|
13
|
+
from ....document.pdf.document import PDFPathInfo
|
|
14
|
+
|
|
15
|
+
from .models import _AxisLine, _GraphicCandidate, _LineItem, _PageSource, _TextLane
|
|
16
|
+
from .geometry import (
|
|
17
|
+
_bbox_area,
|
|
18
|
+
_bbox_axis_overlap_ratio,
|
|
19
|
+
_bbox_center_x,
|
|
20
|
+
_bbox_center_y,
|
|
21
|
+
_bbox_distance,
|
|
22
|
+
_bbox_intersects,
|
|
23
|
+
_bbox_overlap_in_first,
|
|
24
|
+
_bbox_overlap_in_smaller,
|
|
25
|
+
_bbox_union,
|
|
26
|
+
_bbox_union_many,
|
|
27
|
+
_clip_bbox,
|
|
28
|
+
_coerce_bbox,
|
|
29
|
+
_point_in_bbox,
|
|
30
|
+
_rotate_bbox_to_upright,
|
|
31
|
+
)
|
|
32
|
+
from .native_text import _fill_native_typography, _normalize_native_run_text, _sanitize_pdf_control_text
|
|
33
|
+
from .line_layout import _infer_text_lanes, _line_effective_height
|
|
34
|
+
from .line_merging import _join_formula_visual_row
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_MIN_RASTER_IMAGE_PAGE_AREA_RATIO = 0.0038
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_SIGNATURE_IMAGE_BBOX_DEDUP_TOLERANCE = 0.5
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_MIN_FORM_IMAGE_PAGE_AREA_RATIO = 0.01
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_MAX_FORM_IMAGE_PAGE_AREA_RATIO = 0.8
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_IMAGE_CONTAINER_OVERLAP_THRESHOLD = 0.5
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_FIGURE_CAPTION_LINE_RE = re.compile(
|
|
53
|
+
r"^\s*(?:fig(?:ure)?\.?)[ \t]*\d+[A-Za-z]?(?:\s*[.:])?",
|
|
54
|
+
re.IGNORECASE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _form_supersedes_nested_bbox(form_bbox: BBox, nested_bbox: BBox) -> bool:
|
|
59
|
+
"""判断 Form 是否应整体吞并其内部面积明显更小的候选容器。"""
|
|
60
|
+
|
|
61
|
+
form_area = _bbox_area(form_bbox)
|
|
62
|
+
nested_area = _bbox_area(nested_bbox)
|
|
63
|
+
return form_area > 0 and nested_area < 0.5 * form_area and _bbox_overlap_in_first(nested_bbox, form_bbox) >= 0.9
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _tighten_form_image_bbox(
|
|
67
|
+
source: _PageSource,
|
|
68
|
+
form_bbox: BBox,
|
|
69
|
+
) -> BBox:
|
|
70
|
+
"""用充分的 Form 内部矢量与文本证据收紧空白容器,证据不足时保留原框。"""
|
|
71
|
+
|
|
72
|
+
internal_paths = [
|
|
73
|
+
path_info.bbox
|
|
74
|
+
for path_info in source.path_infos
|
|
75
|
+
if path_info.form_depth > 0 and _bbox_overlap_in_first(path_info.bbox, form_bbox) >= 0.9
|
|
76
|
+
]
|
|
77
|
+
internal_drawing_lines = [
|
|
78
|
+
drawing_line.bbox
|
|
79
|
+
for drawing_line in source.drawing_lines
|
|
80
|
+
if _bbox_overlap_in_first(drawing_line.bbox, form_bbox) >= 0.9
|
|
81
|
+
]
|
|
82
|
+
# 至少两个嵌套 Path 和四个矢量元素,避免只凭普通边框或少量文本裁剪 Form。
|
|
83
|
+
if len(internal_paths) < 2 or len(internal_paths) + len(internal_drawing_lines) < 4:
|
|
84
|
+
return form_bbox
|
|
85
|
+
internal_text = [line.bbox for line in source.lines if _bbox_overlap_in_first(line.bbox, form_bbox) >= 0.9]
|
|
86
|
+
evidence_bbox = _clip_bbox(
|
|
87
|
+
_bbox_union_many(internal_paths + internal_drawing_lines + internal_text),
|
|
88
|
+
source.page_size,
|
|
89
|
+
)
|
|
90
|
+
if evidence_bbox is None:
|
|
91
|
+
return form_bbox
|
|
92
|
+
form_width = max(0.1, form_bbox[2] - form_bbox[0])
|
|
93
|
+
form_height = max(0.1, form_bbox[3] - form_bbox[1])
|
|
94
|
+
evidence_width = evidence_bbox[2] - evidence_bbox[0]
|
|
95
|
+
evidence_height = evidence_bbox[3] - evidence_bbox[1]
|
|
96
|
+
if (
|
|
97
|
+
evidence_width < 0.5 * form_width
|
|
98
|
+
or evidence_height < 0.5 * form_height
|
|
99
|
+
or _bbox_area(evidence_bbox) < 0.25 * _bbox_area(form_bbox)
|
|
100
|
+
):
|
|
101
|
+
return form_bbox
|
|
102
|
+
return evidence_bbox
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _select_form_image_bboxes(source: _PageSource) -> list[BBox]:
|
|
106
|
+
"""按页面占比、行高和内部视觉证据筛选矢量 Form 图片候选。"""
|
|
107
|
+
|
|
108
|
+
page_area = max(0.0, source.page_size[0]) * max(0.0, source.page_size[1])
|
|
109
|
+
if page_area <= 0 or not source.form_bboxes:
|
|
110
|
+
return []
|
|
111
|
+
effective_heights = [_line_effective_height(line, line.bbox) for line in source.lines if line.angle == 0]
|
|
112
|
+
median_height = statistics.median(effective_heights) if effective_heights else 1.0
|
|
113
|
+
output: list[BBox] = []
|
|
114
|
+
for raw_bbox in source.form_bboxes:
|
|
115
|
+
bbox = _clip_bbox(_coerce_bbox(raw_bbox), source.page_size)
|
|
116
|
+
if bbox is None:
|
|
117
|
+
continue
|
|
118
|
+
width = bbox[2] - bbox[0]
|
|
119
|
+
height = bbox[3] - bbox[1]
|
|
120
|
+
area_ratio = _bbox_area(bbox) / page_area
|
|
121
|
+
if not (
|
|
122
|
+
_MIN_FORM_IMAGE_PAGE_AREA_RATIO <= area_ratio <= _MAX_FORM_IMAGE_PAGE_AREA_RATIO
|
|
123
|
+
and width >= 4.0 * median_height
|
|
124
|
+
and height >= 4.0 * median_height
|
|
125
|
+
):
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
member_rows = {
|
|
129
|
+
line.visual_row_id if line.visual_row_id is not None else line.source_index
|
|
130
|
+
for line in source.lines
|
|
131
|
+
if _bbox_overlap_in_first(line.bbox, bbox) >= 0.9
|
|
132
|
+
}
|
|
133
|
+
internal_drawing_count = sum(
|
|
134
|
+
_bbox_overlap_in_first(drawing_line.bbox, bbox) >= 0.9 for drawing_line in source.drawing_lines
|
|
135
|
+
)
|
|
136
|
+
if len(member_rows) < 2 and internal_drawing_count < 4:
|
|
137
|
+
continue
|
|
138
|
+
output.append(_tighten_form_image_bbox(source, bbox))
|
|
139
|
+
return sorted(output, key=lambda bbox: (bbox[1], bbox[0], bbox[3], bbox[2]))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_form_image_blocks(
|
|
143
|
+
source: _PageSource,
|
|
144
|
+
form_bboxes: list[BBox],
|
|
145
|
+
claimed_line_indices: set[int],
|
|
146
|
+
) -> tuple[list[dict[str, Any]], set[int]]:
|
|
147
|
+
"""把 Form 及其完整内含文本输出为 image,并保持 source_index 唯一认领。"""
|
|
148
|
+
|
|
149
|
+
if not form_bboxes:
|
|
150
|
+
return [], set()
|
|
151
|
+
members_by_candidate: list[list[_LineItem]] = [[] for _ in form_bboxes]
|
|
152
|
+
claimed: set[int] = set()
|
|
153
|
+
for line in source.lines:
|
|
154
|
+
if line.source_index in claimed_line_indices:
|
|
155
|
+
continue
|
|
156
|
+
matching_indices = [
|
|
157
|
+
candidate_index
|
|
158
|
+
for candidate_index, bbox in enumerate(form_bboxes)
|
|
159
|
+
if _bbox_overlap_in_first(line.bbox, bbox) >= 0.9
|
|
160
|
+
]
|
|
161
|
+
if not matching_indices:
|
|
162
|
+
continue
|
|
163
|
+
candidate_index = min(
|
|
164
|
+
matching_indices,
|
|
165
|
+
key=lambda index: (_bbox_area(form_bboxes[index]), index),
|
|
166
|
+
)
|
|
167
|
+
members_by_candidate[candidate_index].append(line)
|
|
168
|
+
claimed.add(line.source_index)
|
|
169
|
+
|
|
170
|
+
blocks = [
|
|
171
|
+
{
|
|
172
|
+
"type": "image",
|
|
173
|
+
"bbox": bbox,
|
|
174
|
+
"angle": 0,
|
|
175
|
+
"content": _image_members_to_content(members, source.page_size),
|
|
176
|
+
}
|
|
177
|
+
for bbox, members in zip(form_bboxes, members_by_candidate, strict=True)
|
|
178
|
+
]
|
|
179
|
+
blocks.sort(key=lambda block: (block["bbox"][1], block["bbox"][0]))
|
|
180
|
+
return blocks, claimed
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _build_graphic_like_blocks(
|
|
184
|
+
source: _PageSource,
|
|
185
|
+
table_bboxes: list[BBox],
|
|
186
|
+
claimed_line_indices: set[int],
|
|
187
|
+
strong_core_bboxes: list[BBox] | None = None,
|
|
188
|
+
) -> tuple[list[dict[str, Any]], set[int]]:
|
|
189
|
+
"""在表格认领后把紧凑绘图组件及其短标签聚成内部图形文本块。"""
|
|
190
|
+
|
|
191
|
+
lines = [line for line in source.lines if line.source_index not in claimed_line_indices]
|
|
192
|
+
if strong_core_bboxes is None:
|
|
193
|
+
strong_core_bboxes = _detect_strong_graphic_bboxes(source)
|
|
194
|
+
if len(lines) < 2 or (len(source.drawing_lines) < 4 and not strong_core_bboxes):
|
|
195
|
+
return [], set()
|
|
196
|
+
|
|
197
|
+
effective_heights = [
|
|
198
|
+
max(
|
|
199
|
+
0.1,
|
|
200
|
+
line.effective_height
|
|
201
|
+
or min(
|
|
202
|
+
max(0.1, line.bbox[2] - line.bbox[0]),
|
|
203
|
+
max(0.1, line.bbox[3] - line.bbox[1]),
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
for line in lines
|
|
207
|
+
]
|
|
208
|
+
median_height = statistics.median(effective_heights)
|
|
209
|
+
lanes = _infer_graphic_text_lanes(lines, source.page_size, median_height)
|
|
210
|
+
line_candidates = _detect_graphic_candidates(
|
|
211
|
+
source.drawing_lines,
|
|
212
|
+
source.page_size,
|
|
213
|
+
median_height,
|
|
214
|
+
lanes,
|
|
215
|
+
table_bboxes,
|
|
216
|
+
)
|
|
217
|
+
# 复杂 Path 或成对坐标轴形成的强图形核心优先于普通绘图线组件,
|
|
218
|
+
# 避免同一图表被拆成多个相互重叠的 image。
|
|
219
|
+
candidates = [
|
|
220
|
+
candidate
|
|
221
|
+
for candidate in line_candidates
|
|
222
|
+
if not any(_bbox_overlap_in_smaller(candidate.core_bbox, core_bbox) >= 0.5 for core_bbox in strong_core_bboxes)
|
|
223
|
+
]
|
|
224
|
+
candidates.extend(
|
|
225
|
+
_GraphicCandidate(
|
|
226
|
+
core_bbox=core_bbox,
|
|
227
|
+
lane_index=_strong_graphic_lane_index(
|
|
228
|
+
core_bbox,
|
|
229
|
+
lanes,
|
|
230
|
+
median_height,
|
|
231
|
+
),
|
|
232
|
+
label_margin_scale=(
|
|
233
|
+
2.5
|
|
234
|
+
if any(
|
|
235
|
+
_bbox_overlap_in_smaller(candidate.core_bbox, core_bbox) >= 0.5
|
|
236
|
+
and _bbox_area(candidate.core_bbox) >= 0.8 * _bbox_area(core_bbox)
|
|
237
|
+
for candidate in line_candidates
|
|
238
|
+
)
|
|
239
|
+
else 1.0
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
for core_bbox in strong_core_bboxes
|
|
243
|
+
if not any(_bbox_overlap_in_smaller(core_bbox, table_bbox) >= 0.5 for table_bbox in table_bboxes)
|
|
244
|
+
)
|
|
245
|
+
if not candidates:
|
|
246
|
+
return [], set()
|
|
247
|
+
|
|
248
|
+
row_groups: dict[tuple[int, int, int, int], list[_LineItem]] = {}
|
|
249
|
+
for line in lines:
|
|
250
|
+
lane_index = _graphic_lane_index(line.bbox, lanes)
|
|
251
|
+
if line.visual_row_id is None:
|
|
252
|
+
row_kind, row_identity = 1, line.source_index
|
|
253
|
+
else:
|
|
254
|
+
row_kind, row_identity = 0, line.visual_row_id
|
|
255
|
+
row_groups.setdefault(
|
|
256
|
+
(line.angle, row_kind, row_identity, lane_index),
|
|
257
|
+
[],
|
|
258
|
+
).append(line)
|
|
259
|
+
|
|
260
|
+
protected_caption_indices = _graphic_caption_line_indices_to_preserve(
|
|
261
|
+
lines,
|
|
262
|
+
candidates,
|
|
263
|
+
median_height,
|
|
264
|
+
)
|
|
265
|
+
protected_body_tail_indices = _graphic_body_tail_line_indices_to_preserve(
|
|
266
|
+
lines,
|
|
267
|
+
candidates,
|
|
268
|
+
lanes,
|
|
269
|
+
median_height,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
for row_lines in row_groups.values():
|
|
273
|
+
if any(line.source_index in protected_caption_indices | protected_body_tail_indices for line in row_lines):
|
|
274
|
+
continue
|
|
275
|
+
row_lane_index = _graphic_lane_index(row_lines[0].bbox, lanes)
|
|
276
|
+
matches: list[tuple[int, float, int]] = []
|
|
277
|
+
for candidate_index, candidate in enumerate(candidates):
|
|
278
|
+
if candidate.lane_index >= 0 and candidate.lane_index != row_lane_index:
|
|
279
|
+
continue
|
|
280
|
+
member_flags = [
|
|
281
|
+
_is_graphic_label_member(
|
|
282
|
+
line,
|
|
283
|
+
candidate.core_bbox,
|
|
284
|
+
median_height,
|
|
285
|
+
margin_scale=candidate.label_margin_scale,
|
|
286
|
+
)
|
|
287
|
+
for line in row_lines
|
|
288
|
+
]
|
|
289
|
+
# 同一 pdftext 视觉行必须整体归属或整体保留,避免只吞掉 caption 的短碎片。
|
|
290
|
+
if not all(member_flags):
|
|
291
|
+
continue
|
|
292
|
+
inside_count = sum(
|
|
293
|
+
_point_in_bbox(
|
|
294
|
+
(_bbox_center_x(line.bbox), _bbox_center_y(line.bbox)),
|
|
295
|
+
candidate.core_bbox,
|
|
296
|
+
)
|
|
297
|
+
for line in row_lines
|
|
298
|
+
)
|
|
299
|
+
mean_distance = statistics.fmean(_bbox_distance(line.bbox, candidate.core_bbox) for line in row_lines)
|
|
300
|
+
matches.append((-inside_count, mean_distance, candidate_index))
|
|
301
|
+
if not matches:
|
|
302
|
+
continue
|
|
303
|
+
candidate_index = min(matches)[2]
|
|
304
|
+
candidates[candidate_index].line_indices.update(line.source_index for line in row_lines)
|
|
305
|
+
|
|
306
|
+
blocks: list[dict[str, Any]] = []
|
|
307
|
+
claimed: set[int] = set()
|
|
308
|
+
lines_by_index = {line.source_index: line for line in lines}
|
|
309
|
+
for candidate in candidates:
|
|
310
|
+
members = [
|
|
311
|
+
lines_by_index[source_index] for source_index in sorted(candidate.line_indices) if source_index in lines_by_index
|
|
312
|
+
]
|
|
313
|
+
if len(members) < 2:
|
|
314
|
+
continue
|
|
315
|
+
block = _graphic_members_to_block(candidate, members, source.page_size)
|
|
316
|
+
if block is None:
|
|
317
|
+
continue
|
|
318
|
+
blocks.append(block)
|
|
319
|
+
claimed.update(line.source_index for line in members)
|
|
320
|
+
|
|
321
|
+
blocks.sort(key=lambda block: (block["bbox"][1], block["bbox"][0]))
|
|
322
|
+
return blocks, claimed
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _parallel_graphic_rule_pairs(
|
|
326
|
+
drawing_lines: list[_AxisLine],
|
|
327
|
+
image_bboxes: list[BBox],
|
|
328
|
+
table_bboxes: list[BBox],
|
|
329
|
+
page_size: tuple[float, float],
|
|
330
|
+
median_height: float,
|
|
331
|
+
) -> list[tuple[BBox, BBox]]:
|
|
332
|
+
"""筛选分别贴近两个并排图形上沿的同高长横线。"""
|
|
333
|
+
|
|
334
|
+
minimum_rule_width = max(8.0 * median_height, 0.18 * page_size[0])
|
|
335
|
+
long_rules = [
|
|
336
|
+
line.bbox
|
|
337
|
+
for line in drawing_lines
|
|
338
|
+
if line.orientation == "horizontal"
|
|
339
|
+
and line.bbox[2] - line.bbox[0] >= minimum_rule_width
|
|
340
|
+
and not any(
|
|
341
|
+
_point_in_bbox(
|
|
342
|
+
(_bbox_center_x(line.bbox), _bbox_center_y(line.bbox)),
|
|
343
|
+
table_bbox,
|
|
344
|
+
)
|
|
345
|
+
for table_bbox in table_bboxes
|
|
346
|
+
)
|
|
347
|
+
]
|
|
348
|
+
ordered_images = sorted(image_bboxes, key=lambda bbox: (bbox[0], bbox[1]))
|
|
349
|
+
rule_pairs: list[tuple[BBox, BBox]] = []
|
|
350
|
+
seen_pairs: set[tuple[BBox, BBox]] = set()
|
|
351
|
+
for left_index, left_image in enumerate(ordered_images):
|
|
352
|
+
left_height = max(0.1, left_image[3] - left_image[1])
|
|
353
|
+
for right_image in ordered_images[left_index + 1 :]:
|
|
354
|
+
if left_image[2] >= right_image[0]:
|
|
355
|
+
continue
|
|
356
|
+
right_height = max(0.1, right_image[3] - right_image[1])
|
|
357
|
+
image_overlap = max(
|
|
358
|
+
0.0,
|
|
359
|
+
min(left_image[3], right_image[3]) - max(left_image[1], right_image[1]),
|
|
360
|
+
)
|
|
361
|
+
if image_overlap < 0.7 * min(left_height, right_height):
|
|
362
|
+
continue
|
|
363
|
+
if any(
|
|
364
|
+
_bbox_overlap_in_smaller(image_bbox, table_bbox) >= 0.5
|
|
365
|
+
for image_bbox in (left_image, right_image)
|
|
366
|
+
for table_bbox in table_bboxes
|
|
367
|
+
):
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
left_rules = [
|
|
371
|
+
rule_bbox
|
|
372
|
+
for rule_bbox in long_rules
|
|
373
|
+
if _bbox_axis_overlap_ratio(rule_bbox, left_image, axis="x") >= 0.8
|
|
374
|
+
and -0.25 * median_height <= left_image[1] - rule_bbox[3] <= 3.0 * median_height
|
|
375
|
+
]
|
|
376
|
+
right_rules = [
|
|
377
|
+
rule_bbox
|
|
378
|
+
for rule_bbox in long_rules
|
|
379
|
+
if _bbox_axis_overlap_ratio(rule_bbox, right_image, axis="x") >= 0.8
|
|
380
|
+
and -0.25 * median_height <= right_image[1] - rule_bbox[3] <= 3.0 * median_height
|
|
381
|
+
]
|
|
382
|
+
for left_rule in left_rules:
|
|
383
|
+
for right_rule in right_rules:
|
|
384
|
+
if left_rule[2] >= right_rule[0]:
|
|
385
|
+
continue
|
|
386
|
+
if abs(_bbox_center_y(left_rule) - _bbox_center_y(right_rule)) > 0.5 * median_height:
|
|
387
|
+
continue
|
|
388
|
+
rule_gap = right_rule[0] - left_rule[2]
|
|
389
|
+
if not 0.5 * median_height <= rule_gap <= 5.0 * median_height:
|
|
390
|
+
continue
|
|
391
|
+
pair = (left_rule, right_rule)
|
|
392
|
+
if pair not in seen_pairs:
|
|
393
|
+
seen_pairs.add(pair)
|
|
394
|
+
rule_pairs.append(pair)
|
|
395
|
+
return rule_pairs
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _parallel_graphic_row_split_boundary(
|
|
399
|
+
members: list[_LineItem],
|
|
400
|
+
left_rule: BBox,
|
|
401
|
+
right_rule: BBox,
|
|
402
|
+
table_bboxes: list[BBox],
|
|
403
|
+
page_size: tuple[float, float],
|
|
404
|
+
median_height: float,
|
|
405
|
+
) -> float | None:
|
|
406
|
+
"""用横线栏沟和字符投影确认并排图形上方文本的安全切分点。"""
|
|
407
|
+
|
|
408
|
+
if not members or any(member.angle != 0 for member in members) or len({member.semantic_type for member in members}) != 1:
|
|
409
|
+
return None
|
|
410
|
+
row_bbox = _bbox_union_many([member.bbox for member in members])
|
|
411
|
+
if any(_bbox_intersects(row_bbox, table_bbox) for table_bbox in table_bboxes):
|
|
412
|
+
return None
|
|
413
|
+
rule_top = min(left_rule[1], right_rule[1])
|
|
414
|
+
if not -0.2 * median_height <= rule_top - row_bbox[3] <= 1.5 * median_height:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
glyph_bboxes = [
|
|
418
|
+
bbox
|
|
419
|
+
for member in members
|
|
420
|
+
for char in member.chars
|
|
421
|
+
if str(char.get("char") or "").isprintable()
|
|
422
|
+
and not str(char.get("char") or "").isspace()
|
|
423
|
+
and (bbox := _clip_bbox(_coerce_bbox(char.get("bbox")), page_size)) is not None
|
|
424
|
+
]
|
|
425
|
+
if not glyph_bboxes:
|
|
426
|
+
return None
|
|
427
|
+
boundary = 0.5 * (left_rule[2] + right_rule[0])
|
|
428
|
+
if any(
|
|
429
|
+
_bbox_center_x(bbox) < left_rule[0] - median_height or _bbox_center_x(bbox) > right_rule[2] + median_height
|
|
430
|
+
for bbox in glyph_bboxes
|
|
431
|
+
):
|
|
432
|
+
return None
|
|
433
|
+
left_glyphs = [bbox for bbox in glyph_bboxes if _bbox_center_x(bbox) < boundary]
|
|
434
|
+
right_glyphs = [bbox for bbox in glyph_bboxes if _bbox_center_x(bbox) > boundary]
|
|
435
|
+
if len(left_glyphs) < 3 or len(right_glyphs) < 3:
|
|
436
|
+
return None
|
|
437
|
+
left_width = max(bbox[2] for bbox in left_glyphs) - min(bbox[0] for bbox in left_glyphs)
|
|
438
|
+
right_width = max(bbox[2] for bbox in right_glyphs) - min(bbox[0] for bbox in right_glyphs)
|
|
439
|
+
if min(left_width, right_width) < 4.0 * median_height:
|
|
440
|
+
return None
|
|
441
|
+
left_edge = max(bbox[2] for bbox in left_glyphs)
|
|
442
|
+
right_edge = min(bbox[0] for bbox in right_glyphs)
|
|
443
|
+
if right_edge - left_edge < 0.75 * median_height:
|
|
444
|
+
return None
|
|
445
|
+
if not (left_edge <= left_rule[2] <= right_edge and left_edge <= right_rule[0] <= right_edge):
|
|
446
|
+
return None
|
|
447
|
+
return boundary
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _split_parallel_graphic_rule_rows(
|
|
451
|
+
lines: list[_LineItem],
|
|
452
|
+
drawing_lines: list[_AxisLine],
|
|
453
|
+
image_bboxes: list[BBox],
|
|
454
|
+
table_bboxes: list[BBox],
|
|
455
|
+
page_size: tuple[float, float],
|
|
456
|
+
*,
|
|
457
|
+
source_index_start: int | None = None,
|
|
458
|
+
) -> list[_LineItem]:
|
|
459
|
+
"""按成对图形、独立顶边横线和栏沟字符投影拆分并排图形上方文本。"""
|
|
460
|
+
|
|
461
|
+
horizontal_lines = [line for line in lines if line.angle == 0 and line.effective_height > 0]
|
|
462
|
+
if len(horizontal_lines) < 1 or len(image_bboxes) < 2:
|
|
463
|
+
return list(lines)
|
|
464
|
+
median_height = statistics.median(line.effective_height for line in horizontal_lines)
|
|
465
|
+
rule_pairs = _parallel_graphic_rule_pairs(
|
|
466
|
+
drawing_lines,
|
|
467
|
+
image_bboxes,
|
|
468
|
+
table_bboxes,
|
|
469
|
+
page_size,
|
|
470
|
+
median_height,
|
|
471
|
+
)
|
|
472
|
+
if not rule_pairs:
|
|
473
|
+
return list(lines)
|
|
474
|
+
|
|
475
|
+
row_groups: dict[tuple[int, int], list[_LineItem]] = {}
|
|
476
|
+
for line in horizontal_lines:
|
|
477
|
+
if line.visual_row_id is not None:
|
|
478
|
+
row_groups.setdefault((line.angle, line.visual_row_id), []).append(line)
|
|
479
|
+
boundaries_by_row: dict[tuple[int, int], list[float]] = {}
|
|
480
|
+
for row_key, members in row_groups.items():
|
|
481
|
+
for left_rule, right_rule in rule_pairs:
|
|
482
|
+
boundary = _parallel_graphic_row_split_boundary(
|
|
483
|
+
members,
|
|
484
|
+
left_rule,
|
|
485
|
+
right_rule,
|
|
486
|
+
table_bboxes,
|
|
487
|
+
page_size,
|
|
488
|
+
median_height,
|
|
489
|
+
)
|
|
490
|
+
if boundary is None:
|
|
491
|
+
continue
|
|
492
|
+
row_boundaries = boundaries_by_row.setdefault(row_key, [])
|
|
493
|
+
if not any(abs(boundary - existing) <= 0.5 * median_height for existing in row_boundaries):
|
|
494
|
+
row_boundaries.append(boundary)
|
|
495
|
+
if not boundaries_by_row:
|
|
496
|
+
return list(lines)
|
|
497
|
+
|
|
498
|
+
next_source_index = max(
|
|
499
|
+
max((line.source_index for line in lines), default=-1) + 1,
|
|
500
|
+
source_index_start or 0,
|
|
501
|
+
)
|
|
502
|
+
consumed_source_indices: set[int] = set()
|
|
503
|
+
split_lines: list[_LineItem] = []
|
|
504
|
+
for row_key, boundaries in boundaries_by_row.items():
|
|
505
|
+
members = sorted(
|
|
506
|
+
row_groups[row_key],
|
|
507
|
+
key=lambda line: (line.bbox[0], line.run_index, line.source_index),
|
|
508
|
+
)
|
|
509
|
+
ordered_chars = [char for member in members for char in member.chars]
|
|
510
|
+
split_indices: list[int] = []
|
|
511
|
+
for boundary in sorted(boundaries):
|
|
512
|
+
split_index = next(
|
|
513
|
+
(
|
|
514
|
+
index
|
|
515
|
+
for index, char in enumerate(ordered_chars)
|
|
516
|
+
if str(char.get("char") or "").isprintable()
|
|
517
|
+
and not str(char.get("char") or "").isspace()
|
|
518
|
+
and (
|
|
519
|
+
bbox := _clip_bbox(
|
|
520
|
+
_coerce_bbox(char.get("bbox")),
|
|
521
|
+
page_size,
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
is not None
|
|
525
|
+
and _bbox_center_x(bbox) > boundary
|
|
526
|
+
),
|
|
527
|
+
None,
|
|
528
|
+
)
|
|
529
|
+
if split_index is not None and split_index not in split_indices:
|
|
530
|
+
split_indices.append(split_index)
|
|
531
|
+
if not split_indices:
|
|
532
|
+
continue
|
|
533
|
+
ranges: list[tuple[int, int]] = []
|
|
534
|
+
start = 0
|
|
535
|
+
for split_index in sorted(split_indices):
|
|
536
|
+
ranges.append((start, split_index))
|
|
537
|
+
start = split_index
|
|
538
|
+
ranges.append((start, len(ordered_chars)))
|
|
539
|
+
|
|
540
|
+
source_indices = [member.source_index for member in members]
|
|
541
|
+
rebuilt: list[_LineItem] = []
|
|
542
|
+
for run_index, (start, end) in enumerate(ranges):
|
|
543
|
+
run_chars = ordered_chars[start:end]
|
|
544
|
+
run_bboxes = [
|
|
545
|
+
bbox
|
|
546
|
+
for char in run_chars
|
|
547
|
+
if str(char.get("char") or "").isprintable()
|
|
548
|
+
and not str(char.get("char") or "").isspace()
|
|
549
|
+
and (
|
|
550
|
+
bbox := _clip_bbox(
|
|
551
|
+
_coerce_bbox(char.get("bbox")),
|
|
552
|
+
page_size,
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
is not None
|
|
556
|
+
]
|
|
557
|
+
run_text = _normalize_native_run_text("".join(str(char.get("char") or "") for char in run_chars))
|
|
558
|
+
if not run_text or not run_bboxes:
|
|
559
|
+
continue
|
|
560
|
+
if run_index < len(source_indices):
|
|
561
|
+
source_index = source_indices[run_index]
|
|
562
|
+
else:
|
|
563
|
+
source_index = next_source_index
|
|
564
|
+
next_source_index += 1
|
|
565
|
+
template = members[min(run_index, len(members) - 1)]
|
|
566
|
+
rebuilt_line = replace(
|
|
567
|
+
template,
|
|
568
|
+
text=run_text,
|
|
569
|
+
bbox=_bbox_union_many(run_bboxes),
|
|
570
|
+
source_index=source_index,
|
|
571
|
+
chars=list(run_chars),
|
|
572
|
+
visual_row_id=row_key[1],
|
|
573
|
+
run_index=run_index,
|
|
574
|
+
split_from_row=True,
|
|
575
|
+
preserve_split_boundary=True,
|
|
576
|
+
)
|
|
577
|
+
_fill_native_typography(rebuilt_line, page_size)
|
|
578
|
+
rebuilt.append(rebuilt_line)
|
|
579
|
+
if len(rebuilt) < 2:
|
|
580
|
+
continue
|
|
581
|
+
consumed_source_indices.update(member.source_index for member in members)
|
|
582
|
+
split_lines.extend(rebuilt)
|
|
583
|
+
|
|
584
|
+
output = [line for line in lines if line.source_index not in consumed_source_indices]
|
|
585
|
+
output.extend(split_lines)
|
|
586
|
+
output.sort(key=lambda line: (line.angle, line.bbox[1], line.bbox[0], line.source_index))
|
|
587
|
+
return output
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _graphic_caption_line_indices_to_preserve(
|
|
591
|
+
lines: list[_LineItem],
|
|
592
|
+
candidates: list[_GraphicCandidate],
|
|
593
|
+
median_height: float,
|
|
594
|
+
) -> set[int]:
|
|
595
|
+
"""保护贴近图形下沿的图注及其同字体续行,避免末词被图片容器认领。"""
|
|
596
|
+
|
|
597
|
+
protected: set[int] = set()
|
|
598
|
+
ordered_lines = sorted(
|
|
599
|
+
(line for line in lines if line.angle == 0),
|
|
600
|
+
key=lambda line: (line.bbox[1], line.bbox[0], line.source_index),
|
|
601
|
+
)
|
|
602
|
+
for seed_index, seed in enumerate(ordered_lines):
|
|
603
|
+
if not _FIGURE_CAPTION_LINE_RE.match(seed.text):
|
|
604
|
+
continue
|
|
605
|
+
matching_candidates = [
|
|
606
|
+
candidate
|
|
607
|
+
for candidate in candidates
|
|
608
|
+
if _bbox_axis_overlap_ratio(
|
|
609
|
+
seed.bbox,
|
|
610
|
+
candidate.core_bbox,
|
|
611
|
+
axis="x",
|
|
612
|
+
)
|
|
613
|
+
>= 0.35
|
|
614
|
+
and candidate.core_bbox[3] - 2.5 * median_height
|
|
615
|
+
<= _bbox_center_y(seed.bbox)
|
|
616
|
+
<= candidate.core_bbox[3] + 2.5 * median_height
|
|
617
|
+
]
|
|
618
|
+
if not matching_candidates:
|
|
619
|
+
continue
|
|
620
|
+
protected.add(seed.source_index)
|
|
621
|
+
previous = seed
|
|
622
|
+
for candidate_line in ordered_lines[seed_index + 1 :]:
|
|
623
|
+
if candidate_line.bbox[1] - previous.bbox[3] > 0.75 * median_height:
|
|
624
|
+
break
|
|
625
|
+
if _bbox_center_y(candidate_line.bbox) <= _bbox_center_y(previous.bbox):
|
|
626
|
+
continue
|
|
627
|
+
if abs(candidate_line.bbox[0] - seed.bbox[0]) > median_height or (
|
|
628
|
+
seed.font_signature is not None
|
|
629
|
+
and candidate_line.font_signature is not None
|
|
630
|
+
and seed.font_signature != candidate_line.font_signature
|
|
631
|
+
):
|
|
632
|
+
continue
|
|
633
|
+
protected.add(candidate_line.source_index)
|
|
634
|
+
previous = candidate_line
|
|
635
|
+
if candidate_line.text.rstrip().endswith((".", "!", "?")):
|
|
636
|
+
break
|
|
637
|
+
return protected
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _graphic_body_tail_line_indices_to_preserve(
|
|
641
|
+
lines: list[_LineItem],
|
|
642
|
+
candidates: list[_GraphicCandidate],
|
|
643
|
+
lanes: list[_TextLane],
|
|
644
|
+
median_height: float,
|
|
645
|
+
) -> set[int]:
|
|
646
|
+
"""保护贴近图形上沿但延续上方满栏正文排版的短尾行。"""
|
|
647
|
+
|
|
648
|
+
protected: set[int] = set()
|
|
649
|
+
horizontal_lines = [line for line in lines if line.angle == 0]
|
|
650
|
+
for tail in horizontal_lines:
|
|
651
|
+
lane_index = _graphic_lane_index(tail.bbox, lanes)
|
|
652
|
+
lane = lanes[lane_index]
|
|
653
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
654
|
+
tail_width = tail.bbox[2] - tail.bbox[0]
|
|
655
|
+
if tail_width > 0.5 * lane_width:
|
|
656
|
+
continue
|
|
657
|
+
|
|
658
|
+
matching_candidates = [
|
|
659
|
+
candidate
|
|
660
|
+
for candidate in candidates
|
|
661
|
+
if (candidate.lane_index < 0 or candidate.lane_index == lane_index)
|
|
662
|
+
and tail.bbox[3] <= candidate.core_bbox[1] + 0.25 * median_height
|
|
663
|
+
and _is_graphic_label_member(
|
|
664
|
+
tail,
|
|
665
|
+
candidate.core_bbox,
|
|
666
|
+
median_height,
|
|
667
|
+
margin_scale=candidate.label_margin_scale,
|
|
668
|
+
)
|
|
669
|
+
]
|
|
670
|
+
if not matching_candidates:
|
|
671
|
+
continue
|
|
672
|
+
|
|
673
|
+
tail_height = _line_effective_height(tail, tail.bbox)
|
|
674
|
+
for previous in horizontal_lines:
|
|
675
|
+
if previous.source_index == tail.source_index:
|
|
676
|
+
continue
|
|
677
|
+
if _graphic_lane_index(previous.bbox, lanes) != lane_index:
|
|
678
|
+
continue
|
|
679
|
+
vertical_gap = tail.bbox[1] - previous.bbox[3]
|
|
680
|
+
if not -0.25 * median_height <= vertical_gap <= 0.75 * median_height:
|
|
681
|
+
continue
|
|
682
|
+
if abs(previous.bbox[0] - tail.bbox[0]) > 0.75 * median_height:
|
|
683
|
+
continue
|
|
684
|
+
|
|
685
|
+
previous_width = previous.bbox[2] - previous.bbox[0]
|
|
686
|
+
if (
|
|
687
|
+
previous_width < 0.75 * lane_width
|
|
688
|
+
or lane.right - previous.bbox[2] > median_height
|
|
689
|
+
or previous_width < 1.5 * tail_width
|
|
690
|
+
):
|
|
691
|
+
continue
|
|
692
|
+
previous_height = _line_effective_height(previous, previous.bbox)
|
|
693
|
+
if max(previous_height, tail_height) > 1.25 * min(previous_height, tail_height):
|
|
694
|
+
continue
|
|
695
|
+
if (
|
|
696
|
+
previous.font_signature is not None
|
|
697
|
+
and tail.font_signature is not None
|
|
698
|
+
and previous.font_signature != tail.font_signature
|
|
699
|
+
):
|
|
700
|
+
continue
|
|
701
|
+
if any(
|
|
702
|
+
_is_graphic_label_member(
|
|
703
|
+
previous,
|
|
704
|
+
candidate.core_bbox,
|
|
705
|
+
median_height,
|
|
706
|
+
margin_scale=candidate.label_margin_scale,
|
|
707
|
+
)
|
|
708
|
+
for candidate in matching_candidates
|
|
709
|
+
):
|
|
710
|
+
continue
|
|
711
|
+
protected.add(tail.source_index)
|
|
712
|
+
break
|
|
713
|
+
return protected
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _detect_strong_graphic_bboxes(source: _PageSource) -> list[BBox]:
|
|
717
|
+
"""仅按复杂 Path、容器尺度与成对坐标轴识别高置信图形核心。"""
|
|
718
|
+
|
|
719
|
+
if not source.path_infos:
|
|
720
|
+
return []
|
|
721
|
+
effective_heights = [_line_effective_height(line, line.bbox) for line in source.lines if line.angle == 0]
|
|
722
|
+
median_height = statistics.median(effective_heights) if effective_heights else 1.0
|
|
723
|
+
candidates = [
|
|
724
|
+
*_detect_complex_path_containers(
|
|
725
|
+
source.path_infos,
|
|
726
|
+
source.page_size,
|
|
727
|
+
median_height,
|
|
728
|
+
),
|
|
729
|
+
*_detect_axis_path_graphics(
|
|
730
|
+
source.path_infos,
|
|
731
|
+
source.page_size,
|
|
732
|
+
median_height,
|
|
733
|
+
),
|
|
734
|
+
*_detect_complex_drawing_components(
|
|
735
|
+
source.drawing_lines,
|
|
736
|
+
source.path_infos,
|
|
737
|
+
source.page_size,
|
|
738
|
+
median_height,
|
|
739
|
+
),
|
|
740
|
+
]
|
|
741
|
+
|
|
742
|
+
output: list[BBox] = []
|
|
743
|
+
for bbox in sorted(candidates, key=_bbox_area, reverse=True):
|
|
744
|
+
if any(_bbox_overlap_in_first(bbox, accepted) >= 0.9 for accepted in output):
|
|
745
|
+
continue
|
|
746
|
+
output.append(bbox)
|
|
747
|
+
return sorted(output, key=lambda bbox: (bbox[1], bbox[0], bbox[3], bbox[2]))
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _detect_complex_path_containers(
|
|
751
|
+
path_infos: list[PDFPathInfo],
|
|
752
|
+
page_size: tuple[float, float],
|
|
753
|
+
median_height: float,
|
|
754
|
+
) -> list[BBox]:
|
|
755
|
+
"""筛选包含多个内部 Path 且至少含一个二维复杂轮廓的大容器。"""
|
|
756
|
+
|
|
757
|
+
page_area = max(0.1, page_size[0] * page_size[1])
|
|
758
|
+
output: list[BBox] = []
|
|
759
|
+
for path_info in path_infos:
|
|
760
|
+
bbox = path_info.bbox
|
|
761
|
+
width = bbox[2] - bbox[0]
|
|
762
|
+
height = bbox[3] - bbox[1]
|
|
763
|
+
area_ratio = _bbox_area(bbox) / page_area
|
|
764
|
+
if (
|
|
765
|
+
path_info.form_depth != 0
|
|
766
|
+
or not path_info.fill_visible
|
|
767
|
+
or not 0.005 <= area_ratio <= 0.5
|
|
768
|
+
or width < 4.0 * median_height
|
|
769
|
+
or height < 4.0 * median_height
|
|
770
|
+
):
|
|
771
|
+
continue
|
|
772
|
+
inner_paths = [
|
|
773
|
+
item
|
|
774
|
+
for item in path_infos
|
|
775
|
+
if item.source_index != path_info.source_index
|
|
776
|
+
and _bbox_overlap_in_first(item.bbox, bbox) >= 0.9
|
|
777
|
+
and _bbox_area(item.bbox) < 0.95 * _bbox_area(bbox)
|
|
778
|
+
]
|
|
779
|
+
if len(inner_paths) < 4:
|
|
780
|
+
continue
|
|
781
|
+
if not any(_is_two_dimensional_complex_path(item, median_height) for item in inner_paths):
|
|
782
|
+
continue
|
|
783
|
+
output.append(bbox)
|
|
784
|
+
return output
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _detect_axis_path_graphics(
|
|
788
|
+
path_infos: list[PDFPathInfo],
|
|
789
|
+
page_size: tuple[float, float],
|
|
790
|
+
median_height: float,
|
|
791
|
+
) -> list[BBox]:
|
|
792
|
+
"""用相交的长横纵轴和内部二维复杂路径补充无外框图表。"""
|
|
793
|
+
|
|
794
|
+
thin_limit = max(1.0, 0.5 * median_height)
|
|
795
|
+
minimum_axis_length = 6.0 * median_height
|
|
796
|
+
horizontal_axes = [
|
|
797
|
+
item
|
|
798
|
+
for item in path_infos
|
|
799
|
+
if item.form_depth == 0
|
|
800
|
+
and item.stroke_visible
|
|
801
|
+
and item.bbox[3] - item.bbox[1] <= thin_limit
|
|
802
|
+
and item.bbox[2] - item.bbox[0] >= minimum_axis_length
|
|
803
|
+
]
|
|
804
|
+
vertical_axes = [
|
|
805
|
+
item
|
|
806
|
+
for item in path_infos
|
|
807
|
+
if item.form_depth == 0
|
|
808
|
+
and item.stroke_visible
|
|
809
|
+
and item.bbox[2] - item.bbox[0] <= thin_limit
|
|
810
|
+
and item.bbox[3] - item.bbox[1] >= minimum_axis_length
|
|
811
|
+
]
|
|
812
|
+
tolerance = max(2.0, median_height)
|
|
813
|
+
output: list[BBox] = []
|
|
814
|
+
for horizontal in horizontal_axes:
|
|
815
|
+
horizontal_y = _bbox_center_y(horizontal.bbox)
|
|
816
|
+
for vertical in vertical_axes:
|
|
817
|
+
vertical_x = _bbox_center_x(vertical.bbox)
|
|
818
|
+
touches_x = (
|
|
819
|
+
min(
|
|
820
|
+
abs(vertical_x - horizontal.bbox[0]),
|
|
821
|
+
abs(vertical_x - horizontal.bbox[2]),
|
|
822
|
+
)
|
|
823
|
+
<= tolerance
|
|
824
|
+
)
|
|
825
|
+
touches_y = (
|
|
826
|
+
min(
|
|
827
|
+
abs(horizontal_y - vertical.bbox[1]),
|
|
828
|
+
abs(horizontal_y - vertical.bbox[3]),
|
|
829
|
+
)
|
|
830
|
+
<= tolerance
|
|
831
|
+
)
|
|
832
|
+
if not (touches_x and touches_y):
|
|
833
|
+
continue
|
|
834
|
+
plot_bbox = _bbox_union(horizontal.bbox, vertical.bbox)
|
|
835
|
+
width = plot_bbox[2] - plot_bbox[0]
|
|
836
|
+
height = plot_bbox[3] - plot_bbox[1]
|
|
837
|
+
if width > 0.65 * page_size[0] or height > 0.5 * page_size[1]:
|
|
838
|
+
continue
|
|
839
|
+
complex_paths = [
|
|
840
|
+
item
|
|
841
|
+
for item in path_infos
|
|
842
|
+
if _is_two_dimensional_complex_path(item, median_height)
|
|
843
|
+
and _bbox_overlap_in_smaller(item.bbox, plot_bbox) >= 0.2
|
|
844
|
+
]
|
|
845
|
+
if not complex_paths:
|
|
846
|
+
continue
|
|
847
|
+
output.append(
|
|
848
|
+
_bbox_union(
|
|
849
|
+
plot_bbox,
|
|
850
|
+
_bbox_union_many([item.bbox for item in complex_paths]),
|
|
851
|
+
)
|
|
852
|
+
)
|
|
853
|
+
return output
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _is_two_dimensional_complex_path(
|
|
857
|
+
path_info: PDFPathInfo,
|
|
858
|
+
median_height: float,
|
|
859
|
+
) -> bool:
|
|
860
|
+
"""排除细轴线,只保留横纵均有尺寸且段数较多的图形轮廓。"""
|
|
861
|
+
|
|
862
|
+
width = path_info.bbox[2] - path_info.bbox[0]
|
|
863
|
+
height = path_info.bbox[3] - path_info.bbox[1]
|
|
864
|
+
return (
|
|
865
|
+
path_info.form_depth == 0
|
|
866
|
+
and path_info.segment_count >= 6
|
|
867
|
+
and width >= 1.5 * median_height
|
|
868
|
+
and height >= 1.5 * median_height
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _detect_complex_drawing_components(
|
|
873
|
+
drawing_lines: list[_AxisLine],
|
|
874
|
+
path_infos: list[PDFPathInfo],
|
|
875
|
+
page_size: tuple[float, float],
|
|
876
|
+
median_height: float,
|
|
877
|
+
) -> list[BBox]:
|
|
878
|
+
"""以横纵绘图线组件和内部二维复杂 Path 识别坐标图或嵌入式图表。"""
|
|
879
|
+
|
|
880
|
+
tolerance = max(2.0, 0.75 * median_height)
|
|
881
|
+
output: list[BBox] = []
|
|
882
|
+
for component in _connected_drawing_line_components(drawing_lines, tolerance):
|
|
883
|
+
horizontal_count = sum(line.orientation == "horizontal" for line in component)
|
|
884
|
+
vertical_count = len(component) - horizontal_count
|
|
885
|
+
core_bbox = _bbox_union_many([line.bbox for line in component])
|
|
886
|
+
width = core_bbox[2] - core_bbox[0]
|
|
887
|
+
height = core_bbox[3] - core_bbox[1]
|
|
888
|
+
if (
|
|
889
|
+
len(component) < 4
|
|
890
|
+
or horizontal_count < 2
|
|
891
|
+
or vertical_count < 2
|
|
892
|
+
or width < 4.0 * median_height
|
|
893
|
+
or height < 3.0 * median_height
|
|
894
|
+
or width > 0.65 * page_size[0]
|
|
895
|
+
or height > 0.5 * page_size[1]
|
|
896
|
+
):
|
|
897
|
+
continue
|
|
898
|
+
complex_paths = [
|
|
899
|
+
path_info
|
|
900
|
+
for path_info in path_infos
|
|
901
|
+
if _is_two_dimensional_complex_path(path_info, median_height)
|
|
902
|
+
and _bbox_overlap_in_smaller(path_info.bbox, core_bbox) >= 0.2
|
|
903
|
+
]
|
|
904
|
+
if not complex_paths:
|
|
905
|
+
continue
|
|
906
|
+
output.append(
|
|
907
|
+
_bbox_union(
|
|
908
|
+
core_bbox,
|
|
909
|
+
_bbox_union_many([path_info.bbox for path_info in complex_paths]),
|
|
910
|
+
)
|
|
911
|
+
)
|
|
912
|
+
return output
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _infer_graphic_text_lanes(
|
|
916
|
+
lines: list[_LineItem],
|
|
917
|
+
page_size: tuple[float, float],
|
|
918
|
+
median_height: float,
|
|
919
|
+
) -> list[_TextLane]:
|
|
920
|
+
"""用横排正文推断页内栏带,供不同角度的图形标签共享栏归属。"""
|
|
921
|
+
|
|
922
|
+
line_geometry = [(line, line.bbox) for line in lines if line.angle == 0]
|
|
923
|
+
if not line_geometry:
|
|
924
|
+
return [_TextLane(left=0.0, right=page_size[0])]
|
|
925
|
+
angle_heights = [_line_effective_height(line, bbox) for line, bbox in line_geometry]
|
|
926
|
+
angle_median_height = statistics.median(angle_heights) if angle_heights else median_height
|
|
927
|
+
lanes = [
|
|
928
|
+
lane
|
|
929
|
+
for lane in _infer_text_lanes(
|
|
930
|
+
line_geometry,
|
|
931
|
+
page_size[0],
|
|
932
|
+
angle_median_height,
|
|
933
|
+
)
|
|
934
|
+
if not lane.is_span
|
|
935
|
+
]
|
|
936
|
+
return lanes or [_TextLane(left=0.0, right=page_size[0])]
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _graphic_lane_index(bbox: BBox, lanes: list[_TextLane]) -> int:
|
|
940
|
+
"""按中心点、水平覆盖和距离为 bbox 选择唯一栏带。"""
|
|
941
|
+
|
|
942
|
+
center_x = _bbox_center_x(bbox)
|
|
943
|
+
best_index = 0
|
|
944
|
+
best_score = (-1, -1.0, -math.inf)
|
|
945
|
+
for lane_index, lane in enumerate(lanes):
|
|
946
|
+
inside = int(lane.left <= center_x <= lane.right)
|
|
947
|
+
overlap = max(0.0, min(bbox[2], lane.right) - max(bbox[0], lane.left))
|
|
948
|
+
if inside:
|
|
949
|
+
distance = 0.0
|
|
950
|
+
else:
|
|
951
|
+
distance = min(abs(center_x - lane.left), abs(center_x - lane.right))
|
|
952
|
+
score = (inside, overlap, -distance)
|
|
953
|
+
if score > best_score:
|
|
954
|
+
best_score = score
|
|
955
|
+
best_index = lane_index
|
|
956
|
+
return best_index
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _strong_graphic_lane_index(
|
|
960
|
+
core_bbox: BBox,
|
|
961
|
+
lanes: list[_TextLane],
|
|
962
|
+
median_height: float,
|
|
963
|
+
) -> int:
|
|
964
|
+
"""仅把几乎完整落入唯一栏带的强图形核心绑定到该栏。"""
|
|
965
|
+
|
|
966
|
+
core_width = max(0.1, core_bbox[2] - core_bbox[0])
|
|
967
|
+
tolerance = max(1.0, median_height)
|
|
968
|
+
matching_indices = []
|
|
969
|
+
for lane_index, lane in enumerate(lanes):
|
|
970
|
+
overlap = max(
|
|
971
|
+
0.0,
|
|
972
|
+
min(core_bbox[2], lane.right) - max(core_bbox[0], lane.left),
|
|
973
|
+
)
|
|
974
|
+
if overlap / core_width >= 0.9 and core_bbox[0] >= lane.left - tolerance and core_bbox[2] <= lane.right + tolerance:
|
|
975
|
+
matching_indices.append(lane_index)
|
|
976
|
+
return matching_indices[0] if len(matching_indices) == 1 else -1
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _detect_graphic_candidates(
|
|
980
|
+
drawing_lines: list[_AxisLine],
|
|
981
|
+
page_size: tuple[float, float],
|
|
982
|
+
median_height: float,
|
|
983
|
+
lanes: list[_TextLane],
|
|
984
|
+
table_bboxes: list[BBox],
|
|
985
|
+
) -> list[_GraphicCandidate]:
|
|
986
|
+
"""从非表格绘图线连通分量中筛选尺寸受限的图形容器。"""
|
|
987
|
+
|
|
988
|
+
tolerance = max(2.0, 0.75 * median_height)
|
|
989
|
+
candidates: list[_GraphicCandidate] = []
|
|
990
|
+
for component in _connected_drawing_line_components(drawing_lines, tolerance):
|
|
991
|
+
horizontal_count = sum(line.orientation == "horizontal" for line in component)
|
|
992
|
+
vertical_count = len(component) - horizontal_count
|
|
993
|
+
core_bbox = _bbox_union_many([line.bbox for line in component])
|
|
994
|
+
width = core_bbox[2] - core_bbox[0]
|
|
995
|
+
height = core_bbox[3] - core_bbox[1]
|
|
996
|
+
if (
|
|
997
|
+
len(component) < 4
|
|
998
|
+
or horizontal_count < 2
|
|
999
|
+
or vertical_count < 2
|
|
1000
|
+
or width < 4.0 * median_height
|
|
1001
|
+
or height < 3.0 * median_height
|
|
1002
|
+
or width > 0.5 * page_size[0]
|
|
1003
|
+
or height > 0.5 * page_size[1]
|
|
1004
|
+
):
|
|
1005
|
+
continue
|
|
1006
|
+
if any(_bbox_overlap_in_smaller(core_bbox, table_bbox) >= 0.5 for table_bbox in table_bboxes):
|
|
1007
|
+
continue
|
|
1008
|
+
candidates.append(
|
|
1009
|
+
_GraphicCandidate(
|
|
1010
|
+
core_bbox=core_bbox,
|
|
1011
|
+
lane_index=_graphic_lane_index(core_bbox, lanes),
|
|
1012
|
+
)
|
|
1013
|
+
)
|
|
1014
|
+
return candidates
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _connected_drawing_line_components(
|
|
1018
|
+
drawing_lines: list[_AxisLine],
|
|
1019
|
+
tolerance: float,
|
|
1020
|
+
) -> list[list[_AxisLine]]:
|
|
1021
|
+
"""按 bbox 间距连接相邻绘图线,并返回互不重叠的连通分量。"""
|
|
1022
|
+
|
|
1023
|
+
parents = list(range(len(drawing_lines)))
|
|
1024
|
+
|
|
1025
|
+
def find(index: int) -> int:
|
|
1026
|
+
"""查找绘图线连通分量的根节点。"""
|
|
1027
|
+
|
|
1028
|
+
while parents[index] != index:
|
|
1029
|
+
parents[index] = parents[parents[index]]
|
|
1030
|
+
index = parents[index]
|
|
1031
|
+
return index
|
|
1032
|
+
|
|
1033
|
+
def union(first_index: int, second_index: int) -> None:
|
|
1034
|
+
"""合并两个距离满足条件的绘图线分量。"""
|
|
1035
|
+
|
|
1036
|
+
first_root = find(first_index)
|
|
1037
|
+
second_root = find(second_index)
|
|
1038
|
+
if first_root != second_root:
|
|
1039
|
+
parents[second_root] = first_root
|
|
1040
|
+
|
|
1041
|
+
for first_index, first in enumerate(drawing_lines):
|
|
1042
|
+
for second_index in range(first_index + 1, len(drawing_lines)):
|
|
1043
|
+
if _bbox_distance(first.bbox, drawing_lines[second_index].bbox) <= tolerance:
|
|
1044
|
+
union(first_index, second_index)
|
|
1045
|
+
|
|
1046
|
+
components: dict[int, list[_AxisLine]] = {}
|
|
1047
|
+
for line_index, line in enumerate(drawing_lines):
|
|
1048
|
+
components.setdefault(find(line_index), []).append(line)
|
|
1049
|
+
return list(components.values())
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _is_graphic_label_member(
|
|
1053
|
+
line: _LineItem,
|
|
1054
|
+
core_bbox: BBox,
|
|
1055
|
+
median_height: float,
|
|
1056
|
+
*,
|
|
1057
|
+
margin_scale: float = 2.5,
|
|
1058
|
+
) -> bool:
|
|
1059
|
+
"""判断短文本是否位于图形核心内部或对应轴向的邻近标签区。"""
|
|
1060
|
+
|
|
1061
|
+
center = (_bbox_center_x(line.bbox), _bbox_center_y(line.bbox))
|
|
1062
|
+
if _point_in_bbox(center, core_bbox):
|
|
1063
|
+
return True
|
|
1064
|
+
|
|
1065
|
+
line_height = max(
|
|
1066
|
+
0.1,
|
|
1067
|
+
line.effective_height
|
|
1068
|
+
or min(
|
|
1069
|
+
max(0.1, line.bbox[2] - line.bbox[0]),
|
|
1070
|
+
max(0.1, line.bbox[3] - line.bbox[1]),
|
|
1071
|
+
),
|
|
1072
|
+
)
|
|
1073
|
+
if line.angle in {90, 270}:
|
|
1074
|
+
primary_length = line.bbox[3] - line.bbox[1]
|
|
1075
|
+
core_primary_length = core_bbox[3] - core_bbox[1]
|
|
1076
|
+
else:
|
|
1077
|
+
primary_length = line.bbox[2] - line.bbox[0]
|
|
1078
|
+
core_primary_length = core_bbox[2] - core_bbox[0]
|
|
1079
|
+
|
|
1080
|
+
horizontal_gap = max(core_bbox[0] - line.bbox[2], line.bbox[0] - core_bbox[2], 0.0)
|
|
1081
|
+
vertical_gap = max(core_bbox[1] - line.bbox[3], line.bbox[1] - core_bbox[3], 0.0)
|
|
1082
|
+
# 横排坐标轴标题允许比刻度标签略长,但必须与图宽、行高和上下间距同时相容。
|
|
1083
|
+
is_horizontal_axis_title = (
|
|
1084
|
+
line.angle in {0, 180}
|
|
1085
|
+
and primary_length <= 8.0 * line_height
|
|
1086
|
+
and primary_length <= 0.45 * core_primary_length
|
|
1087
|
+
and _bbox_axis_overlap_ratio(line.bbox, core_bbox, axis="x") >= 0.15
|
|
1088
|
+
and vertical_gap <= 2.5 * median_height
|
|
1089
|
+
)
|
|
1090
|
+
if is_horizontal_axis_title:
|
|
1091
|
+
return True
|
|
1092
|
+
if primary_length > min(5.0 * line_height, 0.5 * core_primary_length):
|
|
1093
|
+
return False
|
|
1094
|
+
|
|
1095
|
+
if _bbox_axis_overlap_ratio(line.bbox, core_bbox, axis="x") >= 0.15:
|
|
1096
|
+
return vertical_gap <= margin_scale * median_height
|
|
1097
|
+
if _bbox_axis_overlap_ratio(line.bbox, core_bbox, axis="y") >= 0.15:
|
|
1098
|
+
horizontal_limit = max(
|
|
1099
|
+
margin_scale * median_height,
|
|
1100
|
+
0.2 * (core_bbox[2] - core_bbox[0]),
|
|
1101
|
+
)
|
|
1102
|
+
return horizontal_gap <= horizontal_limit
|
|
1103
|
+
corner_limit = min(margin_scale, 1.5) * median_height
|
|
1104
|
+
return (
|
|
1105
|
+
horizontal_gap <= corner_limit
|
|
1106
|
+
and vertical_gap <= corner_limit
|
|
1107
|
+
and math.hypot(horizontal_gap, vertical_gap) <= corner_limit
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _image_members_to_content(
|
|
1112
|
+
members: list[_LineItem],
|
|
1113
|
+
page_size: tuple[float, float],
|
|
1114
|
+
) -> str:
|
|
1115
|
+
"""按视觉行和页内位置生成图片内部文本,保留不同视觉行之间的换行。"""
|
|
1116
|
+
|
|
1117
|
+
row_groups: dict[tuple[int, int, int], list[_LineItem]] = {}
|
|
1118
|
+
for line in members:
|
|
1119
|
+
if line.visual_row_id is None:
|
|
1120
|
+
row_kind, row_identity = 1, line.source_index
|
|
1121
|
+
else:
|
|
1122
|
+
row_kind, row_identity = 0, line.visual_row_id
|
|
1123
|
+
row_groups.setdefault((line.angle, row_kind, row_identity), []).append(line)
|
|
1124
|
+
|
|
1125
|
+
rows: list[tuple[BBox, str]] = []
|
|
1126
|
+
for row_lines in row_groups.values():
|
|
1127
|
+
row_bbox = _bbox_union_many([line.bbox for line in row_lines])
|
|
1128
|
+
angle = row_lines[0].angle
|
|
1129
|
+
local_geometry = [(line, _rotate_bbox_to_upright(line.bbox, page_size, angle)) for line in row_lines]
|
|
1130
|
+
content = _join_formula_visual_row(local_geometry, page_size)
|
|
1131
|
+
if content:
|
|
1132
|
+
rows.append((row_bbox, content))
|
|
1133
|
+
rows.sort(key=lambda item: (item[0][1], item[0][0]))
|
|
1134
|
+
return _sanitize_pdf_control_text(
|
|
1135
|
+
"\n".join(row_content for _row_bbox, row_content in rows),
|
|
1136
|
+
preserve_newlines=True,
|
|
1137
|
+
).strip()
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _graphic_members_to_block(
|
|
1141
|
+
candidate: _GraphicCandidate,
|
|
1142
|
+
members: list[_LineItem],
|
|
1143
|
+
page_size: tuple[float, float],
|
|
1144
|
+
) -> dict[str, Any] | None:
|
|
1145
|
+
"""生成含内部文本的矢量图 image block,并合并绘图核心与标签 bbox。"""
|
|
1146
|
+
|
|
1147
|
+
content = _image_members_to_content(members, page_size)
|
|
1148
|
+
if not content:
|
|
1149
|
+
return None
|
|
1150
|
+
return {
|
|
1151
|
+
"type": "image",
|
|
1152
|
+
"bbox": _bbox_union(candidate.core_bbox, _bbox_union_many([line.bbox for line in members])),
|
|
1153
|
+
"angle": 0,
|
|
1154
|
+
"content": content,
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _inline_raster_gap_member(
|
|
1159
|
+
source: _PageSource,
|
|
1160
|
+
left_bbox: BBox,
|
|
1161
|
+
right_bbox: BBox,
|
|
1162
|
+
claimed_line_indices: set[int],
|
|
1163
|
+
median_height: float,
|
|
1164
|
+
) -> _LineItem | None:
|
|
1165
|
+
"""查找恰好填充两张同行图片间隙的唯一拆分文本 run。"""
|
|
1166
|
+
|
|
1167
|
+
left_height = max(0.1, left_bbox[3] - left_bbox[1])
|
|
1168
|
+
right_height = max(0.1, right_bbox[3] - right_bbox[1])
|
|
1169
|
+
vertical_overlap = max(
|
|
1170
|
+
0.0,
|
|
1171
|
+
min(left_bbox[3], right_bbox[3]) - max(left_bbox[1], right_bbox[1]),
|
|
1172
|
+
)
|
|
1173
|
+
horizontal_gap = right_bbox[0] - left_bbox[2]
|
|
1174
|
+
if (
|
|
1175
|
+
max(left_height, right_height) / min(left_height, right_height) > 1.25
|
|
1176
|
+
or vertical_overlap / min(left_height, right_height) < 0.8
|
|
1177
|
+
or not 0.0 <= horizontal_gap <= 1.5 * median_height
|
|
1178
|
+
):
|
|
1179
|
+
return None
|
|
1180
|
+
|
|
1181
|
+
edge_tolerance = max(0.5, 0.25 * median_height)
|
|
1182
|
+
band_top = max(left_bbox[1], right_bbox[1])
|
|
1183
|
+
band_bottom = min(left_bbox[3], right_bbox[3])
|
|
1184
|
+
gap_members = [
|
|
1185
|
+
line
|
|
1186
|
+
for line in source.lines
|
|
1187
|
+
if line.source_index not in claimed_line_indices
|
|
1188
|
+
and line.angle == 0
|
|
1189
|
+
and line.split_from_row
|
|
1190
|
+
and line.visual_row_id is not None
|
|
1191
|
+
and left_bbox[2] - edge_tolerance <= _bbox_center_x(line.bbox) <= right_bbox[0] + edge_tolerance
|
|
1192
|
+
and band_top - edge_tolerance <= _bbox_center_y(line.bbox) <= band_bottom + edge_tolerance
|
|
1193
|
+
]
|
|
1194
|
+
if len(gap_members) != 1:
|
|
1195
|
+
return None
|
|
1196
|
+
member = gap_members[0]
|
|
1197
|
+
if abs(member.bbox[0] - left_bbox[2]) > edge_tolerance or abs(member.bbox[2] - right_bbox[0]) > edge_tolerance:
|
|
1198
|
+
return None
|
|
1199
|
+
return member
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _inline_raster_group_has_only_expected_text(
|
|
1203
|
+
source: _PageSource,
|
|
1204
|
+
image_bboxes: list[BBox],
|
|
1205
|
+
gap_members: list[_LineItem],
|
|
1206
|
+
group_bbox: BBox,
|
|
1207
|
+
claimed_line_indices: set[int],
|
|
1208
|
+
) -> bool:
|
|
1209
|
+
"""确认复合图片框内没有图片内部文本和间隔符之外的正文。"""
|
|
1210
|
+
|
|
1211
|
+
gap_member_indices = {line.source_index for line in gap_members}
|
|
1212
|
+
for line in source.lines:
|
|
1213
|
+
if line.source_index in claimed_line_indices:
|
|
1214
|
+
continue
|
|
1215
|
+
center = (_bbox_center_x(line.bbox), _bbox_center_y(line.bbox))
|
|
1216
|
+
if not _point_in_bbox(center, group_bbox):
|
|
1217
|
+
continue
|
|
1218
|
+
if line.source_index in gap_member_indices:
|
|
1219
|
+
continue
|
|
1220
|
+
if any(_point_in_bbox(center, image_bbox) for image_bbox in image_bboxes):
|
|
1221
|
+
continue
|
|
1222
|
+
return False
|
|
1223
|
+
return True
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _merge_inline_raster_image_candidates(
|
|
1227
|
+
source: _PageSource,
|
|
1228
|
+
candidate_bboxes: list[BBox],
|
|
1229
|
+
container_bboxes: list[BBox],
|
|
1230
|
+
claimed_line_indices: set[int],
|
|
1231
|
+
) -> list[tuple[BBox, int | None]]:
|
|
1232
|
+
"""把由同一视觉行间隔符连接的已准入图片合成为单一候选。"""
|
|
1233
|
+
|
|
1234
|
+
if len(candidate_bboxes) < 3:
|
|
1235
|
+
return [(bbox, None) for bbox in candidate_bboxes]
|
|
1236
|
+
effective_heights = [
|
|
1237
|
+
_line_effective_height(line, line.bbox)
|
|
1238
|
+
for line in source.lines
|
|
1239
|
+
if line.source_index not in claimed_line_indices and line.angle == 0
|
|
1240
|
+
]
|
|
1241
|
+
median_height = statistics.median(effective_heights) if effective_heights else 1.0
|
|
1242
|
+
|
|
1243
|
+
adjacency: dict[int, set[int]] = {index: set() for index in range(len(candidate_bboxes))}
|
|
1244
|
+
gap_members: dict[tuple[int, int], _LineItem] = {}
|
|
1245
|
+
for first_index, first_bbox in enumerate(candidate_bboxes):
|
|
1246
|
+
for second_index, second_bbox in enumerate(candidate_bboxes):
|
|
1247
|
+
if first_index == second_index or first_bbox[0] >= second_bbox[0]:
|
|
1248
|
+
continue
|
|
1249
|
+
member = _inline_raster_gap_member(
|
|
1250
|
+
source,
|
|
1251
|
+
first_bbox,
|
|
1252
|
+
second_bbox,
|
|
1253
|
+
claimed_line_indices,
|
|
1254
|
+
median_height,
|
|
1255
|
+
)
|
|
1256
|
+
if member is None:
|
|
1257
|
+
continue
|
|
1258
|
+
adjacency[first_index].add(second_index)
|
|
1259
|
+
adjacency[second_index].add(first_index)
|
|
1260
|
+
gap_members[(first_index, second_index)] = member
|
|
1261
|
+
|
|
1262
|
+
components: list[list[int]] = []
|
|
1263
|
+
visited: set[int] = set()
|
|
1264
|
+
for start_index in range(len(candidate_bboxes)):
|
|
1265
|
+
if start_index in visited:
|
|
1266
|
+
continue
|
|
1267
|
+
component: list[int] = []
|
|
1268
|
+
pending = [start_index]
|
|
1269
|
+
while pending:
|
|
1270
|
+
current_index = pending.pop()
|
|
1271
|
+
if current_index in visited:
|
|
1272
|
+
continue
|
|
1273
|
+
visited.add(current_index)
|
|
1274
|
+
component.append(current_index)
|
|
1275
|
+
pending.extend(adjacency[current_index] - visited)
|
|
1276
|
+
components.append(component)
|
|
1277
|
+
|
|
1278
|
+
merged_specs: list[tuple[BBox, int | None]] = []
|
|
1279
|
+
consumed_indices: set[int] = set()
|
|
1280
|
+
for component in components:
|
|
1281
|
+
if len(component) < 3:
|
|
1282
|
+
continue
|
|
1283
|
+
ordered_indices = sorted(
|
|
1284
|
+
component,
|
|
1285
|
+
key=lambda index: candidate_bboxes[index][0],
|
|
1286
|
+
)
|
|
1287
|
+
ordered_pairs = list(zip(ordered_indices, ordered_indices[1:]))
|
|
1288
|
+
if not all(pair in gap_members for pair in ordered_pairs):
|
|
1289
|
+
continue
|
|
1290
|
+
members = [gap_members[pair] for pair in ordered_pairs]
|
|
1291
|
+
visual_row_ids = {member.visual_row_id for member in members}
|
|
1292
|
+
if len(visual_row_ids) != 1 or None in visual_row_ids:
|
|
1293
|
+
continue
|
|
1294
|
+
image_bboxes = [candidate_bboxes[index] for index in ordered_indices]
|
|
1295
|
+
group_bbox = _bbox_union_many(
|
|
1296
|
+
[*image_bboxes, *[member.bbox for member in members]],
|
|
1297
|
+
)
|
|
1298
|
+
if any(
|
|
1299
|
+
_bbox_overlap_in_smaller(group_bbox, container_bbox) >= _IMAGE_CONTAINER_OVERLAP_THRESHOLD
|
|
1300
|
+
for container_bbox in container_bboxes
|
|
1301
|
+
):
|
|
1302
|
+
continue
|
|
1303
|
+
if not _inline_raster_group_has_only_expected_text(
|
|
1304
|
+
source,
|
|
1305
|
+
image_bboxes,
|
|
1306
|
+
members,
|
|
1307
|
+
group_bbox,
|
|
1308
|
+
claimed_line_indices,
|
|
1309
|
+
):
|
|
1310
|
+
continue
|
|
1311
|
+
merged_specs.append((group_bbox, next(iter(visual_row_ids))))
|
|
1312
|
+
consumed_indices.update(ordered_indices)
|
|
1313
|
+
|
|
1314
|
+
merged_specs.extend((bbox, None) for index, bbox in enumerate(candidate_bboxes) if index not in consumed_indices)
|
|
1315
|
+
merged_specs.sort(key=lambda item: (item[0][1], item[0][0], item[0][3], item[0][2]))
|
|
1316
|
+
return merged_specs
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _image_bboxes_are_near_equal(first: BBox, second: BBox) -> bool:
|
|
1320
|
+
"""用亚 point 边界容差识别同一图片框,避免签名与点阵来源重复输出。"""
|
|
1321
|
+
|
|
1322
|
+
return all(
|
|
1323
|
+
abs(first_value - second_value) <= _SIGNATURE_IMAGE_BBOX_DEDUP_TOLERANCE
|
|
1324
|
+
for first_value, second_value in zip(first, second, strict=True)
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _merge_vertical_raster_tiles(
|
|
1329
|
+
bboxes: list[BBox],
|
|
1330
|
+
page_size: tuple[float, float],
|
|
1331
|
+
) -> list[BBox]:
|
|
1332
|
+
"""把同宽且纵向连续的点阵切片合成一张完整图片。"""
|
|
1333
|
+
|
|
1334
|
+
page_width, page_height = page_size
|
|
1335
|
+
page_area = max(0.0, page_width) * max(0.0, page_height)
|
|
1336
|
+
if len(bboxes) < 3 or page_area <= 0:
|
|
1337
|
+
return list(bboxes)
|
|
1338
|
+
|
|
1339
|
+
endpoint_tolerance = max(0.75, 0.002 * page_width)
|
|
1340
|
+
endpoint_groups: list[list[tuple[int, BBox]]] = []
|
|
1341
|
+
for index, bbox in sorted(
|
|
1342
|
+
enumerate(bboxes),
|
|
1343
|
+
key=lambda item: (item[1][0], item[1][2], item[1][1]),
|
|
1344
|
+
):
|
|
1345
|
+
target = next(
|
|
1346
|
+
(
|
|
1347
|
+
group
|
|
1348
|
+
for group in endpoint_groups
|
|
1349
|
+
if abs(bbox[0] - statistics.median(item[1][0] for item in group)) <= endpoint_tolerance
|
|
1350
|
+
and abs(bbox[2] - statistics.median(item[1][2] for item in group)) <= endpoint_tolerance
|
|
1351
|
+
),
|
|
1352
|
+
None,
|
|
1353
|
+
)
|
|
1354
|
+
if target is None:
|
|
1355
|
+
endpoint_groups.append([(index, bbox)])
|
|
1356
|
+
else:
|
|
1357
|
+
target.append((index, bbox))
|
|
1358
|
+
|
|
1359
|
+
merged: list[BBox] = []
|
|
1360
|
+
consumed: set[int] = set()
|
|
1361
|
+
for group in endpoint_groups:
|
|
1362
|
+
ordered = sorted(group, key=lambda item: (item[1][1], item[1][3]))
|
|
1363
|
+
segments: list[list[tuple[int, BBox]]] = []
|
|
1364
|
+
for item in ordered:
|
|
1365
|
+
if not segments:
|
|
1366
|
+
segments.append([item])
|
|
1367
|
+
continue
|
|
1368
|
+
previous_bbox = segments[-1][-1][1]
|
|
1369
|
+
current_bbox = item[1]
|
|
1370
|
+
previous_height = max(0.1, previous_bbox[3] - previous_bbox[1])
|
|
1371
|
+
current_height = max(0.1, current_bbox[3] - current_bbox[1])
|
|
1372
|
+
maximum_gap = max(
|
|
1373
|
+
1.0,
|
|
1374
|
+
0.5 * max(previous_height, current_height),
|
|
1375
|
+
)
|
|
1376
|
+
if current_bbox[1] - previous_bbox[3] <= maximum_gap:
|
|
1377
|
+
segments[-1].append(item)
|
|
1378
|
+
else:
|
|
1379
|
+
segments.append([item])
|
|
1380
|
+
|
|
1381
|
+
for segment in segments:
|
|
1382
|
+
if len(segment) < 3:
|
|
1383
|
+
continue
|
|
1384
|
+
segment_bboxes = [bbox for _index, bbox in segment]
|
|
1385
|
+
union_bbox = _bbox_union_many(segment_bboxes)
|
|
1386
|
+
union_height = max(0.1, union_bbox[3] - union_bbox[1])
|
|
1387
|
+
covered_height = sum(max(0.0, bbox[3] - bbox[1]) for bbox in segment_bboxes)
|
|
1388
|
+
if (
|
|
1389
|
+
_bbox_area(union_bbox) / page_area < _MIN_RASTER_IMAGE_PAGE_AREA_RATIO
|
|
1390
|
+
or union_bbox[2] - union_bbox[0] < 0.12 * page_width
|
|
1391
|
+
or union_bbox[2] - union_bbox[0] > 0.9 * page_width
|
|
1392
|
+
or covered_height / union_height < 0.9
|
|
1393
|
+
):
|
|
1394
|
+
continue
|
|
1395
|
+
merged.append(union_bbox)
|
|
1396
|
+
consumed.update(index for index, _bbox in segment)
|
|
1397
|
+
|
|
1398
|
+
merged.extend(bbox for index, bbox in enumerate(bboxes) if index not in consumed)
|
|
1399
|
+
return sorted(
|
|
1400
|
+
merged,
|
|
1401
|
+
key=lambda bbox: (bbox[1], bbox[0], bbox[3], bbox[2]),
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _build_raster_image_blocks(
|
|
1406
|
+
source: _PageSource,
|
|
1407
|
+
container_blocks: list[dict[str, Any]],
|
|
1408
|
+
claimed_line_indices: set[int],
|
|
1409
|
+
) -> tuple[list[dict[str, Any]], set[int]]:
|
|
1410
|
+
"""过滤点阵图并接纳签名框,避让高优先级容器后唯一认领内部文本。"""
|
|
1411
|
+
|
|
1412
|
+
page_area = max(0.0, source.page_size[0]) * max(0.0, source.page_size[1])
|
|
1413
|
+
if page_area <= 0:
|
|
1414
|
+
return [], set()
|
|
1415
|
+
|
|
1416
|
+
container_bboxes = [bbox for block in container_blocks if (bbox := _coerce_bbox(block.get("bbox"))) is not None]
|
|
1417
|
+
signature_bboxes: list[BBox] = []
|
|
1418
|
+
for raw_bbox in source.signature_bboxes:
|
|
1419
|
+
bbox = _clip_bbox(_coerce_bbox(raw_bbox), source.page_size)
|
|
1420
|
+
if bbox is None:
|
|
1421
|
+
continue
|
|
1422
|
+
if any(
|
|
1423
|
+
_bbox_overlap_in_smaller(bbox, container_bbox) >= _IMAGE_CONTAINER_OVERLAP_THRESHOLD
|
|
1424
|
+
for container_bbox in container_bboxes
|
|
1425
|
+
):
|
|
1426
|
+
continue
|
|
1427
|
+
if not any(_image_bboxes_are_near_equal(bbox, existing_bbox) for existing_bbox in signature_bboxes):
|
|
1428
|
+
# 已由注释可见性和 /AP 严格确认的签名不再套用普通点阵图面积门槛。
|
|
1429
|
+
signature_bboxes.append(bbox)
|
|
1430
|
+
|
|
1431
|
+
clipped_raster_bboxes = [
|
|
1432
|
+
bbox for raw_bbox in source.image_bboxes if (bbox := _clip_bbox(_coerce_bbox(raw_bbox), source.page_size)) is not None
|
|
1433
|
+
]
|
|
1434
|
+
raster_bboxes: list[BBox] = []
|
|
1435
|
+
for bbox in _merge_vertical_raster_tiles(
|
|
1436
|
+
clipped_raster_bboxes,
|
|
1437
|
+
source.page_size,
|
|
1438
|
+
):
|
|
1439
|
+
if _bbox_area(bbox) / page_area < _MIN_RASTER_IMAGE_PAGE_AREA_RATIO:
|
|
1440
|
+
continue
|
|
1441
|
+
if any(
|
|
1442
|
+
_bbox_overlap_in_smaller(bbox, container_bbox) >= _IMAGE_CONTAINER_OVERLAP_THRESHOLD
|
|
1443
|
+
for container_bbox in container_bboxes
|
|
1444
|
+
):
|
|
1445
|
+
continue
|
|
1446
|
+
if any(_image_bboxes_are_near_equal(bbox, signature_bbox) for signature_bbox in signature_bboxes):
|
|
1447
|
+
continue
|
|
1448
|
+
raster_bboxes.append(bbox)
|
|
1449
|
+
if not raster_bboxes and not signature_bboxes:
|
|
1450
|
+
return [], set()
|
|
1451
|
+
|
|
1452
|
+
candidate_specs = (
|
|
1453
|
+
_merge_inline_raster_image_candidates(
|
|
1454
|
+
source,
|
|
1455
|
+
raster_bboxes,
|
|
1456
|
+
container_bboxes,
|
|
1457
|
+
claimed_line_indices,
|
|
1458
|
+
)
|
|
1459
|
+
if raster_bboxes
|
|
1460
|
+
else []
|
|
1461
|
+
)
|
|
1462
|
+
candidate_specs.extend((bbox, None) for bbox in signature_bboxes)
|
|
1463
|
+
candidate_specs.sort(key=lambda item: (item[0][1], item[0][0], item[0][3], item[0][2]))
|
|
1464
|
+
candidate_bboxes = [bbox for bbox, _row_id in candidate_specs]
|
|
1465
|
+
|
|
1466
|
+
members_by_candidate: list[list[_LineItem]] = [[] for _ in candidate_bboxes]
|
|
1467
|
+
claimed: set[int] = set()
|
|
1468
|
+
for line in source.lines:
|
|
1469
|
+
if line.source_index in claimed_line_indices:
|
|
1470
|
+
continue
|
|
1471
|
+
center = (_bbox_center_x(line.bbox), _bbox_center_y(line.bbox))
|
|
1472
|
+
matching_indices = [
|
|
1473
|
+
candidate_index for candidate_index, bbox in enumerate(candidate_bboxes) if _point_in_bbox(center, bbox)
|
|
1474
|
+
]
|
|
1475
|
+
if not matching_indices:
|
|
1476
|
+
continue
|
|
1477
|
+
# 重叠点阵图共享内部文本时归属最小容器,避免 content 重复。
|
|
1478
|
+
candidate_index = min(
|
|
1479
|
+
matching_indices,
|
|
1480
|
+
key=lambda index: (_bbox_area(candidate_bboxes[index]), index),
|
|
1481
|
+
)
|
|
1482
|
+
members_by_candidate[candidate_index].append(line)
|
|
1483
|
+
claimed.add(line.source_index)
|
|
1484
|
+
|
|
1485
|
+
blocks: list[dict[str, Any]] = []
|
|
1486
|
+
for (bbox, visual_row_id), members in zip(
|
|
1487
|
+
candidate_specs,
|
|
1488
|
+
members_by_candidate,
|
|
1489
|
+
strict=True,
|
|
1490
|
+
):
|
|
1491
|
+
block = {
|
|
1492
|
+
"type": "image",
|
|
1493
|
+
"bbox": bbox,
|
|
1494
|
+
"angle": 0,
|
|
1495
|
+
"content": _image_members_to_content(members, source.page_size),
|
|
1496
|
+
}
|
|
1497
|
+
if visual_row_id is not None:
|
|
1498
|
+
block["_inline_visual_row_id"] = visual_row_id
|
|
1499
|
+
blocks.append(block)
|
|
1500
|
+
blocks.sort(key=lambda block: (block["bbox"][1], block["bbox"][0]))
|
|
1501
|
+
return blocks, claimed
|