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,1106 @@
|
|
|
1
|
+
"""提供文本栏带、行距和行连接的共享布局判定。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import statistics
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from ....foundation.text import is_hyphen_at_line_end
|
|
12
|
+
from ....schema import BBox
|
|
13
|
+
|
|
14
|
+
from .typography import _normalized_font_family
|
|
15
|
+
from .models import _LineItem, _LocalAxisLine, _TextLane
|
|
16
|
+
from .geometry import (
|
|
17
|
+
_bbox_axis_overlap_ratio,
|
|
18
|
+
_bbox_center_x,
|
|
19
|
+
_bbox_center_y,
|
|
20
|
+
_bbox_union_many,
|
|
21
|
+
_clip_bbox,
|
|
22
|
+
_bbox_intersects,
|
|
23
|
+
_coerce_bbox,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_TIGHT_OUTPUT_PADDING = 1.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _title_fonts_compatible(first: _LineItem, second: _LineItem) -> bool:
|
|
31
|
+
"""检查标题字体和字重是否兼容;低字体覆盖率时仍保留可靠字重证据。"""
|
|
32
|
+
|
|
33
|
+
font_conflicts = (
|
|
34
|
+
first.font_signature is not None
|
|
35
|
+
and second.font_signature is not None
|
|
36
|
+
and first.font_coverage >= 0.75
|
|
37
|
+
and second.font_coverage >= 0.75
|
|
38
|
+
and first.font_signature != second.font_signature
|
|
39
|
+
)
|
|
40
|
+
weight_conflicts = _font_weights_conflict(first, second)
|
|
41
|
+
return not (font_conflicts or weight_conflicts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _font_signatures_share_family(
|
|
45
|
+
first: tuple[str, int] | None,
|
|
46
|
+
second: tuple[str, int] | None,
|
|
47
|
+
) -> bool:
|
|
48
|
+
"""判断两个可靠字体签名是否仅因 PDF 子集前缀或描述标志不同。"""
|
|
49
|
+
|
|
50
|
+
first_family = _normalized_font_family(first)
|
|
51
|
+
second_family = _normalized_font_family(second)
|
|
52
|
+
return first_family is not None and second_family is not None and first_family == second_family
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _font_weights_conflict(first: _LineItem, second: _LineItem) -> bool:
|
|
56
|
+
"""判断两行是否存在足以构成段落硬边界的显著字重差异。"""
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
first.dominant_font_weight is not None
|
|
60
|
+
and second.dominant_font_weight is not None
|
|
61
|
+
and abs(first.dominant_font_weight - second.dominant_font_weight) >= 100.0
|
|
62
|
+
and max(first.dominant_font_weight, second.dominant_font_weight)
|
|
63
|
+
>= 1.15 * min(first.dominant_font_weight, second.dominant_font_weight)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _should_connect_semantic_rows(
|
|
68
|
+
previous: tuple[_LineItem, BBox],
|
|
69
|
+
current: tuple[_LineItem, BBox],
|
|
70
|
+
lane: _TextLane,
|
|
71
|
+
regular_gap: float,
|
|
72
|
+
table_bboxes: list[BBox],
|
|
73
|
+
axis_lines: list[_LocalAxisLine],
|
|
74
|
+
) -> bool:
|
|
75
|
+
"""只用几何、字体和障碍连接同类型语义行,避免标题内容影响聚合。"""
|
|
76
|
+
|
|
77
|
+
previous_line, previous_bbox = previous
|
|
78
|
+
current_line, current_bbox = current
|
|
79
|
+
if previous_line.semantic_type != current_line.semantic_type:
|
|
80
|
+
return False
|
|
81
|
+
if _connection_crosses_table(previous_line.bbox, current_line.bbox, table_bboxes):
|
|
82
|
+
return False
|
|
83
|
+
if _horizontal_rule_separates_rows(previous_bbox, current_bbox, lane, axis_lines):
|
|
84
|
+
return False
|
|
85
|
+
previous_height = _line_effective_height(previous_line, previous_bbox)
|
|
86
|
+
current_height = _line_effective_height(current_line, current_bbox)
|
|
87
|
+
pair_height = max(previous_height, current_height)
|
|
88
|
+
if max(previous_height, current_height) / min(previous_height, current_height) > 1.35:
|
|
89
|
+
return False
|
|
90
|
+
vertical_gap = _effective_text_row_gap(previous, current)
|
|
91
|
+
if not -0.25 * pair_height <= vertical_gap <= max(1.25 * pair_height, regular_gap + 0.75 * pair_height):
|
|
92
|
+
return False
|
|
93
|
+
if previous_line.semantic_type == "paragraph_title" and vertical_gap > 0.5 * pair_height:
|
|
94
|
+
return False
|
|
95
|
+
font_conflicts = (
|
|
96
|
+
previous_line.font_signature is not None
|
|
97
|
+
and current_line.font_signature is not None
|
|
98
|
+
and previous_line.font_coverage >= 0.75
|
|
99
|
+
and current_line.font_coverage >= 0.75
|
|
100
|
+
and previous_line.font_signature != current_line.font_signature
|
|
101
|
+
)
|
|
102
|
+
uncertain_document_title_font = (
|
|
103
|
+
previous_line.semantic_type == "doc_title"
|
|
104
|
+
and min(previous_line.font_coverage, current_line.font_coverage) < 0.85
|
|
105
|
+
and (
|
|
106
|
+
previous_line.dominant_font_weight is None
|
|
107
|
+
or current_line.dominant_font_weight is None
|
|
108
|
+
or abs(previous_line.dominant_font_weight - current_line.dominant_font_weight) < 100.0
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
if font_conflicts and not uncertain_document_title_font:
|
|
112
|
+
return False
|
|
113
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
114
|
+
centered_pair = abs(_bbox_center_x(previous_bbox) - _bbox_center_x(current_bbox)) <= 0.15 * lane_width
|
|
115
|
+
aligned_pair = abs(previous_bbox[0] - current_bbox[0]) <= 0.75 * pair_height
|
|
116
|
+
overlapping_pair = _bbox_axis_overlap_ratio(previous_bbox, current_bbox, axis="x") >= 0.35
|
|
117
|
+
return centered_pair or aligned_pair or overlapping_pair
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _line_style_scale(line: _LineItem, local_bbox: BBox) -> float:
|
|
121
|
+
"""返回 canonical 字体尺度,缺失时兼容旧有效行高与局部 bbox。"""
|
|
122
|
+
|
|
123
|
+
return max(
|
|
124
|
+
0.1,
|
|
125
|
+
line.em_height
|
|
126
|
+
if line.style_scale_repaired and line.em_height > 0
|
|
127
|
+
else line.effective_height or (local_bbox[3] - local_bbox[1]),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _line_canonical_style_scale(line: _LineItem, local_bbox: BBox) -> float:
|
|
132
|
+
"""忽略语义选择标记,直接返回 tight/origin 校准后的字体尺度。"""
|
|
133
|
+
|
|
134
|
+
return max(
|
|
135
|
+
0.1,
|
|
136
|
+
line.em_height if line.em_height > 0 else line.effective_height or (local_bbox[3] - local_bbox[1]),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _line_effective_height(line: _LineItem, local_bbox: BBox) -> float:
|
|
141
|
+
"""兼容既有布局调用,并统一转发到 canonical 字体尺度。"""
|
|
142
|
+
|
|
143
|
+
return _line_style_scale(line, local_bbox)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _line_layout_height(_line: _LineItem, local_bbox: BBox) -> float:
|
|
147
|
+
"""返回 canonical 布局包络高度,供公式与视觉容器空间判断使用。"""
|
|
148
|
+
|
|
149
|
+
return max(0.1, local_bbox[3] - local_bbox[1])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _line_tight_output_bbox(
|
|
153
|
+
line: _LineItem,
|
|
154
|
+
page_size: tuple[float, float],
|
|
155
|
+
) -> BBox | None:
|
|
156
|
+
"""把可靠 tight 字形并集四边各扩 1pt,并裁剪到页面范围。"""
|
|
157
|
+
|
|
158
|
+
ink_bbox = _coerce_bbox(line.ink_bbox)
|
|
159
|
+
if ink_bbox is None:
|
|
160
|
+
return None
|
|
161
|
+
return _clip_bbox(
|
|
162
|
+
(
|
|
163
|
+
ink_bbox[0] - _TIGHT_OUTPUT_PADDING,
|
|
164
|
+
ink_bbox[1] - _TIGHT_OUTPUT_PADDING,
|
|
165
|
+
ink_bbox[2] + _TIGHT_OUTPUT_PADDING,
|
|
166
|
+
ink_bbox[3] + _TIGHT_OUTPUT_PADDING,
|
|
167
|
+
),
|
|
168
|
+
page_size,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _lines_tight_output_bbox(
|
|
173
|
+
lines: Sequence[_LineItem],
|
|
174
|
+
page_size: tuple[float, float],
|
|
175
|
+
) -> BBox | None:
|
|
176
|
+
"""合并多行 tight+1pt 候选;缺失 tight 的成员继续使用原 layout bbox。"""
|
|
177
|
+
|
|
178
|
+
output_bboxes: list[BBox] = []
|
|
179
|
+
changed = False
|
|
180
|
+
for line in lines:
|
|
181
|
+
candidate = _line_tight_output_bbox(line, page_size)
|
|
182
|
+
output_bboxes.append(candidate or line.bbox)
|
|
183
|
+
changed = changed or candidate is not None
|
|
184
|
+
if not changed or not output_bboxes:
|
|
185
|
+
return None
|
|
186
|
+
return _bbox_union_many(output_bboxes)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _effective_text_row_gap(
|
|
190
|
+
previous: tuple[_LineItem, BBox],
|
|
191
|
+
current: tuple[_LineItem, BBox],
|
|
192
|
+
) -> float:
|
|
193
|
+
"""按前一行顶边与有效行高计算净空,避免高数学字形拉长 bbox 底边。"""
|
|
194
|
+
|
|
195
|
+
previous_line, previous_bbox = previous
|
|
196
|
+
_current_line, current_bbox = current
|
|
197
|
+
if previous_line.restored_inline_cluster:
|
|
198
|
+
# 二维文本簇的 bbox 底边是真实分母边界;同时截断深度重叠,避免相邻分式互相成为段落屏障。
|
|
199
|
+
return max(
|
|
200
|
+
current_bbox[1] - previous_bbox[3],
|
|
201
|
+
-0.25 * _line_effective_height(previous_line, previous_bbox),
|
|
202
|
+
)
|
|
203
|
+
return current_bbox[1] - (previous_bbox[1] + _line_effective_height(previous_line, previous_bbox))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _effective_body_text_row_gap(
|
|
207
|
+
previous: tuple[_LineItem, BBox],
|
|
208
|
+
current: tuple[_LineItem, BBox],
|
|
209
|
+
) -> float:
|
|
210
|
+
"""正文连接优先使用 origin 基线节奏,缺证据时回退既有 bbox 净空。"""
|
|
211
|
+
|
|
212
|
+
previous_line, previous_bbox = previous
|
|
213
|
+
current_line, _current_bbox = current
|
|
214
|
+
if (
|
|
215
|
+
previous_line.baseline is not None
|
|
216
|
+
and current_line.baseline is not None
|
|
217
|
+
and current_line.baseline > previous_line.baseline
|
|
218
|
+
):
|
|
219
|
+
previous_scale = _line_effective_height(previous_line, previous_bbox)
|
|
220
|
+
current_scale = _line_effective_height(*current)
|
|
221
|
+
pitch = current_line.baseline - previous_line.baseline
|
|
222
|
+
if (
|
|
223
|
+
0.5 * min(previous_scale, current_scale)
|
|
224
|
+
<= pitch
|
|
225
|
+
<= 3.0
|
|
226
|
+
* max(
|
|
227
|
+
previous_scale,
|
|
228
|
+
current_scale,
|
|
229
|
+
)
|
|
230
|
+
):
|
|
231
|
+
return pitch - previous_scale
|
|
232
|
+
return _effective_text_row_gap(previous, current)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _infer_text_lanes(
|
|
236
|
+
line_geometry: list[tuple[_LineItem, BBox]],
|
|
237
|
+
local_page_width: float,
|
|
238
|
+
median_height: float,
|
|
239
|
+
*,
|
|
240
|
+
recalculate_intervals: bool = True,
|
|
241
|
+
) -> list[_TextLane]:
|
|
242
|
+
"""从重复左右边缘推断稳定栏带,并按需用已分配成员重算边界。"""
|
|
243
|
+
|
|
244
|
+
anchor_tolerance = max(3.0, 0.75 * median_height)
|
|
245
|
+
anchor_geometry = [
|
|
246
|
+
item for item in line_geometry if item[0].semantic_type not in {"header", "footer", "page_number", "aside_text"}
|
|
247
|
+
]
|
|
248
|
+
regular_lines = [
|
|
249
|
+
item
|
|
250
|
+
for item in anchor_geometry
|
|
251
|
+
if item[1][2] - item[1][0] >= max(4.0 * _line_effective_height(*item), 0.15 * local_page_width)
|
|
252
|
+
]
|
|
253
|
+
left_clusters: list[list[tuple[_LineItem, BBox]]] = []
|
|
254
|
+
for item in sorted(regular_lines, key=lambda value: value[1][0]):
|
|
255
|
+
if not left_clusters:
|
|
256
|
+
left_clusters.append([item])
|
|
257
|
+
continue
|
|
258
|
+
cluster_left = statistics.median(member[1][0] for member in left_clusters[-1])
|
|
259
|
+
if abs(item[1][0] - cluster_left) <= anchor_tolerance:
|
|
260
|
+
left_clusters[-1].append(item)
|
|
261
|
+
else:
|
|
262
|
+
left_clusters.append([item])
|
|
263
|
+
|
|
264
|
+
supported_intervals = [
|
|
265
|
+
(
|
|
266
|
+
statistics.median(item[1][0] for item in cluster),
|
|
267
|
+
statistics.median(item[1][2] for item in cluster),
|
|
268
|
+
len(cluster),
|
|
269
|
+
)
|
|
270
|
+
for cluster in left_clusters
|
|
271
|
+
if len(cluster) >= 3
|
|
272
|
+
]
|
|
273
|
+
supported_intervals.sort(key=lambda interval: interval[0])
|
|
274
|
+
filtered_intervals: list[tuple[float, float, int]] = []
|
|
275
|
+
for interval in supported_intervals:
|
|
276
|
+
if not filtered_intervals:
|
|
277
|
+
filtered_intervals.append(interval)
|
|
278
|
+
continue
|
|
279
|
+
previous = filtered_intervals[-1]
|
|
280
|
+
minimum_gutter = max(6.0, 0.75 * median_height)
|
|
281
|
+
if interval[0] - previous[1] >= minimum_gutter:
|
|
282
|
+
filtered_intervals.append(interval)
|
|
283
|
+
elif interval[2] > previous[2]:
|
|
284
|
+
filtered_intervals[-1] = interval
|
|
285
|
+
|
|
286
|
+
if not filtered_intervals:
|
|
287
|
+
source = regular_lines or line_geometry
|
|
288
|
+
filtered_intervals = [
|
|
289
|
+
(
|
|
290
|
+
min(item[1][0] for item in source),
|
|
291
|
+
max(item[1][2] for item in source),
|
|
292
|
+
len(source),
|
|
293
|
+
)
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
nested_column_band = None
|
|
297
|
+
if len(filtered_intervals) == 1 and anchor_geometry:
|
|
298
|
+
nested_outer_interval = (
|
|
299
|
+
(
|
|
300
|
+
min(item[1][0] for item in anchor_geometry),
|
|
301
|
+
max(item[1][2] for item in anchor_geometry),
|
|
302
|
+
)
|
|
303
|
+
if recalculate_intervals
|
|
304
|
+
else filtered_intervals[0][:2]
|
|
305
|
+
)
|
|
306
|
+
nested_column_band = _infer_nested_column_band(
|
|
307
|
+
anchor_geometry,
|
|
308
|
+
local_page_width,
|
|
309
|
+
median_height,
|
|
310
|
+
nested_outer_interval,
|
|
311
|
+
enhanced=recalculate_intervals,
|
|
312
|
+
)
|
|
313
|
+
if nested_column_band is not None:
|
|
314
|
+
nested_lanes, band_top, band_bottom = nested_column_band
|
|
315
|
+
fallback_lane = _TextLane(
|
|
316
|
+
left=filtered_intervals[0][0],
|
|
317
|
+
right=filtered_intervals[0][1],
|
|
318
|
+
)
|
|
319
|
+
span_lines: list[tuple[_LineItem, BBox]] = []
|
|
320
|
+
for item in line_geometry:
|
|
321
|
+
line, bbox = item
|
|
322
|
+
center_y = _bbox_center_y(bbox)
|
|
323
|
+
if (
|
|
324
|
+
line.semantic_type in {"header", "footer", "page_number", "page_footnote", "aside_text"}
|
|
325
|
+
or not band_top <= center_y <= band_bottom
|
|
326
|
+
):
|
|
327
|
+
fallback_lane.lines.append(item)
|
|
328
|
+
continue
|
|
329
|
+
line_width = max(0.1, bbox[2] - bbox[0])
|
|
330
|
+
scored_lanes = [
|
|
331
|
+
(
|
|
332
|
+
max(0.0, min(bbox[2], lane.right) - max(bbox[0], lane.left)) / line_width,
|
|
333
|
+
lane,
|
|
334
|
+
)
|
|
335
|
+
for lane in nested_lanes
|
|
336
|
+
]
|
|
337
|
+
coverage_scores = sorted(
|
|
338
|
+
(coverage for coverage, _lane in scored_lanes),
|
|
339
|
+
reverse=True,
|
|
340
|
+
)
|
|
341
|
+
best_coverage, best_lane = max(scored_lanes, key=lambda value: value[0])
|
|
342
|
+
fits_only_one_lane = _fits_only_one_lane(
|
|
343
|
+
bbox,
|
|
344
|
+
best_lane,
|
|
345
|
+
nested_lanes,
|
|
346
|
+
anchor_tolerance,
|
|
347
|
+
)
|
|
348
|
+
if len(coverage_scores) > 1 and coverage_scores[1] >= 0.2 and not fits_only_one_lane:
|
|
349
|
+
span_lines.append(item)
|
|
350
|
+
continue
|
|
351
|
+
if best_coverage >= 0.5 or fits_only_one_lane:
|
|
352
|
+
best_lane.lines.append(item)
|
|
353
|
+
else:
|
|
354
|
+
span_lines.append(item)
|
|
355
|
+
lanes = [lane for lane in nested_lanes if lane.lines]
|
|
356
|
+
if recalculate_intervals:
|
|
357
|
+
_expand_nested_lane_intervals_from_members(
|
|
358
|
+
lanes,
|
|
359
|
+
anchor_tolerance,
|
|
360
|
+
)
|
|
361
|
+
if fallback_lane.lines:
|
|
362
|
+
lanes.append(fallback_lane)
|
|
363
|
+
if span_lines:
|
|
364
|
+
lanes.append(
|
|
365
|
+
_TextLane(
|
|
366
|
+
left=min(item[1][0] for item in span_lines),
|
|
367
|
+
right=max(item[1][2] for item in span_lines),
|
|
368
|
+
lines=span_lines,
|
|
369
|
+
is_span=True,
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
_reattach_span_lane_continuations(lanes, median_height)
|
|
373
|
+
_reattach_cross_lane_short_tails(lanes, median_height)
|
|
374
|
+
return lanes
|
|
375
|
+
|
|
376
|
+
lanes = [_TextLane(left=left, right=right) for left, right, _support in filtered_intervals]
|
|
377
|
+
span_lines: list[tuple[_LineItem, BBox]] = []
|
|
378
|
+
for item in line_geometry:
|
|
379
|
+
bbox = item[1]
|
|
380
|
+
line_width = max(0.1, bbox[2] - bbox[0])
|
|
381
|
+
scored_lanes = [
|
|
382
|
+
(
|
|
383
|
+
max(0.0, min(bbox[2], lane.right) - max(bbox[0], lane.left)) / line_width,
|
|
384
|
+
lane,
|
|
385
|
+
)
|
|
386
|
+
for lane in lanes
|
|
387
|
+
]
|
|
388
|
+
coverage_scores = sorted(
|
|
389
|
+
(coverage for coverage, _lane in scored_lanes),
|
|
390
|
+
reverse=True,
|
|
391
|
+
)
|
|
392
|
+
best_coverage, best_lane = max(scored_lanes, key=lambda value: value[0])
|
|
393
|
+
fits_only_one_lane = _fits_only_one_lane(
|
|
394
|
+
bbox,
|
|
395
|
+
best_lane,
|
|
396
|
+
lanes,
|
|
397
|
+
anchor_tolerance,
|
|
398
|
+
)
|
|
399
|
+
# 同时覆盖两个稳定正文栏的行仍属于跨栏内容;只进入单侧栏且未越过栏沟的
|
|
400
|
+
# 宽正文行则回到该栏,避免窄图注把正文错误挤入 span lane。
|
|
401
|
+
if len(coverage_scores) > 1 and coverage_scores[1] >= 0.2 and not fits_only_one_lane:
|
|
402
|
+
span_lines.append(item)
|
|
403
|
+
continue
|
|
404
|
+
if len(lanes) == 1 or fits_only_one_lane:
|
|
405
|
+
best_lane.lines.append(item)
|
|
406
|
+
else:
|
|
407
|
+
span_lines.append(item)
|
|
408
|
+
|
|
409
|
+
if recalculate_intervals:
|
|
410
|
+
_expand_nested_lane_intervals_from_members(
|
|
411
|
+
lanes,
|
|
412
|
+
anchor_tolerance,
|
|
413
|
+
)
|
|
414
|
+
if span_lines:
|
|
415
|
+
lanes.append(
|
|
416
|
+
_TextLane(
|
|
417
|
+
left=min(item[1][0] for item in span_lines),
|
|
418
|
+
right=max(item[1][2] for item in span_lines),
|
|
419
|
+
lines=span_lines,
|
|
420
|
+
is_span=True,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
_reattach_span_lane_continuations(lanes, median_height)
|
|
424
|
+
_reattach_cross_lane_short_tails(lanes, median_height)
|
|
425
|
+
return lanes
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _fits_only_one_lane(
|
|
429
|
+
bbox: BBox,
|
|
430
|
+
best_lane: _TextLane,
|
|
431
|
+
lanes: list[_TextLane],
|
|
432
|
+
tolerance: float,
|
|
433
|
+
) -> bool:
|
|
434
|
+
"""判断宽行是否仍完整停留在某一栏及其栏沟边界以内。"""
|
|
435
|
+
|
|
436
|
+
ordered = sorted(lanes, key=lambda lane: lane.left)
|
|
437
|
+
lane_index = ordered.index(best_lane)
|
|
438
|
+
if lane_index > 0 and bbox[0] < ordered[lane_index - 1].right - tolerance:
|
|
439
|
+
return False
|
|
440
|
+
if lane_index + 1 < len(ordered) and bbox[2] > ordered[lane_index + 1].left - 0.25 * tolerance:
|
|
441
|
+
return False
|
|
442
|
+
return best_lane.left - tolerance <= _bbox_center_x(bbox) <= best_lane.right + max(tolerance, bbox[2] - best_lane.right)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _expand_nested_lane_intervals_from_members(
|
|
446
|
+
lanes: list[_TextLane],
|
|
447
|
+
tolerance: float,
|
|
448
|
+
) -> None:
|
|
449
|
+
"""按已归属成员扩展局部栏边界,并在相邻栏相交前保留稳定栏沟。"""
|
|
450
|
+
|
|
451
|
+
for lane in lanes:
|
|
452
|
+
alignment_tolerance = max(1.0, 0.5 * tolerance)
|
|
453
|
+
body_members = [bbox for line, bbox in lane.lines if line.semantic_type is None]
|
|
454
|
+
if not body_members:
|
|
455
|
+
continue
|
|
456
|
+
aligned_members = [
|
|
457
|
+
bbox
|
|
458
|
+
for bbox in body_members
|
|
459
|
+
if abs(bbox[0] - lane.left) <= alignment_tolerance or abs(bbox[2] - lane.right) <= alignment_tolerance
|
|
460
|
+
]
|
|
461
|
+
if not aligned_members:
|
|
462
|
+
continue
|
|
463
|
+
# 页眉、页码和标题不参与;同时只让至少一侧锚点稳定的正文扩张栏宽,
|
|
464
|
+
# 避免页面后续另一版式区段把当前局部栏整体拉宽。
|
|
465
|
+
lane.left = min(lane.left, min(bbox[0] for bbox in aligned_members))
|
|
466
|
+
lane.right = max(lane.right, max(bbox[2] for bbox in aligned_members))
|
|
467
|
+
ordered = sorted(lanes, key=lambda lane: lane.left)
|
|
468
|
+
for left_lane, right_lane in zip(ordered, ordered[1:]):
|
|
469
|
+
if left_lane.right < right_lane.left - tolerance:
|
|
470
|
+
continue
|
|
471
|
+
midpoint = (left_lane.right + right_lane.left) / 2.0
|
|
472
|
+
left_lane.right = min(left_lane.right, midpoint)
|
|
473
|
+
right_lane.left = max(right_lane.left, midpoint)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _infer_nested_column_band(
|
|
477
|
+
line_geometry: list[tuple[_LineItem, BBox]],
|
|
478
|
+
local_page_width: float,
|
|
479
|
+
median_height: float,
|
|
480
|
+
outer_interval: tuple[float, float],
|
|
481
|
+
*,
|
|
482
|
+
enhanced: bool = False,
|
|
483
|
+
) -> tuple[list[_TextLane], float, float] | None:
|
|
484
|
+
"""在全宽版心内查找仅占局部纵向区间的并列正文栏。"""
|
|
485
|
+
|
|
486
|
+
outer_width = max(0.1, outer_interval[1] - outer_interval[0])
|
|
487
|
+
candidates = [
|
|
488
|
+
item
|
|
489
|
+
for item in line_geometry
|
|
490
|
+
if item[0].semantic_type is None
|
|
491
|
+
and max(
|
|
492
|
+
4.0 * _line_effective_height(*item),
|
|
493
|
+
0.12 * local_page_width,
|
|
494
|
+
)
|
|
495
|
+
<= item[1][2] - item[1][0]
|
|
496
|
+
<= 0.62 * outer_width
|
|
497
|
+
]
|
|
498
|
+
if len(candidates) < 6:
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
center_tolerance = max(2.0 * median_height, 0.06 * local_page_width)
|
|
502
|
+
center_clusters: list[list[tuple[_LineItem, BBox]]] = []
|
|
503
|
+
for item in sorted(candidates, key=lambda value: _bbox_center_x(value[1])):
|
|
504
|
+
center = _bbox_center_x(item[1])
|
|
505
|
+
target = next(
|
|
506
|
+
(
|
|
507
|
+
cluster
|
|
508
|
+
for cluster in center_clusters
|
|
509
|
+
if abs(center - statistics.median(_bbox_center_x(member[1]) for member in cluster)) <= center_tolerance
|
|
510
|
+
),
|
|
511
|
+
None,
|
|
512
|
+
)
|
|
513
|
+
if target is None:
|
|
514
|
+
center_clusters.append([item])
|
|
515
|
+
else:
|
|
516
|
+
target.append(item)
|
|
517
|
+
|
|
518
|
+
supported = [cluster for cluster in center_clusters if len(cluster) >= 3]
|
|
519
|
+
supported.sort(key=lambda cluster: statistics.median(_bbox_center_x(item[1]) for item in cluster))
|
|
520
|
+
best_pair: (
|
|
521
|
+
tuple[
|
|
522
|
+
tuple[int, float, float],
|
|
523
|
+
list[tuple[_LineItem, BBox]],
|
|
524
|
+
list[tuple[_LineItem, BBox]],
|
|
525
|
+
tuple[float, float],
|
|
526
|
+
tuple[float, float],
|
|
527
|
+
]
|
|
528
|
+
| None
|
|
529
|
+
) = None
|
|
530
|
+
cluster_pairs = (
|
|
531
|
+
[
|
|
532
|
+
(left_cluster, right_cluster)
|
|
533
|
+
for left_index, left_cluster in enumerate(supported[:-1])
|
|
534
|
+
for right_cluster in supported[left_index + 1 :]
|
|
535
|
+
]
|
|
536
|
+
if enhanced
|
|
537
|
+
else list(zip(supported, supported[1:]))
|
|
538
|
+
)
|
|
539
|
+
for left_cluster, right_cluster in cluster_pairs:
|
|
540
|
+
left_interval = (
|
|
541
|
+
statistics.median(item[1][0] for item in left_cluster),
|
|
542
|
+
statistics.median(item[1][2] for item in left_cluster),
|
|
543
|
+
)
|
|
544
|
+
right_interval = (
|
|
545
|
+
statistics.median(item[1][0] for item in right_cluster),
|
|
546
|
+
statistics.median(item[1][2] for item in right_cluster),
|
|
547
|
+
)
|
|
548
|
+
gutter = right_interval[0] - left_interval[1]
|
|
549
|
+
common_top = max(
|
|
550
|
+
min(item[1][1] for item in left_cluster),
|
|
551
|
+
min(item[1][1] for item in right_cluster),
|
|
552
|
+
)
|
|
553
|
+
common_bottom = min(
|
|
554
|
+
max(item[1][3] for item in left_cluster),
|
|
555
|
+
max(item[1][3] for item in right_cluster),
|
|
556
|
+
)
|
|
557
|
+
combined_width = right_interval[1] - left_interval[0]
|
|
558
|
+
if (
|
|
559
|
+
gutter < max(6.0, 0.75 * median_height)
|
|
560
|
+
or common_bottom - common_top < 2.0 * median_height
|
|
561
|
+
or combined_width < 0.55 * outer_width
|
|
562
|
+
):
|
|
563
|
+
continue
|
|
564
|
+
score = (
|
|
565
|
+
min(len(left_cluster), len(right_cluster)),
|
|
566
|
+
common_bottom - common_top,
|
|
567
|
+
gutter,
|
|
568
|
+
)
|
|
569
|
+
candidate_pair = (
|
|
570
|
+
score,
|
|
571
|
+
left_cluster,
|
|
572
|
+
right_cluster,
|
|
573
|
+
left_interval,
|
|
574
|
+
right_interval,
|
|
575
|
+
)
|
|
576
|
+
if best_pair is None or candidate_pair[0] > best_pair[0]:
|
|
577
|
+
best_pair = candidate_pair
|
|
578
|
+
if best_pair is None:
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
_score, left_cluster, right_cluster, left_interval, right_interval = best_pair
|
|
582
|
+
band_top = min(item[1][1] for item in [*left_cluster, *right_cluster]) - 0.5 * median_height
|
|
583
|
+
band_bottom = max(item[1][3] for item in [*left_cluster, *right_cluster]) + 0.5 * median_height
|
|
584
|
+
return (
|
|
585
|
+
[
|
|
586
|
+
_TextLane(left=left_interval[0], right=left_interval[1]),
|
|
587
|
+
_TextLane(left=right_interval[0], right=right_interval[1]),
|
|
588
|
+
],
|
|
589
|
+
band_top,
|
|
590
|
+
band_bottom,
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _reattach_span_lane_continuations(
|
|
595
|
+
lanes: list[_TextLane],
|
|
596
|
+
median_height: float,
|
|
597
|
+
) -> None:
|
|
598
|
+
"""把紧随稳定跨栏多行之后的单栏宽短尾行迁回对应 span lane。"""
|
|
599
|
+
|
|
600
|
+
regular_lanes = [lane for lane in lanes if not lane.is_span]
|
|
601
|
+
span_lanes = [lane for lane in lanes if lane.is_span]
|
|
602
|
+
if len(regular_lanes) < 2 or not span_lanes:
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
for span_lane in span_lanes:
|
|
606
|
+
_reattach_repeated_indented_span_tails(
|
|
607
|
+
span_lane,
|
|
608
|
+
regular_lanes,
|
|
609
|
+
median_height,
|
|
610
|
+
)
|
|
611
|
+
while True:
|
|
612
|
+
span_lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
|
613
|
+
candidates: list[
|
|
614
|
+
tuple[
|
|
615
|
+
float,
|
|
616
|
+
float,
|
|
617
|
+
_TextLane,
|
|
618
|
+
tuple[_LineItem, BBox],
|
|
619
|
+
]
|
|
620
|
+
] = []
|
|
621
|
+
for regular_lane in regular_lanes:
|
|
622
|
+
for candidate in regular_lane.lines:
|
|
623
|
+
candidate_line, candidate_bbox = candidate
|
|
624
|
+
preceding = [
|
|
625
|
+
item
|
|
626
|
+
for item in span_lane.lines
|
|
627
|
+
if item[0].semantic_type == candidate_line.semantic_type and item[1][1] < candidate_bbox[1]
|
|
628
|
+
]
|
|
629
|
+
if len(preceding) < 2:
|
|
630
|
+
continue
|
|
631
|
+
previous, last = preceding[-2:]
|
|
632
|
+
previous_height = _line_effective_height(*previous)
|
|
633
|
+
last_height = _line_effective_height(*last)
|
|
634
|
+
if (
|
|
635
|
+
abs(previous[1][0] - last[1][0]) > 0.75 * median_height
|
|
636
|
+
or max(previous_height, last_height) / min(previous_height, last_height) > 1.35
|
|
637
|
+
or not _title_fonts_compatible(previous[0], last[0])
|
|
638
|
+
or not -0.25 * median_height <= _effective_text_row_gap(previous, last) <= 0.75 * median_height
|
|
639
|
+
):
|
|
640
|
+
continue
|
|
641
|
+
gap = _effective_text_row_gap(last, candidate)
|
|
642
|
+
candidate_height = _line_effective_height(*candidate)
|
|
643
|
+
if (
|
|
644
|
+
not -0.25 * median_height <= gap <= 0.75 * median_height
|
|
645
|
+
or abs(candidate_bbox[0] - last[1][0]) > 0.75 * median_height
|
|
646
|
+
or max(last_height, candidate_height) / min(last_height, candidate_height) > 1.35
|
|
647
|
+
or not _title_fonts_compatible(last[0], candidate_line)
|
|
648
|
+
):
|
|
649
|
+
continue
|
|
650
|
+
has_parallel_peer = any(
|
|
651
|
+
other_line is not candidate_line
|
|
652
|
+
and _bbox_axis_overlap_ratio(
|
|
653
|
+
candidate_bbox,
|
|
654
|
+
other_bbox,
|
|
655
|
+
axis="y",
|
|
656
|
+
)
|
|
657
|
+
>= 0.5
|
|
658
|
+
for lane in regular_lanes
|
|
659
|
+
for other_line, other_bbox in lane.lines
|
|
660
|
+
)
|
|
661
|
+
if has_parallel_peer:
|
|
662
|
+
continue
|
|
663
|
+
candidates.append(
|
|
664
|
+
(
|
|
665
|
+
candidate_bbox[1],
|
|
666
|
+
max(0.0, gap),
|
|
667
|
+
regular_lane,
|
|
668
|
+
candidate,
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
if not candidates:
|
|
672
|
+
break
|
|
673
|
+
_top, _gap, regular_lane, candidate = min(
|
|
674
|
+
candidates,
|
|
675
|
+
key=lambda item: (item[0], item[1]),
|
|
676
|
+
)
|
|
677
|
+
regular_lane.lines.remove(candidate)
|
|
678
|
+
span_lane.lines.append(candidate)
|
|
679
|
+
span_lane.left = min(span_lane.left, candidate[1][0])
|
|
680
|
+
span_lane.right = max(span_lane.right, candidate[1][2])
|
|
681
|
+
span_lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _reattach_cross_lane_short_tails(
|
|
685
|
+
lanes: list[_TextLane],
|
|
686
|
+
median_height: float,
|
|
687
|
+
) -> None:
|
|
688
|
+
"""把误入另一栏带、但完整落在唯一前序栏内的正文短尾迁回原栏。"""
|
|
689
|
+
|
|
690
|
+
while True:
|
|
691
|
+
moves: list[
|
|
692
|
+
tuple[
|
|
693
|
+
float,
|
|
694
|
+
_TextLane,
|
|
695
|
+
_TextLane,
|
|
696
|
+
tuple[_LineItem, BBox],
|
|
697
|
+
]
|
|
698
|
+
] = []
|
|
699
|
+
for source_lane in lanes:
|
|
700
|
+
for candidate in source_lane.lines:
|
|
701
|
+
candidate_line, candidate_bbox = candidate
|
|
702
|
+
if candidate_line.semantic_type is not None:
|
|
703
|
+
continue
|
|
704
|
+
matches: list[tuple[float, _TextLane]] = []
|
|
705
|
+
for target_lane in lanes:
|
|
706
|
+
if target_lane is source_lane or not target_lane.lines:
|
|
707
|
+
continue
|
|
708
|
+
preceding = [
|
|
709
|
+
item
|
|
710
|
+
for item in target_lane.lines
|
|
711
|
+
if item[0].semantic_type == candidate_line.semantic_type and item[1][1] < candidate_bbox[1]
|
|
712
|
+
]
|
|
713
|
+
if not preceding:
|
|
714
|
+
continue
|
|
715
|
+
previous = max(
|
|
716
|
+
preceding,
|
|
717
|
+
key=lambda item: (item[1][1], item[1][0]),
|
|
718
|
+
)
|
|
719
|
+
previous_line, previous_bbox = previous
|
|
720
|
+
pair_height = max(
|
|
721
|
+
_line_effective_height(*previous),
|
|
722
|
+
_line_effective_height(*candidate),
|
|
723
|
+
median_height,
|
|
724
|
+
)
|
|
725
|
+
lane_width = max(0.1, target_lane.right - target_lane.left)
|
|
726
|
+
if (
|
|
727
|
+
previous_bbox[2] - previous_bbox[0] < 0.65 * lane_width
|
|
728
|
+
or candidate_bbox[2] - candidate_bbox[0] > 0.85 * lane_width
|
|
729
|
+
or candidate_bbox[0] < target_lane.left - 0.75 * pair_height
|
|
730
|
+
or candidate_bbox[2] > target_lane.right + 0.75 * pair_height
|
|
731
|
+
or abs(candidate_bbox[0] - previous_bbox[0]) > 0.75 * pair_height
|
|
732
|
+
or not _title_fonts_compatible(previous_line, candidate_line)
|
|
733
|
+
):
|
|
734
|
+
continue
|
|
735
|
+
gap = _effective_body_text_row_gap(previous, candidate)
|
|
736
|
+
if not -0.25 * pair_height <= gap <= 0.9 * pair_height:
|
|
737
|
+
continue
|
|
738
|
+
if (
|
|
739
|
+
previous_line.visual_row_id is not None
|
|
740
|
+
and candidate_line.visual_row_id is not None
|
|
741
|
+
and not 0 < candidate_line.visual_row_id - previous_line.visual_row_id <= 2
|
|
742
|
+
):
|
|
743
|
+
continue
|
|
744
|
+
matches.append((max(0.0, gap), target_lane))
|
|
745
|
+
if len(matches) == 1:
|
|
746
|
+
gap, target_lane = matches[0]
|
|
747
|
+
moves.append((candidate_bbox[1] + gap, source_lane, target_lane, candidate))
|
|
748
|
+
if not moves:
|
|
749
|
+
return
|
|
750
|
+
_score, source_lane, target_lane, candidate = min(
|
|
751
|
+
moves,
|
|
752
|
+
key=lambda item: item[0],
|
|
753
|
+
)
|
|
754
|
+
if candidate not in source_lane.lines:
|
|
755
|
+
continue
|
|
756
|
+
source_lane.lines.remove(candidate)
|
|
757
|
+
target_lane.lines.append(candidate)
|
|
758
|
+
target_lane.lines.sort(
|
|
759
|
+
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _reattach_repeated_indented_span_tails(
|
|
764
|
+
span_lane: _TextLane,
|
|
765
|
+
regular_lanes: list[_TextLane],
|
|
766
|
+
median_height: float,
|
|
767
|
+
) -> None:
|
|
768
|
+
"""识别重复的跨栏首行与缩进短尾,并把短尾统一迁回跨栏栏带。"""
|
|
769
|
+
|
|
770
|
+
span_rows = sorted(
|
|
771
|
+
span_lane.lines,
|
|
772
|
+
key=lambda item: (item[1][1], item[1][0], item[0].source_index),
|
|
773
|
+
)
|
|
774
|
+
matches: list[tuple[_TextLane, tuple[_LineItem, BBox], tuple[_LineItem, BBox], float]] = []
|
|
775
|
+
for span_index, span_row in enumerate(span_rows):
|
|
776
|
+
span_line, span_bbox = span_row
|
|
777
|
+
next_span_top = span_rows[span_index + 1][1][1] if span_index + 1 < len(span_rows) else float("inf")
|
|
778
|
+
for regular_lane in regular_lanes:
|
|
779
|
+
for candidate in regular_lane.lines:
|
|
780
|
+
candidate_line, candidate_bbox = candidate
|
|
781
|
+
if candidate_line.semantic_type != span_line.semantic_type:
|
|
782
|
+
continue
|
|
783
|
+
gap = _effective_text_row_gap(span_row, candidate)
|
|
784
|
+
indent = candidate_bbox[0] - span_bbox[0]
|
|
785
|
+
span_height = _line_effective_height(*span_row)
|
|
786
|
+
candidate_height = _line_effective_height(*candidate)
|
|
787
|
+
if (
|
|
788
|
+
candidate_bbox[1] <= span_bbox[1]
|
|
789
|
+
or candidate_bbox[1] >= next_span_top
|
|
790
|
+
or not -0.25 * median_height <= gap <= 0.75 * median_height
|
|
791
|
+
or not 0.75 * median_height <= indent <= 6.0 * median_height
|
|
792
|
+
or max(span_height, candidate_height) / min(span_height, candidate_height) > 1.35
|
|
793
|
+
or not _title_fonts_compatible(span_line, candidate_line)
|
|
794
|
+
):
|
|
795
|
+
continue
|
|
796
|
+
has_parallel_peer = any(
|
|
797
|
+
other_line is not candidate_line and _bbox_axis_overlap_ratio(candidate_bbox, other_bbox, axis="y") >= 0.5
|
|
798
|
+
for lane in regular_lanes
|
|
799
|
+
for other_line, other_bbox in lane.lines
|
|
800
|
+
)
|
|
801
|
+
if not has_parallel_peer:
|
|
802
|
+
matches.append((regular_lane, candidate, span_row, indent))
|
|
803
|
+
|
|
804
|
+
if len(matches) < 2:
|
|
805
|
+
return
|
|
806
|
+
median_indent = statistics.median(match[3] for match in matches)
|
|
807
|
+
supported = [match for match in matches if abs(match[3] - median_indent) <= max(0.75 * median_height, 0.25 * median_indent)]
|
|
808
|
+
if len(supported) < 2:
|
|
809
|
+
return
|
|
810
|
+
for regular_lane, candidate, _span_row, _indent in supported:
|
|
811
|
+
if candidate not in regular_lane.lines:
|
|
812
|
+
continue
|
|
813
|
+
regular_lane.lines.remove(candidate)
|
|
814
|
+
span_lane.lines.append(candidate)
|
|
815
|
+
span_lane.left = min(span_lane.left, candidate[1][0])
|
|
816
|
+
span_lane.right = max(span_lane.right, candidate[1][2])
|
|
817
|
+
span_lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _estimate_lane_gap(lane: _TextLane) -> tuple[float, float]:
|
|
821
|
+
"""从栏带内兼容相邻行的较小间隙簇估计常规净空和 MAD。"""
|
|
822
|
+
|
|
823
|
+
lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
|
824
|
+
heights = [_line_effective_height(line, bbox) for line, bbox in lane.lines]
|
|
825
|
+
median_height = statistics.median(heights) if heights else 1.0
|
|
826
|
+
gaps: list[float] = []
|
|
827
|
+
for previous, current in zip(lane.lines, lane.lines[1:]):
|
|
828
|
+
previous_line, previous_bbox = previous
|
|
829
|
+
current_line, current_bbox = current
|
|
830
|
+
if previous_line.visual_row_id == current_line.visual_row_id and (
|
|
831
|
+
previous_line.split_from_row or current_line.split_from_row
|
|
832
|
+
):
|
|
833
|
+
continue
|
|
834
|
+
previous_height = _line_effective_height(previous_line, previous_bbox)
|
|
835
|
+
current_height = _line_effective_height(current_line, current_bbox)
|
|
836
|
+
if max(previous_height, current_height) / min(previous_height, current_height) > 1.35:
|
|
837
|
+
continue
|
|
838
|
+
pair_height = max(previous_height, current_height)
|
|
839
|
+
gap = _effective_text_row_gap(previous, current)
|
|
840
|
+
if gap < -0.25 * pair_height or gap > 2.0 * pair_height:
|
|
841
|
+
continue
|
|
842
|
+
if (
|
|
843
|
+
_bbox_axis_overlap_ratio(previous_bbox, current_bbox, axis="x") < 0.5
|
|
844
|
+
and abs(previous_bbox[0] - current_bbox[0]) > 1.5 * median_height
|
|
845
|
+
):
|
|
846
|
+
continue
|
|
847
|
+
# PDF 字符框常在相邻基线间产生极小重叠;按零净空计入常规行距统计。
|
|
848
|
+
gaps.append(max(0.0, gap))
|
|
849
|
+
|
|
850
|
+
if not gaps:
|
|
851
|
+
return 0.35 * median_height, 0.0
|
|
852
|
+
sorted_gaps = sorted(gaps)
|
|
853
|
+
lower_count = max(1, math.ceil(len(sorted_gaps) * 0.6))
|
|
854
|
+
lower_gaps = sorted_gaps[:lower_count]
|
|
855
|
+
regular_gap = statistics.median(lower_gaps)
|
|
856
|
+
gap_mad = statistics.median(abs(gap - regular_gap) for gap in lower_gaps)
|
|
857
|
+
return regular_gap, gap_mad
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _is_structural_typography_gap(
|
|
861
|
+
previous_height: float,
|
|
862
|
+
current_height: float,
|
|
863
|
+
vertical_gap: float,
|
|
864
|
+
regular_gap: float,
|
|
865
|
+
gap_mad: float,
|
|
866
|
+
*,
|
|
867
|
+
reliable_style_change: bool = False,
|
|
868
|
+
) -> bool:
|
|
869
|
+
"""判断异常段间净空是否同时具有行高或可靠字体层级变化。"""
|
|
870
|
+
|
|
871
|
+
pair_height = max(previous_height, current_height)
|
|
872
|
+
minimum_height = max(0.1, min(previous_height, current_height))
|
|
873
|
+
prominent_gap = vertical_gap > regular_gap + max(
|
|
874
|
+
0.75 * pair_height,
|
|
875
|
+
3.0 * gap_mad,
|
|
876
|
+
)
|
|
877
|
+
return prominent_gap and (pair_height / minimum_height >= 1.12 or reliable_style_change)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _should_connect_text_rows(
|
|
881
|
+
previous: tuple[_LineItem, BBox],
|
|
882
|
+
current: tuple[_LineItem, BBox],
|
|
883
|
+
lane: _TextLane,
|
|
884
|
+
regular_gap: float,
|
|
885
|
+
gap_mad: float,
|
|
886
|
+
table_bboxes: list[BBox],
|
|
887
|
+
axis_lines: list[_LocalAxisLine],
|
|
888
|
+
) -> bool:
|
|
889
|
+
"""综合局部间距、首行缩进、字体和障碍判断两个相邻视觉行是否同段。"""
|
|
890
|
+
|
|
891
|
+
previous_line, previous_bbox = previous
|
|
892
|
+
current_line, current_bbox = current
|
|
893
|
+
previous_height = _line_effective_height(previous_line, previous_bbox)
|
|
894
|
+
current_height = _line_effective_height(current_line, current_bbox)
|
|
895
|
+
pair_height = max(previous_height, current_height)
|
|
896
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
897
|
+
previous_width = previous_bbox[2] - previous_bbox[0]
|
|
898
|
+
current_width = current_bbox[2] - current_bbox[0]
|
|
899
|
+
vertical_gap = _effective_body_text_row_gap(previous, current)
|
|
900
|
+
if previous_line.visual_row_id == current_line.visual_row_id and (
|
|
901
|
+
previous_line.split_from_row or current_line.split_from_row
|
|
902
|
+
):
|
|
903
|
+
return False
|
|
904
|
+
if current_height < 0.88 * previous_height and vertical_gap > regular_gap + max(0.25 * previous_height, 3.0 * gap_mad):
|
|
905
|
+
return False
|
|
906
|
+
both_fill_lane = previous_width >= 0.8 * lane_width and current_width >= 0.8 * lane_width
|
|
907
|
+
aligned_left_edges = abs(previous_bbox[0] - current_bbox[0]) <= 0.5 * pair_height
|
|
908
|
+
current_returns_to_lane_left = (
|
|
909
|
+
abs(current_bbox[0] - lane.left) <= 0.75 * pair_height
|
|
910
|
+
and -0.5 * pair_height <= previous_bbox[0] - lane.left <= 2.0 * pair_height
|
|
911
|
+
)
|
|
912
|
+
reliable_font_match = (
|
|
913
|
+
previous_line.font_signature is None
|
|
914
|
+
or current_line.font_signature is None
|
|
915
|
+
or previous_line.font_coverage < 0.75
|
|
916
|
+
or current_line.font_coverage < 0.75
|
|
917
|
+
or previous_line.font_signature == current_line.font_signature
|
|
918
|
+
or _font_signatures_share_family(
|
|
919
|
+
previous_line.font_signature,
|
|
920
|
+
current_line.font_signature,
|
|
921
|
+
)
|
|
922
|
+
or (current_width <= 0.5 * lane_width and previous_line.font_signature[1] == current_line.font_signature[1])
|
|
923
|
+
)
|
|
924
|
+
previous_indent = previous_bbox[0] - lane.left
|
|
925
|
+
repeated_indent_continuation = (
|
|
926
|
+
previous_indent >= max(5.0, 1.8 * pair_height)
|
|
927
|
+
and abs(current_bbox[0] - previous_bbox[0]) <= 0.5 * pair_height
|
|
928
|
+
and reliable_font_match
|
|
929
|
+
and -0.25 * pair_height <= vertical_gap <= regular_gap + max(0.75 * pair_height, 3.0 * gap_mad)
|
|
930
|
+
)
|
|
931
|
+
safe_short_tail = (
|
|
932
|
+
previous_width >= 0.75 * lane_width
|
|
933
|
+
and current_width <= 0.7 * lane_width
|
|
934
|
+
and (aligned_left_edges or current_returns_to_lane_left)
|
|
935
|
+
and reliable_font_match
|
|
936
|
+
and not _font_weights_conflict(previous_line, current_line)
|
|
937
|
+
and -0.25 * pair_height <= vertical_gap <= regular_gap + max(0.75 * pair_height, 3.0 * gap_mad)
|
|
938
|
+
)
|
|
939
|
+
height_ratio = max(previous_height, current_height) / min(previous_height, current_height)
|
|
940
|
+
font_style_changed = (
|
|
941
|
+
previous_line.font_signature is not None
|
|
942
|
+
and current_line.font_signature is not None
|
|
943
|
+
and previous_line.font_signature[1] != current_line.font_signature[1]
|
|
944
|
+
and not _font_signatures_share_family(
|
|
945
|
+
previous_line.font_signature,
|
|
946
|
+
current_line.font_signature,
|
|
947
|
+
)
|
|
948
|
+
)
|
|
949
|
+
reliable_style_conflict = (
|
|
950
|
+
previous_line.font_signature is not None
|
|
951
|
+
and current_line.font_signature is not None
|
|
952
|
+
and previous_line.font_coverage >= 0.75
|
|
953
|
+
and current_line.font_coverage >= 0.75
|
|
954
|
+
and (
|
|
955
|
+
(
|
|
956
|
+
previous_line.font_signature != current_line.font_signature
|
|
957
|
+
and not _font_signatures_share_family(
|
|
958
|
+
previous_line.font_signature,
|
|
959
|
+
current_line.font_signature,
|
|
960
|
+
)
|
|
961
|
+
)
|
|
962
|
+
or _font_weights_conflict(previous_line, current_line)
|
|
963
|
+
)
|
|
964
|
+
)
|
|
965
|
+
fallback_font_continuation = (
|
|
966
|
+
previous_line.font_signature is not None
|
|
967
|
+
and current_line.font_signature is not None
|
|
968
|
+
and previous_line.font_coverage >= 0.75
|
|
969
|
+
and current_line.font_coverage >= 0.75
|
|
970
|
+
and previous_line.font_signature[0] != current_line.font_signature[0]
|
|
971
|
+
and previous_line.font_signature[1] == current_line.font_signature[1]
|
|
972
|
+
and aligned_left_edges
|
|
973
|
+
and height_ratio <= 1.25
|
|
974
|
+
and not _font_weights_conflict(previous_line, current_line)
|
|
975
|
+
and -0.25 * pair_height <= vertical_gap <= regular_gap + max(0.35 * pair_height, 3.0 * gap_mad)
|
|
976
|
+
)
|
|
977
|
+
if font_style_changed and _font_weights_conflict(previous_line, current_line) and not fallback_font_continuation:
|
|
978
|
+
# 显式样式位与显著字重同时变化仍是硬边界,不能被满栏几何放宽。
|
|
979
|
+
return False
|
|
980
|
+
if (
|
|
981
|
+
not is_hyphen_at_line_end(previous_line.text)
|
|
982
|
+
and _is_structural_typography_gap(
|
|
983
|
+
previous_height,
|
|
984
|
+
current_height,
|
|
985
|
+
vertical_gap,
|
|
986
|
+
regular_gap,
|
|
987
|
+
gap_mad,
|
|
988
|
+
reliable_style_change=reliable_style_conflict,
|
|
989
|
+
)
|
|
990
|
+
and not fallback_font_continuation
|
|
991
|
+
):
|
|
992
|
+
# 图注到正文等排版层级转换即使同栏满行,也不能被常规续行规则重新吸收。
|
|
993
|
+
return False
|
|
994
|
+
full_width_continuation = (
|
|
995
|
+
both_fill_lane
|
|
996
|
+
and aligned_left_edges
|
|
997
|
+
and not font_style_changed
|
|
998
|
+
and vertical_gap <= regular_gap + max(0.75 * min(previous_height, current_height), 3.0 * gap_mad)
|
|
999
|
+
)
|
|
1000
|
+
if height_ratio > 1.35 and not safe_short_tail and not full_width_continuation:
|
|
1001
|
+
# 满栏混合字体可跨字号续接,但显式正体/斜体等样式边界仍保持原分段语义。
|
|
1002
|
+
if not both_fill_lane or not aligned_left_edges or font_style_changed:
|
|
1003
|
+
return False
|
|
1004
|
+
|
|
1005
|
+
if vertical_gap < -0.25 * pair_height:
|
|
1006
|
+
return False
|
|
1007
|
+
if (
|
|
1008
|
+
_bbox_axis_overlap_ratio(previous_bbox, current_bbox, axis="x") < 0.5
|
|
1009
|
+
and abs(previous_bbox[0] - current_bbox[0]) > 1.5 * pair_height
|
|
1010
|
+
and not safe_short_tail
|
|
1011
|
+
):
|
|
1012
|
+
return False
|
|
1013
|
+
if _connection_crosses_table(previous_line.bbox, current_line.bbox, table_bboxes):
|
|
1014
|
+
return False
|
|
1015
|
+
if _horizontal_rule_separates_rows(previous_bbox, current_bbox, lane, axis_lines):
|
|
1016
|
+
return False
|
|
1017
|
+
|
|
1018
|
+
gap_limit = max(
|
|
1019
|
+
regular_gap + max(0.5 * pair_height, 3.0 * gap_mad),
|
|
1020
|
+
1.1 * pair_height,
|
|
1021
|
+
)
|
|
1022
|
+
# 排版断词可以跳过缩进、字体和短行规则,但仍须限制在邻近物理行内,
|
|
1023
|
+
# 避免页内远距离的 “cross-” 与后续标题被误拼为同一段。
|
|
1024
|
+
if is_hyphen_at_line_end(previous_line.text):
|
|
1025
|
+
return vertical_gap <= max(gap_limit, 1.8 * pair_height)
|
|
1026
|
+
if vertical_gap > gap_limit:
|
|
1027
|
+
return False
|
|
1028
|
+
|
|
1029
|
+
terminal_previous = bool(re.search(r"[.!?。!?::;;][\]\)})】》”’'\"]*$", previous_line.text.rstrip()))
|
|
1030
|
+
sparse_lane = sum(line.semantic_type is None for line, _bbox in lane.lines) <= 6
|
|
1031
|
+
if (
|
|
1032
|
+
terminal_previous
|
|
1033
|
+
and not repeated_indent_continuation
|
|
1034
|
+
and ((sparse_lane and vertical_gap > 0.65 * pair_height) or vertical_gap > regular_gap + 0.5 * pair_height)
|
|
1035
|
+
):
|
|
1036
|
+
return False
|
|
1037
|
+
|
|
1038
|
+
# 局部版心可能比整栏推断边界更靠左,缩进需同时参考上一物理行。
|
|
1039
|
+
local_lane_left = min(lane.left, previous_bbox[0])
|
|
1040
|
+
local_lane_width = max(0.1, lane.right - local_lane_left)
|
|
1041
|
+
next_indent = current_bbox[0] - local_lane_left
|
|
1042
|
+
previous_fill = max(0.0, previous_bbox[2] - local_lane_left) / local_lane_width
|
|
1043
|
+
if (
|
|
1044
|
+
next_indent >= max(5.0, 0.65 * pair_height)
|
|
1045
|
+
and (previous_fill <= 0.8 or terminal_previous)
|
|
1046
|
+
and not safe_short_tail
|
|
1047
|
+
and not repeated_indent_continuation
|
|
1048
|
+
):
|
|
1049
|
+
# 已确认的同左缘短尾优先于栏左缘缩进,避免参考文献冒号后的末行被切断。
|
|
1050
|
+
return False
|
|
1051
|
+
|
|
1052
|
+
abnormal_gap = vertical_gap > regular_gap + max(0.25 * pair_height, 3.0 * gap_mad)
|
|
1053
|
+
if (
|
|
1054
|
+
reliable_style_conflict
|
|
1055
|
+
and (abnormal_gap or min(previous_width, current_width) <= 0.7 * lane_width)
|
|
1056
|
+
and not both_fill_lane
|
|
1057
|
+
and not safe_short_tail
|
|
1058
|
+
and not fallback_font_continuation
|
|
1059
|
+
):
|
|
1060
|
+
return False
|
|
1061
|
+
if abnormal_gap and min(previous_width, current_width) <= 0.65 * lane_width and not safe_short_tail:
|
|
1062
|
+
return False
|
|
1063
|
+
return True
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _horizontal_rule_separates_rows(
|
|
1067
|
+
previous_bbox: BBox,
|
|
1068
|
+
current_bbox: BBox,
|
|
1069
|
+
lane: _TextLane,
|
|
1070
|
+
axis_lines: list[_LocalAxisLine],
|
|
1071
|
+
) -> bool:
|
|
1072
|
+
"""检查两个相邻文本行之间是否存在覆盖当前栏带的长水平规则线。"""
|
|
1073
|
+
|
|
1074
|
+
if current_bbox[1] <= previous_bbox[3]:
|
|
1075
|
+
return False
|
|
1076
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1077
|
+
for axis_line in axis_lines:
|
|
1078
|
+
if axis_line.orientation != "horizontal":
|
|
1079
|
+
continue
|
|
1080
|
+
line_y = _bbox_center_y(axis_line.bbox)
|
|
1081
|
+
if not previous_bbox[3] <= line_y <= current_bbox[1]:
|
|
1082
|
+
continue
|
|
1083
|
+
overlap = max(0.0, min(axis_line.bbox[2], lane.right) - max(axis_line.bbox[0], lane.left))
|
|
1084
|
+
if overlap / lane_width >= 0.6:
|
|
1085
|
+
return True
|
|
1086
|
+
return False
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _connection_crosses_table(
|
|
1090
|
+
first_bbox: BBox,
|
|
1091
|
+
second_bbox: BBox,
|
|
1092
|
+
table_bboxes: list[BBox],
|
|
1093
|
+
) -> bool:
|
|
1094
|
+
"""检查两行中心连接区域是否穿过已确认表格。"""
|
|
1095
|
+
|
|
1096
|
+
first_center = (_bbox_center_x(first_bbox), _bbox_center_y(first_bbox))
|
|
1097
|
+
second_center = (_bbox_center_x(second_bbox), _bbox_center_y(second_bbox))
|
|
1098
|
+
connector = _coerce_bbox(
|
|
1099
|
+
(
|
|
1100
|
+
min(first_center[0], second_center[0]) - 0.1,
|
|
1101
|
+
min(first_center[1], second_center[1]) - 0.1,
|
|
1102
|
+
max(first_center[0], second_center[0]) + 0.1,
|
|
1103
|
+
max(first_center[1], second_center[1]) + 0.1,
|
|
1104
|
+
)
|
|
1105
|
+
)
|
|
1106
|
+
return connector is not None and any(_bbox_intersects(connector, table_bbox) for table_bbox in table_bboxes)
|