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,1004 @@
|
|
|
1
|
+
"""将 PDF 原生字符重建为带排版信息的视觉文本行。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import statistics
|
|
8
|
+
import unicodedata
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Literal, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from ....document.pdf.text.contracts import Char
|
|
14
|
+
|
|
15
|
+
from ....schema import BBox
|
|
16
|
+
from ....document.pdf.document import PDFDocument, PDFDrawingLine
|
|
17
|
+
|
|
18
|
+
from .typography import _normalized_font_family
|
|
19
|
+
from .models import _AxisLine, _LineItem
|
|
20
|
+
from .geometry import (
|
|
21
|
+
_clip_validated_bbox,
|
|
22
|
+
_bbox_center_y,
|
|
23
|
+
_bbox_union,
|
|
24
|
+
_bbox_union_many,
|
|
25
|
+
_clip_bbox,
|
|
26
|
+
_coerce_bbox,
|
|
27
|
+
_horizontal_bbox_gap,
|
|
28
|
+
_rotate_bbox_from_upright,
|
|
29
|
+
_rotate_bbox_to_upright,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_PDF_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
34
|
+
_PDF_LINE_END_SOFT_HYPHEN_RE = re.compile(r"(?<=[A-Za-z])[\x02\u00ad](?=[\t ]*(?:\n|$))")
|
|
35
|
+
_INLINE_REFERENCE_MARKER_RE = re.compile(
|
|
36
|
+
r"^[\[(([]\s*\d{1,4}\s*[\]))]]$",
|
|
37
|
+
)
|
|
38
|
+
# Unicode Zs 空格在 model_list 中只承担分词作用,统一成可互操作的 ASCII 空格。
|
|
39
|
+
_PDF_SEPARATOR_SPACE_CHARS = "\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000"
|
|
40
|
+
_PDF_UNICODE_TEXT_TRANSLATION = str.maketrans(
|
|
41
|
+
{
|
|
42
|
+
**dict.fromkeys(_PDF_SEPARATOR_SPACE_CHARS, " "),
|
|
43
|
+
"\u0085": "\n",
|
|
44
|
+
"\u2028": "\n",
|
|
45
|
+
"\u2029": "\n",
|
|
46
|
+
"\u200b": None,
|
|
47
|
+
"\u2060": None,
|
|
48
|
+
"\ufeff": None,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
_PDFTEXT_ROTATION_SPLIT_THRESHOLD_DEGREES = 44.9
|
|
52
|
+
_PDFTEXT_LINE_ANGLE_TOLERANCE_DEGREES = 0.1
|
|
53
|
+
_SUPPORTED_PDFTEXT_LINE_ANGLES = (0.0, 90.0, 270.0)
|
|
54
|
+
_PDFTEXT_SHEARED_HORIZONTAL_MAX_ANGLE_DEGREES = 30.0
|
|
55
|
+
_PDFTEXT_HORIZONTAL_BASELINE_MAX_ANGLE_DEGREES = 2.0
|
|
56
|
+
_PDFTEXT_HORIZONTAL_BASELINE_MAX_DISPERSION_RATIO = 0.75
|
|
57
|
+
_PDFTEXT_FORMULA_OPERATOR_CHARS = frozenset("=∑∫√±×÷")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class _NativeVisualResplit:
|
|
62
|
+
"""保存一个粗行及其按 canonical 字符框重切后的成员。"""
|
|
63
|
+
|
|
64
|
+
source: _LineItem
|
|
65
|
+
members: tuple[_LineItem, ...]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_native_line_items(
|
|
69
|
+
pdf_lines: Sequence[dict[str, Any]],
|
|
70
|
+
page_size: tuple[float, float],
|
|
71
|
+
*,
|
|
72
|
+
page_rotation: int = 0,
|
|
73
|
+
supported_angles: Sequence[float] = _SUPPORTED_PDFTEXT_LINE_ANGLES,
|
|
74
|
+
) -> list[_LineItem]:
|
|
75
|
+
"""按指定视觉方向将 pdftext 粗行精修成字符间隙分隔的视觉 run。"""
|
|
76
|
+
|
|
77
|
+
normal_items: list[_LineItem] = []
|
|
78
|
+
formula_items: list[_LineItem] = []
|
|
79
|
+
supported_lines: list[tuple[dict[str, Any], int, bool]] = []
|
|
80
|
+
for pdf_line in pdf_lines:
|
|
81
|
+
for child_line in _split_pdftext_line_by_rotation(pdf_line):
|
|
82
|
+
visual_angle = _resolve_pdftext_line_angle(
|
|
83
|
+
child_line,
|
|
84
|
+
page_rotation=page_rotation,
|
|
85
|
+
supported_angles=supported_angles,
|
|
86
|
+
)
|
|
87
|
+
formula_candidate_only = False
|
|
88
|
+
if visual_angle is None:
|
|
89
|
+
visual_angle = _resolve_pdftext_formula_candidate_angle(
|
|
90
|
+
child_line,
|
|
91
|
+
page_rotation=page_rotation,
|
|
92
|
+
supported_angles=supported_angles,
|
|
93
|
+
)
|
|
94
|
+
formula_candidate_only = visual_angle is not None
|
|
95
|
+
if visual_angle is not None:
|
|
96
|
+
supported_lines.append((child_line, visual_angle, formula_candidate_only))
|
|
97
|
+
for visual_row_id, (pdf_line, visual_angle, formula_candidate_only) in enumerate(supported_lines):
|
|
98
|
+
bbox = _clip_validated_bbox(_coerce_bbox(pdf_line.get("bbox")), page_size)
|
|
99
|
+
if bbox is None:
|
|
100
|
+
continue
|
|
101
|
+
spans = pdf_line.get("spans") or []
|
|
102
|
+
chars = [char for span in spans for char in (span.get("chars") or []) if isinstance(char, dict)]
|
|
103
|
+
coarse_item = _LineItem(
|
|
104
|
+
text="".join(str(span.get("text") or "") for span in spans),
|
|
105
|
+
bbox=bbox,
|
|
106
|
+
angle=visual_angle,
|
|
107
|
+
source_index=-1,
|
|
108
|
+
chars=chars,
|
|
109
|
+
visual_row_id=visual_row_id,
|
|
110
|
+
formula_candidate_only=formula_candidate_only,
|
|
111
|
+
)
|
|
112
|
+
target = formula_items if formula_candidate_only else normal_items
|
|
113
|
+
target.extend(_split_native_visual_runs(coarse_item, page_size))
|
|
114
|
+
|
|
115
|
+
stable_items = _merge_native_inline_scripts(normal_items, page_size)
|
|
116
|
+
for source_index, item in enumerate(stable_items):
|
|
117
|
+
# source_index 必须在页内唯一,表格投影和失败回滚都依赖该精确成员标识。
|
|
118
|
+
item.source_index = source_index
|
|
119
|
+
formula_items = _merge_native_inline_scripts(formula_items, page_size)
|
|
120
|
+
next_source_index = len(stable_items)
|
|
121
|
+
for item in formula_items:
|
|
122
|
+
item.source_index = next_source_index
|
|
123
|
+
next_source_index += 1
|
|
124
|
+
output = [*stable_items, *formula_items]
|
|
125
|
+
output.sort(key=lambda item: (item.visual_row_id if item.visual_row_id is not None else math.inf, item.run_index))
|
|
126
|
+
return output
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _extract_decorative_text_rules(
|
|
130
|
+
lines: list[_LineItem],
|
|
131
|
+
page_size: tuple[float, float],
|
|
132
|
+
) -> tuple[list[_LineItem], list[_AxisLine]]:
|
|
133
|
+
"""把宽幅私用区重复字形转为装饰分隔线,避免其进入文本块。"""
|
|
134
|
+
|
|
135
|
+
retained: list[_LineItem] = []
|
|
136
|
+
decorative_rules: list[_AxisLine] = []
|
|
137
|
+
for line in lines:
|
|
138
|
+
local_bbox = _rotate_bbox_to_upright(
|
|
139
|
+
line.bbox,
|
|
140
|
+
page_size,
|
|
141
|
+
line.angle,
|
|
142
|
+
)
|
|
143
|
+
local_page_width = page_size[1] if line.angle in {90, 270} else page_size[0]
|
|
144
|
+
local_page_height = page_size[0] if line.angle in {90, 270} else page_size[1]
|
|
145
|
+
compact_text = "".join(
|
|
146
|
+
char for char in line.text if not char.isspace() and (char.isprintable() or unicodedata.category(char) == "Co")
|
|
147
|
+
)
|
|
148
|
+
private_use_count = sum(unicodedata.category(char) == "Co" for char in compact_text)
|
|
149
|
+
repeated_count = max(
|
|
150
|
+
Counter(compact_text).values(),
|
|
151
|
+
default=0,
|
|
152
|
+
)
|
|
153
|
+
local_width = max(0.0, local_bbox[2] - local_bbox[0])
|
|
154
|
+
local_height = max(0.1, local_bbox[3] - local_bbox[1])
|
|
155
|
+
is_decorative_rule = (
|
|
156
|
+
len(compact_text) >= 8
|
|
157
|
+
and private_use_count / len(compact_text) >= 0.8
|
|
158
|
+
and repeated_count / len(compact_text) >= 0.8
|
|
159
|
+
and local_width >= 0.4 * local_page_width
|
|
160
|
+
and local_width / local_height >= 20.0
|
|
161
|
+
and _bbox_center_y(local_bbox) <= 0.2 * local_page_height
|
|
162
|
+
)
|
|
163
|
+
if not is_decorative_rule:
|
|
164
|
+
retained.append(line)
|
|
165
|
+
continue
|
|
166
|
+
decorative_rules.append(
|
|
167
|
+
_AxisLine(
|
|
168
|
+
bbox=line.bbox,
|
|
169
|
+
width=min(1.0, local_height),
|
|
170
|
+
orientation=("vertical" if line.angle in {90, 270} else "horizontal"),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
return retained, decorative_rules
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _pdftext_angle_degrees(value: Any) -> float:
|
|
177
|
+
"""把 pdftext 弧度方向转换为 0 到 360 度,非法值按 0 度处理。"""
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
angle_radians = float(value or 0.0)
|
|
181
|
+
except (TypeError, ValueError):
|
|
182
|
+
return 0.0
|
|
183
|
+
if not math.isfinite(angle_radians):
|
|
184
|
+
return 0.0
|
|
185
|
+
return math.degrees(angle_radians) % 360.0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _circular_angle_distance(first: float, second: float) -> float:
|
|
189
|
+
"""返回两个方向之间不超过 180 度的最短圆周角差。"""
|
|
190
|
+
|
|
191
|
+
return abs((first - second + 180.0) % 360.0 - 180.0)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _span_has_visible_text(span: dict[str, Any]) -> bool:
|
|
195
|
+
"""判断 span 是否包含可见的非空白字符,换行与占位空格不参与方向拆分。"""
|
|
196
|
+
|
|
197
|
+
return any(char.isprintable() and not char.isspace() for char in str(span.get("text") or ""))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _build_pdftext_child_line(
|
|
201
|
+
pdf_line: dict[str, Any],
|
|
202
|
+
spans: list[dict[str, Any]],
|
|
203
|
+
angle_degrees: float,
|
|
204
|
+
) -> dict[str, Any]:
|
|
205
|
+
"""使用同方向 span 重建子行,并收缩原粗行被异向内容扩大的 bbox。"""
|
|
206
|
+
|
|
207
|
+
span_bboxes = [bbox for span in spans if (bbox := _coerce_bbox(span.get("bbox"))) is not None]
|
|
208
|
+
child_line = dict(pdf_line)
|
|
209
|
+
child_line["spans"] = spans
|
|
210
|
+
child_line["bbox"] = _bbox_union_many(span_bboxes) if span_bboxes else pdf_line.get("bbox")
|
|
211
|
+
child_line["rotation"] = math.radians(angle_degrees)
|
|
212
|
+
return child_line
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _split_pdftext_line_by_rotation(pdf_line: dict[str, Any]) -> list[dict[str, Any]]:
|
|
216
|
+
"""按 45 度边界拆开 pdftext 误合并的异向 span,并保留小角度仿斜体。"""
|
|
217
|
+
|
|
218
|
+
spans = [span for span in (pdf_line.get("spans") or []) if isinstance(span, dict)]
|
|
219
|
+
if not spans:
|
|
220
|
+
return [pdf_line]
|
|
221
|
+
|
|
222
|
+
output: list[dict[str, Any]] = []
|
|
223
|
+
current_spans: list[dict[str, Any]] = []
|
|
224
|
+
current_angle = _pdftext_angle_degrees(pdf_line.get("rotation"))
|
|
225
|
+
for span in spans:
|
|
226
|
+
span_angle = _pdftext_angle_degrees(span.get("rotation"))
|
|
227
|
+
if (
|
|
228
|
+
current_spans
|
|
229
|
+
and _span_has_visible_text(span)
|
|
230
|
+
and _circular_angle_distance(span_angle, current_angle) >= _PDFTEXT_ROTATION_SPLIT_THRESHOLD_DEGREES
|
|
231
|
+
):
|
|
232
|
+
output.append(_build_pdftext_child_line(pdf_line, current_spans, current_angle))
|
|
233
|
+
current_spans = []
|
|
234
|
+
current_angle = span_angle
|
|
235
|
+
current_spans.append(span)
|
|
236
|
+
|
|
237
|
+
if current_spans:
|
|
238
|
+
output.append(_build_pdftext_child_line(pdf_line, current_spans, current_angle))
|
|
239
|
+
return output
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_supported_pdftext_line_rotation(
|
|
243
|
+
value: Any,
|
|
244
|
+
*,
|
|
245
|
+
page_rotation: int,
|
|
246
|
+
supported_angles: Sequence[float] = _SUPPORTED_PDFTEXT_LINE_ANGLES,
|
|
247
|
+
) -> bool:
|
|
248
|
+
"""应用页面旋转后按调用方白名单筛选视觉文字方向。"""
|
|
249
|
+
|
|
250
|
+
visual_angle = (_pdftext_angle_degrees(value) + int(page_rotation or 0)) % 360.0
|
|
251
|
+
return any(
|
|
252
|
+
_circular_angle_distance(visual_angle, supported_angle) <= _PDFTEXT_LINE_ANGLE_TOLERANCE_DEGREES
|
|
253
|
+
for supported_angle in supported_angles
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _resolve_pdftext_line_angle(
|
|
258
|
+
pdf_line: dict[str, Any],
|
|
259
|
+
*,
|
|
260
|
+
page_rotation: int,
|
|
261
|
+
supported_angles: Sequence[float] = _SUPPORTED_PDFTEXT_LINE_ANGLES,
|
|
262
|
+
) -> int | None:
|
|
263
|
+
"""解析视觉文字方向,并用字符基线纠正字体 shear 造成的伪斜向行。"""
|
|
264
|
+
|
|
265
|
+
visual_angle = (_pdftext_angle_degrees(pdf_line.get("rotation")) + int(page_rotation or 0)) % 360.0
|
|
266
|
+
for supported_angle in supported_angles:
|
|
267
|
+
if _circular_angle_distance(visual_angle, supported_angle) <= _PDFTEXT_LINE_ANGLE_TOLERANCE_DEGREES:
|
|
268
|
+
return int(supported_angle)
|
|
269
|
+
supports_horizontal = any(
|
|
270
|
+
_circular_angle_distance(0.0, supported_angle) <= _PDFTEXT_LINE_ANGLE_TOLERANCE_DEGREES
|
|
271
|
+
for supported_angle in supported_angles
|
|
272
|
+
)
|
|
273
|
+
if (
|
|
274
|
+
supports_horizontal
|
|
275
|
+
and _circular_angle_distance(visual_angle, 0.0) <= _PDFTEXT_SHEARED_HORIZONTAL_MAX_ANGLE_DEGREES
|
|
276
|
+
and _pdftext_line_has_horizontal_char_baseline(pdf_line)
|
|
277
|
+
):
|
|
278
|
+
return 0
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _resolve_pdftext_formula_candidate_angle(
|
|
283
|
+
pdf_line: dict[str, Any],
|
|
284
|
+
*,
|
|
285
|
+
page_rotation: int,
|
|
286
|
+
supported_angles: Sequence[float],
|
|
287
|
+
) -> int | None:
|
|
288
|
+
"""保留小角度字体矩阵下的公式专用粗行,未被公式认领时不回流正文。"""
|
|
289
|
+
visual_angle = (_pdftext_angle_degrees(pdf_line.get("rotation")) + int(page_rotation or 0)) % 360.0
|
|
290
|
+
nearest = min(supported_angles, key=lambda angle: _circular_angle_distance(visual_angle, angle))
|
|
291
|
+
if _circular_angle_distance(visual_angle, nearest) > _PDFTEXT_SHEARED_HORIZONTAL_MAX_ANGLE_DEGREES:
|
|
292
|
+
return None
|
|
293
|
+
spans = [span for span in (pdf_line.get("spans") or []) if isinstance(span, dict)]
|
|
294
|
+
compact_text = "".join(str(span.get("text") or "") for span in spans)
|
|
295
|
+
compact_text = "".join(char for char in compact_text if char.isprintable() and not char.isspace())
|
|
296
|
+
if not compact_text:
|
|
297
|
+
return None
|
|
298
|
+
has_script_flag = any(span.get("superscript") is True or span.get("subscript") is True for span in spans)
|
|
299
|
+
has_math_operator = any(char in _PDFTEXT_FORMULA_OPERATOR_CHARS for char in compact_text)
|
|
300
|
+
font_sizes = [
|
|
301
|
+
float(size)
|
|
302
|
+
for span in spans
|
|
303
|
+
if isinstance(span.get("font"), dict) and isinstance((size := span["font"].get("size")), (int, float)) and size > 0
|
|
304
|
+
]
|
|
305
|
+
has_mixed_sizes = bool(font_sizes) and max(font_sizes) >= 1.35 * min(font_sizes)
|
|
306
|
+
is_compact_identifier = len(compact_text) <= 3 and all(char.isalnum() for char in compact_text)
|
|
307
|
+
return int(nearest) if has_script_flag or has_math_operator or has_mixed_sizes or is_compact_identifier else None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _pdftext_line_has_horizontal_char_baseline(
|
|
311
|
+
pdf_line: dict[str, Any],
|
|
312
|
+
) -> bool:
|
|
313
|
+
"""用字符中心的水平基线确认小角度只来自仿斜体变换,而非真实旋转。"""
|
|
314
|
+
|
|
315
|
+
visible_bboxes: list[BBox] = []
|
|
316
|
+
for span in pdf_line.get("spans") or []:
|
|
317
|
+
if not isinstance(span, dict):
|
|
318
|
+
continue
|
|
319
|
+
for char in span.get("chars") or []:
|
|
320
|
+
if not isinstance(char, dict):
|
|
321
|
+
continue
|
|
322
|
+
raw_char = str(char.get("char") or "")
|
|
323
|
+
bbox = _coerce_bbox(char.get("bbox"))
|
|
324
|
+
if bbox is not None and raw_char.isprintable() and not raw_char.isspace():
|
|
325
|
+
visible_bboxes.append(bbox)
|
|
326
|
+
if len(visible_bboxes) < 4:
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
line_bbox = _bbox_union_many(visible_bboxes)
|
|
330
|
+
line_width = line_bbox[2] - line_bbox[0]
|
|
331
|
+
line_height = line_bbox[3] - line_bbox[1]
|
|
332
|
+
glyph_heights = [bbox[3] - bbox[1] for bbox in visible_bboxes]
|
|
333
|
+
median_height = statistics.median(glyph_heights)
|
|
334
|
+
if line_height <= 0 or median_height <= 0 or line_width / line_height < 3.0:
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
ordered = sorted(
|
|
338
|
+
visible_bboxes,
|
|
339
|
+
key=lambda bbox: ((bbox[0] + bbox[2]) / 2.0, (bbox[1] + bbox[3]) / 2.0),
|
|
340
|
+
)
|
|
341
|
+
first_center = (
|
|
342
|
+
(ordered[0][0] + ordered[0][2]) / 2.0,
|
|
343
|
+
(ordered[0][1] + ordered[0][3]) / 2.0,
|
|
344
|
+
)
|
|
345
|
+
last_center = (
|
|
346
|
+
(ordered[-1][0] + ordered[-1][2]) / 2.0,
|
|
347
|
+
(ordered[-1][1] + ordered[-1][3]) / 2.0,
|
|
348
|
+
)
|
|
349
|
+
baseline_width = last_center[0] - first_center[0]
|
|
350
|
+
if baseline_width <= 0:
|
|
351
|
+
return False
|
|
352
|
+
baseline_angle = abs(math.degrees(math.atan2(last_center[1] - first_center[1], baseline_width)))
|
|
353
|
+
centers_y = [(bbox[1] + bbox[3]) / 2.0 for bbox in ordered]
|
|
354
|
+
return (
|
|
355
|
+
baseline_angle <= _PDFTEXT_HORIZONTAL_BASELINE_MAX_ANGLE_DEGREES
|
|
356
|
+
and max(centers_y) - min(centers_y) <= _PDFTEXT_HORIZONTAL_BASELINE_MAX_DISPERSION_RATIO * median_height
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _split_native_visual_runs(
|
|
361
|
+
line: _LineItem,
|
|
362
|
+
page_size: tuple[float, float],
|
|
363
|
+
*,
|
|
364
|
+
visual_bboxes: Mapping[int, BBox] | None = None,
|
|
365
|
+
preserve_vertical_bbox: BBox | None = None,
|
|
366
|
+
) -> list[_LineItem]:
|
|
367
|
+
"""保留字符源顺序与空白信息,并按 canonical 字符框拆分远距视觉 run。"""
|
|
368
|
+
|
|
369
|
+
tokens: list[tuple[Char, str, BBox | None, BBox | None]] = []
|
|
370
|
+
for char in line.chars:
|
|
371
|
+
raw_char = str(char.get("char") or "")
|
|
372
|
+
if raw_char in {"\r", "\n"}:
|
|
373
|
+
continue
|
|
374
|
+
char_idx = char.get("char_idx")
|
|
375
|
+
visual_bbox = (
|
|
376
|
+
visual_bboxes.get(char_idx)
|
|
377
|
+
if visual_bboxes is not None and isinstance(char_idx, int) and raw_char.isprintable() and not raw_char.isspace()
|
|
378
|
+
else None
|
|
379
|
+
)
|
|
380
|
+
bbox = _clip_bbox(
|
|
381
|
+
_coerce_bbox(visual_bbox) or _coerce_bbox(char.get("bbox")),
|
|
382
|
+
page_size,
|
|
383
|
+
)
|
|
384
|
+
local_bbox = _rotate_bbox_to_upright(bbox, page_size, line.angle) if bbox is not None else None
|
|
385
|
+
tokens.append((char, raw_char, bbox, local_bbox))
|
|
386
|
+
|
|
387
|
+
visible_indices = [
|
|
388
|
+
index
|
|
389
|
+
for index, (_char, raw_char, _bbox, local_bbox) in enumerate(tokens)
|
|
390
|
+
if raw_char.isprintable() and not raw_char.isspace() and local_bbox is not None
|
|
391
|
+
]
|
|
392
|
+
if not visible_indices:
|
|
393
|
+
text = _normalize_native_run_text(line.text)
|
|
394
|
+
if not text:
|
|
395
|
+
return []
|
|
396
|
+
local_bbox = _rotate_bbox_to_upright(line.bbox, page_size, line.angle)
|
|
397
|
+
line.text = text
|
|
398
|
+
line.effective_height = max(0.1, local_bbox[3] - local_bbox[1])
|
|
399
|
+
return [line]
|
|
400
|
+
|
|
401
|
+
glyph_widths = [
|
|
402
|
+
max(0.1, tokens[index][3][2] - tokens[index][3][0]) # type: ignore[index]
|
|
403
|
+
for index in visible_indices
|
|
404
|
+
]
|
|
405
|
+
median_glyph_width = statistics.median(glyph_widths)
|
|
406
|
+
local_page_width = page_size[1] if line.angle in {90, 270} else page_size[0]
|
|
407
|
+
hard_gap_threshold = max(15.0, 3.0 * median_glyph_width, 0.02 * local_page_width)
|
|
408
|
+
adjacent_gaps: list[float] = []
|
|
409
|
+
for previous, current in zip(visible_indices, visible_indices[1:]):
|
|
410
|
+
previous_bbox = tokens[previous][3]
|
|
411
|
+
current_bbox = tokens[current][3]
|
|
412
|
+
if previous_bbox is None or current_bbox is None:
|
|
413
|
+
continue
|
|
414
|
+
gap = _horizontal_bbox_gap(previous_bbox, current_bbox)
|
|
415
|
+
if gap < hard_gap_threshold:
|
|
416
|
+
adjacent_gaps.append(gap)
|
|
417
|
+
# 少于三个相邻样本无法可靠代表“常规”字距;零间隙也是真实的紧排字距,
|
|
418
|
+
# 必须纳入统计,避免唯一的 15pt cell gap 反过来抬高软拆阈值。
|
|
419
|
+
median_regular_gap = statistics.median(adjacent_gaps) if len(adjacent_gaps) >= 3 else 0.0
|
|
420
|
+
|
|
421
|
+
split_indices: list[int] = []
|
|
422
|
+
for previous, current in zip(visible_indices, visible_indices[1:]):
|
|
423
|
+
previous_bbox = tokens[previous][3]
|
|
424
|
+
current_bbox = tokens[current][3]
|
|
425
|
+
if previous_bbox is None or current_bbox is None:
|
|
426
|
+
continue
|
|
427
|
+
gap = _horizontal_bbox_gap(previous_bbox, current_bbox)
|
|
428
|
+
has_source_whitespace = any(tokens[index][1].isspace() for index in range(previous + 1, current))
|
|
429
|
+
soft_gap_threshold = max(
|
|
430
|
+
8.0,
|
|
431
|
+
2.2 * median_glyph_width,
|
|
432
|
+
3.0 * median_regular_gap,
|
|
433
|
+
)
|
|
434
|
+
if gap >= hard_gap_threshold or (has_source_whitespace and gap >= soft_gap_threshold):
|
|
435
|
+
split_indices.append(current)
|
|
436
|
+
|
|
437
|
+
ranges: list[tuple[int, int]] = []
|
|
438
|
+
start = 0
|
|
439
|
+
for split_index in split_indices:
|
|
440
|
+
ranges.append((start, split_index))
|
|
441
|
+
start = split_index
|
|
442
|
+
ranges.append((start, len(tokens)))
|
|
443
|
+
|
|
444
|
+
output: list[_LineItem] = []
|
|
445
|
+
for run_index, (start, end) in enumerate(ranges):
|
|
446
|
+
run_tokens = tokens[start:end]
|
|
447
|
+
run_text = _normalize_native_run_text("".join(token[1] for token in run_tokens))
|
|
448
|
+
run_bboxes = [
|
|
449
|
+
token[2] for token in run_tokens if token[2] is not None and token[1].isprintable() and not token[1].isspace()
|
|
450
|
+
]
|
|
451
|
+
if not run_text or not run_bboxes:
|
|
452
|
+
continue
|
|
453
|
+
run_bbox = _bbox_union_many(run_bboxes)
|
|
454
|
+
if preserve_vertical_bbox is not None:
|
|
455
|
+
local_run_bbox = _rotate_bbox_to_upright(
|
|
456
|
+
run_bbox,
|
|
457
|
+
page_size,
|
|
458
|
+
line.angle,
|
|
459
|
+
)
|
|
460
|
+
local_vertical_bbox = _rotate_bbox_to_upright(
|
|
461
|
+
preserve_vertical_bbox,
|
|
462
|
+
page_size,
|
|
463
|
+
line.angle,
|
|
464
|
+
)
|
|
465
|
+
run_bbox = _rotate_bbox_from_upright(
|
|
466
|
+
(
|
|
467
|
+
local_run_bbox[0],
|
|
468
|
+
local_vertical_bbox[1],
|
|
469
|
+
local_run_bbox[2],
|
|
470
|
+
local_vertical_bbox[3],
|
|
471
|
+
),
|
|
472
|
+
page_size,
|
|
473
|
+
line.angle,
|
|
474
|
+
)
|
|
475
|
+
run_chars = [token[0] for token in run_tokens]
|
|
476
|
+
run_item = _LineItem(
|
|
477
|
+
text=run_text,
|
|
478
|
+
bbox=run_bbox,
|
|
479
|
+
angle=line.angle,
|
|
480
|
+
source_index=-1,
|
|
481
|
+
chars=run_chars,
|
|
482
|
+
visual_row_id=line.visual_row_id,
|
|
483
|
+
run_index=run_index,
|
|
484
|
+
split_from_row=len(ranges) > 1,
|
|
485
|
+
formula_candidate_only=line.formula_candidate_only,
|
|
486
|
+
)
|
|
487
|
+
_fill_native_typography(run_item, page_size)
|
|
488
|
+
output.append(run_item)
|
|
489
|
+
return output
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _resplit_native_visual_runs(
|
|
493
|
+
lines: list[_LineItem],
|
|
494
|
+
page_size: tuple[float, float],
|
|
495
|
+
visual_bboxes: Mapping[int, BBox],
|
|
496
|
+
*,
|
|
497
|
+
source_index_start: int | None = None,
|
|
498
|
+
) -> tuple[list[_LineItem], dict[int, _NativeVisualResplit]]:
|
|
499
|
+
"""重切跨栏粗行,并返回供行内 evidence 分片使用的来源映射。"""
|
|
500
|
+
|
|
501
|
+
if not lines or not visual_bboxes:
|
|
502
|
+
return list(lines), {}
|
|
503
|
+
next_source_index = (
|
|
504
|
+
source_index_start
|
|
505
|
+
if source_index_start is not None
|
|
506
|
+
else max(
|
|
507
|
+
(line.source_index for line in lines),
|
|
508
|
+
default=-1,
|
|
509
|
+
)
|
|
510
|
+
+ 1
|
|
511
|
+
)
|
|
512
|
+
output: list[_LineItem] = []
|
|
513
|
+
resplits: dict[int, _NativeVisualResplit] = {}
|
|
514
|
+
for line in lines:
|
|
515
|
+
local_bbox = _rotate_bbox_to_upright(
|
|
516
|
+
line.bbox,
|
|
517
|
+
page_size,
|
|
518
|
+
line.angle,
|
|
519
|
+
)
|
|
520
|
+
local_page_height = page_size[0] if line.angle in {90, 270} else page_size[1]
|
|
521
|
+
local_page_width = page_size[1] if line.angle in {90, 270} else page_size[0]
|
|
522
|
+
if (
|
|
523
|
+
line.semantic_type is not None
|
|
524
|
+
or local_bbox[2] - local_bbox[0] < 0.45 * local_page_width
|
|
525
|
+
or _bbox_center_y(local_bbox) < 0.07 * local_page_height
|
|
526
|
+
or _bbox_center_y(local_bbox) > 0.93 * local_page_height
|
|
527
|
+
):
|
|
528
|
+
output.append(line)
|
|
529
|
+
continue
|
|
530
|
+
members = _split_native_visual_runs(
|
|
531
|
+
line,
|
|
532
|
+
page_size,
|
|
533
|
+
visual_bboxes=visual_bboxes,
|
|
534
|
+
preserve_vertical_bbox=line.bbox,
|
|
535
|
+
)
|
|
536
|
+
if len(members) <= 1:
|
|
537
|
+
output.append(line)
|
|
538
|
+
continue
|
|
539
|
+
local_members = sorted(
|
|
540
|
+
(
|
|
541
|
+
_rotate_bbox_to_upright(
|
|
542
|
+
member.bbox,
|
|
543
|
+
page_size,
|
|
544
|
+
member.angle,
|
|
545
|
+
)
|
|
546
|
+
for member in members
|
|
547
|
+
),
|
|
548
|
+
key=lambda bbox: bbox[0],
|
|
549
|
+
)
|
|
550
|
+
column_split = (
|
|
551
|
+
len(local_members) == 2
|
|
552
|
+
and all(bbox[2] - bbox[0] >= 0.15 * local_page_width for bbox in local_members)
|
|
553
|
+
and local_members[1][0] - local_members[0][2] >= 0.02 * local_page_width
|
|
554
|
+
and local_members[0][2] <= 0.52 * local_page_width
|
|
555
|
+
and local_members[1][0] >= 0.48 * local_page_width
|
|
556
|
+
)
|
|
557
|
+
if not column_split:
|
|
558
|
+
output.append(line)
|
|
559
|
+
continue
|
|
560
|
+
for member_index, member in enumerate(members):
|
|
561
|
+
member.source_index = line.source_index if member_index == 0 else next_source_index
|
|
562
|
+
if member_index > 0:
|
|
563
|
+
next_source_index += 1
|
|
564
|
+
member.visual_row_id = line.visual_row_id
|
|
565
|
+
member.run_index = line.run_index + member_index
|
|
566
|
+
member.source_bbox = member.bbox
|
|
567
|
+
member.baseline = line.baseline
|
|
568
|
+
member.geometry_state = line.geometry_state
|
|
569
|
+
member.geometry_confidence = line.geometry_confidence
|
|
570
|
+
member.split_y_candidate = line.split_y_candidate
|
|
571
|
+
member.em_height = line.em_height or member.effective_height
|
|
572
|
+
member.split_from_row = True
|
|
573
|
+
member.preserve_split_boundary = line.preserve_split_boundary
|
|
574
|
+
member.semantic_type = line.semantic_type
|
|
575
|
+
member.formula_candidate_only = line.formula_candidate_only
|
|
576
|
+
member.style_scale_repaired = line.style_scale_repaired
|
|
577
|
+
output.append(member)
|
|
578
|
+
resplits[line.source_index] = _NativeVisualResplit(
|
|
579
|
+
source=line,
|
|
580
|
+
members=tuple(members),
|
|
581
|
+
)
|
|
582
|
+
output.sort(
|
|
583
|
+
key=lambda item: (
|
|
584
|
+
item.visual_row_id if item.visual_row_id is not None else math.inf,
|
|
585
|
+
item.run_index,
|
|
586
|
+
item.source_index,
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
return output, resplits
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _normalize_native_run_text(text: str) -> str:
|
|
593
|
+
"""清理原生 run 文本,并把字母后的 PDF 软断词标记转换成 ASCII hyphen。"""
|
|
594
|
+
|
|
595
|
+
normalized = _sanitize_pdf_control_text(text, preserve_newlines=False)
|
|
596
|
+
normalized = re.sub(r"[\t\f\v ]+", " ", normalized)
|
|
597
|
+
return normalized.strip()
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _sanitize_pdf_control_text(text: str, *, preserve_newlines: bool) -> str:
|
|
601
|
+
"""规范 PDF 排版空白与控制字符,并按调用场景决定是否保留物理换行。"""
|
|
602
|
+
|
|
603
|
+
normalized = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
|
604
|
+
normalized = normalized.translate(_PDF_UNICODE_TEXT_TRANSLATION)
|
|
605
|
+
normalized = _PDF_LINE_END_SOFT_HYPHEN_RE.sub("-", normalized)
|
|
606
|
+
normalized = normalized.replace("\u00ad", "")
|
|
607
|
+
normalized = normalized.replace("\t", " ")
|
|
608
|
+
if not preserve_newlines:
|
|
609
|
+
normalized = normalized.replace("\n", "")
|
|
610
|
+
return _PDF_CONTROL_CHAR_RE.sub("", normalized)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _detect_leading_emphasis_width(
|
|
614
|
+
glyphs: list[tuple[BBox, tuple[str, int] | None, float | None]],
|
|
615
|
+
) -> float | None:
|
|
616
|
+
"""从行首连续字体 run 中提取字重显著高于后续正文的几何宽度。"""
|
|
617
|
+
|
|
618
|
+
if len(glyphs) < 4:
|
|
619
|
+
return None
|
|
620
|
+
first_signature = glyphs[0][1]
|
|
621
|
+
if first_signature is None:
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
prefix: list[tuple[BBox, tuple[str, int] | None, float | None]] = []
|
|
625
|
+
body: list[tuple[BBox, tuple[str, int] | None, float | None]] = []
|
|
626
|
+
reached_body = False
|
|
627
|
+
for glyph in glyphs:
|
|
628
|
+
if not reached_body and glyph[1] == first_signature:
|
|
629
|
+
prefix.append(glyph)
|
|
630
|
+
continue
|
|
631
|
+
reached_body = True
|
|
632
|
+
body.append(glyph)
|
|
633
|
+
if len(prefix) < 2 or len(body) < 2:
|
|
634
|
+
return None
|
|
635
|
+
|
|
636
|
+
prefix_weights = [weight for _bbox, _signature, weight in prefix if weight is not None]
|
|
637
|
+
body_weights = [weight for _bbox, _signature, weight in body if weight is not None]
|
|
638
|
+
if not prefix_weights or not body_weights:
|
|
639
|
+
return None
|
|
640
|
+
prefix_weight = statistics.median(prefix_weights)
|
|
641
|
+
body_weight = statistics.median(body_weights)
|
|
642
|
+
if prefix_weight - body_weight < 100.0 or prefix_weight < 1.15 * max(1.0, body_weight):
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
prefix_bbox = _bbox_union_many([bbox for bbox, _signature, _weight in prefix])
|
|
646
|
+
return max(0.1, prefix_bbox[2] - prefix_bbox[0])
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _detect_leading_typography_width(
|
|
650
|
+
glyphs: list[tuple[BBox, tuple[str, int] | None, float | None]],
|
|
651
|
+
) -> float | None:
|
|
652
|
+
"""提取与同行主体字体族不同的连续行首 run 几何宽度。"""
|
|
653
|
+
|
|
654
|
+
if len(glyphs) < 4:
|
|
655
|
+
return None
|
|
656
|
+
first_signature = glyphs[0][1]
|
|
657
|
+
first_family = _normalized_font_family(first_signature)
|
|
658
|
+
if first_signature is None or first_family is None:
|
|
659
|
+
return None
|
|
660
|
+
prefix = []
|
|
661
|
+
body = []
|
|
662
|
+
reached_body = False
|
|
663
|
+
for glyph in glyphs:
|
|
664
|
+
if not reached_body and glyph[1] == first_signature:
|
|
665
|
+
prefix.append(glyph)
|
|
666
|
+
continue
|
|
667
|
+
reached_body = True
|
|
668
|
+
body.append(glyph)
|
|
669
|
+
if len(prefix) < 2 or len(body) < 2:
|
|
670
|
+
return None
|
|
671
|
+
body_signatures = Counter(signature for _bbox, signature, _weight in body if signature is not None)
|
|
672
|
+
if not body_signatures:
|
|
673
|
+
return None
|
|
674
|
+
body_signature, body_count = body_signatures.most_common(1)[0]
|
|
675
|
+
if body_count < 2 or _normalized_font_family(body_signature) == first_family:
|
|
676
|
+
return None
|
|
677
|
+
prefix_bbox = _bbox_union_many(
|
|
678
|
+
[bbox for bbox, _signature, _weight in prefix],
|
|
679
|
+
)
|
|
680
|
+
return max(0.1, prefix_bbox[2] - prefix_bbox[0])
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _fill_native_typography(line: _LineItem, page_size: tuple[float, float]) -> None:
|
|
684
|
+
"""使用原始 bbox、PDF 字号和 dominant font 填充两套排版特征。"""
|
|
685
|
+
|
|
686
|
+
canonical_em_height = line.em_height
|
|
687
|
+
heights: list[float] = []
|
|
688
|
+
glyph_widths: list[float] = []
|
|
689
|
+
font_counts: dict[tuple[str, int], int] = {}
|
|
690
|
+
font_weights: dict[tuple[str, int], list[float]] = {}
|
|
691
|
+
glyph_typography: list[tuple[BBox, tuple[str, int] | None, float | None]] = []
|
|
692
|
+
valid_font_chars = 0
|
|
693
|
+
for char in line.chars:
|
|
694
|
+
raw_char = str(char.get("char") or "")
|
|
695
|
+
if not raw_char.isprintable() or raw_char.isspace():
|
|
696
|
+
continue
|
|
697
|
+
bbox = _clip_validated_bbox(_coerce_bbox(char.get("bbox")), page_size)
|
|
698
|
+
if bbox is None:
|
|
699
|
+
continue
|
|
700
|
+
local_bbox = _rotate_bbox_to_upright(bbox, page_size, line.angle)
|
|
701
|
+
heights.append(max(0.1, local_bbox[3] - local_bbox[1]))
|
|
702
|
+
glyph_widths.append(max(0.1, local_bbox[2] - local_bbox[0]))
|
|
703
|
+
font = char.get("font") or {}
|
|
704
|
+
font_name = str(font.get("name") or "")
|
|
705
|
+
if not font_name:
|
|
706
|
+
glyph_typography.append((local_bbox, None, None))
|
|
707
|
+
continue
|
|
708
|
+
try:
|
|
709
|
+
font_flags = int(font.get("flags") or 0)
|
|
710
|
+
except (TypeError, ValueError):
|
|
711
|
+
font_flags = 0
|
|
712
|
+
signature = (font_name, font_flags)
|
|
713
|
+
font_counts[signature] = font_counts.get(signature, 0) + 1
|
|
714
|
+
try:
|
|
715
|
+
font_weight = float(font.get("weight"))
|
|
716
|
+
except (TypeError, ValueError):
|
|
717
|
+
font_weight = math.nan
|
|
718
|
+
if math.isfinite(font_weight):
|
|
719
|
+
font_weights.setdefault(signature, []).append(font_weight)
|
|
720
|
+
glyph_typography.append((local_bbox, signature, font_weight))
|
|
721
|
+
else:
|
|
722
|
+
glyph_typography.append((local_bbox, signature, None))
|
|
723
|
+
valid_font_chars += 1
|
|
724
|
+
|
|
725
|
+
local_bbox = _rotate_bbox_to_upright(line.bbox, page_size, line.angle)
|
|
726
|
+
line.effective_height = statistics.median(heights) if heights else max(0.1, local_bbox[3] - local_bbox[1])
|
|
727
|
+
line.em_height = canonical_em_height if canonical_em_height > 0 else line.effective_height
|
|
728
|
+
line.median_glyph_width = statistics.median(glyph_widths) if glyph_widths else None
|
|
729
|
+
if font_counts and valid_font_chars:
|
|
730
|
+
line.font_signature, dominant_count = max(font_counts.items(), key=lambda item: item[1])
|
|
731
|
+
line.font_coverage = dominant_count / valid_font_chars
|
|
732
|
+
dominant_weights = font_weights.get(line.font_signature, [])
|
|
733
|
+
line.dominant_font_weight = statistics.median(dominant_weights) if dominant_weights else None
|
|
734
|
+
else:
|
|
735
|
+
line.font_signature = None
|
|
736
|
+
line.font_coverage = 0.0
|
|
737
|
+
line.dominant_font_weight = None
|
|
738
|
+
line.leading_emphasis_width = _detect_leading_emphasis_width(glyph_typography)
|
|
739
|
+
line.leading_typography_width = _detect_leading_typography_width(
|
|
740
|
+
glyph_typography,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _is_detached_inline_script_candidate(
|
|
745
|
+
small_bbox: BBox,
|
|
746
|
+
base_bbox: BBox,
|
|
747
|
+
base_height: float,
|
|
748
|
+
) -> bool:
|
|
749
|
+
"""仅依据紧凑宽度、边缘邻接和垂直偏移确认低重叠外置上下标。"""
|
|
750
|
+
|
|
751
|
+
small_width = max(0.0, small_bbox[2] - small_bbox[0])
|
|
752
|
+
edge_distance = min(
|
|
753
|
+
abs(base_bbox[0] - small_bbox[2]),
|
|
754
|
+
abs(small_bbox[0] - base_bbox[2]),
|
|
755
|
+
)
|
|
756
|
+
vertical_gap = max(
|
|
757
|
+
0.0,
|
|
758
|
+
base_bbox[1] - small_bbox[3],
|
|
759
|
+
small_bbox[1] - base_bbox[3],
|
|
760
|
+
)
|
|
761
|
+
center_offset = abs(_bbox_center_y(small_bbox) - _bbox_center_y(base_bbox))
|
|
762
|
+
outside_offset = max(
|
|
763
|
+
base_bbox[1] - small_bbox[1],
|
|
764
|
+
small_bbox[3] - base_bbox[3],
|
|
765
|
+
)
|
|
766
|
+
return (
|
|
767
|
+
small_width <= 0.75 * base_height
|
|
768
|
+
and edge_distance <= max(1.0, 0.1 * base_height)
|
|
769
|
+
and vertical_gap <= max(0.5, 0.15 * base_height)
|
|
770
|
+
and center_offset >= max(0.5, 0.25 * base_height)
|
|
771
|
+
and outside_offset >= max(0.5, 0.2 * base_height)
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _native_typographic_scale(line: _LineItem) -> float:
|
|
776
|
+
"""返回原生行的字体尺度,禁止 loose 空间高度参与上下标字号比较。"""
|
|
777
|
+
|
|
778
|
+
font_sizes: list[float] = []
|
|
779
|
+
for char in line.chars:
|
|
780
|
+
try:
|
|
781
|
+
font_size = float((char.get("font") or {}).get("size") or 0.0)
|
|
782
|
+
except (TypeError, ValueError):
|
|
783
|
+
continue
|
|
784
|
+
if math.isfinite(font_size) and font_size > 0:
|
|
785
|
+
font_sizes.append(font_size)
|
|
786
|
+
return max(
|
|
787
|
+
0.1,
|
|
788
|
+
statistics.median(font_sizes) if font_sizes else line.em_height or line.effective_height,
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _merge_native_inline_scripts(
|
|
793
|
+
lines: list[_LineItem],
|
|
794
|
+
page_size: tuple[float, float],
|
|
795
|
+
) -> list[_LineItem]:
|
|
796
|
+
"""以 mutual-nearest 规则把跨粗行的小字号前后置标记合入主体视觉行。"""
|
|
797
|
+
|
|
798
|
+
candidates: list[tuple[float, int, int, Literal["prefix", "suffix"]]] = []
|
|
799
|
+
detached_candidate_pairs: set[tuple[int, int, Literal["prefix", "suffix"]]] = set()
|
|
800
|
+
# 候选生成期间行不会修改,按本轮索引缓存字符统计,避免每对行重算字号中位数。
|
|
801
|
+
compact_texts = ["".join(char for char in line.text if not char.isspace()) for line in lines]
|
|
802
|
+
reference_markers = [_INLINE_REFERENCE_MARKER_RE.fullmatch(text) is not None for text in compact_texts]
|
|
803
|
+
local_bboxes = [_rotate_bbox_to_upright(line.bbox, page_size, line.angle) for line in lines]
|
|
804
|
+
canonical_scales = [_native_typographic_scale(line) for line in lines]
|
|
805
|
+
for small_index, small in enumerate(lines):
|
|
806
|
+
compact_text = compact_texts[small_index]
|
|
807
|
+
if not compact_text:
|
|
808
|
+
continue
|
|
809
|
+
small_local_bbox = local_bboxes[small_index]
|
|
810
|
+
for base_index, base in enumerate(lines):
|
|
811
|
+
if small_index == base_index or small.angle != base.angle or small.visual_row_id == base.visual_row_id:
|
|
812
|
+
continue
|
|
813
|
+
if small.effective_height <= 0 or base.effective_height <= 0:
|
|
814
|
+
continue
|
|
815
|
+
canonical_small_scale = canonical_scales[small_index]
|
|
816
|
+
canonical_base_scale = canonical_scales[base_index]
|
|
817
|
+
legacy_small_scale = max(0.1, small.effective_height)
|
|
818
|
+
legacy_base_scale = max(0.1, base.effective_height)
|
|
819
|
+
legacy_ratio = legacy_small_scale / legacy_base_scale
|
|
820
|
+
use_canonical_reference_scale = (
|
|
821
|
+
reference_markers[small_index]
|
|
822
|
+
and not 0.35 <= legacy_ratio <= 0.8
|
|
823
|
+
and 0.35 <= canonical_small_scale / canonical_base_scale <= 0.8
|
|
824
|
+
)
|
|
825
|
+
small_scale = canonical_small_scale if use_canonical_reference_scale else legacy_small_scale
|
|
826
|
+
base_scale = canonical_base_scale if use_canonical_reference_scale else legacy_base_scale
|
|
827
|
+
height_ratio = small_scale / base_scale
|
|
828
|
+
if not 0.35 <= height_ratio <= 0.8:
|
|
829
|
+
continue
|
|
830
|
+
if len(compact_text) > 8 and small_local_bbox[2] - small_local_bbox[0] > 3.0 * base_scale:
|
|
831
|
+
continue
|
|
832
|
+
base_local_bbox = local_bboxes[base_index]
|
|
833
|
+
vertical_overlap = max(
|
|
834
|
+
0.0,
|
|
835
|
+
min(small_local_bbox[3], base_local_bbox[3]) - max(small_local_bbox[1], base_local_bbox[1]),
|
|
836
|
+
)
|
|
837
|
+
small_height = max(0.1, small_local_bbox[3] - small_local_bbox[1])
|
|
838
|
+
overlap_ratio = vertical_overlap / small_height
|
|
839
|
+
detached_candidate = overlap_ratio < 0.5 and _is_detached_inline_script_candidate(
|
|
840
|
+
small_local_bbox,
|
|
841
|
+
base_local_bbox,
|
|
842
|
+
base_scale,
|
|
843
|
+
)
|
|
844
|
+
if overlap_ratio < 0.5 and not detached_candidate:
|
|
845
|
+
continue
|
|
846
|
+
center_offset = abs(_bbox_center_y(small_local_bbox) - _bbox_center_y(base_local_bbox))
|
|
847
|
+
if center_offset < max(0.5, 0.12 * base_scale):
|
|
848
|
+
# 同基线居中的小字号文本更可能是表格相邻 cell,而不是上下标。
|
|
849
|
+
continue
|
|
850
|
+
gap_limit = max(1.5, 0.35 * base_scale)
|
|
851
|
+
edge_options: list[tuple[float, Literal["prefix", "suffix"], float]] = []
|
|
852
|
+
for position, gap in (
|
|
853
|
+
("prefix", base_local_bbox[0] - small_local_bbox[2]),
|
|
854
|
+
("suffix", small_local_bbox[0] - base_local_bbox[2]),
|
|
855
|
+
):
|
|
856
|
+
if -0.35 * base_scale <= gap <= gap_limit:
|
|
857
|
+
edge_options.append((abs(gap), position, gap))
|
|
858
|
+
if not edge_options:
|
|
859
|
+
continue
|
|
860
|
+
_edge_distance, position, gap = min(edge_options, key=lambda item: item[0])
|
|
861
|
+
|
|
862
|
+
outside_offset = max(
|
|
863
|
+
base_local_bbox[1] - small_local_bbox[1],
|
|
864
|
+
small_local_bbox[3] - base_local_bbox[3],
|
|
865
|
+
)
|
|
866
|
+
tightly_attached = abs(gap) <= max(1.0, 0.1 * base_scale)
|
|
867
|
+
if outside_offset < max(0.5, 0.08 * base_scale) and not tightly_attached:
|
|
868
|
+
# 紧贴边缘的小字号上下标可能完全落入高字形 bbox;其余内嵌小字仍按普通 cell 排除。
|
|
869
|
+
continue
|
|
870
|
+
metric = abs(gap) + (1.0 - overlap_ratio) * base_scale
|
|
871
|
+
candidates.append((metric, small_index, base_index, position))
|
|
872
|
+
if detached_candidate:
|
|
873
|
+
detached_candidate_pairs.add((small_index, base_index, position))
|
|
874
|
+
|
|
875
|
+
best_base_for_small: dict[int, tuple[float, int, Literal["prefix", "suffix"]]] = {}
|
|
876
|
+
best_small_for_base: dict[tuple[int, str], tuple[float, int]] = {}
|
|
877
|
+
for metric, small_index, base_index, position in candidates:
|
|
878
|
+
if small_index not in best_base_for_small or metric < best_base_for_small[small_index][0]:
|
|
879
|
+
best_base_for_small[small_index] = (metric, base_index, position)
|
|
880
|
+
base_key = (base_index, position)
|
|
881
|
+
if base_key not in best_small_for_base or metric < best_small_for_base[base_key][0]:
|
|
882
|
+
best_small_for_base[base_key] = (metric, small_index)
|
|
883
|
+
|
|
884
|
+
matches: dict[int, dict[Literal["prefix", "suffix"], int]] = {}
|
|
885
|
+
for small_index, (_metric, base_index, position) in best_base_for_small.items():
|
|
886
|
+
if best_small_for_base.get((base_index, position), (math.inf, -1))[1] == small_index:
|
|
887
|
+
matches.setdefault(base_index, {})[position] = small_index
|
|
888
|
+
|
|
889
|
+
consumed_small_indices = {small_index for positions in matches.values() for small_index in positions.values()}
|
|
890
|
+
merged_base_indices: set[int] = set()
|
|
891
|
+
|
|
892
|
+
def merge_children(base_index: int, visiting: set[int]) -> None:
|
|
893
|
+
"""先合并更小的依赖标记,再把当前完整节点递归合入更大的主体行。"""
|
|
894
|
+
|
|
895
|
+
if base_index in merged_base_indices or base_index in visiting:
|
|
896
|
+
return
|
|
897
|
+
visiting.add(base_index)
|
|
898
|
+
positions = matches.get(base_index, {})
|
|
899
|
+
for child_index in positions.values():
|
|
900
|
+
merge_children(child_index, visiting)
|
|
901
|
+
base = lines[base_index]
|
|
902
|
+
stable_source_indices = [
|
|
903
|
+
source_index
|
|
904
|
+
for source_index in [base.source_index, *(lines[child_index].source_index for child_index in positions.values())]
|
|
905
|
+
if source_index >= 0
|
|
906
|
+
]
|
|
907
|
+
if stable_source_indices:
|
|
908
|
+
base.source_index = min(stable_source_indices)
|
|
909
|
+
formula_candidate_only = base.formula_candidate_only and all(
|
|
910
|
+
lines[child_index].formula_candidate_only for child_index in positions.values()
|
|
911
|
+
)
|
|
912
|
+
merged_bbox = base.bbox
|
|
913
|
+
merged_chars = list(base.chars)
|
|
914
|
+
if "prefix" in positions:
|
|
915
|
+
prefix_index = positions["prefix"]
|
|
916
|
+
prefix = lines[prefix_index]
|
|
917
|
+
base.text = f"{prefix.text.strip()} {base.text.lstrip()}"
|
|
918
|
+
merged_bbox = _bbox_union(merged_bbox, prefix.bbox)
|
|
919
|
+
merged_chars = [*prefix.chars, *merged_chars]
|
|
920
|
+
base.split_from_row = base.split_from_row or prefix.split_from_row
|
|
921
|
+
base.inline_math_regions.extend(prefix.inline_math_regions)
|
|
922
|
+
if "suffix" in positions:
|
|
923
|
+
suffix_index = positions["suffix"]
|
|
924
|
+
suffix = lines[suffix_index]
|
|
925
|
+
base.text = f"{base.text.rstrip()}{suffix.text.strip()}"
|
|
926
|
+
merged_bbox = _bbox_union(merged_bbox, suffix.bbox)
|
|
927
|
+
merged_chars.extend(suffix.chars)
|
|
928
|
+
base.split_from_row = base.split_from_row or suffix.split_from_row
|
|
929
|
+
base.inline_math_regions.extend(suffix.inline_math_regions)
|
|
930
|
+
base.bbox = merged_bbox
|
|
931
|
+
base.chars = merged_chars
|
|
932
|
+
# 只有低重叠外置候选才需要按完整二维 bbox 计算后继行距;普通上下标保持原有基线行为。
|
|
933
|
+
base.restored_inline_cluster = base.restored_inline_cluster or any(
|
|
934
|
+
lines[child_index].restored_inline_cluster or (child_index, base_index, position) in detached_candidate_pairs
|
|
935
|
+
for position, child_index in positions.items()
|
|
936
|
+
)
|
|
937
|
+
base.formula_candidate_only = formula_candidate_only
|
|
938
|
+
_fill_native_typography(base, page_size)
|
|
939
|
+
visiting.remove(base_index)
|
|
940
|
+
merged_base_indices.add(base_index)
|
|
941
|
+
|
|
942
|
+
# 从最终不会被消费的根主体开始,确保 small -> medium -> large 链不会丢失最小节点。
|
|
943
|
+
root_base_indices = [base_index for base_index in matches if base_index not in consumed_small_indices]
|
|
944
|
+
for base_index in root_base_indices:
|
|
945
|
+
merge_children(base_index, set())
|
|
946
|
+
for base_index in matches:
|
|
947
|
+
merge_children(base_index, set())
|
|
948
|
+
|
|
949
|
+
output = [line for index, line in enumerate(lines) if index not in consumed_small_indices]
|
|
950
|
+
output.sort(key=lambda item: (item.visual_row_id if item.visual_row_id is not None else math.inf, item.run_index))
|
|
951
|
+
return output
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _normalize_pdftext_angle(value: Any) -> int:
|
|
955
|
+
"""将 pdftext 弧度方向就近归一到四个标准角度。"""
|
|
956
|
+
|
|
957
|
+
try:
|
|
958
|
+
angle_radians = float(value or 0.0)
|
|
959
|
+
except (TypeError, ValueError):
|
|
960
|
+
return 0
|
|
961
|
+
angle_degrees = math.degrees(angle_radians)
|
|
962
|
+
normalized = int(round(angle_degrees / 90.0) * 90) % 360
|
|
963
|
+
return normalized if normalized in {0, 90, 180, 270} else 0
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _get_pdf_drawing_lines(pdf_doc: PDFDocument, page_idx: int) -> list[_AxisLine]:
|
|
967
|
+
"""读取 PDFDocument 的公共绘图线结果,并隔离具体 PDFium 类型。"""
|
|
968
|
+
|
|
969
|
+
return _coerce_pdf_drawing_lines(pdf_doc.get_page_drawing_lines(page_idx))
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _coerce_pdf_drawing_lines(drawing_lines: Sequence[PDFDrawingLine]) -> list[_AxisLine]:
|
|
973
|
+
"""把独立接口或批量快照中的绘图线统一转换为 Flash 内部坐标类型。"""
|
|
974
|
+
|
|
975
|
+
output: list[_AxisLine] = []
|
|
976
|
+
for drawing_line in drawing_lines:
|
|
977
|
+
bbox = _coerce_bbox(drawing_line.bbox)
|
|
978
|
+
if bbox is None:
|
|
979
|
+
continue
|
|
980
|
+
output.append(
|
|
981
|
+
_AxisLine(
|
|
982
|
+
bbox=bbox,
|
|
983
|
+
width=max(0.0, float(drawing_line.width)),
|
|
984
|
+
orientation=drawing_line.orientation,
|
|
985
|
+
)
|
|
986
|
+
)
|
|
987
|
+
return output
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _median_native_glyph_width(line: _LineItem, page_size: tuple[float, float]) -> float | None:
|
|
991
|
+
"""返回单个原生 run 的可见字符中位宽度,缺少字符时返回空。"""
|
|
992
|
+
|
|
993
|
+
if line.median_glyph_width is not None:
|
|
994
|
+
return line.median_glyph_width
|
|
995
|
+
widths: list[float] = []
|
|
996
|
+
for char in line.chars:
|
|
997
|
+
raw_char = str(char.get("char") or "")
|
|
998
|
+
bbox = _clip_validated_bbox(_coerce_bbox(char.get("bbox")), page_size)
|
|
999
|
+
if not raw_char.isprintable() or raw_char.isspace() or bbox is None:
|
|
1000
|
+
continue
|
|
1001
|
+
local_bbox = _rotate_bbox_to_upright(bbox, page_size, line.angle)
|
|
1002
|
+
widths.append(max(0.1, local_bbox[2] - local_bbox[0]))
|
|
1003
|
+
line.median_glyph_width = statistics.median(widths) if widths else None
|
|
1004
|
+
return line.median_glyph_width
|