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,1985 @@
|
|
|
1
|
+
"""按空间关系检测并物化原生 PDF 公式块。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import statistics
|
|
7
|
+
import unicodedata
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from ....schema import BBox
|
|
13
|
+
from ....document.pdf.document import PDFPathInfo
|
|
14
|
+
from ....foundation.text import build_tagged_formula_content
|
|
15
|
+
|
|
16
|
+
from .models import _AxisLine, _FormulaAnchor, _LineItem, _PageSource, _TextLane
|
|
17
|
+
from .geometry import (
|
|
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
|
+
_expand_bbox,
|
|
30
|
+
_rotate_bbox_to_upright,
|
|
31
|
+
_transform_axis_lines,
|
|
32
|
+
)
|
|
33
|
+
from .native_text import _sanitize_pdf_control_text
|
|
34
|
+
from .line_layout import (
|
|
35
|
+
_connection_crosses_table,
|
|
36
|
+
_infer_text_lanes,
|
|
37
|
+
_line_effective_height,
|
|
38
|
+
_line_style_scale,
|
|
39
|
+
_line_tight_output_bbox,
|
|
40
|
+
_lines_tight_output_bbox,
|
|
41
|
+
)
|
|
42
|
+
from .line_merging import _join_formula_visual_row, _merge_overlapping_inline_cluster
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_FORMULA_NUMBER_SUFFIX_RE = re.compile(r"^(?P<prefix>.*?)(?P<marker>[((﹙][^()()﹙﹚\r\n]+[))﹚])\s*$")
|
|
46
|
+
_FORMULA_NUMBER_MARKER_RE = re.compile(
|
|
47
|
+
r"^[((﹙]\s*(?:[A-Za-z]?\d+(?:[.\-]\d+)*)\s*[))﹚]$",
|
|
48
|
+
)
|
|
49
|
+
_FORMULA_OPERATOR_CHARS = frozenset("=∑∫√±×÷")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_FORMULA_PAGE_MARGIN_RATIO = 0.05
|
|
53
|
+
_VECTOR_FORMULA_COMPLEX_SEGMENTS = 8
|
|
54
|
+
_VECTOR_FORMULA_MIN_PATHS = 5
|
|
55
|
+
_VECTOR_FORMULA_MIN_COMPLEX_PATHS = 5
|
|
56
|
+
_VECTOR_FORMULA_MIN_COMPLEX_RATIO = 0.5
|
|
57
|
+
_VECTOR_FORMULA_NUMBER_MIN_PATHS = 3
|
|
58
|
+
_VECTOR_FORMULA_NUMBER_MAX_PATHS = 6
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class _VectorPathComponent:
|
|
63
|
+
"""保存同栏邻接 Path 形成的矢量组件。"""
|
|
64
|
+
|
|
65
|
+
lane_index: int
|
|
66
|
+
path_infos: list[PDFPathInfo]
|
|
67
|
+
bbox: BBox
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(slots=True)
|
|
71
|
+
class _VectorFormulaCandidate:
|
|
72
|
+
"""保存已通过主体校验、等待吸收横线和编号的矢量公式。"""
|
|
73
|
+
|
|
74
|
+
lane_index: int
|
|
75
|
+
bbox: BBox
|
|
76
|
+
path_source_indices: set[int]
|
|
77
|
+
has_number: bool = False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_vector_formula_blocks(
|
|
81
|
+
source: _PageSource,
|
|
82
|
+
container_blocks: list[dict[str, Any]],
|
|
83
|
+
claimed_line_indices: set[int],
|
|
84
|
+
) -> tuple[list[dict[str, Any]], set[int]]:
|
|
85
|
+
"""从根层填充 Path 构建空内容公式,并唯一认领可提取的公式编号。"""
|
|
86
|
+
|
|
87
|
+
available_lines = [line for line in source.lines if line.angle == 0 and line.source_index not in claimed_line_indices]
|
|
88
|
+
if len(available_lines) < 3 or not source.path_infos:
|
|
89
|
+
return [], set()
|
|
90
|
+
|
|
91
|
+
line_geometry = [(line, line.bbox) for line in available_lines]
|
|
92
|
+
effective_heights = [_line_effective_height(line, bbox) for line, bbox in line_geometry]
|
|
93
|
+
median_height = statistics.median(effective_heights) if effective_heights else 0.0
|
|
94
|
+
if median_height <= 0:
|
|
95
|
+
return [], set()
|
|
96
|
+
lanes = [
|
|
97
|
+
lane
|
|
98
|
+
for lane in _infer_text_lanes(line_geometry, source.page_size[0], median_height)
|
|
99
|
+
if not lane.is_span and len(lane.lines) >= 3
|
|
100
|
+
]
|
|
101
|
+
if not lanes:
|
|
102
|
+
return [], set()
|
|
103
|
+
|
|
104
|
+
components = _build_vector_path_components(
|
|
105
|
+
source.path_infos,
|
|
106
|
+
lanes,
|
|
107
|
+
median_height,
|
|
108
|
+
)
|
|
109
|
+
container_bboxes = [bbox for block in container_blocks if (bbox := _coerce_bbox(block.get("bbox"))) is not None]
|
|
110
|
+
candidates = [
|
|
111
|
+
_VectorFormulaCandidate(
|
|
112
|
+
lane_index=component.lane_index,
|
|
113
|
+
bbox=component.bbox,
|
|
114
|
+
path_source_indices={item.source_index for item in component.path_infos},
|
|
115
|
+
)
|
|
116
|
+
for component in components
|
|
117
|
+
if _is_vector_formula_core(
|
|
118
|
+
component,
|
|
119
|
+
lanes[component.lane_index],
|
|
120
|
+
median_height,
|
|
121
|
+
source.page_size,
|
|
122
|
+
container_bboxes,
|
|
123
|
+
)
|
|
124
|
+
]
|
|
125
|
+
if not candidates:
|
|
126
|
+
return [], set()
|
|
127
|
+
|
|
128
|
+
_attach_vector_formula_rules(candidates, components, median_height)
|
|
129
|
+
_attach_vector_formula_path_numbers(candidates, components, lanes, median_height)
|
|
130
|
+
claimed_number_indices = _attach_vector_formula_text_numbers(
|
|
131
|
+
candidates,
|
|
132
|
+
lanes,
|
|
133
|
+
median_height,
|
|
134
|
+
claimed_line_indices,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
padding = min(1.5, 0.1 * median_height)
|
|
138
|
+
blocks: list[dict[str, Any]] = []
|
|
139
|
+
for candidate in sorted(candidates, key=lambda item: (item.bbox[1], item.bbox[0])):
|
|
140
|
+
padded_bbox = _clip_bbox(
|
|
141
|
+
(
|
|
142
|
+
candidate.bbox[0] - padding,
|
|
143
|
+
candidate.bbox[1] - padding,
|
|
144
|
+
candidate.bbox[2] + padding,
|
|
145
|
+
candidate.bbox[3] + padding,
|
|
146
|
+
),
|
|
147
|
+
source.page_size,
|
|
148
|
+
)
|
|
149
|
+
if padded_bbox is None:
|
|
150
|
+
continue
|
|
151
|
+
blocks.append(
|
|
152
|
+
{
|
|
153
|
+
"type": "equation",
|
|
154
|
+
"bbox": padded_bbox,
|
|
155
|
+
"angle": 0,
|
|
156
|
+
"content": "",
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
return blocks, claimed_number_indices
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _build_vector_path_components(
|
|
163
|
+
path_infos: list[PDFPathInfo],
|
|
164
|
+
lanes: list[_TextLane],
|
|
165
|
+
median_height: float,
|
|
166
|
+
) -> list[_VectorPathComponent]:
|
|
167
|
+
"""按文本栏带筛选矢量字形,并用空间网格生成局部连通组件。"""
|
|
168
|
+
|
|
169
|
+
members_by_lane: dict[int, list[PDFPathInfo]] = {}
|
|
170
|
+
for path_info in path_infos:
|
|
171
|
+
if path_info.form_depth != 0 or not path_info.fill_visible or path_info.stroke_visible:
|
|
172
|
+
continue
|
|
173
|
+
lane_index = _assign_vector_path_lane(path_info.bbox, lanes, median_height)
|
|
174
|
+
if lane_index is None:
|
|
175
|
+
continue
|
|
176
|
+
if not _is_vector_formula_path_member(
|
|
177
|
+
path_info.bbox,
|
|
178
|
+
lanes[lane_index],
|
|
179
|
+
median_height,
|
|
180
|
+
):
|
|
181
|
+
continue
|
|
182
|
+
members_by_lane.setdefault(lane_index, []).append(path_info)
|
|
183
|
+
|
|
184
|
+
components: list[_VectorPathComponent] = []
|
|
185
|
+
for lane_index, members in members_by_lane.items():
|
|
186
|
+
components.extend(
|
|
187
|
+
_connect_vector_path_members(
|
|
188
|
+
members,
|
|
189
|
+
lane_index,
|
|
190
|
+
median_height,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return sorted(
|
|
194
|
+
components,
|
|
195
|
+
key=lambda item: (item.bbox[1], item.bbox[0], item.path_infos[0].source_index),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _assign_vector_path_lane(
|
|
200
|
+
bbox: BBox,
|
|
201
|
+
lanes: list[_TextLane],
|
|
202
|
+
median_height: float,
|
|
203
|
+
) -> int | None:
|
|
204
|
+
"""按中心点和水平覆盖率把 Path 唯一分配给一个正文栏带。"""
|
|
205
|
+
|
|
206
|
+
center_x = _bbox_center_x(bbox)
|
|
207
|
+
path_width = max(0.1, bbox[2] - bbox[0])
|
|
208
|
+
tolerance = 0.75 * median_height
|
|
209
|
+
matches: list[tuple[float, float, int]] = []
|
|
210
|
+
for lane_index, lane in enumerate(lanes):
|
|
211
|
+
if not lane.left - tolerance <= center_x <= lane.right + tolerance:
|
|
212
|
+
continue
|
|
213
|
+
overlap = max(0.0, min(bbox[2], lane.right) - max(bbox[0], lane.left))
|
|
214
|
+
coverage = overlap / path_width
|
|
215
|
+
lane_center = (lane.left + lane.right) / 2.0
|
|
216
|
+
matches.append((-coverage, abs(center_x - lane_center), lane_index))
|
|
217
|
+
return min(matches)[2] if matches else None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _is_vector_formula_path_member(
|
|
221
|
+
bbox: BBox,
|
|
222
|
+
lane: _TextLane,
|
|
223
|
+
median_height: float,
|
|
224
|
+
) -> bool:
|
|
225
|
+
"""保留小字形轮廓和细横线,过滤跨栏或过大的普通矢量对象。"""
|
|
226
|
+
|
|
227
|
+
width = bbox[2] - bbox[0]
|
|
228
|
+
height = bbox[3] - bbox[1]
|
|
229
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
230
|
+
is_glyph = width <= 3.0 * median_height and height <= 3.0 * median_height
|
|
231
|
+
is_formula_rule = height <= 0.2 * median_height and width <= lane_width + median_height
|
|
232
|
+
return is_glyph or is_formula_rule
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _connect_vector_path_members(
|
|
236
|
+
members: list[PDFPathInfo],
|
|
237
|
+
lane_index: int,
|
|
238
|
+
median_height: float,
|
|
239
|
+
) -> list[_VectorPathComponent]:
|
|
240
|
+
"""用扩张 bbox 的网格邻接和并查集连接同栏 Path,避免全量两两比较。"""
|
|
241
|
+
|
|
242
|
+
if not members:
|
|
243
|
+
return []
|
|
244
|
+
ordered = sorted(members, key=lambda item: item.source_index)
|
|
245
|
+
parents = list(range(len(ordered)))
|
|
246
|
+
|
|
247
|
+
def find(index: int) -> int:
|
|
248
|
+
"""查找并压缩一个 Path 的并查集根节点。"""
|
|
249
|
+
|
|
250
|
+
while parents[index] != index:
|
|
251
|
+
parents[index] = parents[parents[index]]
|
|
252
|
+
index = parents[index]
|
|
253
|
+
return index
|
|
254
|
+
|
|
255
|
+
def merge(first: int, second: int) -> None:
|
|
256
|
+
"""合并两个相交扩张框所属的连通分量。"""
|
|
257
|
+
|
|
258
|
+
first_root = find(first)
|
|
259
|
+
second_root = find(second)
|
|
260
|
+
if first_root != second_root:
|
|
261
|
+
parents[second_root] = first_root
|
|
262
|
+
|
|
263
|
+
margin = 0.5 * median_height
|
|
264
|
+
cell_size = max(1.0, median_height)
|
|
265
|
+
expanded_bboxes = [_expand_bbox(item.bbox, margin) for item in ordered]
|
|
266
|
+
grid: dict[tuple[int, int], list[int]] = {}
|
|
267
|
+
seen_pairs: set[tuple[int, int]] = set()
|
|
268
|
+
for index, bbox in enumerate(expanded_bboxes):
|
|
269
|
+
start_x = int(bbox[0] // cell_size)
|
|
270
|
+
end_x = int(bbox[2] // cell_size)
|
|
271
|
+
start_y = int(bbox[1] // cell_size)
|
|
272
|
+
end_y = int(bbox[3] // cell_size)
|
|
273
|
+
for cell_x in range(start_x, end_x + 1):
|
|
274
|
+
for cell_y in range(start_y, end_y + 1):
|
|
275
|
+
cell = (cell_x, cell_y)
|
|
276
|
+
for other_index in grid.get(cell, []):
|
|
277
|
+
pair = (other_index, index)
|
|
278
|
+
if pair in seen_pairs:
|
|
279
|
+
continue
|
|
280
|
+
seen_pairs.add(pair)
|
|
281
|
+
if _bbox_intersects(bbox, expanded_bboxes[other_index]):
|
|
282
|
+
merge(index, other_index)
|
|
283
|
+
grid.setdefault(cell, []).append(index)
|
|
284
|
+
|
|
285
|
+
grouped: dict[int, list[PDFPathInfo]] = {}
|
|
286
|
+
for index, path_info in enumerate(ordered):
|
|
287
|
+
grouped.setdefault(find(index), []).append(path_info)
|
|
288
|
+
return [
|
|
289
|
+
_VectorPathComponent(
|
|
290
|
+
lane_index=lane_index,
|
|
291
|
+
path_infos=group,
|
|
292
|
+
bbox=_bbox_union_many([item.bbox for item in group]),
|
|
293
|
+
)
|
|
294
|
+
for group in grouped.values()
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _is_vector_formula_core(
|
|
299
|
+
component: _VectorPathComponent,
|
|
300
|
+
lane: _TextLane,
|
|
301
|
+
median_height: float,
|
|
302
|
+
page_size: tuple[float, float],
|
|
303
|
+
container_bboxes: list[BBox],
|
|
304
|
+
) -> bool:
|
|
305
|
+
"""按复杂度、尺寸、正文碰撞和容器优先级校验公式主体组件。"""
|
|
306
|
+
|
|
307
|
+
path_count = len(component.path_infos)
|
|
308
|
+
complex_count = sum(item.segment_count >= _VECTOR_FORMULA_COMPLEX_SEGMENTS for item in component.path_infos)
|
|
309
|
+
if (
|
|
310
|
+
path_count < _VECTOR_FORMULA_MIN_PATHS
|
|
311
|
+
or complex_count < _VECTOR_FORMULA_MIN_COMPLEX_PATHS
|
|
312
|
+
or complex_count / path_count < _VECTOR_FORMULA_MIN_COMPLEX_RATIO
|
|
313
|
+
):
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
bbox = component.bbox
|
|
317
|
+
width = bbox[2] - bbox[0]
|
|
318
|
+
height = bbox[3] - bbox[1]
|
|
319
|
+
if not (width >= 2.5 * median_height and 0.9 * median_height <= height <= 8.0 * median_height and width >= 1.4 * height):
|
|
320
|
+
return False
|
|
321
|
+
if _is_formula_component_in_page_margin(bbox, page_size[1]):
|
|
322
|
+
return False
|
|
323
|
+
if any(_bbox_overlap_in_smaller(bbox, container_bbox) >= 0.5 for container_bbox in container_bboxes):
|
|
324
|
+
return False
|
|
325
|
+
return not any(_vector_formula_collides_with_text(bbox, line, line_bbox, median_height) for line, line_bbox in lane.lines)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _is_formula_component_in_page_margin(bbox: BBox, page_height: float) -> bool:
|
|
329
|
+
"""仅当公式组件完全落在页面顶部或底部边缘带时排除。"""
|
|
330
|
+
|
|
331
|
+
margin = _FORMULA_PAGE_MARGIN_RATIO * page_height
|
|
332
|
+
return bbox[3] <= margin or bbox[1] >= page_height - margin
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _vector_formula_collides_with_text(
|
|
336
|
+
formula_bbox: BBox,
|
|
337
|
+
line: _LineItem,
|
|
338
|
+
line_bbox: BBox,
|
|
339
|
+
median_height: float,
|
|
340
|
+
) -> bool:
|
|
341
|
+
"""排除覆盖正文或紧贴正文同行的 Path 组件,独立公式编号除外。"""
|
|
342
|
+
|
|
343
|
+
if _standalone_formula_number_marker(line.text) is not None:
|
|
344
|
+
return False
|
|
345
|
+
if _bbox_overlap_in_first(formula_bbox, line_bbox) >= 0.2:
|
|
346
|
+
return True
|
|
347
|
+
horizontal_gap = max(
|
|
348
|
+
formula_bbox[0] - line_bbox[2],
|
|
349
|
+
line_bbox[0] - formula_bbox[2],
|
|
350
|
+
0.0,
|
|
351
|
+
)
|
|
352
|
+
return _bbox_axis_overlap_ratio(formula_bbox, line_bbox, axis="y") >= 0.5 and horizontal_gap <= median_height
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _attach_vector_formula_rules(
|
|
356
|
+
candidates: list[_VectorFormulaCandidate],
|
|
357
|
+
components: list[_VectorPathComponent],
|
|
358
|
+
median_height: float,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""把靠近公式主体且横向覆盖充分的孤立细横线唯一并入主体。"""
|
|
361
|
+
|
|
362
|
+
used_sources = {source_index for candidate in candidates for source_index in candidate.path_source_indices}
|
|
363
|
+
for component in components:
|
|
364
|
+
component_sources = {item.source_index for item in component.path_infos}
|
|
365
|
+
if component_sources & used_sources or not all(
|
|
366
|
+
item.bbox[3] - item.bbox[1] <= 0.2 * median_height for item in component.path_infos
|
|
367
|
+
):
|
|
368
|
+
continue
|
|
369
|
+
matches = [
|
|
370
|
+
(
|
|
371
|
+
_bbox_distance(candidate.bbox, component.bbox),
|
|
372
|
+
abs(_bbox_center_y(candidate.bbox) - _bbox_center_y(component.bbox)),
|
|
373
|
+
candidate_index,
|
|
374
|
+
)
|
|
375
|
+
for candidate_index, candidate in enumerate(candidates)
|
|
376
|
+
if candidate.lane_index == component.lane_index
|
|
377
|
+
and _bbox_distance(candidate.bbox, component.bbox) <= 0.5 * median_height
|
|
378
|
+
and _bbox_axis_overlap_ratio(candidate.bbox, component.bbox, axis="x") >= 0.5
|
|
379
|
+
]
|
|
380
|
+
if not matches:
|
|
381
|
+
continue
|
|
382
|
+
candidate = candidates[min(matches)[2]]
|
|
383
|
+
candidate.bbox = _bbox_union(candidate.bbox, component.bbox)
|
|
384
|
+
candidate.path_source_indices.update(component_sources)
|
|
385
|
+
used_sources.update(component_sources)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _attach_vector_formula_path_numbers(
|
|
389
|
+
candidates: list[_VectorFormulaCandidate],
|
|
390
|
+
components: list[_VectorPathComponent],
|
|
391
|
+
lanes: list[_TextLane],
|
|
392
|
+
median_height: float,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""把栏右缘的小型复杂 Path 组件作为公式编号并入唯一主体。"""
|
|
395
|
+
|
|
396
|
+
used_sources = {source_index for candidate in candidates for source_index in candidate.path_source_indices}
|
|
397
|
+
for component in components:
|
|
398
|
+
component_sources = {item.source_index for item in component.path_infos}
|
|
399
|
+
if component_sources & used_sources or not _is_vector_formula_number_component(
|
|
400
|
+
component,
|
|
401
|
+
lanes[component.lane_index],
|
|
402
|
+
median_height,
|
|
403
|
+
):
|
|
404
|
+
continue
|
|
405
|
+
matches = _vector_formula_number_matches(
|
|
406
|
+
component.bbox,
|
|
407
|
+
component.lane_index,
|
|
408
|
+
candidates,
|
|
409
|
+
median_height,
|
|
410
|
+
)
|
|
411
|
+
if not matches:
|
|
412
|
+
continue
|
|
413
|
+
candidate = candidates[min(matches)[3]]
|
|
414
|
+
candidate.bbox = _bbox_union(candidate.bbox, component.bbox)
|
|
415
|
+
candidate.path_source_indices.update(component_sources)
|
|
416
|
+
candidate.has_number = True
|
|
417
|
+
used_sources.update(component_sources)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _is_vector_formula_number_component(
|
|
421
|
+
component: _VectorPathComponent,
|
|
422
|
+
lane: _TextLane,
|
|
423
|
+
median_height: float,
|
|
424
|
+
) -> bool:
|
|
425
|
+
"""识别位于栏右缘、尺寸接近正文行高的全复杂路径编号组件。"""
|
|
426
|
+
|
|
427
|
+
path_count = len(component.path_infos)
|
|
428
|
+
bbox = component.bbox
|
|
429
|
+
width = bbox[2] - bbox[0]
|
|
430
|
+
height = bbox[3] - bbox[1]
|
|
431
|
+
return (
|
|
432
|
+
_VECTOR_FORMULA_NUMBER_MIN_PATHS <= path_count <= _VECTOR_FORMULA_NUMBER_MAX_PATHS
|
|
433
|
+
and all(item.segment_count >= _VECTOR_FORMULA_COMPLEX_SEGMENTS for item in component.path_infos)
|
|
434
|
+
and 0.5 * median_height <= width <= 2.0 * median_height
|
|
435
|
+
and 0.6 * median_height <= height <= 1.4 * median_height
|
|
436
|
+
and abs(lane.right - bbox[2]) <= 1.5 * median_height
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _vector_formula_number_matches(
|
|
441
|
+
number_bbox: BBox,
|
|
442
|
+
lane_index: int,
|
|
443
|
+
candidates: list[_VectorFormulaCandidate],
|
|
444
|
+
median_height: float,
|
|
445
|
+
) -> list[tuple[float, float, float, int]]:
|
|
446
|
+
"""返回编号可关联的公式主体及稳定排序分值。"""
|
|
447
|
+
|
|
448
|
+
matches: list[tuple[float, float, float, int]] = []
|
|
449
|
+
for candidate_index, candidate in enumerate(candidates):
|
|
450
|
+
if candidate.has_number or candidate.lane_index != lane_index:
|
|
451
|
+
continue
|
|
452
|
+
vertical_overlap = _bbox_axis_overlap_ratio(candidate.bbox, number_bbox, axis="y")
|
|
453
|
+
if number_bbox[0] < candidate.bbox[2] or vertical_overlap < 0.6:
|
|
454
|
+
continue
|
|
455
|
+
center_distance = abs(_bbox_center_y(candidate.bbox) - _bbox_center_y(number_bbox))
|
|
456
|
+
horizontal_gap = max(0.0, number_bbox[0] - candidate.bbox[2])
|
|
457
|
+
matches.append((-vertical_overlap, center_distance, horizontal_gap / max(0.1, median_height), candidate_index))
|
|
458
|
+
return matches
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _attach_vector_formula_text_numbers(
|
|
462
|
+
candidates: list[_VectorFormulaCandidate],
|
|
463
|
+
lanes: list[_TextLane],
|
|
464
|
+
median_height: float,
|
|
465
|
+
claimed_line_indices: set[int],
|
|
466
|
+
) -> set[int]:
|
|
467
|
+
"""关联可提取的独立公式编号并认领其文本身份,防止重复输出。"""
|
|
468
|
+
|
|
469
|
+
claimed: set[int] = set()
|
|
470
|
+
for lane_index, lane in enumerate(lanes):
|
|
471
|
+
for line, bbox in sorted(lane.lines, key=lambda item: (item[1][1], item[1][0])):
|
|
472
|
+
if line.source_index in claimed_line_indices or _standalone_formula_number_marker(line.text) is None:
|
|
473
|
+
continue
|
|
474
|
+
width = bbox[2] - bbox[0]
|
|
475
|
+
height = bbox[3] - bbox[1]
|
|
476
|
+
if not (
|
|
477
|
+
0.5 * median_height <= width <= 2.0 * median_height
|
|
478
|
+
and 0.6 * median_height <= height <= 1.4 * median_height
|
|
479
|
+
and abs(lane.right - bbox[2]) <= 1.5 * median_height
|
|
480
|
+
):
|
|
481
|
+
continue
|
|
482
|
+
matches = _vector_formula_number_matches(
|
|
483
|
+
bbox,
|
|
484
|
+
lane_index,
|
|
485
|
+
candidates,
|
|
486
|
+
median_height,
|
|
487
|
+
)
|
|
488
|
+
if not matches:
|
|
489
|
+
continue
|
|
490
|
+
candidate = candidates[min(matches)[3]]
|
|
491
|
+
candidate.bbox = _bbox_union(candidate.bbox, bbox)
|
|
492
|
+
candidate.has_number = True
|
|
493
|
+
claimed.add(line.source_index)
|
|
494
|
+
return claimed
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _standalone_formula_number_marker(text: str) -> str | None:
|
|
498
|
+
"""仅接受整行由圆括号公式编号构成的文本,不接纳带正文前缀的后缀。"""
|
|
499
|
+
|
|
500
|
+
parts = _split_trailing_formula_number(text)
|
|
501
|
+
if parts is None:
|
|
502
|
+
return None
|
|
503
|
+
prefix, marker = parts
|
|
504
|
+
return marker if not prefix else None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _build_formula_like_blocks(
|
|
508
|
+
lines: list[_LineItem],
|
|
509
|
+
table_bboxes: list[BBox],
|
|
510
|
+
page_size: tuple[float, float],
|
|
511
|
+
*,
|
|
512
|
+
drawing_lines: list[_AxisLine] | None = None,
|
|
513
|
+
) -> tuple[list[dict[str, Any]], list[_LineItem]]:
|
|
514
|
+
"""仅依据栏带、右侧短锚点和空间连通关系聚合公式状区域。"""
|
|
515
|
+
|
|
516
|
+
blocks, claimed_source_indices = _build_split_visual_row_formula_blocks(
|
|
517
|
+
lines,
|
|
518
|
+
table_bboxes,
|
|
519
|
+
page_size,
|
|
520
|
+
)
|
|
521
|
+
paragraph_lines: list[_LineItem] = []
|
|
522
|
+
for angle in sorted({line.angle for line in lines}):
|
|
523
|
+
angle_geometry = [
|
|
524
|
+
(line, _rotate_bbox_to_upright(line.bbox, page_size, angle))
|
|
525
|
+
for line in lines
|
|
526
|
+
if line.angle == angle and line.source_index not in claimed_source_indices
|
|
527
|
+
]
|
|
528
|
+
if len(angle_geometry) < 2:
|
|
529
|
+
continue
|
|
530
|
+
effective_heights = [_line_effective_height(line, bbox) for line, bbox in angle_geometry]
|
|
531
|
+
median_height = statistics.median(effective_heights) if effective_heights else 1.0
|
|
532
|
+
local_page_width = page_size[1] if angle in {90, 270} else page_size[0]
|
|
533
|
+
local_page_height = page_size[0] if angle in {90, 270} else page_size[1]
|
|
534
|
+
local_horizontal_rules = [
|
|
535
|
+
rule.bbox
|
|
536
|
+
for rule in _transform_axis_lines(
|
|
537
|
+
drawing_lines or [],
|
|
538
|
+
page_size,
|
|
539
|
+
angle,
|
|
540
|
+
)
|
|
541
|
+
if rule.orientation == "horizontal"
|
|
542
|
+
]
|
|
543
|
+
lanes = _infer_text_lanes(angle_geometry, local_page_width, median_height)
|
|
544
|
+
for lane in lanes:
|
|
545
|
+
if lane.is_span:
|
|
546
|
+
continue
|
|
547
|
+
lane.lines.sort(key=lambda item: (item[1][1], item[1][0], item[0].source_index))
|
|
548
|
+
dominant_body_font = _infer_formula_body_font(
|
|
549
|
+
lane,
|
|
550
|
+
median_height,
|
|
551
|
+
)
|
|
552
|
+
for line, bbox in list(lane.lines):
|
|
553
|
+
if not _is_single_line_numbered_formula(
|
|
554
|
+
(line, bbox),
|
|
555
|
+
lane,
|
|
556
|
+
median_height,
|
|
557
|
+
):
|
|
558
|
+
continue
|
|
559
|
+
members = _expand_single_line_numbered_formula_members(
|
|
560
|
+
(line, bbox),
|
|
561
|
+
lane,
|
|
562
|
+
claimed_source_indices,
|
|
563
|
+
table_bboxes,
|
|
564
|
+
dominant_body_font,
|
|
565
|
+
median_height,
|
|
566
|
+
)
|
|
567
|
+
block = _formula_members_to_block(
|
|
568
|
+
members,
|
|
569
|
+
page_size,
|
|
570
|
+
angle,
|
|
571
|
+
anchor_source_index=line.source_index,
|
|
572
|
+
)
|
|
573
|
+
if block is None:
|
|
574
|
+
continue
|
|
575
|
+
blocks.append(block)
|
|
576
|
+
claimed_source_indices.update(member_line.source_index for member_line, _member_bbox in members)
|
|
577
|
+
lane.lines = [item for item in lane.lines if item[0].source_index not in claimed_source_indices]
|
|
578
|
+
for line, bbox in list(lane.lines):
|
|
579
|
+
if (
|
|
580
|
+
(
|
|
581
|
+
line.compact_formula_cluster
|
|
582
|
+
and not _compact_cluster_has_nearby_number_anchor(
|
|
583
|
+
(line, bbox),
|
|
584
|
+
lane,
|
|
585
|
+
median_height,
|
|
586
|
+
)
|
|
587
|
+
and _is_isolated_compact_formula_cluster(
|
|
588
|
+
(line, bbox),
|
|
589
|
+
lane,
|
|
590
|
+
median_height,
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
or _is_isolated_unnumbered_formula_line(
|
|
594
|
+
(line, bbox),
|
|
595
|
+
lane,
|
|
596
|
+
median_height,
|
|
597
|
+
dominant_body_font,
|
|
598
|
+
)
|
|
599
|
+
) and not _is_formula_component_in_page_margin(
|
|
600
|
+
bbox,
|
|
601
|
+
local_page_height,
|
|
602
|
+
):
|
|
603
|
+
content = _sanitize_pdf_control_text(
|
|
604
|
+
line.text,
|
|
605
|
+
preserve_newlines=False,
|
|
606
|
+
).strip()
|
|
607
|
+
if not content:
|
|
608
|
+
continue
|
|
609
|
+
block = {
|
|
610
|
+
"type": "equation",
|
|
611
|
+
"bbox": line.bbox,
|
|
612
|
+
"angle": angle,
|
|
613
|
+
"content": content,
|
|
614
|
+
}
|
|
615
|
+
tight_output_bbox = _line_tight_output_bbox(
|
|
616
|
+
line,
|
|
617
|
+
page_size,
|
|
618
|
+
)
|
|
619
|
+
if tight_output_bbox is not None:
|
|
620
|
+
block["_tight_output_bbox"] = tight_output_bbox
|
|
621
|
+
blocks.append(block)
|
|
622
|
+
claimed_source_indices.add(line.source_index)
|
|
623
|
+
lane.lines = [item for item in lane.lines if item[0].source_index not in claimed_source_indices]
|
|
624
|
+
if len(lane.lines) < 2:
|
|
625
|
+
continue
|
|
626
|
+
anchors = _find_formula_spatial_anchors(
|
|
627
|
+
lane,
|
|
628
|
+
median_height,
|
|
629
|
+
dominant_body_font,
|
|
630
|
+
)
|
|
631
|
+
if not anchors:
|
|
632
|
+
continue
|
|
633
|
+
anchor_centers = [_bbox_center_y(anchor.bbox) for anchor in anchors]
|
|
634
|
+
lane_top = min(bbox[1] for _line, bbox in lane.lines)
|
|
635
|
+
lane_bottom = max(bbox[3] for _line, bbox in lane.lines)
|
|
636
|
+
for anchor_index, anchor in enumerate(anchors):
|
|
637
|
+
anchor_line = anchor.line
|
|
638
|
+
if anchor_line.source_index in claimed_source_indices:
|
|
639
|
+
continue
|
|
640
|
+
band_top = lane_top
|
|
641
|
+
band_bottom = lane_bottom
|
|
642
|
+
if anchor_index > 0:
|
|
643
|
+
band_top = max(
|
|
644
|
+
band_top,
|
|
645
|
+
(anchor_centers[anchor_index - 1] + anchor_centers[anchor_index]) / 2.0,
|
|
646
|
+
)
|
|
647
|
+
if anchor_index + 1 < len(anchors):
|
|
648
|
+
band_bottom = min(
|
|
649
|
+
band_bottom,
|
|
650
|
+
(anchor_centers[anchor_index] + anchor_centers[anchor_index + 1]) / 2.0,
|
|
651
|
+
)
|
|
652
|
+
members = _grow_formula_spatial_component(
|
|
653
|
+
lane,
|
|
654
|
+
anchor,
|
|
655
|
+
band_top,
|
|
656
|
+
band_bottom,
|
|
657
|
+
claimed_source_indices,
|
|
658
|
+
table_bboxes,
|
|
659
|
+
dominant_body_font,
|
|
660
|
+
median_height,
|
|
661
|
+
)
|
|
662
|
+
has_isolated_numbered_fraction = _formula_component_has_isolated_numbered_fraction(
|
|
663
|
+
members,
|
|
664
|
+
lane,
|
|
665
|
+
median_height,
|
|
666
|
+
local_horizontal_rules,
|
|
667
|
+
)
|
|
668
|
+
if (
|
|
669
|
+
_formula_component_has_left_prose(
|
|
670
|
+
members,
|
|
671
|
+
lane,
|
|
672
|
+
median_height,
|
|
673
|
+
)
|
|
674
|
+
and not has_isolated_numbered_fraction
|
|
675
|
+
):
|
|
676
|
+
for member_line, _member_bbox in members:
|
|
677
|
+
member_line.paragraph_formula_context = True
|
|
678
|
+
if _fragmented_left_prose(members, lane, median_height):
|
|
679
|
+
paragraph_lines.append(_merge_paragraph_formula_members(members, page_size, median_height))
|
|
680
|
+
claimed_source_indices.update(line.source_index for line, _bbox in members)
|
|
681
|
+
continue
|
|
682
|
+
if len(members) < 2:
|
|
683
|
+
continue
|
|
684
|
+
if (
|
|
685
|
+
len(members) == 2
|
|
686
|
+
and _bbox_axis_overlap_ratio(
|
|
687
|
+
members[0][1],
|
|
688
|
+
members[1][1],
|
|
689
|
+
axis="y",
|
|
690
|
+
)
|
|
691
|
+
< 0.2
|
|
692
|
+
and not any(
|
|
693
|
+
_is_wide_tagged_formula_member(
|
|
694
|
+
anchor_line,
|
|
695
|
+
member_line,
|
|
696
|
+
member_bbox,
|
|
697
|
+
max(0.1, lane.right - lane.left),
|
|
698
|
+
)
|
|
699
|
+
for member_line, member_bbox in members
|
|
700
|
+
if member_line is not anchor_line
|
|
701
|
+
)
|
|
702
|
+
):
|
|
703
|
+
continue
|
|
704
|
+
component_bbox = _bbox_union_many([member_bbox for _member_line, member_bbox in members])
|
|
705
|
+
if _is_formula_component_in_page_margin(
|
|
706
|
+
component_bbox,
|
|
707
|
+
local_page_height,
|
|
708
|
+
):
|
|
709
|
+
continue
|
|
710
|
+
block = _formula_members_to_block(
|
|
711
|
+
members,
|
|
712
|
+
page_size,
|
|
713
|
+
angle,
|
|
714
|
+
anchor_source_index=anchor_line.source_index,
|
|
715
|
+
)
|
|
716
|
+
if block is None:
|
|
717
|
+
continue
|
|
718
|
+
blocks.append(block)
|
|
719
|
+
claimed_source_indices.update(line.source_index for line, _bbox in members)
|
|
720
|
+
|
|
721
|
+
remaining_lines = [
|
|
722
|
+
line
|
|
723
|
+
for line in lines
|
|
724
|
+
if line.source_index not in claimed_source_indices
|
|
725
|
+
and (not line.formula_candidate_only or line.paragraph_formula_context)
|
|
726
|
+
]
|
|
727
|
+
return blocks, remaining_lines + paragraph_lines
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _formula_line_has_math_operator(text: str) -> bool:
|
|
731
|
+
"""检查文本行是否具有独立公式常见的数学运算符。"""
|
|
732
|
+
|
|
733
|
+
return any(character in _FORMULA_OPERATOR_CHARS for character in text)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _formula_prefix_has_prose(prefix: str) -> bool:
|
|
737
|
+
"""用通用文字数量识别公式前的正文片段,不依赖特定引导词或标点。"""
|
|
738
|
+
|
|
739
|
+
prose_prefix = re.sub(
|
|
740
|
+
r"[({\[([【{][^)}\])]】}]*[)}\])]】}]",
|
|
741
|
+
" ",
|
|
742
|
+
prefix,
|
|
743
|
+
)
|
|
744
|
+
if len(re.findall(r"[\u3400-\u9fff]", prose_prefix)) >= 2:
|
|
745
|
+
return True
|
|
746
|
+
latin_word_count = sum(re.search(r"[A-Za-z]{2,}", token) is not None for token in prose_prefix.split())
|
|
747
|
+
return latin_word_count >= 2
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _formula_component_has_left_prose(
|
|
751
|
+
members: list[tuple[_LineItem, BBox]],
|
|
752
|
+
lane: _TextLane,
|
|
753
|
+
median_height: float,
|
|
754
|
+
) -> bool:
|
|
755
|
+
"""识别贴栏左缘且在首个运算符前带同行正文的伪行间公式。"""
|
|
756
|
+
|
|
757
|
+
for line, bbox in members:
|
|
758
|
+
normalized = unicodedata.normalize("NFKC", line.text).strip()
|
|
759
|
+
operator_positions = [index for index, character in enumerate(normalized) if character in _FORMULA_OPERATOR_CHARS]
|
|
760
|
+
if not operator_positions:
|
|
761
|
+
continue
|
|
762
|
+
prefix = normalized[: min(operator_positions)]
|
|
763
|
+
if not _formula_prefix_has_prose(prefix):
|
|
764
|
+
continue
|
|
765
|
+
if abs(bbox[0] - lane.left) <= 0.75 * median_height:
|
|
766
|
+
return True
|
|
767
|
+
return _fragmented_left_prose(members, lane, median_height)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _fragmented_left_prose(members: list[tuple[_LineItem, BBox]], lane: _TextLane, median_height: float) -> bool:
|
|
771
|
+
"""运算符前的正文被拆成多个 run 时,沿同行邻接找到栏左缘的正文证据。"""
|
|
772
|
+
for line, bbox in members:
|
|
773
|
+
normalized = unicodedata.normalize("NFKC", line.text).strip()
|
|
774
|
+
positions = [index for index, character in enumerate(normalized) if character in _FORMULA_OPERATOR_CHARS]
|
|
775
|
+
if not positions or not _formula_prefix_has_prose(normalized[: min(positions)]):
|
|
776
|
+
continue
|
|
777
|
+
if any(
|
|
778
|
+
other is not line
|
|
779
|
+
and abs(other_bbox[0] - lane.left) <= 0.75 * median_height
|
|
780
|
+
and 0 <= bbox[0] - other_bbox[2] <= 0.75 * median_height
|
|
781
|
+
and _bbox_axis_overlap_ratio(bbox, other_bbox, axis="y") >= 0.7
|
|
782
|
+
for other, other_bbox in members
|
|
783
|
+
):
|
|
784
|
+
return True
|
|
785
|
+
return False
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _merge_paragraph_formula_members(
|
|
789
|
+
members: list[tuple[_LineItem, BBox]], page_size: tuple[float, float], median_height: float
|
|
790
|
+
) -> _LineItem:
|
|
791
|
+
"""把已确认的行内分式按重叠视觉行恢复顺序,避免字形高度差把正文前缀排到分子之后。"""
|
|
792
|
+
rows: list[list[tuple[_LineItem, BBox]]] = []
|
|
793
|
+
for item in sorted(members, key=lambda item: (item[1][1], item[1][0])):
|
|
794
|
+
match = next(
|
|
795
|
+
(row for row in rows if any(_bbox_axis_overlap_ratio(item[1], other[1], axis="y") >= 0.55 for other in row)),
|
|
796
|
+
None,
|
|
797
|
+
)
|
|
798
|
+
if match is None:
|
|
799
|
+
rows.append([item])
|
|
800
|
+
else:
|
|
801
|
+
match.append(item)
|
|
802
|
+
merged = _merge_overlapping_inline_cluster(members, page_size, median_height, compact_formula_cluster=False)
|
|
803
|
+
merged.text = " ".join(_join_formula_visual_row(row, page_size) for row in rows)
|
|
804
|
+
merged.paragraph_formula_context = True
|
|
805
|
+
merged.formula_candidate_only = False
|
|
806
|
+
return merged
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _formula_component_has_isolated_numbered_fraction(
|
|
810
|
+
members: list[tuple[_LineItem, BBox]],
|
|
811
|
+
lane: _TextLane,
|
|
812
|
+
median_height: float,
|
|
813
|
+
horizontal_rules: list[BBox],
|
|
814
|
+
) -> bool:
|
|
815
|
+
"""用右侧编号、内部分数线和上下留白确认独立多层公式。"""
|
|
816
|
+
|
|
817
|
+
if len(members) < 3 or not horizontal_rules:
|
|
818
|
+
return False
|
|
819
|
+
markers = [(line, bbox) for line, bbox in members if _standalone_formula_number_marker(line.text) is not None]
|
|
820
|
+
if len(markers) != 1:
|
|
821
|
+
return False
|
|
822
|
+
marker_line, marker_bbox = markers[0]
|
|
823
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
824
|
+
if marker_bbox[2] < lane.right - max(3.0, 0.08 * lane_width) or marker_bbox[2] - marker_bbox[0] > 0.12 * lane_width:
|
|
825
|
+
return False
|
|
826
|
+
body_members = [(line, bbox) for line, bbox in members if line is not marker_line]
|
|
827
|
+
if len(body_members) < 2:
|
|
828
|
+
return False
|
|
829
|
+
body_bbox = _bbox_union_many(
|
|
830
|
+
[bbox for _line, bbox in body_members],
|
|
831
|
+
)
|
|
832
|
+
member_sources = {line.source_index for line, _bbox in members}
|
|
833
|
+
rows_above = [bbox for line, bbox in lane.lines if line.source_index not in member_sources and bbox[3] <= body_bbox[1]]
|
|
834
|
+
rows_below = [bbox for line, bbox in lane.lines if line.source_index not in member_sources and bbox[1] >= body_bbox[3]]
|
|
835
|
+
if not rows_above or not rows_below:
|
|
836
|
+
return False
|
|
837
|
+
gap_above = body_bbox[1] - max(bbox[3] for bbox in rows_above)
|
|
838
|
+
gap_below = min(bbox[1] for bbox in rows_below) - body_bbox[3]
|
|
839
|
+
if min(gap_above, gap_below) < 0.75 * median_height:
|
|
840
|
+
return False
|
|
841
|
+
|
|
842
|
+
body_centers = [_bbox_center_y(bbox) for _line, bbox in body_members]
|
|
843
|
+
for rule_bbox in horizontal_rules:
|
|
844
|
+
rule_width = rule_bbox[2] - rule_bbox[0]
|
|
845
|
+
if not (3.0 * median_height <= rule_width <= 0.75 * lane_width):
|
|
846
|
+
continue
|
|
847
|
+
horizontal_overlap = max(
|
|
848
|
+
0.0,
|
|
849
|
+
min(rule_bbox[2], body_bbox[2]) - max(rule_bbox[0], body_bbox[0]),
|
|
850
|
+
)
|
|
851
|
+
if horizontal_overlap < 0.6 * rule_width:
|
|
852
|
+
continue
|
|
853
|
+
rule_center = _bbox_center_y(rule_bbox)
|
|
854
|
+
if any(center <= rule_center - 0.1 * median_height for center in body_centers) and any(
|
|
855
|
+
center >= rule_center + 0.1 * median_height for center in body_centers
|
|
856
|
+
):
|
|
857
|
+
return True
|
|
858
|
+
return False
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _is_wide_tagged_formula_member(
|
|
862
|
+
anchor_line: _LineItem,
|
|
863
|
+
member_line: _LineItem,
|
|
864
|
+
member_bbox: BBox,
|
|
865
|
+
lane_width: float,
|
|
866
|
+
) -> bool:
|
|
867
|
+
"""判断独立编号左侧是否为接近满栏的单行公式主体。"""
|
|
868
|
+
|
|
869
|
+
member_width = member_bbox[2] - member_bbox[0]
|
|
870
|
+
marker = _standalone_formula_number_marker(anchor_line.text)
|
|
871
|
+
return (
|
|
872
|
+
anchor_line.style_scale_repaired
|
|
873
|
+
and marker is not None
|
|
874
|
+
and _FORMULA_NUMBER_MARKER_RE.fullmatch(marker)
|
|
875
|
+
and 0.75 * lane_width < member_width <= 0.95 * lane_width
|
|
876
|
+
and _formula_line_has_math_operator(member_line.text)
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _is_single_line_numbered_formula(
|
|
881
|
+
candidate: tuple[_LineItem, BBox],
|
|
882
|
+
lane: _TextLane,
|
|
883
|
+
median_height: float,
|
|
884
|
+
) -> bool:
|
|
885
|
+
"""识别公式主体与右侧编号已落在同一原生文本行的情形。"""
|
|
886
|
+
|
|
887
|
+
line, bbox = candidate
|
|
888
|
+
if not line.style_scale_repaired:
|
|
889
|
+
return False
|
|
890
|
+
parts = _split_trailing_formula_number(line.text)
|
|
891
|
+
if parts is None:
|
|
892
|
+
return False
|
|
893
|
+
prefix, marker = parts
|
|
894
|
+
if not prefix or not _FORMULA_NUMBER_MARKER_RE.fullmatch(marker) or not _formula_line_has_math_operator(prefix):
|
|
895
|
+
return False
|
|
896
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
897
|
+
width_ratio = (bbox[2] - bbox[0]) / lane_width
|
|
898
|
+
if not 0.12 <= width_ratio <= 0.98:
|
|
899
|
+
return False
|
|
900
|
+
if abs(_bbox_center_x(bbox) - 0.5 * (lane.left + lane.right)) > 0.2 * lane_width:
|
|
901
|
+
return False
|
|
902
|
+
if bbox[3] - bbox[1] > 4.0 * median_height:
|
|
903
|
+
return False
|
|
904
|
+
return not _is_hanging_indent_tail_line(
|
|
905
|
+
candidate,
|
|
906
|
+
lane,
|
|
907
|
+
median_height,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _expand_single_line_numbered_formula_members(
|
|
912
|
+
core: tuple[_LineItem, BBox],
|
|
913
|
+
lane: _TextLane,
|
|
914
|
+
claimed_source_indices: set[int],
|
|
915
|
+
table_bboxes: list[BBox],
|
|
916
|
+
dominant_body_font: tuple[str, int] | None,
|
|
917
|
+
median_height: float,
|
|
918
|
+
) -> list[tuple[_LineItem, BBox]]:
|
|
919
|
+
"""为已带编号的公式核心吸收同栏连通的等号前缀和窄分式碎片。"""
|
|
920
|
+
|
|
921
|
+
core_line, core_bbox = core
|
|
922
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
923
|
+
candidates = []
|
|
924
|
+
for candidate_line, candidate_bbox in lane.lines:
|
|
925
|
+
if (
|
|
926
|
+
candidate_line.source_index == core_line.source_index
|
|
927
|
+
or candidate_line.source_index in claimed_source_indices
|
|
928
|
+
or candidate_bbox[2] - candidate_bbox[0] > 0.35 * lane_width
|
|
929
|
+
or _is_formula_body_barrier(
|
|
930
|
+
(candidate_line, candidate_bbox),
|
|
931
|
+
lane,
|
|
932
|
+
dominant_body_font,
|
|
933
|
+
median_height,
|
|
934
|
+
)
|
|
935
|
+
or _is_formula_title_barrier(
|
|
936
|
+
(candidate_line, candidate_bbox),
|
|
937
|
+
lane,
|
|
938
|
+
dominant_body_font,
|
|
939
|
+
median_height,
|
|
940
|
+
)
|
|
941
|
+
):
|
|
942
|
+
continue
|
|
943
|
+
narrow_fragment = (
|
|
944
|
+
candidate_bbox[2] - candidate_bbox[0] <= 1.5 * median_height
|
|
945
|
+
and core_bbox[0] - median_height <= _bbox_center_x(candidate_bbox) <= core_bbox[2] + median_height
|
|
946
|
+
)
|
|
947
|
+
if not (
|
|
948
|
+
_formula_line_has_math_operator(candidate_line.text)
|
|
949
|
+
or candidate_line.compact_formula_cluster
|
|
950
|
+
or candidate_line.formula_candidate_only
|
|
951
|
+
or narrow_fragment
|
|
952
|
+
):
|
|
953
|
+
continue
|
|
954
|
+
if not _formula_lines_are_connected(
|
|
955
|
+
core_line,
|
|
956
|
+
core_bbox,
|
|
957
|
+
candidate_line,
|
|
958
|
+
candidate_bbox,
|
|
959
|
+
table_bboxes,
|
|
960
|
+
):
|
|
961
|
+
continue
|
|
962
|
+
candidates.append(
|
|
963
|
+
(candidate_line, candidate_bbox),
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
members = [core, *candidates]
|
|
967
|
+
member_sources = {line.source_index for line, _bbox in members}
|
|
968
|
+
changed = True
|
|
969
|
+
while changed:
|
|
970
|
+
changed = False
|
|
971
|
+
for candidate in lane.lines:
|
|
972
|
+
candidate_line, candidate_bbox = candidate
|
|
973
|
+
if (
|
|
974
|
+
candidate_line.source_index in member_sources
|
|
975
|
+
or candidate_line.source_index in claimed_source_indices
|
|
976
|
+
or candidate_bbox[2] - candidate_bbox[0] > 0.35 * lane_width
|
|
977
|
+
or _is_formula_body_barrier(
|
|
978
|
+
candidate,
|
|
979
|
+
lane,
|
|
980
|
+
dominant_body_font,
|
|
981
|
+
median_height,
|
|
982
|
+
)
|
|
983
|
+
or _is_formula_title_barrier(
|
|
984
|
+
candidate,
|
|
985
|
+
lane,
|
|
986
|
+
dominant_body_font,
|
|
987
|
+
median_height,
|
|
988
|
+
)
|
|
989
|
+
):
|
|
990
|
+
continue
|
|
991
|
+
if any(
|
|
992
|
+
_formula_lines_are_connected(
|
|
993
|
+
member_line,
|
|
994
|
+
member_bbox,
|
|
995
|
+
candidate_line,
|
|
996
|
+
candidate_bbox,
|
|
997
|
+
table_bboxes,
|
|
998
|
+
)
|
|
999
|
+
for member_line, member_bbox in members
|
|
1000
|
+
) and (
|
|
1001
|
+
_formula_line_has_math_operator(candidate_line.text)
|
|
1002
|
+
or candidate_line.compact_formula_cluster
|
|
1003
|
+
or candidate_line.formula_candidate_only
|
|
1004
|
+
or candidate_bbox[2] - candidate_bbox[0] <= 1.5 * median_height
|
|
1005
|
+
):
|
|
1006
|
+
members.append(candidate)
|
|
1007
|
+
member_sources.add(candidate_line.source_index)
|
|
1008
|
+
changed = True
|
|
1009
|
+
return members
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _build_split_visual_row_formula_blocks(
|
|
1013
|
+
lines: list[_LineItem],
|
|
1014
|
+
table_bboxes: list[BBox],
|
|
1015
|
+
page_size: tuple[float, float],
|
|
1016
|
+
) -> tuple[list[dict[str, Any]], set[int]]:
|
|
1017
|
+
"""在栏带推断前恢复同一视觉行中带右侧编号的多字体公式。"""
|
|
1018
|
+
|
|
1019
|
+
row_groups: dict[tuple[int, int], list[_LineItem]] = {}
|
|
1020
|
+
for line in lines:
|
|
1021
|
+
if line.visual_row_id is None or not line.split_from_row:
|
|
1022
|
+
continue
|
|
1023
|
+
row_groups.setdefault((line.angle, line.visual_row_id), []).append(line)
|
|
1024
|
+
|
|
1025
|
+
blocks: list[dict[str, Any]] = []
|
|
1026
|
+
claimed: set[int] = set()
|
|
1027
|
+
for (angle, _row_id), members in row_groups.items():
|
|
1028
|
+
if len(members) < 3:
|
|
1029
|
+
continue
|
|
1030
|
+
markers = [member for member in members if _standalone_formula_number_marker(member.text) is not None]
|
|
1031
|
+
if len(markers) != 1:
|
|
1032
|
+
continue
|
|
1033
|
+
marker = markers[0]
|
|
1034
|
+
if any(_bbox_intersects(member.bbox, table_bbox) for member in members for table_bbox in table_bboxes):
|
|
1035
|
+
continue
|
|
1036
|
+
local_members = [
|
|
1037
|
+
(
|
|
1038
|
+
member,
|
|
1039
|
+
_rotate_bbox_to_upright(
|
|
1040
|
+
member.bbox,
|
|
1041
|
+
page_size,
|
|
1042
|
+
angle,
|
|
1043
|
+
),
|
|
1044
|
+
)
|
|
1045
|
+
for member in members
|
|
1046
|
+
]
|
|
1047
|
+
marker_bbox = next(bbox for member, bbox in local_members if member is marker)
|
|
1048
|
+
body_members = [(member, bbox) for member, bbox in local_members if member is not marker]
|
|
1049
|
+
if not body_members or marker_bbox[0] <= max(_bbox_center_x(bbox) for _member, bbox in body_members):
|
|
1050
|
+
continue
|
|
1051
|
+
median_height = statistics.median(_line_effective_height(member, bbox) for member, bbox in local_members)
|
|
1052
|
+
row_center = statistics.median(_bbox_center_y(bbox) for _member, bbox in local_members)
|
|
1053
|
+
if any(abs(_bbox_center_y(bbox) - row_center) > 0.75 * median_height for _member, bbox in local_members):
|
|
1054
|
+
continue
|
|
1055
|
+
body_fonts = {member.font_signature for member, _bbox in body_members if member.font_signature is not None}
|
|
1056
|
+
has_math_typography = len(body_fonts) >= 2 or any(
|
|
1057
|
+
member.compact_formula_cluster or member.font_coverage < 0.8 for member, _bbox in body_members
|
|
1058
|
+
)
|
|
1059
|
+
if not has_math_typography:
|
|
1060
|
+
continue
|
|
1061
|
+
body_bbox = _bbox_union_many([bbox for _member, bbox in body_members])
|
|
1062
|
+
body_width = max(0.1, body_bbox[2] - body_bbox[0])
|
|
1063
|
+
# 同行成员可能只是分式尾部;窄尾部不能压低外部公式片段的宽度容差,
|
|
1064
|
+
# 否则会提前认领分母、右括号和编号,使左侧公式主体落回普通文本。
|
|
1065
|
+
nearby_fragment_width_limit = max(
|
|
1066
|
+
0.65 * body_width,
|
|
1067
|
+
3.0 * median_height,
|
|
1068
|
+
)
|
|
1069
|
+
member_ids = {id(member) for member in members}
|
|
1070
|
+
has_nearby_formula_fragment = False
|
|
1071
|
+
for other in lines:
|
|
1072
|
+
if id(other) in member_ids or other.angle != angle:
|
|
1073
|
+
continue
|
|
1074
|
+
other_bbox = _rotate_bbox_to_upright(
|
|
1075
|
+
other.bbox,
|
|
1076
|
+
page_size,
|
|
1077
|
+
angle,
|
|
1078
|
+
)
|
|
1079
|
+
vertical_gap = max(
|
|
1080
|
+
0.0,
|
|
1081
|
+
max(other_bbox[1], body_bbox[1]) - min(other_bbox[3], body_bbox[3]),
|
|
1082
|
+
)
|
|
1083
|
+
if (
|
|
1084
|
+
vertical_gap <= 0.75 * median_height
|
|
1085
|
+
and other_bbox[2] - other_bbox[0] <= nearby_fragment_width_limit
|
|
1086
|
+
and max(
|
|
1087
|
+
0.0,
|
|
1088
|
+
max(other_bbox[0], body_bbox[0]) - min(other_bbox[2], body_bbox[2]),
|
|
1089
|
+
)
|
|
1090
|
+
<= median_height
|
|
1091
|
+
):
|
|
1092
|
+
has_nearby_formula_fragment = True
|
|
1093
|
+
break
|
|
1094
|
+
if has_nearby_formula_fragment:
|
|
1095
|
+
continue
|
|
1096
|
+
block = _formula_members_to_block(
|
|
1097
|
+
local_members,
|
|
1098
|
+
page_size,
|
|
1099
|
+
angle,
|
|
1100
|
+
anchor_source_index=marker.source_index,
|
|
1101
|
+
)
|
|
1102
|
+
if block is None:
|
|
1103
|
+
continue
|
|
1104
|
+
local_bbox = _bbox_union_many([bbox for _member, bbox in local_members])
|
|
1105
|
+
local_page_height = page_size[0] if angle in {90, 270} else page_size[1]
|
|
1106
|
+
if _is_formula_component_in_page_margin(
|
|
1107
|
+
local_bbox,
|
|
1108
|
+
local_page_height,
|
|
1109
|
+
):
|
|
1110
|
+
continue
|
|
1111
|
+
blocks.append(block)
|
|
1112
|
+
claimed.update(member.source_index for member in members)
|
|
1113
|
+
return blocks, claimed
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _is_isolated_compact_formula_cluster(
|
|
1117
|
+
candidate: tuple[_LineItem, BBox],
|
|
1118
|
+
lane: _TextLane,
|
|
1119
|
+
median_height: float,
|
|
1120
|
+
) -> bool:
|
|
1121
|
+
"""用上下正文邻行确认紧凑二维文本簇是独立行间公式。"""
|
|
1122
|
+
|
|
1123
|
+
line, bbox = candidate
|
|
1124
|
+
if not line.compact_formula_cluster:
|
|
1125
|
+
return False
|
|
1126
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1127
|
+
if bbox[2] - bbox[0] > 0.6 * lane_width:
|
|
1128
|
+
return False
|
|
1129
|
+
if bbox[3] - bbox[1] > 3.0 * median_height:
|
|
1130
|
+
return False
|
|
1131
|
+
center_delta_ratio = abs(_bbox_center_x(bbox) - 0.5 * (lane.left + lane.right)) / lane_width
|
|
1132
|
+
left_indent_ratio = (bbox[0] - lane.left) / lane_width
|
|
1133
|
+
right_blank_ratio = (lane.right - bbox[2]) / lane_width
|
|
1134
|
+
# 部分期刊把独立公式按固定左缩进排版;同时要求右侧大留白,排除贴栏正文。
|
|
1135
|
+
deliberately_left_indented = 0.03 <= left_indent_ratio <= 0.25 and right_blank_ratio >= 0.35
|
|
1136
|
+
if center_delta_ratio > 0.2 and not deliberately_left_indented:
|
|
1137
|
+
return False
|
|
1138
|
+
|
|
1139
|
+
candidate_center = _bbox_center_y(bbox)
|
|
1140
|
+
body_rows = [
|
|
1141
|
+
item
|
|
1142
|
+
for item in lane.lines
|
|
1143
|
+
if item[0].source_index != line.source_index
|
|
1144
|
+
and item[1][2] - item[1][0] >= 0.45 * lane_width
|
|
1145
|
+
and 0.8 * median_height <= _line_effective_height(*item) <= 1.25 * median_height
|
|
1146
|
+
]
|
|
1147
|
+
rows_above = [item for item in body_rows if _bbox_center_y(item[1]) < candidate_center]
|
|
1148
|
+
rows_below = [item for item in body_rows if _bbox_center_y(item[1]) > candidate_center]
|
|
1149
|
+
if not rows_above or not rows_below:
|
|
1150
|
+
return False
|
|
1151
|
+
previous = max(rows_above, key=lambda item: _bbox_center_y(item[1]))
|
|
1152
|
+
following = min(rows_below, key=lambda item: _bbox_center_y(item[1]))
|
|
1153
|
+
return (
|
|
1154
|
+
candidate_center - _bbox_center_y(previous[1]) <= 8.0 * median_height
|
|
1155
|
+
and _bbox_center_y(following[1]) - candidate_center <= 8.0 * median_height
|
|
1156
|
+
)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _compact_cluster_has_nearby_number_anchor(
|
|
1160
|
+
candidate: tuple[_LineItem, BBox],
|
|
1161
|
+
lane: _TextLane,
|
|
1162
|
+
median_height: float,
|
|
1163
|
+
) -> bool:
|
|
1164
|
+
"""检测紧凑公式右侧的独立编号,保留给既有空间锚点统一扩张。"""
|
|
1165
|
+
|
|
1166
|
+
line, bbox = candidate
|
|
1167
|
+
return any(
|
|
1168
|
+
other_line.source_index != line.source_index
|
|
1169
|
+
and _standalone_formula_number_marker(other_line.text) is not None
|
|
1170
|
+
and other_bbox[0] > _bbox_center_x(bbox)
|
|
1171
|
+
and abs(_bbox_center_y(other_bbox) - _bbox_center_y(bbox)) <= 2.5 * median_height
|
|
1172
|
+
for other_line, other_bbox in lane.lines
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _is_isolated_unnumbered_formula_line(
|
|
1177
|
+
candidate: tuple[_LineItem, BBox],
|
|
1178
|
+
lane: _TextLane,
|
|
1179
|
+
median_height: float,
|
|
1180
|
+
dominant_body_font: tuple[str, int] | None,
|
|
1181
|
+
) -> bool:
|
|
1182
|
+
"""用低正文覆盖的数学排版和上下正文邻接识别无编号行间公式。"""
|
|
1183
|
+
|
|
1184
|
+
line, bbox = candidate
|
|
1185
|
+
if (
|
|
1186
|
+
line.compact_formula_cluster
|
|
1187
|
+
or dominant_body_font is None
|
|
1188
|
+
or line.font_signature is None
|
|
1189
|
+
or line.font_signature == dominant_body_font
|
|
1190
|
+
or line.font_coverage >= 0.75
|
|
1191
|
+
):
|
|
1192
|
+
return False
|
|
1193
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1194
|
+
line_width = bbox[2] - bbox[0]
|
|
1195
|
+
if not 0.15 * lane_width <= line_width <= 0.8 * lane_width:
|
|
1196
|
+
return False
|
|
1197
|
+
if abs(_bbox_center_x(bbox) - 0.5 * (lane.left + lane.right)) > 0.08 * lane_width:
|
|
1198
|
+
return False
|
|
1199
|
+
if bbox[3] - bbox[1] > 1.8 * median_height:
|
|
1200
|
+
return False
|
|
1201
|
+
if _is_hanging_indent_tail_line(candidate, lane, median_height):
|
|
1202
|
+
return False
|
|
1203
|
+
candidate_center = _bbox_center_y(bbox)
|
|
1204
|
+
if any(
|
|
1205
|
+
other_line.source_index != line.source_index
|
|
1206
|
+
and _standalone_formula_number_marker(other_line.text) is not None
|
|
1207
|
+
and abs(_bbox_center_y(other_bbox) - candidate_center) <= 4.0 * median_height
|
|
1208
|
+
for other_line, other_bbox in lane.lines
|
|
1209
|
+
) or _has_nearby_punctuated_formula_number_anchor(
|
|
1210
|
+
candidate,
|
|
1211
|
+
lane,
|
|
1212
|
+
median_height,
|
|
1213
|
+
):
|
|
1214
|
+
return False
|
|
1215
|
+
body_rows = [
|
|
1216
|
+
item
|
|
1217
|
+
for item in lane.lines
|
|
1218
|
+
if item[0].source_index != line.source_index
|
|
1219
|
+
and item[0].font_signature == dominant_body_font
|
|
1220
|
+
and item[0].font_coverage >= 0.75
|
|
1221
|
+
and item[1][2] - item[1][0] >= 0.45 * lane_width
|
|
1222
|
+
and 0.8 * median_height <= _line_effective_height(*item) <= 1.25 * median_height
|
|
1223
|
+
]
|
|
1224
|
+
rows_above = [item for item in body_rows if _bbox_center_y(item[1]) < candidate_center]
|
|
1225
|
+
rows_below = [item for item in body_rows if _bbox_center_y(item[1]) > candidate_center]
|
|
1226
|
+
if not rows_above or not rows_below:
|
|
1227
|
+
return False
|
|
1228
|
+
previous = max(rows_above, key=lambda item: _bbox_center_y(item[1]))
|
|
1229
|
+
following = min(rows_below, key=lambda item: _bbox_center_y(item[1]))
|
|
1230
|
+
return (
|
|
1231
|
+
candidate_center - _bbox_center_y(previous[1]) <= 4.0 * median_height
|
|
1232
|
+
and _bbox_center_y(following[1]) - candidate_center <= 4.0 * median_height
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _is_hanging_indent_tail_line(
|
|
1237
|
+
candidate: tuple[_LineItem, BBox],
|
|
1238
|
+
lane: _TextLane,
|
|
1239
|
+
median_height: float,
|
|
1240
|
+
) -> bool:
|
|
1241
|
+
"""用相邻行缩进、字体和节奏识别参考条目的悬挂缩进尾行。"""
|
|
1242
|
+
|
|
1243
|
+
line, bbox = candidate
|
|
1244
|
+
if line.font_signature is None:
|
|
1245
|
+
return False
|
|
1246
|
+
candidate_center = _bbox_center_y(bbox)
|
|
1247
|
+
rows_above = [
|
|
1248
|
+
item for item in lane.lines if item[0].source_index != line.source_index and _bbox_center_y(item[1]) < candidate_center
|
|
1249
|
+
]
|
|
1250
|
+
rows_below = [
|
|
1251
|
+
item for item in lane.lines if item[0].source_index != line.source_index and _bbox_center_y(item[1]) > candidate_center
|
|
1252
|
+
]
|
|
1253
|
+
if not rows_above or not rows_below:
|
|
1254
|
+
return False
|
|
1255
|
+
previous = max(rows_above, key=lambda item: _bbox_center_y(item[1]))
|
|
1256
|
+
following = min(rows_below, key=lambda item: _bbox_center_y(item[1]))
|
|
1257
|
+
previous_line, previous_bbox = previous
|
|
1258
|
+
_following_line, following_bbox = following
|
|
1259
|
+
previous_pitch = candidate_center - _bbox_center_y(previous_bbox)
|
|
1260
|
+
following_pitch = _bbox_center_y(following_bbox) - candidate_center
|
|
1261
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1262
|
+
candidate_width = bbox[2] - bbox[0]
|
|
1263
|
+
previous_width = previous_bbox[2] - previous_bbox[0]
|
|
1264
|
+
return (
|
|
1265
|
+
previous_line.font_signature == line.font_signature
|
|
1266
|
+
and abs(previous_bbox[0] - bbox[0]) <= 0.5 * median_height
|
|
1267
|
+
and candidate_width <= 0.9 * previous_width
|
|
1268
|
+
and 0.65 * median_height <= previous_pitch <= 1.6 * median_height
|
|
1269
|
+
and 0.65 * median_height <= following_pitch <= 1.6 * median_height
|
|
1270
|
+
and following_bbox[0] <= bbox[0] - 1.5 * median_height
|
|
1271
|
+
and following_bbox[2] - following_bbox[0] >= 0.75 * lane_width
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _has_nearby_punctuated_formula_number_anchor(
|
|
1276
|
+
candidate: tuple[_LineItem, BBox],
|
|
1277
|
+
lane: _TextLane,
|
|
1278
|
+
median_height: float,
|
|
1279
|
+
) -> bool:
|
|
1280
|
+
"""识别同一公式带右侧仅带标点前缀的编号,避免分式上下行被提前认领。"""
|
|
1281
|
+
|
|
1282
|
+
line, bbox = candidate
|
|
1283
|
+
for other_line, other_bbox in lane.lines:
|
|
1284
|
+
if other_line.source_index == line.source_index:
|
|
1285
|
+
continue
|
|
1286
|
+
parts = _split_trailing_formula_number(other_line.text)
|
|
1287
|
+
if parts is None:
|
|
1288
|
+
continue
|
|
1289
|
+
prefix, _marker = parts
|
|
1290
|
+
compact_prefix = prefix.strip()
|
|
1291
|
+
if not compact_prefix or len(compact_prefix) > 3 or any(character.isalnum() for character in compact_prefix):
|
|
1292
|
+
continue
|
|
1293
|
+
vertical_gap = max(
|
|
1294
|
+
0.0,
|
|
1295
|
+
max(other_bbox[1], bbox[1]) - min(other_bbox[3], bbox[3]),
|
|
1296
|
+
)
|
|
1297
|
+
horizontal_gap = max(0.0, other_bbox[0] - bbox[2])
|
|
1298
|
+
if (
|
|
1299
|
+
other_bbox[0] >= bbox[2] - 0.5 * median_height
|
|
1300
|
+
and vertical_gap <= 0.75 * median_height
|
|
1301
|
+
and horizontal_gap <= 4.0 * median_height
|
|
1302
|
+
):
|
|
1303
|
+
return True
|
|
1304
|
+
return False
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _find_repeated_formula_number_anchors(
|
|
1308
|
+
lane: _TextLane,
|
|
1309
|
+
median_height: float,
|
|
1310
|
+
body_interval: tuple[float, float] | None,
|
|
1311
|
+
) -> list[_FormulaAnchor]:
|
|
1312
|
+
"""用栏右缘重复编号恢复正文区间之外的行间公式锚点。"""
|
|
1313
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1314
|
+
markers = [
|
|
1315
|
+
(line, bbox)
|
|
1316
|
+
for line, bbox in lane.lines
|
|
1317
|
+
if (parts := _split_trailing_formula_number(line.text)) is not None
|
|
1318
|
+
and not parts[0]
|
|
1319
|
+
and abs(lane.right - bbox[2]) <= max(3.0, 0.02 * lane_width)
|
|
1320
|
+
]
|
|
1321
|
+
output: list[_FormulaAnchor] = []
|
|
1322
|
+
for line, bbox in markers:
|
|
1323
|
+
if not any(
|
|
1324
|
+
other_line.source_index != line.source_index
|
|
1325
|
+
and abs(_bbox_center_y(other_bbox) - _bbox_center_y(bbox)) <= 6.0 * median_height
|
|
1326
|
+
for other_line, other_bbox in markers
|
|
1327
|
+
):
|
|
1328
|
+
continue
|
|
1329
|
+
line_height = _line_effective_height(line, bbox)
|
|
1330
|
+
left_peers = [
|
|
1331
|
+
(other_line, other_bbox)
|
|
1332
|
+
for other_line, other_bbox in lane.lines
|
|
1333
|
+
if other_line.source_index != line.source_index
|
|
1334
|
+
and _bbox_center_x(other_bbox) < bbox[0]
|
|
1335
|
+
and _formula_detached_seed_vertical_match(
|
|
1336
|
+
bbox,
|
|
1337
|
+
line_height,
|
|
1338
|
+
other_bbox,
|
|
1339
|
+
_line_effective_height(other_line, other_bbox),
|
|
1340
|
+
)
|
|
1341
|
+
]
|
|
1342
|
+
if len(left_peers) < 2 or not any(
|
|
1343
|
+
peer.formula_candidate_only or peer.compact_formula_cluster or peer.font_coverage < 0.75
|
|
1344
|
+
for peer, _peer_bbox in left_peers
|
|
1345
|
+
):
|
|
1346
|
+
continue
|
|
1347
|
+
center_y = _bbox_center_y(bbox)
|
|
1348
|
+
detached_above = body_interval is None or center_y < body_interval[0]
|
|
1349
|
+
detached_below = body_interval is not None and center_y > body_interval[1]
|
|
1350
|
+
output.append(
|
|
1351
|
+
_FormulaAnchor(
|
|
1352
|
+
line=line,
|
|
1353
|
+
bbox=bbox,
|
|
1354
|
+
detached_below_body=detached_below,
|
|
1355
|
+
detached_above_body=detached_above,
|
|
1356
|
+
repeated_number_band=True,
|
|
1357
|
+
)
|
|
1358
|
+
)
|
|
1359
|
+
return output
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def _find_formula_spatial_anchors(
|
|
1363
|
+
lane: _TextLane,
|
|
1364
|
+
median_height: float,
|
|
1365
|
+
dominant_body_font: tuple[str, int] | None = None,
|
|
1366
|
+
) -> list[_FormulaAnchor]:
|
|
1367
|
+
"""查找栏带右缘短块或带编号后缀的非正文字体公式锚点。"""
|
|
1368
|
+
|
|
1369
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1370
|
+
body_interval = _formula_lane_body_interval(lane, median_height)
|
|
1371
|
+
repeated_anchors = _find_repeated_formula_number_anchors(
|
|
1372
|
+
lane,
|
|
1373
|
+
median_height,
|
|
1374
|
+
body_interval,
|
|
1375
|
+
)
|
|
1376
|
+
if body_interval is None:
|
|
1377
|
+
return _deduplicate_formula_anchors(repeated_anchors, median_height)
|
|
1378
|
+
body_top, body_bottom = body_interval
|
|
1379
|
+
anchors: list[_FormulaAnchor] = list(repeated_anchors)
|
|
1380
|
+
repeated_sources = {anchor.line.source_index for anchor in repeated_anchors}
|
|
1381
|
+
for line, bbox in lane.lines:
|
|
1382
|
+
if line.source_index in repeated_sources:
|
|
1383
|
+
continue
|
|
1384
|
+
line_height = _line_effective_height(line, bbox)
|
|
1385
|
+
line_width = bbox[2] - bbox[0]
|
|
1386
|
+
is_short_right_anchor = line_width <= max(4.0 * line_height, 0.12 * lane_width)
|
|
1387
|
+
has_formula_number_suffix = _split_trailing_formula_number(line.text) is not None
|
|
1388
|
+
is_wide_numbered_anchor = (
|
|
1389
|
+
has_formula_number_suffix
|
|
1390
|
+
and line_width <= 0.75 * lane_width
|
|
1391
|
+
and dominant_body_font is not None
|
|
1392
|
+
and (line.font_signature != dominant_body_font or line.font_coverage < 0.75)
|
|
1393
|
+
)
|
|
1394
|
+
if not is_short_right_anchor and not is_wide_numbered_anchor:
|
|
1395
|
+
continue
|
|
1396
|
+
same_row_fragments = [
|
|
1397
|
+
other_line
|
|
1398
|
+
for other_line, _other_bbox in lane.lines
|
|
1399
|
+
if line.visual_row_id is not None
|
|
1400
|
+
and other_line.visual_row_id == line.visual_row_id
|
|
1401
|
+
and (line.split_from_row or other_line.split_from_row)
|
|
1402
|
+
]
|
|
1403
|
+
if len(same_row_fragments) >= 3 and not any(
|
|
1404
|
+
other_line.font_coverage < 0.75
|
|
1405
|
+
or (dominant_body_font is not None and other_line.font_signature != dominant_body_font)
|
|
1406
|
+
for other_line in same_row_fragments
|
|
1407
|
+
if other_line.source_index != line.source_index
|
|
1408
|
+
):
|
|
1409
|
+
# 一条粗行被多个大空格拆成密集词组时更像普通排版行,不能把末词当作公式编号锚点。
|
|
1410
|
+
continue
|
|
1411
|
+
if _split_visual_row_has_prose_continuation(
|
|
1412
|
+
lane,
|
|
1413
|
+
line,
|
|
1414
|
+
same_row_fragments,
|
|
1415
|
+
median_height,
|
|
1416
|
+
):
|
|
1417
|
+
continue
|
|
1418
|
+
if abs(lane.right - bbox[2]) > max(3.0, 0.02 * lane_width):
|
|
1419
|
+
continue
|
|
1420
|
+
center_y = _bbox_center_y(bbox)
|
|
1421
|
+
detached_below_body = body_bottom < center_y <= body_bottom + 6.0 * median_height
|
|
1422
|
+
detached_above_body = body_top - 6.0 * median_height <= center_y < body_top
|
|
1423
|
+
if not body_top <= center_y <= body_bottom and not detached_below_body and not detached_above_body:
|
|
1424
|
+
continue
|
|
1425
|
+
left_peers = [
|
|
1426
|
+
(other_line, other_bbox)
|
|
1427
|
+
for other_line, other_bbox in lane.lines
|
|
1428
|
+
if other_line.source_index != line.source_index
|
|
1429
|
+
and (
|
|
1430
|
+
other_bbox[2] - other_bbox[0] <= 0.75 * lane_width
|
|
1431
|
+
or _is_wide_tagged_formula_member(
|
|
1432
|
+
line,
|
|
1433
|
+
other_line,
|
|
1434
|
+
other_bbox,
|
|
1435
|
+
lane_width,
|
|
1436
|
+
)
|
|
1437
|
+
)
|
|
1438
|
+
and _bbox_center_x(other_bbox) < bbox[0]
|
|
1439
|
+
and (
|
|
1440
|
+
_formula_detached_seed_vertical_match(
|
|
1441
|
+
bbox,
|
|
1442
|
+
line_height,
|
|
1443
|
+
other_bbox,
|
|
1444
|
+
_line_effective_height(other_line, other_bbox),
|
|
1445
|
+
)
|
|
1446
|
+
if detached_below_body
|
|
1447
|
+
or detached_above_body
|
|
1448
|
+
or _is_wide_tagged_formula_member(
|
|
1449
|
+
line,
|
|
1450
|
+
other_line,
|
|
1451
|
+
other_bbox,
|
|
1452
|
+
lane_width,
|
|
1453
|
+
)
|
|
1454
|
+
else _formula_seed_vertical_match(
|
|
1455
|
+
bbox,
|
|
1456
|
+
line_height,
|
|
1457
|
+
other_bbox,
|
|
1458
|
+
_line_effective_height(other_line, other_bbox),
|
|
1459
|
+
)
|
|
1460
|
+
)
|
|
1461
|
+
]
|
|
1462
|
+
if is_short_right_anchor and not has_formula_number_suffix:
|
|
1463
|
+
# 非编号短锚点必须与左侧主体真正分离;分母字符与正文横向重叠时不能扩张成公式。
|
|
1464
|
+
if any(_bbox_axis_overlap_ratio(bbox, other_bbox, axis="x") >= 0.5 for _other_line, other_bbox in left_peers):
|
|
1465
|
+
continue
|
|
1466
|
+
minimum_gap = max(0.5, 0.1 * line_height)
|
|
1467
|
+
if not any(bbox[0] - other_bbox[2] >= minimum_gap for _other_line, other_bbox in left_peers):
|
|
1468
|
+
continue
|
|
1469
|
+
if left_peers:
|
|
1470
|
+
anchors.append(
|
|
1471
|
+
_FormulaAnchor(
|
|
1472
|
+
line=line,
|
|
1473
|
+
bbox=bbox,
|
|
1474
|
+
detached_below_body=detached_below_body,
|
|
1475
|
+
detached_above_body=detached_above_body,
|
|
1476
|
+
)
|
|
1477
|
+
)
|
|
1478
|
+
return _deduplicate_formula_anchors(anchors, median_height)
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _split_visual_row_has_prose_continuation(
|
|
1482
|
+
lane: _TextLane,
|
|
1483
|
+
anchor_line: _LineItem,
|
|
1484
|
+
same_row_fragments: list[_LineItem],
|
|
1485
|
+
median_height: float,
|
|
1486
|
+
) -> bool:
|
|
1487
|
+
"""识别覆盖大部分栏宽且紧接下一正文行的同行拆分文本。"""
|
|
1488
|
+
|
|
1489
|
+
if len(same_row_fragments) < 3 or anchor_line.visual_row_id is None:
|
|
1490
|
+
return False
|
|
1491
|
+
fragment_sources = {line.source_index for line in same_row_fragments}
|
|
1492
|
+
fragment_geometry = [(line, bbox) for line, bbox in lane.lines if line.source_index in fragment_sources]
|
|
1493
|
+
if len(fragment_geometry) < 3:
|
|
1494
|
+
return False
|
|
1495
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1496
|
+
row_bbox = _bbox_union_many([bbox for _line, bbox in fragment_geometry])
|
|
1497
|
+
if row_bbox[2] - row_bbox[0] < 0.75 * lane_width:
|
|
1498
|
+
return False
|
|
1499
|
+
row_center = statistics.median(_bbox_center_y(bbox) for _line, bbox in fragment_geometry)
|
|
1500
|
+
if any(
|
|
1501
|
+
abs(_bbox_center_y(bbox) - row_center) > 0.25 * median_height
|
|
1502
|
+
or not 0.7 * median_height <= _line_effective_height(line, bbox) <= 1.3 * median_height
|
|
1503
|
+
for line, bbox in fragment_geometry
|
|
1504
|
+
):
|
|
1505
|
+
return False
|
|
1506
|
+
following_rows = [
|
|
1507
|
+
(line, bbox)
|
|
1508
|
+
for line, bbox in lane.lines
|
|
1509
|
+
if line.source_index not in fragment_sources and _bbox_center_y(bbox) > row_center + 0.5 * median_height
|
|
1510
|
+
]
|
|
1511
|
+
if not following_rows:
|
|
1512
|
+
return False
|
|
1513
|
+
following_line, following_bbox = min(
|
|
1514
|
+
following_rows,
|
|
1515
|
+
key=lambda item: (_bbox_center_y(item[1]), item[1][0]),
|
|
1516
|
+
)
|
|
1517
|
+
following_height = _line_effective_height(
|
|
1518
|
+
following_line,
|
|
1519
|
+
following_bbox,
|
|
1520
|
+
)
|
|
1521
|
+
return (
|
|
1522
|
+
following_bbox[2] - following_bbox[0] >= 0.6 * lane_width
|
|
1523
|
+
and abs(following_bbox[0] - lane.left) <= 0.75 * median_height
|
|
1524
|
+
and 0.75 * median_height <= following_height <= 1.3 * median_height
|
|
1525
|
+
and following_bbox[1] - row_bbox[3] <= 1.25 * median_height
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _infer_formula_body_font(
|
|
1530
|
+
lane: _TextLane,
|
|
1531
|
+
median_height: float,
|
|
1532
|
+
) -> tuple[str, int] | None:
|
|
1533
|
+
"""从栏内常规宽正文行推断 dominant font,供公式扩张排除正文前缀。"""
|
|
1534
|
+
|
|
1535
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1536
|
+
font_counts: dict[tuple[str, int], int] = {}
|
|
1537
|
+
for line, bbox in lane.lines:
|
|
1538
|
+
line_height = _line_effective_height(line, bbox)
|
|
1539
|
+
if bbox[2] - bbox[0] < 0.35 * lane_width:
|
|
1540
|
+
continue
|
|
1541
|
+
if not 0.8 * median_height <= line_height <= 1.25 * median_height:
|
|
1542
|
+
continue
|
|
1543
|
+
if line.font_signature is None or line.font_coverage < 0.75:
|
|
1544
|
+
continue
|
|
1545
|
+
font_counts[line.font_signature] = font_counts.get(line.font_signature, 0) + 1
|
|
1546
|
+
if not font_counts:
|
|
1547
|
+
return None
|
|
1548
|
+
return max(font_counts.items(), key=lambda item: (item[1], item[0]))[0]
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
def _formula_lane_body_interval(
|
|
1552
|
+
lane: _TextLane,
|
|
1553
|
+
median_height: float,
|
|
1554
|
+
) -> tuple[float, float] | None:
|
|
1555
|
+
"""用连续出现的常规宽行确定栏带正文纵向范围,排除孤立页眉。"""
|
|
1556
|
+
|
|
1557
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1558
|
+
body_lines = sorted(
|
|
1559
|
+
(item for item in lane.lines if item[1][2] - item[1][0] >= max(4.0 * _line_effective_height(*item), 0.35 * lane_width)),
|
|
1560
|
+
key=lambda item: (item[1][1], item[1][0]),
|
|
1561
|
+
)
|
|
1562
|
+
if len(body_lines) < 3:
|
|
1563
|
+
return None
|
|
1564
|
+
dense_lines: list[tuple[_LineItem, BBox]] = []
|
|
1565
|
+
for index, item in enumerate(body_lines):
|
|
1566
|
+
has_close_previous = index > 0 and item[1][1] - body_lines[index - 1][1][3] <= 1.5 * median_height
|
|
1567
|
+
has_close_next = index + 1 < len(body_lines) and body_lines[index + 1][1][1] - item[1][3] <= 1.5 * median_height
|
|
1568
|
+
if has_close_previous or has_close_next:
|
|
1569
|
+
dense_lines.append(item)
|
|
1570
|
+
if len(dense_lines) < 3:
|
|
1571
|
+
return None
|
|
1572
|
+
return (
|
|
1573
|
+
min(bbox[1] for _line, bbox in dense_lines),
|
|
1574
|
+
max(bbox[3] for _line, bbox in dense_lines),
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def _deduplicate_formula_anchors(
|
|
1579
|
+
anchors: list[_FormulaAnchor],
|
|
1580
|
+
median_height: float,
|
|
1581
|
+
) -> list[_FormulaAnchor]:
|
|
1582
|
+
"""同一高度出现多个右缘短块时只保留最靠右的空间锚点。"""
|
|
1583
|
+
|
|
1584
|
+
if not anchors:
|
|
1585
|
+
return []
|
|
1586
|
+
output: list[_FormulaAnchor] = []
|
|
1587
|
+
tolerance = max(1.5, 0.35 * median_height)
|
|
1588
|
+
for anchor in sorted(anchors, key=lambda item: (_bbox_center_y(item.bbox), -item.bbox[2])):
|
|
1589
|
+
if output and abs(_bbox_center_y(anchor.bbox) - _bbox_center_y(output[-1].bbox)) <= tolerance:
|
|
1590
|
+
if (anchor.bbox[2], -anchor.bbox[0]) > (output[-1].bbox[2], -output[-1].bbox[0]):
|
|
1591
|
+
output[-1] = anchor
|
|
1592
|
+
continue
|
|
1593
|
+
output.append(anchor)
|
|
1594
|
+
return output
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def _grow_formula_spatial_component(
|
|
1598
|
+
lane: _TextLane,
|
|
1599
|
+
anchor: _FormulaAnchor,
|
|
1600
|
+
band_top: float,
|
|
1601
|
+
band_bottom: float,
|
|
1602
|
+
claimed_source_indices: set[int],
|
|
1603
|
+
table_bboxes: list[BBox],
|
|
1604
|
+
dominant_body_font: tuple[str, int] | None,
|
|
1605
|
+
median_height: float,
|
|
1606
|
+
) -> list[tuple[_LineItem, BBox]]:
|
|
1607
|
+
"""从右缘锚点的左侧首批成员出发,按二维邻接扩展公式分量。"""
|
|
1608
|
+
|
|
1609
|
+
anchor_line, anchor_bbox = anchor.line, anchor.bbox
|
|
1610
|
+
anchor_geometry = (anchor_line, anchor_bbox)
|
|
1611
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1612
|
+
candidates = [
|
|
1613
|
+
item
|
|
1614
|
+
for item in lane.lines
|
|
1615
|
+
if item[0].source_index not in claimed_source_indices
|
|
1616
|
+
and (
|
|
1617
|
+
item[1][2] - item[1][0] <= 0.8 * lane_width
|
|
1618
|
+
or _is_wide_tagged_formula_member(
|
|
1619
|
+
anchor_line,
|
|
1620
|
+
item[0],
|
|
1621
|
+
item[1],
|
|
1622
|
+
lane_width,
|
|
1623
|
+
)
|
|
1624
|
+
)
|
|
1625
|
+
and band_top <= _bbox_center_y(item[1]) <= band_bottom
|
|
1626
|
+
and not _is_formula_body_barrier(
|
|
1627
|
+
item,
|
|
1628
|
+
lane,
|
|
1629
|
+
dominant_body_font,
|
|
1630
|
+
median_height,
|
|
1631
|
+
)
|
|
1632
|
+
and not _is_formula_title_barrier(
|
|
1633
|
+
item,
|
|
1634
|
+
lane,
|
|
1635
|
+
dominant_body_font,
|
|
1636
|
+
median_height,
|
|
1637
|
+
)
|
|
1638
|
+
and not _is_formula_body_prefix(
|
|
1639
|
+
item,
|
|
1640
|
+
lane,
|
|
1641
|
+
anchor_geometry,
|
|
1642
|
+
dominant_body_font,
|
|
1643
|
+
median_height,
|
|
1644
|
+
minimum_font_coverage=0.5 if anchor.repeated_number_band else 0.75,
|
|
1645
|
+
)
|
|
1646
|
+
]
|
|
1647
|
+
seeds = [
|
|
1648
|
+
item
|
|
1649
|
+
for item in candidates
|
|
1650
|
+
if item[0].source_index != anchor_line.source_index
|
|
1651
|
+
and _bbox_center_x(item[1]) < anchor_bbox[0]
|
|
1652
|
+
and (
|
|
1653
|
+
_formula_detached_seed_vertical_match(
|
|
1654
|
+
anchor_bbox,
|
|
1655
|
+
_line_effective_height(anchor_line, anchor_bbox),
|
|
1656
|
+
item[1],
|
|
1657
|
+
_line_effective_height(*item),
|
|
1658
|
+
)
|
|
1659
|
+
if anchor.detached_below_body
|
|
1660
|
+
or anchor.detached_above_body
|
|
1661
|
+
or _is_wide_tagged_formula_member(
|
|
1662
|
+
anchor_line,
|
|
1663
|
+
item[0],
|
|
1664
|
+
item[1],
|
|
1665
|
+
lane_width,
|
|
1666
|
+
)
|
|
1667
|
+
else _formula_seed_vertical_match(
|
|
1668
|
+
anchor_bbox,
|
|
1669
|
+
_line_effective_height(anchor_line, anchor_bbox),
|
|
1670
|
+
item[1],
|
|
1671
|
+
_line_effective_height(*item),
|
|
1672
|
+
)
|
|
1673
|
+
)
|
|
1674
|
+
and not _connection_crosses_table(anchor_line.bbox, item[0].bbox, table_bboxes)
|
|
1675
|
+
]
|
|
1676
|
+
if not seeds:
|
|
1677
|
+
return []
|
|
1678
|
+
|
|
1679
|
+
members = [anchor_geometry, *seeds]
|
|
1680
|
+
member_sources = {line.source_index for line, _bbox in members}
|
|
1681
|
+
changed = True
|
|
1682
|
+
while changed:
|
|
1683
|
+
changed = False
|
|
1684
|
+
for candidate in candidates:
|
|
1685
|
+
candidate_line, candidate_bbox = candidate
|
|
1686
|
+
if candidate_line.source_index in member_sources:
|
|
1687
|
+
continue
|
|
1688
|
+
if any(
|
|
1689
|
+
_formula_lines_are_connected(
|
|
1690
|
+
member_line,
|
|
1691
|
+
member_bbox,
|
|
1692
|
+
candidate_line,
|
|
1693
|
+
candidate_bbox,
|
|
1694
|
+
table_bboxes,
|
|
1695
|
+
)
|
|
1696
|
+
for member_line, member_bbox in members
|
|
1697
|
+
):
|
|
1698
|
+
members.append(candidate)
|
|
1699
|
+
member_sources.add(candidate_line.source_index)
|
|
1700
|
+
changed = True
|
|
1701
|
+
return members
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _is_formula_body_barrier(
|
|
1705
|
+
candidate: tuple[_LineItem, BBox],
|
|
1706
|
+
lane: _TextLane,
|
|
1707
|
+
dominant_body_font: tuple[str, int] | None,
|
|
1708
|
+
median_height: float,
|
|
1709
|
+
) -> bool:
|
|
1710
|
+
"""识别具有稳定正文排版的行,阻止公式分量吸收正文尾行。"""
|
|
1711
|
+
|
|
1712
|
+
line, bbox = candidate
|
|
1713
|
+
if not line.style_scale_repaired:
|
|
1714
|
+
if dominant_body_font is None:
|
|
1715
|
+
return False
|
|
1716
|
+
line_height = _line_effective_height(line, bbox)
|
|
1717
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1718
|
+
return (
|
|
1719
|
+
line.font_signature == dominant_body_font
|
|
1720
|
+
and line.font_coverage >= 0.75
|
|
1721
|
+
and bbox[2] - bbox[0] >= 0.3 * lane_width
|
|
1722
|
+
and 0.8 * median_height <= line_height <= 1.25 * median_height
|
|
1723
|
+
)
|
|
1724
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1725
|
+
body_style_scales = [
|
|
1726
|
+
_line_style_scale(other_line, other_bbox)
|
|
1727
|
+
for other_line, other_bbox in lane.lines
|
|
1728
|
+
if other_bbox[2] - other_bbox[0] >= 0.35 * lane_width and not _formula_line_has_math_operator(other_line.text)
|
|
1729
|
+
]
|
|
1730
|
+
body_scale = statistics.median(body_style_scales) if body_style_scales else median_height
|
|
1731
|
+
line_scale = _line_style_scale(line, bbox)
|
|
1732
|
+
line_width = bbox[2] - bbox[0]
|
|
1733
|
+
left_aligned = abs(bbox[0] - lane.left) <= max(3.0, 0.75 * body_scale)
|
|
1734
|
+
return (
|
|
1735
|
+
not _formula_line_has_math_operator(line.text)
|
|
1736
|
+
and (line_width >= 0.3 * lane_width or (left_aligned and line_width >= 0.08 * lane_width))
|
|
1737
|
+
and 0.75 * body_scale <= line_scale <= 1.35 * body_scale
|
|
1738
|
+
and (
|
|
1739
|
+
(dominant_body_font is not None and line.font_signature == dominant_body_font and line.font_coverage >= 0.75)
|
|
1740
|
+
or left_aligned
|
|
1741
|
+
)
|
|
1742
|
+
)
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _is_formula_title_barrier(
|
|
1746
|
+
candidate: tuple[_LineItem, BBox],
|
|
1747
|
+
lane: _TextLane,
|
|
1748
|
+
dominant_body_font: tuple[str, int] | None,
|
|
1749
|
+
median_height: float,
|
|
1750
|
+
) -> bool:
|
|
1751
|
+
"""用左对齐、字号突变和字体变化隔离公式下方的章节标题。"""
|
|
1752
|
+
|
|
1753
|
+
if dominant_body_font is None:
|
|
1754
|
+
return False
|
|
1755
|
+
line, bbox = candidate
|
|
1756
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1757
|
+
line_height = _line_effective_height(line, bbox)
|
|
1758
|
+
return (
|
|
1759
|
+
line.font_signature is not None
|
|
1760
|
+
and line.font_signature != dominant_body_font
|
|
1761
|
+
and line.font_coverage >= 0.75
|
|
1762
|
+
and 1.1 * median_height <= line_height <= 1.6 * median_height
|
|
1763
|
+
and bbox[2] - bbox[0] >= 0.25 * lane_width
|
|
1764
|
+
and abs(bbox[0] - lane.left) <= median_height
|
|
1765
|
+
)
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def _is_formula_body_prefix(
|
|
1769
|
+
candidate: tuple[_LineItem, BBox],
|
|
1770
|
+
lane: _TextLane,
|
|
1771
|
+
anchor: tuple[_LineItem, BBox],
|
|
1772
|
+
dominant_body_font: tuple[str, int] | None,
|
|
1773
|
+
median_height: float,
|
|
1774
|
+
*,
|
|
1775
|
+
minimum_font_coverage: float = 0.75,
|
|
1776
|
+
) -> bool:
|
|
1777
|
+
"""识别锚点上方左对齐的常规正文行,防止公式空间扩张越界认领。"""
|
|
1778
|
+
|
|
1779
|
+
line, bbox = candidate
|
|
1780
|
+
anchor_line, anchor_bbox = anchor
|
|
1781
|
+
if line.formula_candidate_only:
|
|
1782
|
+
return False
|
|
1783
|
+
line_height = _line_effective_height(line, bbox)
|
|
1784
|
+
anchor_height = _line_effective_height(anchor_line, anchor_bbox)
|
|
1785
|
+
if _bbox_center_y(bbox) > _bbox_center_y(anchor_bbox) - 0.2 * max(line_height, anchor_height):
|
|
1786
|
+
return False
|
|
1787
|
+
if abs(bbox[0] - lane.left) > max(3.0, 0.75 * median_height):
|
|
1788
|
+
return False
|
|
1789
|
+
if not line.style_scale_repaired and not anchor_line.style_scale_repaired:
|
|
1790
|
+
return (
|
|
1791
|
+
dominant_body_font is not None
|
|
1792
|
+
and line.font_signature == dominant_body_font
|
|
1793
|
+
and line.font_coverage >= minimum_font_coverage
|
|
1794
|
+
and 0.8 * median_height <= line_height <= 1.25 * median_height
|
|
1795
|
+
)
|
|
1796
|
+
lane_width = max(0.1, lane.right - lane.left)
|
|
1797
|
+
if bbox[2] - bbox[0] < 0.08 * lane_width:
|
|
1798
|
+
return False
|
|
1799
|
+
if _formula_line_has_math_operator(line.text):
|
|
1800
|
+
return False
|
|
1801
|
+
body_style_scales = [
|
|
1802
|
+
_line_style_scale(other_line, other_bbox)
|
|
1803
|
+
for other_line, other_bbox in lane.lines
|
|
1804
|
+
if other_bbox[2] - other_bbox[0] >= 0.35 * lane_width
|
|
1805
|
+
and abs(other_bbox[0] - lane.left) <= max(3.0, 0.75 * median_height)
|
|
1806
|
+
and not _formula_line_has_math_operator(other_line.text)
|
|
1807
|
+
]
|
|
1808
|
+
body_scale = statistics.median(body_style_scales) if body_style_scales else median_height
|
|
1809
|
+
line_scale = _line_style_scale(line, bbox)
|
|
1810
|
+
return 0.75 * body_scale <= line_scale <= 1.35 * body_scale and (
|
|
1811
|
+
dominant_body_font is None or line.font_signature == dominant_body_font or line.font_coverage <= minimum_font_coverage
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def _formula_detached_seed_vertical_match(
|
|
1816
|
+
anchor_bbox: BBox,
|
|
1817
|
+
anchor_height: float,
|
|
1818
|
+
candidate_bbox: BBox,
|
|
1819
|
+
candidate_height: float,
|
|
1820
|
+
) -> bool:
|
|
1821
|
+
"""放宽正文密集区下方锚点的同高匹配,以接纳多行分段公式底部。"""
|
|
1822
|
+
|
|
1823
|
+
has_vertical_overlap = min(anchor_bbox[3], candidate_bbox[3]) > max(anchor_bbox[1], candidate_bbox[1])
|
|
1824
|
+
center_difference = abs(_bbox_center_y(anchor_bbox) - _bbox_center_y(candidate_bbox))
|
|
1825
|
+
return has_vertical_overlap or center_difference <= max(anchor_height, candidate_height)
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def _formula_seed_vertical_match(
|
|
1829
|
+
anchor_bbox: BBox,
|
|
1830
|
+
anchor_height: float,
|
|
1831
|
+
candidate_bbox: BBox,
|
|
1832
|
+
candidate_height: float,
|
|
1833
|
+
) -> bool:
|
|
1834
|
+
"""判断左侧短行是否与右缘锚点处在同一公式高度带。"""
|
|
1835
|
+
|
|
1836
|
+
overlap_ratio = _bbox_axis_overlap_ratio(anchor_bbox, candidate_bbox, axis="y")
|
|
1837
|
+
center_difference = abs(_bbox_center_y(anchor_bbox) - _bbox_center_y(candidate_bbox))
|
|
1838
|
+
return overlap_ratio >= 0.3 or center_difference <= 0.6 * max(anchor_height, candidate_height)
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _formula_lines_are_connected(
|
|
1842
|
+
first_line: _LineItem,
|
|
1843
|
+
first_bbox: BBox,
|
|
1844
|
+
second_line: _LineItem,
|
|
1845
|
+
second_bbox: BBox,
|
|
1846
|
+
table_bboxes: list[BBox],
|
|
1847
|
+
) -> bool:
|
|
1848
|
+
"""按垂直接近和水平覆盖判断两个公式成员是否空间连通。"""
|
|
1849
|
+
|
|
1850
|
+
if first_line.angle != second_line.angle:
|
|
1851
|
+
return False
|
|
1852
|
+
if _connection_crosses_table(first_line.bbox, second_line.bbox, table_bboxes):
|
|
1853
|
+
return False
|
|
1854
|
+
first_height = _line_effective_height(first_line, first_bbox)
|
|
1855
|
+
second_height = _line_effective_height(second_line, second_bbox)
|
|
1856
|
+
pair_height = max(first_height, second_height)
|
|
1857
|
+
vertical_overlap = _bbox_axis_overlap_ratio(first_bbox, second_bbox, axis="y")
|
|
1858
|
+
vertical_gap = max(first_bbox[1] - second_bbox[3], second_bbox[1] - first_bbox[3], 0.0)
|
|
1859
|
+
if vertical_overlap < 0.2 and vertical_gap > 0.6 * pair_height:
|
|
1860
|
+
return False
|
|
1861
|
+
horizontal_overlap = _bbox_axis_overlap_ratio(first_bbox, second_bbox, axis="x")
|
|
1862
|
+
horizontal_gap = max(first_bbox[0] - second_bbox[2], second_bbox[0] - first_bbox[2], 0.0)
|
|
1863
|
+
return horizontal_overlap > 0.0 or horizontal_gap <= 1.5 * pair_height
|
|
1864
|
+
|
|
1865
|
+
|
|
1866
|
+
def _is_detached_formula_sidecar(
|
|
1867
|
+
anchor: tuple[_LineItem, BBox],
|
|
1868
|
+
members: list[tuple[_LineItem, BBox]],
|
|
1869
|
+
median_height: float,
|
|
1870
|
+
) -> bool:
|
|
1871
|
+
"""仅依据 bbox 判断右侧锚点是否为与公式主体分离的窄幅 sidecar。"""
|
|
1872
|
+
|
|
1873
|
+
anchor_line, anchor_bbox = anchor
|
|
1874
|
+
body_bboxes = [bbox for line, bbox in members if line.source_index != anchor_line.source_index]
|
|
1875
|
+
if not body_bboxes:
|
|
1876
|
+
return False
|
|
1877
|
+
|
|
1878
|
+
body_bbox = _bbox_union_many(body_bboxes)
|
|
1879
|
+
component_bbox = _bbox_union(body_bbox, anchor_bbox)
|
|
1880
|
+
effective_height = max(0.1, median_height)
|
|
1881
|
+
anchor_width = max(0.0, anchor_bbox[2] - anchor_bbox[0])
|
|
1882
|
+
component_width = max(0.1, component_bbox[2] - component_bbox[0])
|
|
1883
|
+
horizontal_gap = anchor_bbox[0] - body_bbox[2]
|
|
1884
|
+
right_tolerance = max(0.5, 0.1 * effective_height)
|
|
1885
|
+
minimum_gap = max(2.5 * effective_height, 0.08 * component_width)
|
|
1886
|
+
|
|
1887
|
+
return (
|
|
1888
|
+
anchor_bbox[0] >= body_bbox[2]
|
|
1889
|
+
and anchor_bbox[2] >= component_bbox[2] - right_tolerance
|
|
1890
|
+
and anchor_width <= 2.0 * effective_height
|
|
1891
|
+
and horizontal_gap > minimum_gap
|
|
1892
|
+
)
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def _split_trailing_formula_number(text: str) -> tuple[str, str] | None:
|
|
1896
|
+
"""拆出右缘文本末尾的圆括号公式序号,并保留序号前的标点或正文。"""
|
|
1897
|
+
|
|
1898
|
+
match = _FORMULA_NUMBER_SUFFIX_RE.fullmatch(str(text or "").strip())
|
|
1899
|
+
if match is None:
|
|
1900
|
+
return None
|
|
1901
|
+
return match.group("prefix").rstrip(), match.group("marker").strip()
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _formula_members_to_block(
|
|
1905
|
+
members: list[tuple[_LineItem, BBox]],
|
|
1906
|
+
page_size: tuple[float, float],
|
|
1907
|
+
angle: int,
|
|
1908
|
+
*,
|
|
1909
|
+
anchor_source_index: int,
|
|
1910
|
+
) -> dict[str, Any] | None:
|
|
1911
|
+
"""把公式空间分量按视觉行聚类,将编号序列化为 tag 并后置其他 sidecar。"""
|
|
1912
|
+
|
|
1913
|
+
anchor_line = next(
|
|
1914
|
+
(line for line, _bbox in members if line.source_index == anchor_source_index),
|
|
1915
|
+
None,
|
|
1916
|
+
)
|
|
1917
|
+
anchor_formula_number_parts = _split_trailing_formula_number(anchor_line.text) if anchor_line is not None else None
|
|
1918
|
+
heights = [_line_effective_height(line, bbox) for line, bbox in members]
|
|
1919
|
+
median_height = statistics.median(heights) if heights else 1.0
|
|
1920
|
+
row_tolerance = max(1.5, 0.35 * median_height)
|
|
1921
|
+
rows: list[list[tuple[_LineItem, BBox]]] = []
|
|
1922
|
+
for member in sorted(members, key=lambda item: (_bbox_center_y(item[1]), item[1][0], item[0].source_index)):
|
|
1923
|
+
if not rows:
|
|
1924
|
+
rows.append([member])
|
|
1925
|
+
continue
|
|
1926
|
+
row_center = statistics.median(_bbox_center_y(bbox) for _line, bbox in rows[-1])
|
|
1927
|
+
if abs(_bbox_center_y(member[1]) - row_center) <= row_tolerance:
|
|
1928
|
+
rows[-1].append(member)
|
|
1929
|
+
else:
|
|
1930
|
+
rows.append([member])
|
|
1931
|
+
|
|
1932
|
+
trailing_sidecar_content: str | None = None
|
|
1933
|
+
# 右侧 sidecar 按视觉 y 常落在分式中部;仅在其后仍有公式行时转为逻辑末行。
|
|
1934
|
+
for row_index, row in enumerate(rows[:-1]):
|
|
1935
|
+
anchor_member = next(
|
|
1936
|
+
(member for member in row if member[0].source_index == anchor_source_index),
|
|
1937
|
+
None,
|
|
1938
|
+
)
|
|
1939
|
+
if anchor_member is None:
|
|
1940
|
+
continue
|
|
1941
|
+
formula_number_parts = _split_trailing_formula_number(anchor_member[0].text)
|
|
1942
|
+
if formula_number_parts is not None:
|
|
1943
|
+
prefix, marker = formula_number_parts
|
|
1944
|
+
rows[row_index] = [
|
|
1945
|
+
(
|
|
1946
|
+
(replace(member[0], text=prefix), member[1])
|
|
1947
|
+
if member[0].source_index == anchor_source_index and prefix
|
|
1948
|
+
else member
|
|
1949
|
+
)
|
|
1950
|
+
for member in row
|
|
1951
|
+
if member[0].source_index != anchor_source_index or prefix
|
|
1952
|
+
]
|
|
1953
|
+
trailing_sidecar_content = marker
|
|
1954
|
+
elif _is_detached_formula_sidecar(anchor_member, members, median_height):
|
|
1955
|
+
rows[row_index] = [member for member in row if member[0].source_index != anchor_source_index]
|
|
1956
|
+
trailing_sidecar_content = anchor_member[0].text.strip()
|
|
1957
|
+
break
|
|
1958
|
+
|
|
1959
|
+
row_contents = [_join_formula_visual_row(row, page_size) for row in rows if row]
|
|
1960
|
+
if trailing_sidecar_content is not None:
|
|
1961
|
+
row_contents.append(trailing_sidecar_content)
|
|
1962
|
+
content = _sanitize_pdf_control_text("\n".join(filter(None, row_contents)), preserve_newlines=True)
|
|
1963
|
+
if anchor_formula_number_parts is not None:
|
|
1964
|
+
_anchor_prefix, tag_content = anchor_formula_number_parts
|
|
1965
|
+
stripped_content = content.rstrip()
|
|
1966
|
+
if stripped_content.endswith(tag_content):
|
|
1967
|
+
formula_content = stripped_content[: -len(tag_content)].rstrip()
|
|
1968
|
+
tagged_content = build_tagged_formula_content(formula_content, tag_content)
|
|
1969
|
+
if tagged_content is not None:
|
|
1970
|
+
content = tagged_content
|
|
1971
|
+
if not content.strip():
|
|
1972
|
+
return None
|
|
1973
|
+
block = {
|
|
1974
|
+
"type": "equation",
|
|
1975
|
+
"bbox": _bbox_union_many([line.bbox for line, _bbox in members]),
|
|
1976
|
+
"angle": angle,
|
|
1977
|
+
"content": content,
|
|
1978
|
+
}
|
|
1979
|
+
tight_output_bbox = _lines_tight_output_bbox(
|
|
1980
|
+
[line for line, _bbox in members],
|
|
1981
|
+
page_size,
|
|
1982
|
+
)
|
|
1983
|
+
if tight_output_bbox is not None:
|
|
1984
|
+
block["_tight_output_bbox"] = tight_output_bbox
|
|
1985
|
+
return block
|