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,1662 @@
|
|
|
1
|
+
"""为 Flash 原生文本生成 loose/tight/origin 协商后的 canonical 几何。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
import statistics
|
|
8
|
+
import unicodedata
|
|
9
|
+
from collections import Counter, defaultdict
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
from typing import Any, Literal, Sequence, TypeAlias
|
|
13
|
+
|
|
14
|
+
from ....schema import BBox
|
|
15
|
+
from ....document.pdf.document import PDFPageTextGeometry
|
|
16
|
+
from .geometry import (
|
|
17
|
+
_clip_validated_bbox,
|
|
18
|
+
_bbox_axis_overlap_ratio,
|
|
19
|
+
_bbox_union_many,
|
|
20
|
+
_clip_bbox,
|
|
21
|
+
_coerce_bbox,
|
|
22
|
+
_rotate_bbox_from_upright,
|
|
23
|
+
_rotate_bbox_to_upright,
|
|
24
|
+
_rotate_origin_to_upright,
|
|
25
|
+
)
|
|
26
|
+
from .models import _LineItem
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
X_RELIABLE_PAIR_MIN = 30
|
|
30
|
+
X_STRONG_MEDIAN_RATIO = 1.30
|
|
31
|
+
X_STRONG_RATIO_THRESHOLD = 1.35
|
|
32
|
+
X_STRONG_RATIO_SHARE = 0.30
|
|
33
|
+
X_STRONG_NEXT_TIGHT_OVERLAP_SHARE = 0.30
|
|
34
|
+
X_SIBLING_PAIR_MIN = 10
|
|
35
|
+
X_SIBLING_MEDIAN_RATIO = 1.15
|
|
36
|
+
X_SIBLING_NEXT_TIGHT_OVERLAP_SHARE = 0.50
|
|
37
|
+
X_SIBLING_P90_RATIO = 1.50
|
|
38
|
+
|
|
39
|
+
ANCHOR_MIN_TIGHT_HEIGHT_RATIO = 0.38
|
|
40
|
+
Y_MIN_ANCHOR_COUNT = 4
|
|
41
|
+
Y_MIN_GEOMETRY_COVERAGE = 0.80
|
|
42
|
+
Y_DOMINANT_ROW_SHARE = 0.80
|
|
43
|
+
Y_NEIGHBOR_CORE_INTRUSION = 0.25
|
|
44
|
+
Y_MIN_REPEATED_LINES = 3
|
|
45
|
+
Y_MIN_REPEATED_SHARE = 0.50
|
|
46
|
+
Y_HEALTHY_LOOSE_SAMPLE_MIN = 20
|
|
47
|
+
Y_DOCUMENT_RISK_P95_RATIO = 2.20
|
|
48
|
+
STYLE_INFLATION_LOOSE_FONT_RATIO = 1.50
|
|
49
|
+
STYLE_INFLATION_LOOSE_TIGHT_RATIO = 1.80
|
|
50
|
+
STYLE_INFLATION_MIN_LINE_COUNT = 3
|
|
51
|
+
STYLE_INFLATION_MIN_LINE_SHARE = 0.50
|
|
52
|
+
STYLE_INFLATION_MIN_PAGE_COUNT = 2
|
|
53
|
+
STYLE_INFLATION_MIN_SCALE = 4.0
|
|
54
|
+
STYLE_TIER_MIN_MEMBER_COUNT = 2
|
|
55
|
+
STYLE_TIER_MIN_MEMBER_SHARE = 0.15
|
|
56
|
+
STYLE_TIER_GAP_RATIO = 1.35
|
|
57
|
+
|
|
58
|
+
RunKey: TypeAlias = tuple[str, float, int, int, int, str]
|
|
59
|
+
XRunStyleKey: TypeAlias = tuple[str, float, int, int, int]
|
|
60
|
+
LineKey: TypeAlias = tuple[int, int]
|
|
61
|
+
CharKey: TypeAlias = tuple[int, int]
|
|
62
|
+
LooseTierSample: TypeAlias = tuple[float, float, float, float]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(slots=True)
|
|
66
|
+
class CharLayoutGeometry:
|
|
67
|
+
"""保存一个实际需要修复的字符几何及分轴状态。"""
|
|
68
|
+
|
|
69
|
+
source_bbox: BBox
|
|
70
|
+
tight_bbox: BBox
|
|
71
|
+
origin: tuple[float, float]
|
|
72
|
+
layout_bbox: BBox
|
|
73
|
+
ink_bbox: BBox
|
|
74
|
+
baseline: float
|
|
75
|
+
advance: float | None
|
|
76
|
+
em_height: float
|
|
77
|
+
x_state: Literal["healthy", "abnormal", "unknown"]
|
|
78
|
+
y_state: Literal["healthy", "abnormal", "unknown"]
|
|
79
|
+
confidence: float
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(slots=True)
|
|
83
|
+
class LineGeometryRepair:
|
|
84
|
+
"""保存一条 legacy line 的 canonical 修复和 shadow 诊断。"""
|
|
85
|
+
|
|
86
|
+
source_bbox: BBox
|
|
87
|
+
layout_bbox: BBox
|
|
88
|
+
ink_bbox: BBox | None
|
|
89
|
+
baseline: float | None
|
|
90
|
+
em_height: float
|
|
91
|
+
state: Literal["healthy", "repair_x", "trim_y", "repair_xy", "uncertain"] = "healthy"
|
|
92
|
+
confidence: float = 1.0
|
|
93
|
+
split_y_candidate: bool = False
|
|
94
|
+
repaired_char_count: int = 0
|
|
95
|
+
y_intrusion_ratio: float = 0.0
|
|
96
|
+
run_key: RunKey | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(slots=True)
|
|
100
|
+
class DocumentGeometryPlan:
|
|
101
|
+
"""保存文档级 run 结论、局部字符修复和逐行 canonical 几何。"""
|
|
102
|
+
|
|
103
|
+
char_repairs: dict[CharKey, CharLayoutGeometry] = field(default_factory=dict)
|
|
104
|
+
line_repairs: dict[LineKey, LineGeometryRepair] = field(default_factory=dict)
|
|
105
|
+
line_style_scales: dict[LineKey, float] = field(default_factory=dict)
|
|
106
|
+
line_ink_bboxes: dict[LineKey, BBox] = field(default_factory=dict)
|
|
107
|
+
line_baselines: dict[LineKey, float] = field(default_factory=dict)
|
|
108
|
+
style_inflated_runs: set[RunKey] = field(default_factory=set)
|
|
109
|
+
run_diagnostics: list[dict[str, Any]] = field(default_factory=list)
|
|
110
|
+
document_style_anomaly: bool = False
|
|
111
|
+
|
|
112
|
+
def to_dict(self) -> dict[str, Any]:
|
|
113
|
+
"""转换为 review 脚本可序列化的稳定诊断。"""
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"document_style_anomaly": self.document_style_anomaly,
|
|
117
|
+
"run_diagnostics": self.run_diagnostics,
|
|
118
|
+
"char_repairs": [
|
|
119
|
+
{
|
|
120
|
+
"page_index": page_index,
|
|
121
|
+
"char_idx": char_idx,
|
|
122
|
+
"source_bbox": list(repair.source_bbox),
|
|
123
|
+
"tight_bbox": list(repair.tight_bbox),
|
|
124
|
+
"origin": list(repair.origin),
|
|
125
|
+
"layout_bbox": list(repair.layout_bbox),
|
|
126
|
+
"advance": repair.advance,
|
|
127
|
+
"em_height": repair.em_height,
|
|
128
|
+
"x_state": repair.x_state,
|
|
129
|
+
"y_state": repair.y_state,
|
|
130
|
+
"confidence": repair.confidence,
|
|
131
|
+
}
|
|
132
|
+
for (page_index, char_idx), repair in sorted(self.char_repairs.items())
|
|
133
|
+
],
|
|
134
|
+
"line_repairs": [
|
|
135
|
+
{
|
|
136
|
+
"page_index": page_index,
|
|
137
|
+
"source_index": source_index,
|
|
138
|
+
"source_bbox": list(repair.source_bbox),
|
|
139
|
+
"layout_bbox": list(repair.layout_bbox),
|
|
140
|
+
"ink_bbox": list(repair.ink_bbox) if repair.ink_bbox is not None else None,
|
|
141
|
+
"baseline": repair.baseline,
|
|
142
|
+
"em_height": repair.em_height,
|
|
143
|
+
"state": repair.state,
|
|
144
|
+
"confidence": repair.confidence,
|
|
145
|
+
"split_y_candidate": repair.split_y_candidate,
|
|
146
|
+
"repaired_char_count": repair.repaired_char_count,
|
|
147
|
+
"y_intrusion_ratio": repair.y_intrusion_ratio,
|
|
148
|
+
"run_key": list(repair.run_key) if repair.run_key is not None else None,
|
|
149
|
+
}
|
|
150
|
+
for (page_index, source_index), repair in sorted(self.line_repairs.items())
|
|
151
|
+
if repair.state != "healthy" or repair.split_y_candidate
|
|
152
|
+
],
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class _DocumentGeometryRisk:
|
|
158
|
+
"""区分需要完整字符几何的布局风险与仅需字号校准的样式风险。"""
|
|
159
|
+
|
|
160
|
+
layout: bool = False
|
|
161
|
+
style: bool = False
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def any(self) -> bool:
|
|
165
|
+
"""返回当前文档是否需要进入完整字符样本收集。"""
|
|
166
|
+
|
|
167
|
+
return self.layout or self.style
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(slots=True)
|
|
171
|
+
class _CharSample:
|
|
172
|
+
"""保存分析阶段使用的局部字符样本。"""
|
|
173
|
+
|
|
174
|
+
page_index: int
|
|
175
|
+
line: _LineItem
|
|
176
|
+
position: int
|
|
177
|
+
char_idx: int
|
|
178
|
+
text: str
|
|
179
|
+
source_bbox: BBox
|
|
180
|
+
tight_bbox: BBox
|
|
181
|
+
origin: tuple[float, float]
|
|
182
|
+
local_source_bbox: BBox
|
|
183
|
+
local_tight_bbox: BBox
|
|
184
|
+
local_origin: tuple[float, float]
|
|
185
|
+
run_key: RunKey
|
|
186
|
+
font_size: float
|
|
187
|
+
is_anchor: bool = False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(slots=True)
|
|
191
|
+
class _RunStats:
|
|
192
|
+
"""保存一个字体 run 的 origin-advance 与覆盖统计。"""
|
|
193
|
+
|
|
194
|
+
key: RunKey
|
|
195
|
+
pair_ratios: list[float] = field(default_factory=list)
|
|
196
|
+
pair_overlaps: list[bool] = field(default_factory=list)
|
|
197
|
+
advances: list[float] = field(default_factory=list)
|
|
198
|
+
tight_left_bearings: list[float] = field(default_factory=list)
|
|
199
|
+
samples: list[_CharSample] = field(default_factory=list)
|
|
200
|
+
strong_x_bad: bool = False
|
|
201
|
+
sibling_x_bad: bool = False
|
|
202
|
+
style_y_bad: bool = False
|
|
203
|
+
median_advance: float | None = None
|
|
204
|
+
median_tight_left_bearing: float = 0.0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(slots=True)
|
|
208
|
+
class _LineAnalysis:
|
|
209
|
+
"""保存 Y 轴 row 聚类和邻行侵入判定所需信息。"""
|
|
210
|
+
|
|
211
|
+
key: LineKey
|
|
212
|
+
line: _LineItem
|
|
213
|
+
samples: list[_CharSample]
|
|
214
|
+
anchors: list[_CharSample]
|
|
215
|
+
dominant: list[_CharSample]
|
|
216
|
+
baseline: float
|
|
217
|
+
tight_core: BBox
|
|
218
|
+
local_source_bbox: BBox
|
|
219
|
+
explicit_all_source_bbox: BBox
|
|
220
|
+
legacy_local_bbox: BBox
|
|
221
|
+
run_key: RunKey
|
|
222
|
+
geometry_coverage: float
|
|
223
|
+
dominant_share: float
|
|
224
|
+
split_y_candidate: bool
|
|
225
|
+
neighbors: list[_LineAnalysis] = field(default_factory=list)
|
|
226
|
+
intrusion_ratio: float = 0.0
|
|
227
|
+
y_candidate: bool = False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _quantile(values: list[float], fraction: float) -> float:
|
|
231
|
+
"""返回确定性的线性插值分位数。"""
|
|
232
|
+
|
|
233
|
+
if not values:
|
|
234
|
+
return 0.0
|
|
235
|
+
ordered = sorted(values)
|
|
236
|
+
position = max(0.0, min(1.0, fraction)) * (len(ordered) - 1)
|
|
237
|
+
lower = int(math.floor(position))
|
|
238
|
+
upper = int(math.ceil(position))
|
|
239
|
+
if lower == upper:
|
|
240
|
+
return ordered[lower]
|
|
241
|
+
weight = position - lower
|
|
242
|
+
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _line_loose_tier_offsets(
|
|
246
|
+
samples: list[LooseTierSample],
|
|
247
|
+
em_height: float,
|
|
248
|
+
) -> tuple[float, float] | None:
|
|
249
|
+
"""把归一化 loose 高度分档,并返回最大异常档回缩到次档后的 ascent/descent。"""
|
|
250
|
+
|
|
251
|
+
if len(samples) < 2 * STYLE_TIER_MIN_MEMBER_COUNT or em_height <= 0:
|
|
252
|
+
return None
|
|
253
|
+
normalized = sorted(
|
|
254
|
+
(
|
|
255
|
+
(ascent + descent) / max(font_size, tight_height, 0.1),
|
|
256
|
+
ascent,
|
|
257
|
+
descent,
|
|
258
|
+
tight_height,
|
|
259
|
+
font_size,
|
|
260
|
+
)
|
|
261
|
+
for ascent, descent, tight_height, font_size in samples
|
|
262
|
+
)
|
|
263
|
+
tiers: list[list[tuple[float, float, float, float, float]]] = []
|
|
264
|
+
for item in normalized:
|
|
265
|
+
if not tiers or item[0] > STYLE_TIER_GAP_RATIO * statistics.median(member[0] for member in tiers[-1]):
|
|
266
|
+
tiers.append([item])
|
|
267
|
+
else:
|
|
268
|
+
tiers[-1].append(item)
|
|
269
|
+
if len(tiers) < 2:
|
|
270
|
+
return None
|
|
271
|
+
upper_tier = tiers[-1]
|
|
272
|
+
reference_tier = tiers[-2]
|
|
273
|
+
minimum_members = max(
|
|
274
|
+
STYLE_TIER_MIN_MEMBER_COUNT,
|
|
275
|
+
math.ceil(STYLE_TIER_MIN_MEMBER_SHARE * len(samples)),
|
|
276
|
+
)
|
|
277
|
+
if len(upper_tier) < minimum_members or len(reference_tier) < STYLE_TIER_MIN_MEMBER_COUNT:
|
|
278
|
+
return None
|
|
279
|
+
upper_loose_heights = [member[1] + member[2] for member in upper_tier]
|
|
280
|
+
upper_font_sizes = [member[4] for member in upper_tier if member[4] > 0]
|
|
281
|
+
upper_tight_heights = [member[3] for member in upper_tier]
|
|
282
|
+
if statistics.median(upper_loose_heights) <= STYLE_INFLATION_LOOSE_FONT_RATIO * (
|
|
283
|
+
statistics.median(upper_font_sizes) if upper_font_sizes else 0.0
|
|
284
|
+
) or statistics.median(upper_loose_heights) <= STYLE_INFLATION_LOOSE_TIGHT_RATIO * max(
|
|
285
|
+
0.1, _quantile(upper_tight_heights, 0.75)
|
|
286
|
+
):
|
|
287
|
+
return None
|
|
288
|
+
reference_ascent_ratios = [member[1] / max(member[4], member[3], 0.1) for member in reference_tier]
|
|
289
|
+
reference_descent_ratios = [member[2] / max(member[4], member[3], 0.1) for member in reference_tier]
|
|
290
|
+
return (
|
|
291
|
+
statistics.median(reference_ascent_ratios) * em_height,
|
|
292
|
+
statistics.median(reference_descent_ratios) * em_height,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _coerce_origin(value: Any) -> tuple[float, float] | None:
|
|
297
|
+
"""把 side map 中的 origin 收敛为有限二维坐标。"""
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
origin = (float(value[0]), float(value[1]))
|
|
301
|
+
except (IndexError, TypeError, ValueError):
|
|
302
|
+
return None
|
|
303
|
+
return origin if all(math.isfinite(item) for item in origin) else None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@lru_cache(maxsize=512)
|
|
307
|
+
def _normalized_font_family(name: str) -> str:
|
|
308
|
+
"""移除 PDF 子集前缀并归一化字体族名称。"""
|
|
309
|
+
|
|
310
|
+
value = re.sub(r"^[A-Z]{6}\+", "", name)
|
|
311
|
+
return re.sub(r"[\s_-]+", "", value).casefold() or "<unknown>"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@lru_cache(maxsize=8192)
|
|
315
|
+
def _script_group(text: str) -> str:
|
|
316
|
+
"""把字符归入字体 run 使用的宽粒度文字类别。"""
|
|
317
|
+
|
|
318
|
+
if text.isascii() and text.isalpha():
|
|
319
|
+
return "latin"
|
|
320
|
+
if text.isdigit():
|
|
321
|
+
return "digit"
|
|
322
|
+
codepoint = ord(text[0]) if text else 0
|
|
323
|
+
if (
|
|
324
|
+
0x3400 <= codepoint <= 0x9FFF
|
|
325
|
+
or 0xF900 <= codepoint <= 0xFAFF
|
|
326
|
+
or 0x3040 <= codepoint <= 0x30FF
|
|
327
|
+
or 0xAC00 <= codepoint <= 0xD7AF
|
|
328
|
+
):
|
|
329
|
+
return "cjk"
|
|
330
|
+
category = unicodedata.category(text[0]) if text else "Cn"
|
|
331
|
+
if category.startswith("L"):
|
|
332
|
+
return "letter"
|
|
333
|
+
if category.startswith("N"):
|
|
334
|
+
return "number"
|
|
335
|
+
return "other"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _font_run_key(char: dict[str, Any], angle: int, text: str) -> tuple[RunKey, float]:
|
|
339
|
+
"""构造字体族、字号、字重、方向和文字类别组成的 run key。"""
|
|
340
|
+
|
|
341
|
+
font = char.get("font") or {}
|
|
342
|
+
font_name = str(font.get("name") or "<unknown>")
|
|
343
|
+
try:
|
|
344
|
+
font_size = float(font.get("size") or 0.0)
|
|
345
|
+
except (TypeError, ValueError):
|
|
346
|
+
font_size = 0.0
|
|
347
|
+
try:
|
|
348
|
+
flags = int(font.get("flags") or 0)
|
|
349
|
+
except (TypeError, ValueError):
|
|
350
|
+
flags = 0
|
|
351
|
+
try:
|
|
352
|
+
weight = int(round(float(font.get("weight") or 0.0) / 100.0) * 100)
|
|
353
|
+
except (TypeError, ValueError):
|
|
354
|
+
weight = 0
|
|
355
|
+
return _cached_run_key(
|
|
356
|
+
font_name,
|
|
357
|
+
font_size,
|
|
358
|
+
flags,
|
|
359
|
+
weight,
|
|
360
|
+
angle,
|
|
361
|
+
_script_group(text),
|
|
362
|
+
), font_size
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@lru_cache(maxsize=4096)
|
|
366
|
+
def _cached_run_key(
|
|
367
|
+
font_name: str,
|
|
368
|
+
font_size: float,
|
|
369
|
+
flags: int,
|
|
370
|
+
weight: int,
|
|
371
|
+
angle: int,
|
|
372
|
+
script: str,
|
|
373
|
+
) -> RunKey:
|
|
374
|
+
"""缓存同字体样式反复出现的规范化 run key。"""
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
_normalized_font_family(font_name),
|
|
378
|
+
round(font_size * 4.0) / 4.0,
|
|
379
|
+
flags,
|
|
380
|
+
weight,
|
|
381
|
+
angle,
|
|
382
|
+
script,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _is_anchor_text(text: str) -> bool:
|
|
387
|
+
"""仅让字母、数字和 CJK 等完整字形参与主要基线统计。"""
|
|
388
|
+
|
|
389
|
+
if not text or text.isspace() or not text.isprintable():
|
|
390
|
+
return False
|
|
391
|
+
category = unicodedata.category(text[0])
|
|
392
|
+
return category.startswith("L") or category.startswith("N")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _source_bbox(
|
|
396
|
+
char: dict[str, Any],
|
|
397
|
+
geometry: PDFPageTextGeometry,
|
|
398
|
+
char_idx: int,
|
|
399
|
+
) -> BBox | None:
|
|
400
|
+
"""按提取契约读取 loose:零旋转使用 char bbox,旋转字符才接受 side-map。"""
|
|
401
|
+
|
|
402
|
+
raw_bbox = _coerce_bbox(char.get("bbox"))
|
|
403
|
+
try:
|
|
404
|
+
rotation = float(char.get("rotation") or 0.0)
|
|
405
|
+
except (TypeError, ValueError):
|
|
406
|
+
rotation = math.nan
|
|
407
|
+
if math.isfinite(rotation) and abs(rotation) <= 1e-9:
|
|
408
|
+
return raw_bbox
|
|
409
|
+
side_bbox = _coerce_bbox(geometry.loose_bboxes.get(char_idx))
|
|
410
|
+
if side_bbox is None or raw_bbox is None:
|
|
411
|
+
return side_bbox or raw_bbox
|
|
412
|
+
tight_bbox = _coerce_bbox(geometry.tight_bboxes.get(char_idx))
|
|
413
|
+
stable_width = max(
|
|
414
|
+
raw_bbox[2] - raw_bbox[0],
|
|
415
|
+
(tight_bbox[2] - tight_bbox[0]) if tight_bbox is not None else 0.0,
|
|
416
|
+
0.1,
|
|
417
|
+
)
|
|
418
|
+
if side_bbox[2] - side_bbox[0] > 1.6 * stable_width:
|
|
419
|
+
return raw_bbox
|
|
420
|
+
return side_bbox
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _style_line_is_inflated(
|
|
424
|
+
source_height: float,
|
|
425
|
+
font_sizes: Sequence[float],
|
|
426
|
+
tight_heights: Sequence[float],
|
|
427
|
+
) -> bool:
|
|
428
|
+
"""按统一阈值判断一行 loose 高度是否显著偏离字号与 tight 字形。"""
|
|
429
|
+
|
|
430
|
+
if not tight_heights:
|
|
431
|
+
return False
|
|
432
|
+
style_scale = max(
|
|
433
|
+
statistics.median(font_sizes) if font_sizes else 0.0,
|
|
434
|
+
_quantile(tight_heights, 0.75),
|
|
435
|
+
)
|
|
436
|
+
if style_scale < STYLE_INFLATION_MIN_SCALE:
|
|
437
|
+
return False
|
|
438
|
+
tight_height = max(0.1, _quantile(tight_heights, 0.75))
|
|
439
|
+
return (
|
|
440
|
+
source_height > STYLE_INFLATION_LOOSE_FONT_RATIO * style_scale
|
|
441
|
+
and source_height > STYLE_INFLATION_LOOSE_TIGHT_RATIO * tight_height
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _document_requires_full_geometry(
|
|
446
|
+
lines_by_page: list[list[_LineItem]],
|
|
447
|
+
geometries: list[PDFPageTextGeometry],
|
|
448
|
+
page_sizes: list[tuple[float, float]],
|
|
449
|
+
) -> _DocumentGeometryRisk:
|
|
450
|
+
"""流式识别布局与样式风险,避免健康文档保留整本字符样本。"""
|
|
451
|
+
|
|
452
|
+
x_ratios: dict[RunKey, list[float]] = defaultdict(list)
|
|
453
|
+
x_overlaps: dict[RunKey, int] = defaultdict(int)
|
|
454
|
+
y_ratios: list[float] = []
|
|
455
|
+
y_extreme_runs: Counter[RunKey] = Counter()
|
|
456
|
+
style_line_counts: Counter[RunKey] = Counter()
|
|
457
|
+
style_inflated_lines: dict[RunKey, set[LineKey]] = defaultdict(set)
|
|
458
|
+
for page_index, (lines, geometry, page_size) in enumerate(
|
|
459
|
+
zip(lines_by_page, geometries, page_sizes, strict=True),
|
|
460
|
+
):
|
|
461
|
+
for line in lines:
|
|
462
|
+
entries: list[tuple[int, str, BBox, BBox, tuple[float, float], RunKey, float]] = []
|
|
463
|
+
for position, char in enumerate(line.chars):
|
|
464
|
+
text = str(char.get("char") or "")
|
|
465
|
+
char_idx = char.get("char_idx")
|
|
466
|
+
if not _is_anchor_text(text) or isinstance(char_idx, bool) or not isinstance(char_idx, int):
|
|
467
|
+
continue
|
|
468
|
+
source = _clip_validated_bbox(_source_bbox(char, geometry, char_idx), page_size)
|
|
469
|
+
tight = _clip_validated_bbox(_coerce_bbox(geometry.tight_bboxes.get(char_idx)), page_size)
|
|
470
|
+
origin = _coerce_origin(geometry.origins.get(char_idx))
|
|
471
|
+
if source is None or tight is None or origin is None:
|
|
472
|
+
continue
|
|
473
|
+
run_key, font_size = _font_run_key(char, line.angle, text)
|
|
474
|
+
entries.append(
|
|
475
|
+
(
|
|
476
|
+
position,
|
|
477
|
+
text,
|
|
478
|
+
_rotate_bbox_to_upright(source, page_size, line.angle),
|
|
479
|
+
_rotate_bbox_to_upright(tight, page_size, line.angle),
|
|
480
|
+
_rotate_origin_to_upright(origin, page_size, line.angle),
|
|
481
|
+
run_key,
|
|
482
|
+
font_size,
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
if not entries:
|
|
486
|
+
continue
|
|
487
|
+
height_q75 = _quantile([entry[3][3] - entry[3][1] for entry in entries], 0.75)
|
|
488
|
+
anchors = [entry for entry in entries if entry[3][3] - entry[3][1] >= ANCHOR_MIN_TIGHT_HEIGHT_RATIO * height_q75]
|
|
489
|
+
for current, following in zip(anchors, anchors[1:]):
|
|
490
|
+
if current[5] != following[5]:
|
|
491
|
+
continue
|
|
492
|
+
tight_height = max(current[3][3] - current[3][1], following[3][3] - following[3][1])
|
|
493
|
+
if abs(current[4][1] - following[4][1]) > max(0.5, 0.25 * tight_height):
|
|
494
|
+
continue
|
|
495
|
+
advance = following[4][0] - current[4][0]
|
|
496
|
+
tight_width = max(current[3][2] - current[3][0], following[3][2] - following[3][0])
|
|
497
|
+
if not 0.1 < advance <= max(5.0 * max(current[6], 1.0), 8.0 * tight_width):
|
|
498
|
+
continue
|
|
499
|
+
ratio = (current[2][2] - current[2][0]) / advance
|
|
500
|
+
x_ratios[current[5]].append(ratio)
|
|
501
|
+
following_width = following[3][2] - following[3][0]
|
|
502
|
+
if current[2][2] - following[3][0] >= 0.05 * max(following_width, 0.1):
|
|
503
|
+
x_overlaps[current[5]] += 1
|
|
504
|
+
|
|
505
|
+
anchors_by_run: dict[RunKey, list[tuple[int, str, BBox, BBox, tuple[float, float], RunKey, float]]] = defaultdict(
|
|
506
|
+
list
|
|
507
|
+
)
|
|
508
|
+
for entry in anchors:
|
|
509
|
+
anchors_by_run[entry[5]].append(entry)
|
|
510
|
+
for run_key, run_entries in anchors_by_run.items():
|
|
511
|
+
style_line_counts[run_key] += 1
|
|
512
|
+
if _style_line_is_inflated(
|
|
513
|
+
line.effective_height,
|
|
514
|
+
[entry[6] for entry in run_entries if entry[6] > 0],
|
|
515
|
+
[entry[3][3] - entry[3][1] for entry in run_entries],
|
|
516
|
+
):
|
|
517
|
+
style_inflated_lines[run_key].add(
|
|
518
|
+
(page_index, line.source_index),
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# 四锚点门槛只约束 Y 分析;短行仍须为文档级 X 统计贡献相邻字符对。
|
|
522
|
+
if len(anchors) < Y_MIN_ANCHOR_COUNT:
|
|
523
|
+
continue
|
|
524
|
+
if line.angle != 0 or line.formula_candidate_only or line.restored_inline_cluster or line.compact_formula_cluster:
|
|
525
|
+
continue
|
|
526
|
+
baseline_entries = sorted(anchors, key=lambda entry: entry[4][1])
|
|
527
|
+
tolerance = max(0.5, 0.25 * height_q75)
|
|
528
|
+
clusters: list[list[tuple[int, str, BBox, BBox, tuple[float, float], RunKey, float]]] = []
|
|
529
|
+
for entry in baseline_entries:
|
|
530
|
+
if not clusters or abs(entry[4][1] - statistics.median(item[4][1] for item in clusters[-1])) > tolerance:
|
|
531
|
+
clusters.append([entry])
|
|
532
|
+
else:
|
|
533
|
+
clusters[-1].append(entry)
|
|
534
|
+
supported = [cluster for cluster in clusters if len(cluster) >= 3 and len(cluster) / len(anchors) >= 0.20]
|
|
535
|
+
if len(supported) >= 2:
|
|
536
|
+
return _DocumentGeometryRisk(layout=True)
|
|
537
|
+
dominant = max(clusters, key=len)
|
|
538
|
+
if len(dominant) / len(anchors) < Y_DOMINANT_ROW_SHARE:
|
|
539
|
+
continue
|
|
540
|
+
source_union = _bbox_union_many([entry[2] for entry in dominant])
|
|
541
|
+
tight_union = _bbox_union_many([entry[3] for entry in dominant])
|
|
542
|
+
tight_height = tight_union[3] - tight_union[1]
|
|
543
|
+
if tight_height <= 0:
|
|
544
|
+
continue
|
|
545
|
+
ratio = (source_union[3] - source_union[1]) / tight_height
|
|
546
|
+
y_ratios.append(ratio)
|
|
547
|
+
if ratio >= 3.0:
|
|
548
|
+
y_extreme_runs[Counter(entry[5] for entry in dominant).most_common(1)[0][0]] += 1
|
|
549
|
+
|
|
550
|
+
layout_risk = False
|
|
551
|
+
for run_key, ratios in x_ratios.items():
|
|
552
|
+
pair_count = len(ratios)
|
|
553
|
+
if pair_count < X_RELIABLE_PAIR_MIN:
|
|
554
|
+
continue
|
|
555
|
+
if (
|
|
556
|
+
statistics.median(ratios) >= X_STRONG_MEDIAN_RATIO
|
|
557
|
+
and sum(value > X_STRONG_RATIO_THRESHOLD for value in ratios) / pair_count >= X_STRONG_RATIO_SHARE
|
|
558
|
+
and x_overlaps[run_key] / pair_count >= X_STRONG_NEXT_TIGHT_OVERLAP_SHARE
|
|
559
|
+
):
|
|
560
|
+
layout_risk = True
|
|
561
|
+
break
|
|
562
|
+
layout_risk = (
|
|
563
|
+
layout_risk
|
|
564
|
+
or bool(y_ratios)
|
|
565
|
+
and (
|
|
566
|
+
_quantile(y_ratios, 0.95) >= Y_DOCUMENT_RISK_P95_RATIO
|
|
567
|
+
or any(count >= Y_MIN_REPEATED_LINES for count in y_extreme_runs.values())
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
style_risk = any(
|
|
571
|
+
len(inflated_lines) >= STYLE_INFLATION_MIN_LINE_COUNT
|
|
572
|
+
and len(inflated_lines) / style_line_counts[run_key] >= STYLE_INFLATION_MIN_LINE_SHARE
|
|
573
|
+
and len({page_index for page_index, _source_index in inflated_lines}) >= STYLE_INFLATION_MIN_PAGE_COUNT
|
|
574
|
+
for run_key, inflated_lines in style_inflated_lines.items()
|
|
575
|
+
if style_line_counts[run_key] >= STYLE_INFLATION_MIN_LINE_COUNT
|
|
576
|
+
)
|
|
577
|
+
return _DocumentGeometryRisk(
|
|
578
|
+
layout=layout_risk,
|
|
579
|
+
style=style_risk,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _collect_samples(
|
|
584
|
+
lines_by_page: list[list[_LineItem]],
|
|
585
|
+
geometries: list[PDFPageTextGeometry],
|
|
586
|
+
page_sizes: list[tuple[float, float]],
|
|
587
|
+
) -> tuple[list[_CharSample], dict[LineKey, list[_CharSample]]]:
|
|
588
|
+
"""收集具有合法 loose/tight/origin 的可见字符样本。"""
|
|
589
|
+
|
|
590
|
+
samples: list[_CharSample] = []
|
|
591
|
+
by_line: dict[LineKey, list[_CharSample]] = defaultdict(list)
|
|
592
|
+
for page_index, (lines, geometry, page_size) in enumerate(zip(lines_by_page, geometries, page_sizes, strict=True)):
|
|
593
|
+
for line in lines:
|
|
594
|
+
for position, char in enumerate(line.chars):
|
|
595
|
+
text = str(char.get("char") or "")
|
|
596
|
+
char_idx = char.get("char_idx")
|
|
597
|
+
if (
|
|
598
|
+
not text
|
|
599
|
+
or not text.isprintable()
|
|
600
|
+
or text.isspace()
|
|
601
|
+
or isinstance(char_idx, bool)
|
|
602
|
+
or not isinstance(char_idx, int)
|
|
603
|
+
):
|
|
604
|
+
continue
|
|
605
|
+
source_bbox = _clip_validated_bbox(_source_bbox(char, geometry, char_idx), page_size)
|
|
606
|
+
tight_bbox = _clip_validated_bbox(_coerce_bbox(geometry.tight_bboxes.get(char_idx)), page_size)
|
|
607
|
+
origin = _coerce_origin(geometry.origins.get(char_idx))
|
|
608
|
+
if source_bbox is None or tight_bbox is None or origin is None:
|
|
609
|
+
continue
|
|
610
|
+
run_key, font_size = _font_run_key(char, line.angle, text)
|
|
611
|
+
sample = _CharSample(
|
|
612
|
+
page_index=page_index,
|
|
613
|
+
line=line,
|
|
614
|
+
position=position,
|
|
615
|
+
char_idx=char_idx,
|
|
616
|
+
text=text,
|
|
617
|
+
source_bbox=source_bbox,
|
|
618
|
+
tight_bbox=tight_bbox,
|
|
619
|
+
origin=origin,
|
|
620
|
+
local_source_bbox=_rotate_bbox_to_upright(source_bbox, page_size, line.angle),
|
|
621
|
+
local_tight_bbox=_rotate_bbox_to_upright(tight_bbox, page_size, line.angle),
|
|
622
|
+
local_origin=_rotate_origin_to_upright(origin, page_size, line.angle),
|
|
623
|
+
run_key=run_key,
|
|
624
|
+
font_size=font_size,
|
|
625
|
+
)
|
|
626
|
+
samples.append(sample)
|
|
627
|
+
by_line[(page_index, line.source_index)].append(sample)
|
|
628
|
+
# canonical sample 已持有所需 source bbox;后续表格、脚本和字符回填只读取
|
|
629
|
+
# tight/origin,逐页释放 loose side-map 可限制长文档峰值内存。
|
|
630
|
+
geometry.loose_bboxes.clear()
|
|
631
|
+
|
|
632
|
+
for line_samples in by_line.values():
|
|
633
|
+
anchor_heights = [
|
|
634
|
+
sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in line_samples if _is_anchor_text(sample.text)
|
|
635
|
+
]
|
|
636
|
+
height_q75 = _quantile(anchor_heights, 0.75)
|
|
637
|
+
for sample in line_samples:
|
|
638
|
+
tight_height = sample.local_tight_bbox[3] - sample.local_tight_bbox[1]
|
|
639
|
+
sample.is_anchor = (
|
|
640
|
+
_is_anchor_text(sample.text) and height_q75 > 0 and tight_height >= ANCHOR_MIN_TIGHT_HEIGHT_RATIO * height_q75
|
|
641
|
+
)
|
|
642
|
+
return samples, by_line
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _build_run_stats(
|
|
646
|
+
samples: list[_CharSample],
|
|
647
|
+
by_line: dict[LineKey, list[_CharSample]],
|
|
648
|
+
) -> dict[RunKey, _RunStats]:
|
|
649
|
+
"""统计同 run 相邻 origin advance 与 loose 覆盖下一 tight 的比例。"""
|
|
650
|
+
|
|
651
|
+
runs = {key: _RunStats(key=key) for key in {sample.run_key for sample in samples}}
|
|
652
|
+
for sample in samples:
|
|
653
|
+
run = runs[sample.run_key]
|
|
654
|
+
run.samples.append(sample)
|
|
655
|
+
run.tight_left_bearings.append(sample.local_tight_bbox[0] - sample.local_origin[0])
|
|
656
|
+
|
|
657
|
+
for line_samples in by_line.values():
|
|
658
|
+
anchors = [sample for sample in line_samples if sample.is_anchor]
|
|
659
|
+
anchors.sort(key=lambda sample: sample.position)
|
|
660
|
+
for current, following in zip(anchors, anchors[1:]):
|
|
661
|
+
if current.run_key != following.run_key:
|
|
662
|
+
continue
|
|
663
|
+
tight_height = max(
|
|
664
|
+
current.local_tight_bbox[3] - current.local_tight_bbox[1],
|
|
665
|
+
following.local_tight_bbox[3] - following.local_tight_bbox[1],
|
|
666
|
+
)
|
|
667
|
+
if abs(current.local_origin[1] - following.local_origin[1]) > max(0.5, 0.25 * tight_height):
|
|
668
|
+
continue
|
|
669
|
+
advance = following.local_origin[0] - current.local_origin[0]
|
|
670
|
+
tight_width = max(
|
|
671
|
+
current.local_tight_bbox[2] - current.local_tight_bbox[0],
|
|
672
|
+
following.local_tight_bbox[2] - following.local_tight_bbox[0],
|
|
673
|
+
)
|
|
674
|
+
limit = max(5.0 * max(current.font_size, 1.0), 8.0 * tight_width)
|
|
675
|
+
if not 0.1 < advance <= limit:
|
|
676
|
+
continue
|
|
677
|
+
source_width = current.local_source_bbox[2] - current.local_source_bbox[0]
|
|
678
|
+
if source_width <= 0:
|
|
679
|
+
continue
|
|
680
|
+
overlap = current.local_source_bbox[2] - following.local_tight_bbox[0]
|
|
681
|
+
following_width = following.local_tight_bbox[2] - following.local_tight_bbox[0]
|
|
682
|
+
run = runs[current.run_key]
|
|
683
|
+
run.pair_ratios.append(source_width / advance)
|
|
684
|
+
run.pair_overlaps.append(overlap >= 0.05 * max(following_width, 0.1))
|
|
685
|
+
run.advances.append(advance)
|
|
686
|
+
|
|
687
|
+
for run in runs.values():
|
|
688
|
+
run.median_advance = statistics.median(run.advances) if run.advances else None
|
|
689
|
+
run.median_tight_left_bearing = statistics.median(run.tight_left_bearings) if run.tight_left_bearings else 0.0
|
|
690
|
+
pair_count = len(run.pair_ratios)
|
|
691
|
+
if pair_count < X_RELIABLE_PAIR_MIN:
|
|
692
|
+
continue
|
|
693
|
+
median_ratio = statistics.median(run.pair_ratios)
|
|
694
|
+
large_share = sum(value > X_STRONG_RATIO_THRESHOLD for value in run.pair_ratios) / pair_count
|
|
695
|
+
overlap_share = sum(run.pair_overlaps) / pair_count
|
|
696
|
+
run.strong_x_bad = (
|
|
697
|
+
median_ratio >= X_STRONG_MEDIAN_RATIO
|
|
698
|
+
and large_share >= X_STRONG_RATIO_SHARE
|
|
699
|
+
and overlap_share >= X_STRONG_NEXT_TIGHT_OVERLAP_SHARE
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
bad_families = {run.key[0] for run in runs.values() if run.strong_x_bad}
|
|
703
|
+
for run in runs.values():
|
|
704
|
+
if run.strong_x_bad or run.key[0] not in bad_families or len(run.pair_ratios) < X_SIBLING_PAIR_MIN:
|
|
705
|
+
continue
|
|
706
|
+
median_ratio = statistics.median(run.pair_ratios)
|
|
707
|
+
overlap_share = sum(run.pair_overlaps) / len(run.pair_overlaps)
|
|
708
|
+
run.sibling_x_bad = (
|
|
709
|
+
median_ratio >= X_SIBLING_MEDIAN_RATIO and overlap_share >= X_SIBLING_NEXT_TIGHT_OVERLAP_SHARE
|
|
710
|
+
) or _quantile(run.pair_ratios, 0.9) >= X_SIBLING_P90_RATIO
|
|
711
|
+
return runs
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _mark_style_inflated_runs(
|
|
715
|
+
runs: dict[RunKey, _RunStats],
|
|
716
|
+
) -> set[RunKey]:
|
|
717
|
+
"""标记跨页重复出现且 loose 高度同时偏离字号与 tight 的字体 run。"""
|
|
718
|
+
|
|
719
|
+
output: set[RunKey] = set()
|
|
720
|
+
for run in runs.values():
|
|
721
|
+
samples_by_line: dict[LineKey, list[_CharSample]] = defaultdict(list)
|
|
722
|
+
for sample in run.samples:
|
|
723
|
+
if sample.is_anchor:
|
|
724
|
+
samples_by_line[(sample.page_index, sample.line.source_index)].append(sample)
|
|
725
|
+
if len(samples_by_line) < STYLE_INFLATION_MIN_LINE_COUNT:
|
|
726
|
+
continue
|
|
727
|
+
inflated_lines: list[LineKey] = []
|
|
728
|
+
for line_key, line_samples in samples_by_line.items():
|
|
729
|
+
if _style_line_is_inflated(
|
|
730
|
+
line_samples[0].line.effective_height,
|
|
731
|
+
[sample.font_size for sample in line_samples if sample.font_size > 0],
|
|
732
|
+
[sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in line_samples],
|
|
733
|
+
):
|
|
734
|
+
inflated_lines.append(line_key)
|
|
735
|
+
if (
|
|
736
|
+
len(inflated_lines) >= STYLE_INFLATION_MIN_LINE_COUNT
|
|
737
|
+
and len(inflated_lines) / len(samples_by_line) >= STYLE_INFLATION_MIN_LINE_SHARE
|
|
738
|
+
and len({page_index for page_index, _source_index in inflated_lines}) >= STYLE_INFLATION_MIN_PAGE_COUNT
|
|
739
|
+
):
|
|
740
|
+
run.style_y_bad = True
|
|
741
|
+
output.add(run.key)
|
|
742
|
+
return output
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _document_uses_global_style_calibration(
|
|
746
|
+
style_inflated_runs: set[RunKey],
|
|
747
|
+
) -> bool:
|
|
748
|
+
"""只要存在已通过跨页重复证据的异常 run,就启用全文统一字体尺度。"""
|
|
749
|
+
|
|
750
|
+
return bool(style_inflated_runs)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _apply_style_scale_repairs(
|
|
754
|
+
plan: DocumentGeometryPlan,
|
|
755
|
+
style_inflated_runs: set[RunKey],
|
|
756
|
+
by_line: dict[LineKey, list[_CharSample]],
|
|
757
|
+
) -> None:
|
|
758
|
+
"""异常文档触发后统一写入逐行 canonical 字号,不改变公开来源 bbox。"""
|
|
759
|
+
|
|
760
|
+
if not style_inflated_runs:
|
|
761
|
+
return
|
|
762
|
+
for line_key, line_samples in by_line.items():
|
|
763
|
+
anchor_samples = [sample for sample in line_samples if sample.is_anchor]
|
|
764
|
+
if not anchor_samples:
|
|
765
|
+
continue
|
|
766
|
+
dominant_run = Counter(sample.run_key for sample in anchor_samples).most_common(1)[0][0]
|
|
767
|
+
style_samples = [sample for sample in anchor_samples if sample.run_key == dominant_run]
|
|
768
|
+
font_sizes = [sample.font_size for sample in style_samples if sample.font_size > 0]
|
|
769
|
+
tight_heights = [sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in style_samples]
|
|
770
|
+
style_scale = max(
|
|
771
|
+
statistics.median(font_sizes) if font_sizes else 0.0,
|
|
772
|
+
_quantile(tight_heights, 0.75),
|
|
773
|
+
0.1,
|
|
774
|
+
)
|
|
775
|
+
if style_scale < STYLE_INFLATION_MIN_SCALE:
|
|
776
|
+
continue
|
|
777
|
+
plan.line_style_scales[line_key] = style_scale
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _line_uses_repaired_style_scale(
|
|
781
|
+
line: _LineItem,
|
|
782
|
+
style_inflated_runs: set[RunKey],
|
|
783
|
+
) -> bool:
|
|
784
|
+
"""按字体族、字号、flags 和方向把异常 run 证据投影到当前视觉行。"""
|
|
785
|
+
|
|
786
|
+
if line.font_signature is None or line.em_height <= 0:
|
|
787
|
+
return False
|
|
788
|
+
family = _normalized_font_family(line.font_signature[0])
|
|
789
|
+
style_size = round(line.em_height * 4.0) / 4.0
|
|
790
|
+
flags = line.font_signature[1]
|
|
791
|
+
return any(
|
|
792
|
+
family == run_key[0] and abs(style_size - run_key[1]) <= 0.25 and flags == run_key[2] and line.angle == run_key[4]
|
|
793
|
+
for run_key in style_inflated_runs
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _restore_stable_legacy_source_bboxes(
|
|
798
|
+
by_line: dict[LineKey, list[_CharSample]],
|
|
799
|
+
page_sizes: list[tuple[float, float]],
|
|
800
|
+
) -> None:
|
|
801
|
+
"""全文异常成立后改用原始字符 bbox,隔离仅存在于 loose side-map 的扰动。"""
|
|
802
|
+
|
|
803
|
+
for (page_index, _source_index), line_samples in by_line.items():
|
|
804
|
+
page_size = page_sizes[page_index]
|
|
805
|
+
for sample in line_samples:
|
|
806
|
+
raw_bbox = _clip_validated_bbox(
|
|
807
|
+
_coerce_bbox(sample.line.chars[sample.position].get("bbox")),
|
|
808
|
+
page_size,
|
|
809
|
+
)
|
|
810
|
+
if raw_bbox is None:
|
|
811
|
+
continue
|
|
812
|
+
sample.source_bbox = raw_bbox
|
|
813
|
+
sample.local_source_bbox = _rotate_bbox_to_upright(
|
|
814
|
+
raw_bbox,
|
|
815
|
+
page_size,
|
|
816
|
+
sample.line.angle,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def _next_compatible_sample(
|
|
821
|
+
current: _CharSample,
|
|
822
|
+
line_samples: list[_CharSample],
|
|
823
|
+
) -> _CharSample | None:
|
|
824
|
+
"""返回同 run、同基线且 origin 正向的下一可见字符。"""
|
|
825
|
+
|
|
826
|
+
for candidate in line_samples:
|
|
827
|
+
if candidate.position <= current.position or candidate.run_key != current.run_key:
|
|
828
|
+
continue
|
|
829
|
+
tight_height = max(
|
|
830
|
+
current.local_tight_bbox[3] - current.local_tight_bbox[1],
|
|
831
|
+
candidate.local_tight_bbox[3] - candidate.local_tight_bbox[1],
|
|
832
|
+
)
|
|
833
|
+
if abs(current.local_origin[1] - candidate.local_origin[1]) > max(0.5, 0.25 * tight_height):
|
|
834
|
+
continue
|
|
835
|
+
advance = candidate.local_origin[0] - current.local_origin[0]
|
|
836
|
+
if advance > 0.1:
|
|
837
|
+
return candidate
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _x_run_style_key(run_key: RunKey) -> XRunStyleKey:
|
|
842
|
+
"""移除文字类别,返回字体样式和方向一致的 X 修复分组键。"""
|
|
843
|
+
|
|
844
|
+
return run_key[:5]
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _samples_share_baseline(first: _CharSample, second: _CharSample) -> bool:
|
|
848
|
+
"""按 tight 字形高度判断两个字符是否位于同一局部基线。"""
|
|
849
|
+
|
|
850
|
+
tight_height = max(
|
|
851
|
+
first.local_tight_bbox[3] - first.local_tight_bbox[1],
|
|
852
|
+
second.local_tight_bbox[3] - second.local_tight_bbox[1],
|
|
853
|
+
)
|
|
854
|
+
return abs(first.local_origin[1] - second.local_origin[1]) <= max(0.5, 0.25 * tight_height)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _next_style_compatible_sample(
|
|
858
|
+
current: _CharSample,
|
|
859
|
+
line_samples: list[_CharSample],
|
|
860
|
+
) -> _CharSample | None:
|
|
861
|
+
"""返回同字体样式、同基线且 origin 正向的下一可见字符。"""
|
|
862
|
+
|
|
863
|
+
style_key = _x_run_style_key(current.run_key)
|
|
864
|
+
for candidate in line_samples:
|
|
865
|
+
if (
|
|
866
|
+
candidate.position <= current.position
|
|
867
|
+
or _x_run_style_key(candidate.run_key) != style_key
|
|
868
|
+
or not _samples_share_baseline(current, candidate)
|
|
869
|
+
):
|
|
870
|
+
continue
|
|
871
|
+
if candidate.local_origin[0] - current.local_origin[0] > 0.1:
|
|
872
|
+
return candidate
|
|
873
|
+
return None
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _build_x_char_repair(
|
|
877
|
+
sample: _CharSample,
|
|
878
|
+
donor_run: _RunStats,
|
|
879
|
+
next_sample: _CharSample | None,
|
|
880
|
+
page_size: tuple[float, float],
|
|
881
|
+
*,
|
|
882
|
+
left_bearing: float,
|
|
883
|
+
use_donor_advance: bool = False,
|
|
884
|
+
) -> CharLayoutGeometry | None:
|
|
885
|
+
"""用可靠 run 的 advance 为单个字符构造包含 tight 字形的 X-only 修复。"""
|
|
886
|
+
|
|
887
|
+
advance = (
|
|
888
|
+
donor_run.median_advance
|
|
889
|
+
if use_donor_advance or next_sample is None
|
|
890
|
+
else next_sample.local_origin[0] - sample.local_origin[0]
|
|
891
|
+
)
|
|
892
|
+
if advance is None or advance <= 0:
|
|
893
|
+
return None
|
|
894
|
+
left = min(sample.local_tight_bbox[0], sample.local_origin[0] + left_bearing)
|
|
895
|
+
right = max(sample.local_tight_bbox[2], sample.local_origin[0] + advance)
|
|
896
|
+
if next_sample is not None:
|
|
897
|
+
right = min(right, max(sample.local_tight_bbox[2], next_sample.local_origin[0]))
|
|
898
|
+
if right <= left:
|
|
899
|
+
return None
|
|
900
|
+
layout_bbox = _clip_bbox(
|
|
901
|
+
_rotate_bbox_from_upright(
|
|
902
|
+
(
|
|
903
|
+
left,
|
|
904
|
+
sample.local_source_bbox[1],
|
|
905
|
+
right,
|
|
906
|
+
sample.local_source_bbox[3],
|
|
907
|
+
),
|
|
908
|
+
page_size,
|
|
909
|
+
sample.line.angle,
|
|
910
|
+
),
|
|
911
|
+
page_size,
|
|
912
|
+
)
|
|
913
|
+
if layout_bbox is None:
|
|
914
|
+
return None
|
|
915
|
+
return CharLayoutGeometry(
|
|
916
|
+
source_bbox=sample.source_bbox,
|
|
917
|
+
tight_bbox=sample.tight_bbox,
|
|
918
|
+
origin=sample.origin,
|
|
919
|
+
layout_bbox=layout_bbox,
|
|
920
|
+
ink_bbox=sample.tight_bbox,
|
|
921
|
+
baseline=sample.local_origin[1],
|
|
922
|
+
advance=advance,
|
|
923
|
+
em_height=max(
|
|
924
|
+
sample.font_size,
|
|
925
|
+
sample.local_tight_bbox[3] - sample.local_tight_bbox[1],
|
|
926
|
+
),
|
|
927
|
+
x_state="abnormal",
|
|
928
|
+
y_state="healthy",
|
|
929
|
+
confidence=min(1.0, len(donor_run.pair_ratios) / X_RELIABLE_PAIR_MIN),
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _x_repair_donors(
|
|
934
|
+
runs: dict[RunKey, _RunStats],
|
|
935
|
+
) -> dict[XRunStyleKey, _RunStats]:
|
|
936
|
+
"""按强异常、pair 数和完整键稳定选择各字体样式的 X 修复 donor。"""
|
|
937
|
+
|
|
938
|
+
donors: dict[XRunStyleKey, _RunStats] = {}
|
|
939
|
+
candidates = [
|
|
940
|
+
run
|
|
941
|
+
for run in runs.values()
|
|
942
|
+
if (run.strong_x_bad or run.sibling_x_bad) and run.median_advance is not None and run.median_advance > 0
|
|
943
|
+
]
|
|
944
|
+
for run in sorted(
|
|
945
|
+
candidates,
|
|
946
|
+
key=lambda item: (
|
|
947
|
+
not item.strong_x_bad,
|
|
948
|
+
-len(item.pair_ratios),
|
|
949
|
+
item.key,
|
|
950
|
+
),
|
|
951
|
+
):
|
|
952
|
+
donors.setdefault(_x_run_style_key(run.key), run)
|
|
953
|
+
return donors
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _has_adjacent_repaired_style_sample(
|
|
957
|
+
sample: _CharSample,
|
|
958
|
+
line_samples: list[_CharSample],
|
|
959
|
+
page_index: int,
|
|
960
|
+
plan: DocumentGeometryPlan,
|
|
961
|
+
) -> bool:
|
|
962
|
+
"""确认稀疏字符紧邻同字体样式、同基线且已完成 X 修复的字符。"""
|
|
963
|
+
|
|
964
|
+
style_key = _x_run_style_key(sample.run_key)
|
|
965
|
+
return any(
|
|
966
|
+
abs(candidate.position - sample.position) == 1
|
|
967
|
+
and _x_run_style_key(candidate.run_key) == style_key
|
|
968
|
+
and _samples_share_baseline(sample, candidate)
|
|
969
|
+
and (page_index, candidate.char_idx) in plan.char_repairs
|
|
970
|
+
for candidate in line_samples
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _sparse_x_repair_affects_layout(
|
|
975
|
+
sample: _CharSample,
|
|
976
|
+
line_samples: list[_CharSample],
|
|
977
|
+
donor_run: _RunStats,
|
|
978
|
+
page_size: tuple[float, float],
|
|
979
|
+
) -> bool:
|
|
980
|
+
"""仅接纳撑开行尾或遮蔽跨栏硬间隙的稀疏字符 X 异常。"""
|
|
981
|
+
|
|
982
|
+
donor_advance = donor_run.median_advance
|
|
983
|
+
if donor_advance is None or donor_advance <= 0:
|
|
984
|
+
return False
|
|
985
|
+
tight_width = max(
|
|
986
|
+
0.1,
|
|
987
|
+
sample.local_tight_bbox[2] - sample.local_tight_bbox[0],
|
|
988
|
+
)
|
|
989
|
+
canonical_width = max(donor_advance, tight_width)
|
|
990
|
+
source_width = sample.local_source_bbox[2] - sample.local_source_bbox[0]
|
|
991
|
+
canonical_right = max(
|
|
992
|
+
sample.local_tight_bbox[2],
|
|
993
|
+
sample.local_origin[0] + donor_advance,
|
|
994
|
+
)
|
|
995
|
+
local_page_width = page_size[1] if sample.line.angle in {90, 270} else page_size[0]
|
|
996
|
+
forward_overhang = sample.local_source_bbox[2] - canonical_right
|
|
997
|
+
if source_width <= X_STRONG_RATIO_THRESHOLD * canonical_width or forward_overhang < max(
|
|
998
|
+
2.0 * canonical_width, 0.01 * local_page_width
|
|
999
|
+
):
|
|
1000
|
+
return False
|
|
1001
|
+
|
|
1002
|
+
baseline_samples = sorted(
|
|
1003
|
+
(
|
|
1004
|
+
candidate
|
|
1005
|
+
for candidate in line_samples
|
|
1006
|
+
if candidate.position != sample.position and _samples_share_baseline(sample, candidate)
|
|
1007
|
+
),
|
|
1008
|
+
key=lambda candidate: candidate.position,
|
|
1009
|
+
)
|
|
1010
|
+
following = next(
|
|
1011
|
+
(candidate for candidate in baseline_samples if candidate.position > sample.position),
|
|
1012
|
+
None,
|
|
1013
|
+
)
|
|
1014
|
+
if following is None:
|
|
1015
|
+
right_edge = max(candidate.local_source_bbox[2] for candidate in [sample, *baseline_samples])
|
|
1016
|
+
return abs(sample.local_source_bbox[2] - right_edge) <= 0.25
|
|
1017
|
+
|
|
1018
|
+
median_tight_width = statistics.median(
|
|
1019
|
+
max(
|
|
1020
|
+
0.1,
|
|
1021
|
+
candidate.local_tight_bbox[2] - candidate.local_tight_bbox[0],
|
|
1022
|
+
)
|
|
1023
|
+
for candidate in [sample, *baseline_samples]
|
|
1024
|
+
)
|
|
1025
|
+
hard_gap_threshold = max(
|
|
1026
|
+
15.0,
|
|
1027
|
+
3.0 * median_tight_width,
|
|
1028
|
+
0.02 * local_page_width,
|
|
1029
|
+
)
|
|
1030
|
+
return (
|
|
1031
|
+
following.local_tight_bbox[0] - canonical_right >= hard_gap_threshold
|
|
1032
|
+
and sample.local_source_bbox[2] >= following.local_origin[0]
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _repair_x_chars(
|
|
1037
|
+
plan: DocumentGeometryPlan,
|
|
1038
|
+
runs: dict[RunKey, _RunStats],
|
|
1039
|
+
by_line: dict[LineKey, list[_CharSample]],
|
|
1040
|
+
page_sizes: list[tuple[float, float]],
|
|
1041
|
+
) -> None:
|
|
1042
|
+
"""对强异常 run 及其同样式稀疏邻接字符重建前进方向 cell。"""
|
|
1043
|
+
|
|
1044
|
+
donors = _x_repair_donors(runs)
|
|
1045
|
+
for line_key, line_samples in by_line.items():
|
|
1046
|
+
page_index, _source_index = line_key
|
|
1047
|
+
page_size = page_sizes[page_index]
|
|
1048
|
+
for sample in line_samples:
|
|
1049
|
+
run = runs[sample.run_key]
|
|
1050
|
+
if not (run.strong_x_bad or run.sibling_x_bad):
|
|
1051
|
+
continue
|
|
1052
|
+
next_sample = _next_compatible_sample(sample, line_samples)
|
|
1053
|
+
repair = _build_x_char_repair(
|
|
1054
|
+
sample,
|
|
1055
|
+
run,
|
|
1056
|
+
next_sample,
|
|
1057
|
+
page_size,
|
|
1058
|
+
left_bearing=run.median_tight_left_bearing,
|
|
1059
|
+
)
|
|
1060
|
+
if repair is None:
|
|
1061
|
+
continue
|
|
1062
|
+
plan.char_repairs[(page_index, sample.char_idx)] = repair
|
|
1063
|
+
|
|
1064
|
+
for sample in line_samples:
|
|
1065
|
+
char_key = (page_index, sample.char_idx)
|
|
1066
|
+
run = runs[sample.run_key]
|
|
1067
|
+
donor = donors.get(_x_run_style_key(sample.run_key))
|
|
1068
|
+
if (
|
|
1069
|
+
char_key in plan.char_repairs
|
|
1070
|
+
or donor is None
|
|
1071
|
+
or len(run.pair_ratios) >= X_SIBLING_PAIR_MIN
|
|
1072
|
+
or not _has_adjacent_repaired_style_sample(
|
|
1073
|
+
sample,
|
|
1074
|
+
line_samples,
|
|
1075
|
+
page_index,
|
|
1076
|
+
plan,
|
|
1077
|
+
)
|
|
1078
|
+
or not _sparse_x_repair_affects_layout(
|
|
1079
|
+
sample,
|
|
1080
|
+
line_samples,
|
|
1081
|
+
donor,
|
|
1082
|
+
page_size,
|
|
1083
|
+
)
|
|
1084
|
+
):
|
|
1085
|
+
continue
|
|
1086
|
+
repair = _build_x_char_repair(
|
|
1087
|
+
sample,
|
|
1088
|
+
donor,
|
|
1089
|
+
_next_style_compatible_sample(sample, line_samples),
|
|
1090
|
+
page_size,
|
|
1091
|
+
left_bearing=sample.local_tight_bbox[0] - sample.local_origin[0],
|
|
1092
|
+
use_donor_advance=True,
|
|
1093
|
+
)
|
|
1094
|
+
if repair is not None:
|
|
1095
|
+
plan.char_repairs[char_key] = repair
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _baseline_clusters(samples: list[_CharSample]) -> tuple[list[list[_CharSample]], float]:
|
|
1099
|
+
"""按 origin-v 将 anchor 聚为独立视觉基线。"""
|
|
1100
|
+
|
|
1101
|
+
tight_heights = [sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in samples]
|
|
1102
|
+
tolerance = max(0.5, 0.25 * _quantile(tight_heights, 0.75))
|
|
1103
|
+
clusters: list[list[_CharSample]] = []
|
|
1104
|
+
for sample in sorted(samples, key=lambda item: item.local_origin[1]):
|
|
1105
|
+
target = next(
|
|
1106
|
+
(
|
|
1107
|
+
cluster
|
|
1108
|
+
for cluster in clusters
|
|
1109
|
+
if abs(sample.local_origin[1] - statistics.median(item.local_origin[1] for item in cluster)) <= tolerance
|
|
1110
|
+
),
|
|
1111
|
+
None,
|
|
1112
|
+
)
|
|
1113
|
+
if target is None:
|
|
1114
|
+
clusters.append([sample])
|
|
1115
|
+
else:
|
|
1116
|
+
target.append(sample)
|
|
1117
|
+
return clusters, tolerance
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _record_line_canonical_metrics(
|
|
1121
|
+
plan: DocumentGeometryPlan,
|
|
1122
|
+
by_line: dict[LineKey, list[_CharSample]],
|
|
1123
|
+
) -> None:
|
|
1124
|
+
"""为无需改写 bbox 的普通行保存 tight 字形并集和 dominant origin 基线。"""
|
|
1125
|
+
|
|
1126
|
+
for line_key, line_samples in by_line.items():
|
|
1127
|
+
if not line_samples:
|
|
1128
|
+
continue
|
|
1129
|
+
line = line_samples[0].line
|
|
1130
|
+
plan.line_ink_bboxes[line_key] = _bbox_union_many(
|
|
1131
|
+
[sample.tight_bbox for sample in line_samples],
|
|
1132
|
+
)
|
|
1133
|
+
if line.formula_candidate_only or line.compact_formula_cluster:
|
|
1134
|
+
continue
|
|
1135
|
+
anchors = [sample for sample in line_samples if sample.is_anchor]
|
|
1136
|
+
if len(anchors) < 2:
|
|
1137
|
+
continue
|
|
1138
|
+
clusters, _tolerance = _baseline_clusters(anchors)
|
|
1139
|
+
dominant = max(
|
|
1140
|
+
clusters,
|
|
1141
|
+
key=lambda cluster: (
|
|
1142
|
+
sum(sample.local_tight_bbox[2] - sample.local_tight_bbox[0] for sample in cluster),
|
|
1143
|
+
len(cluster),
|
|
1144
|
+
),
|
|
1145
|
+
)
|
|
1146
|
+
if len(dominant) / len(anchors) < 0.5:
|
|
1147
|
+
continue
|
|
1148
|
+
plan.line_baselines[line_key] = statistics.median(sample.local_origin[1] for sample in dominant)
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _analyze_lines(
|
|
1152
|
+
lines_by_page: list[list[_LineItem]],
|
|
1153
|
+
by_line: dict[LineKey, list[_CharSample]],
|
|
1154
|
+
page_sizes: list[tuple[float, float]],
|
|
1155
|
+
) -> dict[LineKey, _LineAnalysis]:
|
|
1156
|
+
"""分析 legacy line 的 dominant anchor row 与 split shadow 候选。"""
|
|
1157
|
+
|
|
1158
|
+
analyses: dict[LineKey, _LineAnalysis] = {}
|
|
1159
|
+
for page_index, lines in enumerate(lines_by_page):
|
|
1160
|
+
page_size = page_sizes[page_index]
|
|
1161
|
+
for line in lines:
|
|
1162
|
+
if line.angle != 0 or line.formula_candidate_only or line.restored_inline_cluster or line.compact_formula_cluster:
|
|
1163
|
+
continue
|
|
1164
|
+
line_key = (page_index, line.source_index)
|
|
1165
|
+
line_samples = by_line.get(line_key, [])
|
|
1166
|
+
visible_count = sum(
|
|
1167
|
+
1
|
|
1168
|
+
for char in line.chars
|
|
1169
|
+
if str(char.get("char") or "").isprintable() and not str(char.get("char") or "").isspace()
|
|
1170
|
+
)
|
|
1171
|
+
anchors = [sample for sample in line_samples if sample.is_anchor]
|
|
1172
|
+
if visible_count == 0 or len(anchors) < Y_MIN_ANCHOR_COUNT:
|
|
1173
|
+
continue
|
|
1174
|
+
clusters, _tolerance = _baseline_clusters(anchors)
|
|
1175
|
+
dominant = max(
|
|
1176
|
+
clusters,
|
|
1177
|
+
key=lambda cluster: (
|
|
1178
|
+
sum(item.local_tight_bbox[2] - item.local_tight_bbox[0] for item in cluster),
|
|
1179
|
+
len(cluster),
|
|
1180
|
+
),
|
|
1181
|
+
)
|
|
1182
|
+
dominant_share = len(dominant) / len(anchors)
|
|
1183
|
+
geometry_coverage = len(line_samples) / visible_count
|
|
1184
|
+
supported_clusters = [cluster for cluster in clusters if len(cluster) >= 3 and len(cluster) / len(anchors) >= 0.20]
|
|
1185
|
+
baseline = statistics.median(sample.local_origin[1] for sample in dominant)
|
|
1186
|
+
tight_core = _bbox_union_many([sample.local_tight_bbox for sample in dominant])
|
|
1187
|
+
source_envelope = _bbox_union_many([sample.local_source_bbox for sample in anchors])
|
|
1188
|
+
all_source_envelope = _bbox_union_many([sample.local_source_bbox for sample in line_samples])
|
|
1189
|
+
run_key = Counter(sample.run_key for sample in dominant).most_common(1)[0][0]
|
|
1190
|
+
analyses[line_key] = _LineAnalysis(
|
|
1191
|
+
key=line_key,
|
|
1192
|
+
line=line,
|
|
1193
|
+
samples=line_samples,
|
|
1194
|
+
anchors=anchors,
|
|
1195
|
+
dominant=dominant,
|
|
1196
|
+
baseline=baseline,
|
|
1197
|
+
tight_core=tight_core,
|
|
1198
|
+
local_source_bbox=source_envelope,
|
|
1199
|
+
explicit_all_source_bbox=all_source_envelope,
|
|
1200
|
+
legacy_local_bbox=_rotate_bbox_to_upright(line.bbox, page_size, line.angle),
|
|
1201
|
+
run_key=run_key,
|
|
1202
|
+
geometry_coverage=geometry_coverage,
|
|
1203
|
+
dominant_share=dominant_share,
|
|
1204
|
+
split_y_candidate=len(supported_clusters) >= 2,
|
|
1205
|
+
)
|
|
1206
|
+
return analyses
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _has_document_y_risk(by_line: dict[LineKey, list[_CharSample]]) -> bool:
|
|
1210
|
+
"""用稳定单基线行的尾部分布实现正常文档 Y 分析快速否决。"""
|
|
1211
|
+
|
|
1212
|
+
ratios: list[float] = []
|
|
1213
|
+
extreme_by_run: Counter[RunKey] = Counter()
|
|
1214
|
+
for line_samples in by_line.values():
|
|
1215
|
+
line = line_samples[0].line if line_samples else None
|
|
1216
|
+
if (
|
|
1217
|
+
line is None
|
|
1218
|
+
or line.angle != 0
|
|
1219
|
+
or line.formula_candidate_only
|
|
1220
|
+
or line.restored_inline_cluster
|
|
1221
|
+
or line.compact_formula_cluster
|
|
1222
|
+
):
|
|
1223
|
+
continue
|
|
1224
|
+
anchors = [sample for sample in line_samples if sample.is_anchor]
|
|
1225
|
+
if len(anchors) < Y_MIN_ANCHOR_COUNT:
|
|
1226
|
+
continue
|
|
1227
|
+
clusters, _tolerance = _baseline_clusters(anchors)
|
|
1228
|
+
supported_clusters = [cluster for cluster in clusters if len(cluster) >= 3 and len(cluster) / len(anchors) >= 0.20]
|
|
1229
|
+
if len(supported_clusters) >= 2:
|
|
1230
|
+
return True
|
|
1231
|
+
dominant = max(clusters, key=len)
|
|
1232
|
+
if len(dominant) / len(anchors) < Y_DOMINANT_ROW_SHARE:
|
|
1233
|
+
continue
|
|
1234
|
+
source_union = _bbox_union_many([sample.local_source_bbox for sample in dominant])
|
|
1235
|
+
tight_union = _bbox_union_many([sample.local_tight_bbox for sample in dominant])
|
|
1236
|
+
tight_height = tight_union[3] - tight_union[1]
|
|
1237
|
+
if tight_height <= 0:
|
|
1238
|
+
continue
|
|
1239
|
+
ratio = (source_union[3] - source_union[1]) / tight_height
|
|
1240
|
+
ratios.append(ratio)
|
|
1241
|
+
if ratio >= 3.0:
|
|
1242
|
+
run_key = Counter(sample.run_key for sample in dominant).most_common(1)[0][0]
|
|
1243
|
+
extreme_by_run[run_key] += 1
|
|
1244
|
+
return bool(ratios) and (
|
|
1245
|
+
_quantile(ratios, 0.95) >= Y_DOCUMENT_RISK_P95_RATIO
|
|
1246
|
+
or any(count >= Y_MIN_REPEATED_LINES for count in extreme_by_run.values())
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _same_lane(first: _LineAnalysis, second: _LineAnalysis) -> bool:
|
|
1251
|
+
"""用水平覆盖和左边缘近似判断两行是否属于同一栏。"""
|
|
1252
|
+
|
|
1253
|
+
height = max(
|
|
1254
|
+
first.tight_core[3] - first.tight_core[1],
|
|
1255
|
+
second.tight_core[3] - second.tight_core[1],
|
|
1256
|
+
)
|
|
1257
|
+
return _bbox_axis_overlap_ratio(first.local_source_bbox, second.local_source_bbox, axis="x") >= 0.35 or abs(
|
|
1258
|
+
first.local_source_bbox[0] - second.local_source_bbox[0]
|
|
1259
|
+
) <= 2.0 * max(height, 1.0)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _assign_neighbors(analyses: dict[LineKey, _LineAnalysis]) -> None:
|
|
1263
|
+
"""为每条 eligible line 选择上下最近的同栏正文行。"""
|
|
1264
|
+
|
|
1265
|
+
by_page: dict[int, list[_LineAnalysis]] = defaultdict(list)
|
|
1266
|
+
for analysis in analyses.values():
|
|
1267
|
+
by_page[analysis.key[0]].append(analysis)
|
|
1268
|
+
for page_analyses in by_page.values():
|
|
1269
|
+
for current in page_analyses:
|
|
1270
|
+
candidates = [
|
|
1271
|
+
other
|
|
1272
|
+
for other in page_analyses
|
|
1273
|
+
if other is not current
|
|
1274
|
+
and _same_lane(current, other)
|
|
1275
|
+
and abs(other.baseline - current.baseline) >= max(2.0, 0.6 * (current.tight_core[3] - current.tight_core[1]))
|
|
1276
|
+
]
|
|
1277
|
+
above = [other for other in candidates if other.baseline < current.baseline]
|
|
1278
|
+
below = [other for other in candidates if other.baseline > current.baseline]
|
|
1279
|
+
if above:
|
|
1280
|
+
current.neighbors.append(max(above, key=lambda other: other.baseline))
|
|
1281
|
+
if below:
|
|
1282
|
+
current.neighbors.append(min(below, key=lambda other: other.baseline))
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _intrusion_ratio(source_bbox: BBox, tight_core: BBox) -> float:
|
|
1286
|
+
"""计算 loose envelope 侵入邻行 tight core 的纵向比例。"""
|
|
1287
|
+
|
|
1288
|
+
overlap = max(0.0, min(source_bbox[3], tight_core[3]) - max(source_bbox[1], tight_core[1]))
|
|
1289
|
+
height = tight_core[3] - tight_core[1]
|
|
1290
|
+
return overlap / height if height > 0 else 0.0
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _mark_y_candidates(analyses: dict[LineKey, _LineAnalysis]) -> set[RunKey]:
|
|
1294
|
+
"""按局部侵入和文档级重复支持确认允许 trim_y 的 run。"""
|
|
1295
|
+
|
|
1296
|
+
_assign_neighbors(analyses)
|
|
1297
|
+
eligible_by_run: dict[RunKey, list[_LineAnalysis]] = defaultdict(list)
|
|
1298
|
+
for analysis in analyses.values():
|
|
1299
|
+
if (
|
|
1300
|
+
len(analysis.anchors) < Y_MIN_ANCHOR_COUNT
|
|
1301
|
+
or analysis.geometry_coverage < Y_MIN_GEOMETRY_COVERAGE
|
|
1302
|
+
or analysis.dominant_share < Y_DOMINANT_ROW_SHARE
|
|
1303
|
+
):
|
|
1304
|
+
continue
|
|
1305
|
+
analysis.intrusion_ratio = max(
|
|
1306
|
+
(_intrusion_ratio(analysis.local_source_bbox, neighbor.tight_core) for neighbor in analysis.neighbors),
|
|
1307
|
+
default=0.0,
|
|
1308
|
+
)
|
|
1309
|
+
analysis.y_candidate = analysis.intrusion_ratio >= Y_NEIGHBOR_CORE_INTRUSION
|
|
1310
|
+
eligible_by_run[analysis.run_key].append(analysis)
|
|
1311
|
+
|
|
1312
|
+
confirmed: set[RunKey] = set()
|
|
1313
|
+
for run_key, members in eligible_by_run.items():
|
|
1314
|
+
candidates = [member for member in members if member.y_candidate]
|
|
1315
|
+
if len(candidates) >= Y_MIN_REPEATED_LINES and len(candidates) / len(members) >= Y_MIN_REPEATED_SHARE:
|
|
1316
|
+
confirmed.add(run_key)
|
|
1317
|
+
return confirmed
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def _healthy_loose_offsets_by_run(
|
|
1321
|
+
analyses: dict[LineKey, _LineAnalysis],
|
|
1322
|
+
confirmed_runs: set[RunKey],
|
|
1323
|
+
) -> dict[RunKey, tuple[list[float], list[float]]]:
|
|
1324
|
+
"""单次遍历收集各确认 run 的健康 loose ascent/descent。"""
|
|
1325
|
+
|
|
1326
|
+
values: dict[RunKey, tuple[list[float], list[float]]] = {run_key: ([], []) for run_key in confirmed_runs}
|
|
1327
|
+
for analysis in analyses.values():
|
|
1328
|
+
if analysis.run_key not in confirmed_runs or analysis.y_candidate:
|
|
1329
|
+
continue
|
|
1330
|
+
ascents, descents = values[analysis.run_key]
|
|
1331
|
+
for sample in analysis.dominant:
|
|
1332
|
+
ascents.append(max(0.0, sample.local_origin[1] - sample.local_source_bbox[1]))
|
|
1333
|
+
descents.append(max(0.0, sample.local_source_bbox[3] - sample.local_origin[1]))
|
|
1334
|
+
return values
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _repair_y_lines(
|
|
1338
|
+
plan: DocumentGeometryPlan,
|
|
1339
|
+
analyses: dict[LineKey, _LineAnalysis],
|
|
1340
|
+
confirmed_runs: set[RunKey],
|
|
1341
|
+
x_bad_runs: set[RunKey],
|
|
1342
|
+
page_sizes: list[tuple[float, float]],
|
|
1343
|
+
) -> None:
|
|
1344
|
+
"""为确认异常的单基线行生成 baseline 锚定的 Y envelope。"""
|
|
1345
|
+
|
|
1346
|
+
healthy_offsets = _healthy_loose_offsets_by_run(analyses, confirmed_runs)
|
|
1347
|
+
for line_key, analysis in analyses.items():
|
|
1348
|
+
current = plan.line_repairs.get(line_key)
|
|
1349
|
+
source_bbox = analysis.line.source_bbox or analysis.line.bbox
|
|
1350
|
+
should_trim = (
|
|
1351
|
+
analysis.run_key in confirmed_runs
|
|
1352
|
+
and (analysis.y_candidate or analysis.run_key in x_bad_runs)
|
|
1353
|
+
and len(analysis.anchors) >= Y_MIN_ANCHOR_COUNT
|
|
1354
|
+
and analysis.geometry_coverage >= Y_MIN_GEOMETRY_COVERAGE
|
|
1355
|
+
and analysis.dominant_share >= Y_DOMINANT_ROW_SHARE
|
|
1356
|
+
)
|
|
1357
|
+
if current is None and not analysis.split_y_candidate and not should_trim:
|
|
1358
|
+
continue
|
|
1359
|
+
if current is None:
|
|
1360
|
+
current = LineGeometryRepair(
|
|
1361
|
+
source_bbox=source_bbox,
|
|
1362
|
+
layout_bbox=analysis.line.bbox,
|
|
1363
|
+
ink_bbox=_bbox_union_many([sample.tight_bbox for sample in analysis.samples]) if analysis.samples else None,
|
|
1364
|
+
baseline=analysis.baseline,
|
|
1365
|
+
em_height=analysis.line.effective_height,
|
|
1366
|
+
split_y_candidate=analysis.split_y_candidate,
|
|
1367
|
+
run_key=analysis.run_key,
|
|
1368
|
+
)
|
|
1369
|
+
plan.line_repairs[line_key] = current
|
|
1370
|
+
else:
|
|
1371
|
+
current.split_y_candidate = analysis.split_y_candidate
|
|
1372
|
+
current.baseline = analysis.baseline
|
|
1373
|
+
current.run_key = analysis.run_key
|
|
1374
|
+
if not should_trim:
|
|
1375
|
+
continue
|
|
1376
|
+
|
|
1377
|
+
local_tight_boxes = [sample.local_tight_bbox for sample in analysis.samples]
|
|
1378
|
+
tight_union = _bbox_union_many(local_tight_boxes)
|
|
1379
|
+
font_sizes = [sample.font_size for sample in analysis.dominant if sample.font_size > 0]
|
|
1380
|
+
tight_heights = [sample.local_tight_bbox[3] - sample.local_tight_bbox[1] for sample in analysis.dominant]
|
|
1381
|
+
em_height = max(
|
|
1382
|
+
statistics.median(font_sizes) if font_sizes else 0.0,
|
|
1383
|
+
_quantile(tight_heights, 0.75),
|
|
1384
|
+
0.1,
|
|
1385
|
+
)
|
|
1386
|
+
healthy_ascents, healthy_descents = healthy_offsets[analysis.run_key]
|
|
1387
|
+
healthy_loose_height = (
|
|
1388
|
+
statistics.median(healthy_ascents) + statistics.median(healthy_descents)
|
|
1389
|
+
if healthy_ascents and healthy_descents
|
|
1390
|
+
else math.inf
|
|
1391
|
+
)
|
|
1392
|
+
tier_offsets = _line_loose_tier_offsets(
|
|
1393
|
+
[
|
|
1394
|
+
(
|
|
1395
|
+
max(
|
|
1396
|
+
0.0,
|
|
1397
|
+
sample.local_origin[1] - sample.local_source_bbox[1],
|
|
1398
|
+
),
|
|
1399
|
+
max(
|
|
1400
|
+
0.0,
|
|
1401
|
+
sample.local_source_bbox[3] - sample.local_origin[1],
|
|
1402
|
+
),
|
|
1403
|
+
sample.local_tight_bbox[3] - sample.local_tight_bbox[1],
|
|
1404
|
+
sample.font_size,
|
|
1405
|
+
)
|
|
1406
|
+
for sample in analysis.dominant
|
|
1407
|
+
if sample.run_key == analysis.run_key
|
|
1408
|
+
],
|
|
1409
|
+
em_height,
|
|
1410
|
+
)
|
|
1411
|
+
if tier_offsets is not None:
|
|
1412
|
+
ascent, descent = tier_offsets
|
|
1413
|
+
elif len(healthy_ascents) >= Y_HEALTHY_LOOSE_SAMPLE_MIN and healthy_loose_height <= 1.5 * em_height:
|
|
1414
|
+
ascent = statistics.median(healthy_ascents)
|
|
1415
|
+
descent = statistics.median(healthy_descents)
|
|
1416
|
+
else:
|
|
1417
|
+
ascent = _quantile(
|
|
1418
|
+
[max(0.0, sample.local_origin[1] - sample.local_tight_bbox[1]) for sample in analysis.dominant],
|
|
1419
|
+
0.9,
|
|
1420
|
+
)
|
|
1421
|
+
descent = _quantile(
|
|
1422
|
+
[max(0.0, sample.local_tight_bbox[3] - sample.local_origin[1]) for sample in analysis.dominant],
|
|
1423
|
+
0.9,
|
|
1424
|
+
)
|
|
1425
|
+
padding = max(0.5, 0.08 * em_height)
|
|
1426
|
+
ascent += padding
|
|
1427
|
+
descent += padding
|
|
1428
|
+
|
|
1429
|
+
top = min(analysis.baseline - ascent, tight_union[1])
|
|
1430
|
+
bottom = max(analysis.baseline + descent, tight_union[3])
|
|
1431
|
+
for neighbor in analysis.neighbors:
|
|
1432
|
+
midpoint = (analysis.baseline + neighbor.baseline) / 2.0
|
|
1433
|
+
if neighbor.baseline < analysis.baseline:
|
|
1434
|
+
top = max(top, min(midpoint, tight_union[1]))
|
|
1435
|
+
else:
|
|
1436
|
+
bottom = min(bottom, max(midpoint, tight_union[3]))
|
|
1437
|
+
legacy_height = analysis.legacy_local_bbox[3] - analysis.legacy_local_bbox[1]
|
|
1438
|
+
provenance_tolerance = max(1.05, 0.05 * legacy_height)
|
|
1439
|
+
preserve_legacy_typography = (
|
|
1440
|
+
abs(analysis.explicit_all_source_bbox[1] - analysis.legacy_local_bbox[1]) > provenance_tolerance
|
|
1441
|
+
or abs(analysis.explicit_all_source_bbox[3] - analysis.legacy_local_bbox[3]) > provenance_tolerance
|
|
1442
|
+
)
|
|
1443
|
+
if preserve_legacy_typography:
|
|
1444
|
+
# explicit loose 与 legacy char bbox 来源不同时保留已经健康的 legacy envelope;
|
|
1445
|
+
# 该分支也让 review 扰动只验证 side-map 修复,不重写既有 line membership。
|
|
1446
|
+
top = analysis.legacy_local_bbox[1]
|
|
1447
|
+
bottom = analysis.legacy_local_bbox[3]
|
|
1448
|
+
page_size = page_sizes[line_key[0]]
|
|
1449
|
+
local_current = _rotate_bbox_to_upright(current.layout_bbox, page_size, analysis.line.angle)
|
|
1450
|
+
layout_bbox = _clip_bbox(
|
|
1451
|
+
_rotate_bbox_from_upright(
|
|
1452
|
+
(local_current[0], top, local_current[2], bottom),
|
|
1453
|
+
page_size,
|
|
1454
|
+
analysis.line.angle,
|
|
1455
|
+
),
|
|
1456
|
+
page_size,
|
|
1457
|
+
)
|
|
1458
|
+
if layout_bbox is None:
|
|
1459
|
+
continue
|
|
1460
|
+
current.layout_bbox = layout_bbox
|
|
1461
|
+
current.ink_bbox = _bbox_union_many([sample.tight_bbox for sample in analysis.samples])
|
|
1462
|
+
current.em_height = analysis.line.effective_height if preserve_legacy_typography else max(0.1, bottom - top)
|
|
1463
|
+
current.state = "repair_xy" if current.state == "repair_x" else "trim_y"
|
|
1464
|
+
current.confidence = min(analysis.geometry_coverage, analysis.dominant_share)
|
|
1465
|
+
current.y_intrusion_ratio = analysis.intrusion_ratio
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _build_line_repairs_from_x(
|
|
1469
|
+
plan: DocumentGeometryPlan,
|
|
1470
|
+
lines_by_page: list[list[_LineItem]],
|
|
1471
|
+
by_line: dict[LineKey, list[_CharSample]],
|
|
1472
|
+
page_sizes: list[tuple[float, float]],
|
|
1473
|
+
) -> None:
|
|
1474
|
+
"""把已修复字符并集投影为只改变 X 的 canonical line。"""
|
|
1475
|
+
|
|
1476
|
+
for page_index, lines in enumerate(lines_by_page):
|
|
1477
|
+
page_size = page_sizes[page_index]
|
|
1478
|
+
for line in lines:
|
|
1479
|
+
line_key = (page_index, line.source_index)
|
|
1480
|
+
line_samples = by_line.get(line_key, [])
|
|
1481
|
+
repaired = [
|
|
1482
|
+
plan.char_repairs[(page_index, sample.char_idx)]
|
|
1483
|
+
for sample in line_samples
|
|
1484
|
+
if (page_index, sample.char_idx) in plan.char_repairs
|
|
1485
|
+
]
|
|
1486
|
+
if not repaired:
|
|
1487
|
+
continue
|
|
1488
|
+
local_source = _rotate_bbox_to_upright(line.bbox, page_size, line.angle)
|
|
1489
|
+
explicit_source = _bbox_union_many([sample.local_source_bbox for sample in line_samples])
|
|
1490
|
+
if abs(explicit_source[0] - local_source[0]) > 0.25 or abs(explicit_source[2] - local_source[2]) > 0.25:
|
|
1491
|
+
# 旋转字符或 shadow 扰动的 explicit loose 与 legacy bbox 来源不同,
|
|
1492
|
+
# 首版保留 legacy X,避免把诊断 side-map 变化误写入公开输出。
|
|
1493
|
+
continue
|
|
1494
|
+
local_boxes = []
|
|
1495
|
+
for sample in line_samples:
|
|
1496
|
+
repair = plan.char_repairs.get((page_index, sample.char_idx))
|
|
1497
|
+
page_bbox = repair.layout_bbox if repair is not None else sample.source_bbox
|
|
1498
|
+
local_boxes.append(_rotate_bbox_to_upright(page_bbox, page_size, line.angle))
|
|
1499
|
+
local_union = _bbox_union_many(local_boxes)
|
|
1500
|
+
layout_bbox = _clip_bbox(
|
|
1501
|
+
_rotate_bbox_from_upright(
|
|
1502
|
+
(local_union[0], local_source[1], local_union[2], local_source[3]),
|
|
1503
|
+
page_size,
|
|
1504
|
+
line.angle,
|
|
1505
|
+
),
|
|
1506
|
+
page_size,
|
|
1507
|
+
)
|
|
1508
|
+
if layout_bbox is None:
|
|
1509
|
+
continue
|
|
1510
|
+
ink_bbox = _bbox_union_many([sample.tight_bbox for sample in line_samples]) if line_samples else None
|
|
1511
|
+
plan.line_repairs[line_key] = LineGeometryRepair(
|
|
1512
|
+
source_bbox=line.bbox,
|
|
1513
|
+
layout_bbox=layout_bbox,
|
|
1514
|
+
ink_bbox=ink_bbox,
|
|
1515
|
+
baseline=None,
|
|
1516
|
+
em_height=line.effective_height,
|
|
1517
|
+
state="repair_x",
|
|
1518
|
+
confidence=min(repair.confidence for repair in repaired),
|
|
1519
|
+
repaired_char_count=len(repaired),
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _run_diagnostics(runs: dict[RunKey, _RunStats]) -> list[dict[str, Any]]:
|
|
1524
|
+
"""生成不依赖字符对象的 run 级可序列化诊断。"""
|
|
1525
|
+
|
|
1526
|
+
output = []
|
|
1527
|
+
for run in sorted(runs.values(), key=lambda item: item.key):
|
|
1528
|
+
pair_count = len(run.pair_ratios)
|
|
1529
|
+
output.append(
|
|
1530
|
+
{
|
|
1531
|
+
"run_key": list(run.key),
|
|
1532
|
+
"sample_count": len(run.samples),
|
|
1533
|
+
"reliable_pair_count": pair_count,
|
|
1534
|
+
"median_ratio": statistics.median(run.pair_ratios) if pair_count else None,
|
|
1535
|
+
"p90_ratio": _quantile(run.pair_ratios, 0.9) if pair_count else None,
|
|
1536
|
+
"ratio_gt_1_35_share": (
|
|
1537
|
+
sum(value > X_STRONG_RATIO_THRESHOLD for value in run.pair_ratios) / pair_count if pair_count else 0.0
|
|
1538
|
+
),
|
|
1539
|
+
"next_tight_overlap_share": sum(run.pair_overlaps) / pair_count if pair_count else 0.0,
|
|
1540
|
+
"strong_x_bad": run.strong_x_bad,
|
|
1541
|
+
"sibling_x_bad": run.sibling_x_bad,
|
|
1542
|
+
"style_y_bad": run.style_y_bad,
|
|
1543
|
+
}
|
|
1544
|
+
)
|
|
1545
|
+
return output
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def build_document_geometry_plan(
|
|
1549
|
+
lines_by_page: list[list[_LineItem]],
|
|
1550
|
+
geometries: list[PDFPageTextGeometry],
|
|
1551
|
+
page_sizes: list[tuple[float, float]],
|
|
1552
|
+
) -> DocumentGeometryPlan:
|
|
1553
|
+
"""构建文档级 X 修复、Y trim 与 split shadow 计划。"""
|
|
1554
|
+
|
|
1555
|
+
plan = DocumentGeometryPlan()
|
|
1556
|
+
if not any(geometry.tight_bboxes and geometry.origins for geometry in geometries):
|
|
1557
|
+
return plan
|
|
1558
|
+
risk = _document_requires_full_geometry(
|
|
1559
|
+
lines_by_page,
|
|
1560
|
+
geometries,
|
|
1561
|
+
page_sizes,
|
|
1562
|
+
)
|
|
1563
|
+
if not risk.any:
|
|
1564
|
+
for geometry in geometries:
|
|
1565
|
+
geometry.loose_bboxes.clear()
|
|
1566
|
+
return plan
|
|
1567
|
+
samples, by_line = _collect_samples(lines_by_page, geometries, page_sizes)
|
|
1568
|
+
if risk.layout:
|
|
1569
|
+
# 只有布局风险才记录公开输出候选;仅样式异常时只校准内部字号。
|
|
1570
|
+
_record_line_canonical_metrics(plan, by_line)
|
|
1571
|
+
runs = _build_run_stats(samples, by_line)
|
|
1572
|
+
style_inflated_runs = _mark_style_inflated_runs(runs)
|
|
1573
|
+
plan.style_inflated_runs = style_inflated_runs
|
|
1574
|
+
plan.document_style_anomaly = _document_uses_global_style_calibration(
|
|
1575
|
+
style_inflated_runs,
|
|
1576
|
+
)
|
|
1577
|
+
_apply_style_scale_repairs(
|
|
1578
|
+
plan,
|
|
1579
|
+
style_inflated_runs if plan.document_style_anomaly else set(),
|
|
1580
|
+
by_line,
|
|
1581
|
+
)
|
|
1582
|
+
if plan.document_style_anomaly and risk.layout:
|
|
1583
|
+
_restore_stable_legacy_source_bboxes(by_line, page_sizes)
|
|
1584
|
+
runs = _build_run_stats(samples, by_line)
|
|
1585
|
+
for run in runs.values():
|
|
1586
|
+
run.style_y_bad = run.key in style_inflated_runs
|
|
1587
|
+
plan.run_diagnostics = _run_diagnostics(runs)
|
|
1588
|
+
if not risk.layout:
|
|
1589
|
+
return plan
|
|
1590
|
+
x_bad_runs = {run.key for run in runs.values() if run.strong_x_bad or run.sibling_x_bad}
|
|
1591
|
+
if x_bad_runs:
|
|
1592
|
+
_repair_x_chars(plan, runs, by_line, page_sizes)
|
|
1593
|
+
_build_line_repairs_from_x(plan, lines_by_page, by_line, page_sizes)
|
|
1594
|
+
if not _has_document_y_risk(by_line):
|
|
1595
|
+
return plan
|
|
1596
|
+
analyses = _analyze_lines(lines_by_page, by_line, page_sizes)
|
|
1597
|
+
confirmed_runs = _mark_y_candidates(analyses)
|
|
1598
|
+
_repair_y_lines(plan, analyses, confirmed_runs, x_bad_runs, page_sizes)
|
|
1599
|
+
return plan
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def apply_line_geometry_repairs(
|
|
1603
|
+
lines: list[_LineItem],
|
|
1604
|
+
*,
|
|
1605
|
+
page_index: int,
|
|
1606
|
+
plan: DocumentGeometryPlan,
|
|
1607
|
+
allow_y_trim: bool,
|
|
1608
|
+
) -> None:
|
|
1609
|
+
"""把文档计划应用到当前仍可参与 Flash 布局的行。"""
|
|
1610
|
+
|
|
1611
|
+
for line in lines:
|
|
1612
|
+
canonical_ink_bbox = plan.line_ink_bboxes.get(
|
|
1613
|
+
(page_index, line.source_index),
|
|
1614
|
+
)
|
|
1615
|
+
if canonical_ink_bbox is not None:
|
|
1616
|
+
line.ink_bbox = canonical_ink_bbox
|
|
1617
|
+
canonical_baseline = plan.line_baselines.get(
|
|
1618
|
+
(page_index, line.source_index),
|
|
1619
|
+
)
|
|
1620
|
+
if canonical_baseline is not None:
|
|
1621
|
+
line.baseline = canonical_baseline
|
|
1622
|
+
style_scale = plan.line_style_scales.get(
|
|
1623
|
+
(page_index, line.source_index),
|
|
1624
|
+
)
|
|
1625
|
+
if style_scale is not None:
|
|
1626
|
+
line.em_height = style_scale
|
|
1627
|
+
line.style_scale_repaired = _line_uses_repaired_style_scale(
|
|
1628
|
+
line,
|
|
1629
|
+
plan.style_inflated_runs,
|
|
1630
|
+
)
|
|
1631
|
+
repair = plan.line_repairs.get((page_index, line.source_index))
|
|
1632
|
+
if repair is None:
|
|
1633
|
+
continue
|
|
1634
|
+
if repair.state in {"trim_y", "repair_xy"} and not allow_y_trim:
|
|
1635
|
+
if repair.state == "trim_y":
|
|
1636
|
+
continue
|
|
1637
|
+
local_state = "repair_x"
|
|
1638
|
+
source = repair.source_bbox
|
|
1639
|
+
layout = (repair.layout_bbox[0], source[1], repair.layout_bbox[2], source[3])
|
|
1640
|
+
else:
|
|
1641
|
+
local_state = repair.state
|
|
1642
|
+
layout = repair.layout_bbox
|
|
1643
|
+
line.source_bbox = repair.source_bbox
|
|
1644
|
+
line.ink_bbox = repair.ink_bbox
|
|
1645
|
+
if repair.baseline is not None:
|
|
1646
|
+
line.baseline = repair.baseline
|
|
1647
|
+
line.geometry_state = local_state
|
|
1648
|
+
line.geometry_confidence = repair.confidence
|
|
1649
|
+
line.split_y_candidate = repair.split_y_candidate
|
|
1650
|
+
line.bbox = layout
|
|
1651
|
+
line.em_height = style_scale if style_scale is not None else repair.em_height
|
|
1652
|
+
if allow_y_trim and repair.state in {"trim_y", "repair_xy"}:
|
|
1653
|
+
line.effective_height = repair.em_height
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
__all__ = [
|
|
1657
|
+
"CharLayoutGeometry",
|
|
1658
|
+
"DocumentGeometryPlan",
|
|
1659
|
+
"LineGeometryRepair",
|
|
1660
|
+
"apply_line_geometry_repairs",
|
|
1661
|
+
"build_document_geometry_plan",
|
|
1662
|
+
]
|