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,816 @@
|
|
|
1
|
+
"""DOCX 字段与目录处理;共享当前 Converter 的单文档状态。"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterator, Optional, Union
|
|
6
|
+
from docx.oxml.xmlchemy import BaseOxmlElement
|
|
7
|
+
from docx.text.hyperlink import Hyperlink
|
|
8
|
+
from docx.text.paragraph import Paragraph
|
|
9
|
+
from docx.text.run import Run
|
|
10
|
+
from loguru import logger
|
|
11
|
+
from .....schema import BlockType
|
|
12
|
+
from .formatting_types import Formatting
|
|
13
|
+
|
|
14
|
+
from .context import _DocxConstants, _DocxComplexFieldFrame, _ParagraphElement, _ParagraphHyperlink
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _DocxFields:
|
|
18
|
+
"""集中维护字段与目录,不自行创建文档或持有跨文档缓存。"""
|
|
19
|
+
|
|
20
|
+
def _collect_toc_anchor_set(self) -> set[str]:
|
|
21
|
+
"""从真实超链接和复杂域中收集整份文档的 TOC bookmark 目标。"""
|
|
22
|
+
anchor_attr = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}anchor"
|
|
23
|
+
anchors: set[str] = set()
|
|
24
|
+
for hl in self.docx_obj.element.body.findall(".//w:hyperlink", namespaces=_DocxConstants._BLIP_NAMESPACES):
|
|
25
|
+
anchor = hl.get(anchor_attr, "").strip()
|
|
26
|
+
if anchor and anchor.startswith("_Toc"):
|
|
27
|
+
anchors.add(anchor)
|
|
28
|
+
for paragraph in self.docx_obj.element.body.findall(
|
|
29
|
+
".//w:p",
|
|
30
|
+
namespaces=_DocxConstants._BLIP_NAMESPACES,
|
|
31
|
+
):
|
|
32
|
+
for instruction in self._complex_field_instructions(paragraph):
|
|
33
|
+
target, is_internal = self._complex_field_hyperlink_target(instruction)
|
|
34
|
+
anchor = target.removeprefix("#") if target and is_internal else ""
|
|
35
|
+
if anchor.startswith("_Toc"):
|
|
36
|
+
anchors.add(anchor)
|
|
37
|
+
return anchors
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def _paragraph_bookmark_names(
|
|
41
|
+
cls,
|
|
42
|
+
paragraph_element: BaseOxmlElement,
|
|
43
|
+
) -> list[str]:
|
|
44
|
+
"""按文档顺序返回段落内可公开的 bookmark 名称,并排除 Word 导航标记。"""
|
|
45
|
+
|
|
46
|
+
bookmark_name_attr = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}name"
|
|
47
|
+
names: list[str] = []
|
|
48
|
+
for bookmark in paragraph_element.findall(
|
|
49
|
+
".//w:bookmarkStart",
|
|
50
|
+
namespaces=cls._BLIP_NAMESPACES,
|
|
51
|
+
):
|
|
52
|
+
name = bookmark.get(bookmark_name_attr, "").strip()
|
|
53
|
+
if name and not name.startswith("_GoBack"):
|
|
54
|
+
names.append(name)
|
|
55
|
+
return names
|
|
56
|
+
|
|
57
|
+
def _collect_toc_anchor_aliases(
|
|
58
|
+
self,
|
|
59
|
+
referenced_anchors: set[str],
|
|
60
|
+
) -> dict[str, str]:
|
|
61
|
+
"""把同一段落的多个 TOC bookmark 收敛到一个 Middle JSON canonical anchor。"""
|
|
62
|
+
|
|
63
|
+
aliases: dict[str, str] = {}
|
|
64
|
+
for paragraph in self.docx_obj.element.body.findall(
|
|
65
|
+
".//w:p",
|
|
66
|
+
namespaces=_DocxConstants._BLIP_NAMESPACES,
|
|
67
|
+
):
|
|
68
|
+
toc_names = [name for name in self._paragraph_bookmark_names(paragraph) if name.startswith("_Toc")]
|
|
69
|
+
if not toc_names:
|
|
70
|
+
continue
|
|
71
|
+
referenced_names = [name for name in toc_names if name in referenced_anchors]
|
|
72
|
+
canonical = referenced_names[0] if referenced_names else toc_names[0]
|
|
73
|
+
for name in toc_names:
|
|
74
|
+
aliases.setdefault(name, canonical)
|
|
75
|
+
return aliases
|
|
76
|
+
|
|
77
|
+
def _canonical_toc_anchor(self, anchor: str) -> str:
|
|
78
|
+
"""返回 bookmark alias 对应的唯一公开 anchor,未知名称保持原值。"""
|
|
79
|
+
|
|
80
|
+
return self.toc_anchor_aliases.get(anchor, anchor)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _complex_field_hyperlink_target(instruction: str) -> tuple[str | None, bool]:
|
|
84
|
+
"""从复杂字段指令中提取外部 URL 或内部 bookmark fragment。"""
|
|
85
|
+
external_match = re.search(r'\bHYPERLINK\s+"([^"]+)"', instruction, re.IGNORECASE)
|
|
86
|
+
bookmark_match = re.search(r'\\l\s+"([^"]+)"', instruction, re.IGNORECASE)
|
|
87
|
+
address = external_match.group(1).strip() if external_match else ""
|
|
88
|
+
bookmark = bookmark_match.group(1).strip() if bookmark_match else ""
|
|
89
|
+
if address:
|
|
90
|
+
return (f"{address}#{bookmark}" if bookmark else address), False
|
|
91
|
+
if bookmark:
|
|
92
|
+
return f"#{bookmark}", True
|
|
93
|
+
return None, False
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def _complex_field_instructions(
|
|
97
|
+
cls,
|
|
98
|
+
paragraph_element: BaseOxmlElement,
|
|
99
|
+
) -> list[str]:
|
|
100
|
+
"""按字段边界与嵌套顺序合并段落中被拆分的复杂字段指令。"""
|
|
101
|
+
|
|
102
|
+
word_namespace = cls._BLIP_NAMESPACES["w"]
|
|
103
|
+
field_char_tag = f"{{{word_namespace}}}fldChar"
|
|
104
|
+
instruction_tag = f"{{{word_namespace}}}instrText"
|
|
105
|
+
field_type_attr = f"{{{word_namespace}}}fldCharType"
|
|
106
|
+
field_stack: list[_DocxComplexFieldFrame] = []
|
|
107
|
+
instructions: list[str] = []
|
|
108
|
+
|
|
109
|
+
def append_instruction(frame: _DocxComplexFieldFrame) -> None:
|
|
110
|
+
"""把一个字段已累计的非空指令追加到输出。"""
|
|
111
|
+
|
|
112
|
+
instruction = "".join(frame.instruction_parts).strip()
|
|
113
|
+
if instruction:
|
|
114
|
+
instructions.append(instruction)
|
|
115
|
+
|
|
116
|
+
for element in paragraph_element.iter():
|
|
117
|
+
if element.tag == field_char_tag:
|
|
118
|
+
field_type = element.get(field_type_attr)
|
|
119
|
+
if field_type == "begin":
|
|
120
|
+
field_stack.append(_DocxComplexFieldFrame())
|
|
121
|
+
elif field_type == "separate" and field_stack:
|
|
122
|
+
frame = field_stack[-1]
|
|
123
|
+
if frame.phase == "instr":
|
|
124
|
+
append_instruction(frame)
|
|
125
|
+
frame.phase = "result"
|
|
126
|
+
elif field_type == "end" and field_stack:
|
|
127
|
+
frame = field_stack.pop()
|
|
128
|
+
if frame.phase == "instr":
|
|
129
|
+
append_instruction(frame)
|
|
130
|
+
continue
|
|
131
|
+
if element.tag != instruction_tag:
|
|
132
|
+
continue
|
|
133
|
+
text = element.text or ""
|
|
134
|
+
if field_stack and field_stack[-1].phase == "instr":
|
|
135
|
+
field_stack[-1].instruction_parts.append(text)
|
|
136
|
+
elif text.strip():
|
|
137
|
+
# 兼容缺少 fldChar 包裹、但过去可被逐节点解析的非规范指令。
|
|
138
|
+
instructions.append(text.strip())
|
|
139
|
+
|
|
140
|
+
for frame in field_stack:
|
|
141
|
+
if frame.phase == "instr":
|
|
142
|
+
append_instruction(frame)
|
|
143
|
+
return instructions
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _python_docx_hyperlink_target(hyperlink: Hyperlink) -> _ParagraphHyperlink:
|
|
147
|
+
"""把 python-docx Hyperlink 的地址或 fragment 转换为行内目标。"""
|
|
148
|
+
address = hyperlink.address
|
|
149
|
+
fragment = hyperlink.fragment
|
|
150
|
+
if address and fragment:
|
|
151
|
+
return f"{address}#{fragment}"
|
|
152
|
+
if address and "://" in address:
|
|
153
|
+
return address
|
|
154
|
+
if address:
|
|
155
|
+
return Path(address)
|
|
156
|
+
if fragment:
|
|
157
|
+
return f"#{fragment}"
|
|
158
|
+
return Path(".")
|
|
159
|
+
|
|
160
|
+
def _resolve_complex_field_elements(
|
|
161
|
+
self,
|
|
162
|
+
frame: _DocxComplexFieldFrame,
|
|
163
|
+
*,
|
|
164
|
+
suppress_internal_links: bool,
|
|
165
|
+
) -> list[_ParagraphElement]:
|
|
166
|
+
"""闭合复杂字段,并把字段结果绑定到解析出的超链接目标。"""
|
|
167
|
+
target, is_internal = self._complex_field_hyperlink_target("".join(frame.instruction_parts))
|
|
168
|
+
if target is None or (is_internal and suppress_internal_links):
|
|
169
|
+
return frame.result_elements
|
|
170
|
+
return [(text, format_obj, existing_target or target) for text, format_obj, existing_target in frame.result_elements]
|
|
171
|
+
|
|
172
|
+
def _flatten_paragraph_elements(
|
|
173
|
+
self,
|
|
174
|
+
paragraph: Paragraph,
|
|
175
|
+
inner_contents: list[Union[Run, Hyperlink]],
|
|
176
|
+
) -> list[_ParagraphElement]:
|
|
177
|
+
"""按文档顺序展开普通 run、真实超链接与可嵌套复杂字段。"""
|
|
178
|
+
elements: list[_ParagraphElement] = []
|
|
179
|
+
field_stack: list[_DocxComplexFieldFrame] = []
|
|
180
|
+
suppress_internal_links = self._get_toc_item_level(paragraph) is not None
|
|
181
|
+
word_namespace = _DocxConstants._BLIP_NAMESPACES["w"]
|
|
182
|
+
|
|
183
|
+
for content_index, content in enumerate(inner_contents):
|
|
184
|
+
if isinstance(content, Hyperlink):
|
|
185
|
+
hyperlink_target = self._python_docx_hyperlink_target(content)
|
|
186
|
+
if suppress_internal_links and isinstance(hyperlink_target, str) and hyperlink_target.startswith("#"):
|
|
187
|
+
hyperlink_target = None
|
|
188
|
+
hyperlink_elements: list[_ParagraphElement] = []
|
|
189
|
+
for hyperlink_run in content.runs:
|
|
190
|
+
if self._is_hidden_run(hyperlink_run):
|
|
191
|
+
continue
|
|
192
|
+
text = hyperlink_run.text or ""
|
|
193
|
+
format_obj = self._normalize_format_for_text(
|
|
194
|
+
self._get_format_from_run(hyperlink_run),
|
|
195
|
+
text,
|
|
196
|
+
preserve_blank_non_visible_style=True,
|
|
197
|
+
)
|
|
198
|
+
if text != "" or self._has_visible_style(format_obj):
|
|
199
|
+
hyperlink_elements.append((text, format_obj, hyperlink_target))
|
|
200
|
+
if field_stack and field_stack[-1].phase == "result":
|
|
201
|
+
field_stack[-1].result_elements.extend(hyperlink_elements)
|
|
202
|
+
else:
|
|
203
|
+
elements.extend(hyperlink_elements)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if not isinstance(content, Run):
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
field_char = content._element.find(f"{{{word_namespace}}}fldChar")
|
|
210
|
+
if field_char is not None:
|
|
211
|
+
field_type = field_char.get(f"{{{word_namespace}}}fldCharType")
|
|
212
|
+
if field_type == "begin":
|
|
213
|
+
field_stack.append(_DocxComplexFieldFrame())
|
|
214
|
+
elif field_type == "separate" and field_stack:
|
|
215
|
+
field_stack[-1].phase = "result"
|
|
216
|
+
elif field_type == "end" and field_stack:
|
|
217
|
+
frame = field_stack.pop()
|
|
218
|
+
resolved = self._resolve_complex_field_elements(
|
|
219
|
+
frame,
|
|
220
|
+
suppress_internal_links=suppress_internal_links,
|
|
221
|
+
)
|
|
222
|
+
if field_stack and field_stack[-1].phase == "result":
|
|
223
|
+
field_stack[-1].result_elements.extend(resolved)
|
|
224
|
+
else:
|
|
225
|
+
elements.extend(resolved)
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
instruction = content._element.find(f"{{{word_namespace}}}instrText")
|
|
229
|
+
if instruction is not None and field_stack and field_stack[-1].phase == "instr":
|
|
230
|
+
if instruction.text:
|
|
231
|
+
field_stack[-1].instruction_parts.append(instruction.text)
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
text = content.text or ""
|
|
235
|
+
raw_format = self._get_format_from_run(content)
|
|
236
|
+
preserve_blank_non_visible_style = self._should_preserve_blank_non_visible_style(
|
|
237
|
+
inner_contents,
|
|
238
|
+
content_index,
|
|
239
|
+
text,
|
|
240
|
+
raw_format,
|
|
241
|
+
)
|
|
242
|
+
format_obj = self._normalize_format_for_text(
|
|
243
|
+
raw_format,
|
|
244
|
+
text,
|
|
245
|
+
preserve_blank_non_visible_style=preserve_blank_non_visible_style,
|
|
246
|
+
)
|
|
247
|
+
element = (text, format_obj, None)
|
|
248
|
+
if field_stack:
|
|
249
|
+
if field_stack[-1].phase == "result":
|
|
250
|
+
field_stack[-1].result_elements.append(element)
|
|
251
|
+
continue
|
|
252
|
+
elements.append(element)
|
|
253
|
+
|
|
254
|
+
while field_stack:
|
|
255
|
+
frame = field_stack.pop()
|
|
256
|
+
resolved = self._resolve_complex_field_elements(
|
|
257
|
+
frame,
|
|
258
|
+
suppress_internal_links=suppress_internal_links,
|
|
259
|
+
)
|
|
260
|
+
if field_stack and field_stack[-1].phase == "result":
|
|
261
|
+
field_stack[-1].result_elements.extend(resolved)
|
|
262
|
+
else:
|
|
263
|
+
elements.extend(resolved)
|
|
264
|
+
return elements
|
|
265
|
+
|
|
266
|
+
def _get_paragraph_elements(self, paragraph: Paragraph) -> list[_ParagraphElement]:
|
|
267
|
+
"""
|
|
268
|
+
提取段落元素及其格式和超链接信息。
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
paragraph: 段落对象
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
list[_ParagraphElement]:
|
|
275
|
+
段落元素列表,每个元素包含文本、格式和超链接信息
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
inner_contents = list(self._iter_paragraph_inner_content(paragraph))
|
|
279
|
+
paragraph_text = self._get_paragraph_text_from_contents(inner_contents)
|
|
280
|
+
|
|
281
|
+
# 目前保留空段落以保持向后兼容性:
|
|
282
|
+
if paragraph_text.strip() == "":
|
|
283
|
+
# 检查是否存在带可见样式(下划线或删除线)的空白文本 run。
|
|
284
|
+
# 有可见样式的空白文本(如带下划线的空格)在视觉上是可见的,应予保留,
|
|
285
|
+
# 因此跳过提前返回,交由后续完整 run 处理流程处理。
|
|
286
|
+
has_visible_style_run = any(
|
|
287
|
+
isinstance(c, Run) and c.text and self._has_visible_style(self._get_format_from_run(c)) for c in inner_contents
|
|
288
|
+
)
|
|
289
|
+
if not has_visible_style_run:
|
|
290
|
+
return [("", None, None)]
|
|
291
|
+
|
|
292
|
+
paragraph_elements: list[_ParagraphElement] = []
|
|
293
|
+
group_text = ""
|
|
294
|
+
previous_format: Optional[Formatting] = None
|
|
295
|
+
|
|
296
|
+
# 遍历已经展开的普通 run、超链接与复杂字段结果,并按格式分组。
|
|
297
|
+
flattened_elements = self._flatten_paragraph_elements(paragraph, inner_contents)
|
|
298
|
+
for text, format_obj, hyperlink in flattened_elements:
|
|
299
|
+
# 当新 run 有可见内容(非空或带可见样式的空白)且格式变化时触发分组
|
|
300
|
+
has_visible_content = len(text.strip()) > 0 or self._has_visible_style(format_obj)
|
|
301
|
+
is_blank_text = bool(text) and not text.strip()
|
|
302
|
+
format_changed = format_obj != previous_format
|
|
303
|
+
has_visible_boundary = self._has_visible_style(previous_format) or self._has_visible_style(format_obj)
|
|
304
|
+
should_split_blank_boundary = is_blank_text and bool(group_text) and format_changed and has_visible_boundary
|
|
305
|
+
if (has_visible_content and format_changed) or should_split_blank_boundary or (hyperlink is not None):
|
|
306
|
+
# 前一组有实质内容(非空或带可见样式的空白)时才保存
|
|
307
|
+
preserve_plain_blank = (
|
|
308
|
+
bool(group_text)
|
|
309
|
+
and not group_text.strip()
|
|
310
|
+
and (self._has_visible_style(previous_format) or self._has_visible_style(format_obj))
|
|
311
|
+
)
|
|
312
|
+
prev_has_visible = self._should_keep_group_text(
|
|
313
|
+
group_text,
|
|
314
|
+
previous_format,
|
|
315
|
+
preserve_plain_blank=preserve_plain_blank,
|
|
316
|
+
)
|
|
317
|
+
if prev_has_visible:
|
|
318
|
+
paragraph_elements.append((group_text, previous_format, None))
|
|
319
|
+
group_text = ""
|
|
320
|
+
|
|
321
|
+
# 如果有超链接,则立即添加
|
|
322
|
+
if hyperlink is not None:
|
|
323
|
+
self._append_paragraph_element(paragraph_elements, text, format_obj, hyperlink)
|
|
324
|
+
text = ""
|
|
325
|
+
else:
|
|
326
|
+
previous_format = format_obj
|
|
327
|
+
|
|
328
|
+
group_text += text
|
|
329
|
+
|
|
330
|
+
# 格式化最后一个组
|
|
331
|
+
# 注意:使用 previous_format(当前累积组的格式),而非 format(最后一次循环迭代的格式)。
|
|
332
|
+
# 最后一次迭代可能是无样式的空 run,若使用 format 会导致样式丢失。
|
|
333
|
+
last_has_visible = self._should_keep_group_text(
|
|
334
|
+
group_text,
|
|
335
|
+
previous_format,
|
|
336
|
+
)
|
|
337
|
+
if last_has_visible:
|
|
338
|
+
paragraph_elements.append((group_text, previous_format, None))
|
|
339
|
+
|
|
340
|
+
return self._normalize_hyperlink_group_boundaries(paragraph_elements)
|
|
341
|
+
|
|
342
|
+
def _iter_paragraph_inner_content(
|
|
343
|
+
self,
|
|
344
|
+
paragraph: Paragraph,
|
|
345
|
+
container: Optional[BaseOxmlElement] = None,
|
|
346
|
+
) -> Iterator[Union[Run, Hyperlink]]:
|
|
347
|
+
"""Yield visible paragraph inline containers in document order.
|
|
348
|
+
|
|
349
|
+
python-docx only walks direct ``w:r`` and ``w:hyperlink`` children of ``w:p``.
|
|
350
|
+
Inline ``w:sdt`` content controls are skipped entirely, which drops their text
|
|
351
|
+
from both ``paragraph.text`` and ``paragraph.iter_inner_content()``. This walker
|
|
352
|
+
treats ``w:sdt`` and a few transparent wrapper nodes as pass-through containers
|
|
353
|
+
and reuses the existing Run/Hyperlink wrappers for the actual visible content.
|
|
354
|
+
"""
|
|
355
|
+
if container is None:
|
|
356
|
+
container = paragraph._element
|
|
357
|
+
|
|
358
|
+
_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
359
|
+
|
|
360
|
+
for child in container:
|
|
361
|
+
tag_name = self._local_name(child)
|
|
362
|
+
if tag_name is None:
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
if tag_name == "r":
|
|
366
|
+
yield Run(child, paragraph)
|
|
367
|
+
elif tag_name == "hyperlink":
|
|
368
|
+
yield Hyperlink(child, paragraph)
|
|
369
|
+
elif tag_name == "sdt":
|
|
370
|
+
sdt_content = child.find(f"{{{_W_NS}}}sdtContent")
|
|
371
|
+
if sdt_content is not None:
|
|
372
|
+
yield from self._iter_paragraph_inner_content(paragraph, sdt_content)
|
|
373
|
+
elif tag_name in self._PARAGRAPH_TRANSPARENT_INLINE_CONTAINERS:
|
|
374
|
+
yield from self._iter_paragraph_inner_content(paragraph, child)
|
|
375
|
+
|
|
376
|
+
def _is_toc_sdt(self, element: BaseOxmlElement) -> bool:
|
|
377
|
+
"""
|
|
378
|
+
检测SDT元素是否为目录(Table of Contents)。
|
|
379
|
+
|
|
380
|
+
检测策略:
|
|
381
|
+
1. 检查 w:sdtPr 中的 docPartGallery 或 tag 元素
|
|
382
|
+
2. 回退到检查内容中的段落样式是否为 "TOC N" 格式
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
element: SDT XML元素
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
bool: 如果是目录SDT返回 True,否则返回 False
|
|
389
|
+
"""
|
|
390
|
+
# 方法1: 检查 w:sdtPr 中的 docPartGallery
|
|
391
|
+
sdt_pr = element.find("w:sdtPr", namespaces=_DocxConstants._BLIP_NAMESPACES)
|
|
392
|
+
if sdt_pr is not None:
|
|
393
|
+
doc_part_gallery = sdt_pr.find(".//w:docPartGallery", namespaces=_DocxConstants._BLIP_NAMESPACES)
|
|
394
|
+
if doc_part_gallery is not None:
|
|
395
|
+
val = doc_part_gallery.get(self.XML_KEY, "")
|
|
396
|
+
if "Table of Contents" in val or "toc" in val.lower():
|
|
397
|
+
return True
|
|
398
|
+
|
|
399
|
+
# 检查 tag 元素的值
|
|
400
|
+
tag_elem = sdt_pr.find("w:tag", namespaces=_DocxConstants._BLIP_NAMESPACES)
|
|
401
|
+
if tag_elem is not None:
|
|
402
|
+
val = tag_elem.get(self.XML_KEY, "").lower().replace(" ", "")
|
|
403
|
+
if "toc" in val or "contents" in val or "tableofcontents" in val:
|
|
404
|
+
return True
|
|
405
|
+
|
|
406
|
+
# 方法2: 检查内容段落的样式是否为 "TOC N" 格式
|
|
407
|
+
sdt_content = element.find("w:sdtContent", namespaces=_DocxConstants._BLIP_NAMESPACES)
|
|
408
|
+
if sdt_content is not None:
|
|
409
|
+
paragraphs = sdt_content.findall("w:p", namespaces=_DocxConstants._BLIP_NAMESPACES)
|
|
410
|
+
for p in paragraphs[:5]: # 只检查前5个段落即可判断
|
|
411
|
+
try:
|
|
412
|
+
p_obj = Paragraph(p, self.docx_obj)
|
|
413
|
+
paragraph_style = self._get_paragraph_style(p_obj)
|
|
414
|
+
if paragraph_style and paragraph_style.name:
|
|
415
|
+
style_name = paragraph_style.name
|
|
416
|
+
if re.match(r"^TOC\s*\d+$", style_name, re.IGNORECASE) or re.match(r"^目录\s*\d+$", style_name):
|
|
417
|
+
return True
|
|
418
|
+
except Exception:
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
return False
|
|
422
|
+
|
|
423
|
+
def _get_toc_item_level(self, paragraph: Paragraph) -> Optional[int]:
|
|
424
|
+
"""
|
|
425
|
+
从段落样式中获取目录项的层级(0-based)。
|
|
426
|
+
|
|
427
|
+
"TOC 1" -> 0
|
|
428
|
+
"TOC 2" -> 1
|
|
429
|
+
"目录 1" -> 0
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
paragraph: 段落对象
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Optional[int]: 层级(0-based),如果不是目录样式则返回 None
|
|
436
|
+
"""
|
|
437
|
+
paragraph_style = self._get_paragraph_style(paragraph)
|
|
438
|
+
if paragraph_style is None:
|
|
439
|
+
return None
|
|
440
|
+
style_name = paragraph_style.name
|
|
441
|
+
if style_name:
|
|
442
|
+
match = re.match(r"^(?:TOC|目录)\s*(\d+)$", style_name, re.IGNORECASE)
|
|
443
|
+
if match:
|
|
444
|
+
level = int(match.group(1))
|
|
445
|
+
return level - 1 # 转换为 0-based
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
def _is_flat_list_toc(self, items: list[tuple[int, str, list, list, Optional[str]]]) -> bool:
|
|
449
|
+
"""
|
|
450
|
+
检测目录是否为扁平列表(插图清单、列表清单等),
|
|
451
|
+
这类目录的所有条目应在同一层级,不应嵌套。
|
|
452
|
+
|
|
453
|
+
策略:检查是否超过 50% 的条目以"图"或"表"开头。
|
|
454
|
+
"""
|
|
455
|
+
match_count = 0
|
|
456
|
+
total_count = 0
|
|
457
|
+
for _level, text, _elements, _equations, _anchor in items:
|
|
458
|
+
stripped = text.strip()
|
|
459
|
+
if not stripped:
|
|
460
|
+
continue
|
|
461
|
+
total_count += 1
|
|
462
|
+
if re.match(r"^[图表][\d\s.]", stripped) or re.match(r"^(Figure|Table)\s+\d", stripped, re.IGNORECASE):
|
|
463
|
+
match_count += 1
|
|
464
|
+
if total_count == 0:
|
|
465
|
+
return False
|
|
466
|
+
return match_count / total_count > 0.5
|
|
467
|
+
|
|
468
|
+
def _correct_toc_level_by_text(self, toc_level: int, text: str) -> int:
|
|
469
|
+
"""
|
|
470
|
+
通过文本中的编号深度修正目录项的层级。
|
|
471
|
+
|
|
472
|
+
仅对 toc_level > 0 的条目进行修正,避免影响顶层章节标题。
|
|
473
|
+
例如:
|
|
474
|
+
- "1.1 LYSO..." (toc 3 → ilevel=2) → text depth 2 → 返回 1
|
|
475
|
+
- "1.1.1 LYSO..." (toc 3 → ilevel=2) → text depth 3 → 返回 2
|
|
476
|
+
- "本章小结" (toc 1 → ilevel=0) → 返回 0(不修正)
|
|
477
|
+
"""
|
|
478
|
+
if toc_level == 0:
|
|
479
|
+
return 0
|
|
480
|
+
stripped = text.strip()
|
|
481
|
+
match = re.match(r"^(\d+(?:\.\d+)+)(?![\d.])", stripped)
|
|
482
|
+
if match:
|
|
483
|
+
parts = match.group(1).split(".")
|
|
484
|
+
# 只用明确的多级章节号把异常偏深的 TOC 样式修浅,避免普通列表编号被提升层级。
|
|
485
|
+
text_level = len(parts) - 1
|
|
486
|
+
if text_level < toc_level:
|
|
487
|
+
return text_level
|
|
488
|
+
return toc_level
|
|
489
|
+
|
|
490
|
+
def _add_index_item(
|
|
491
|
+
self,
|
|
492
|
+
*,
|
|
493
|
+
ilevel: int,
|
|
494
|
+
elements: list,
|
|
495
|
+
text: str = "",
|
|
496
|
+
equations: list = None,
|
|
497
|
+
anchor: Optional[str] = None,
|
|
498
|
+
) -> None:
|
|
499
|
+
"""
|
|
500
|
+
添加目录项到索引块。
|
|
501
|
+
|
|
502
|
+
生成的索引结构:
|
|
503
|
+
{
|
|
504
|
+
"type": "index",
|
|
505
|
+
"ilevel": 0,
|
|
506
|
+
"content": [
|
|
507
|
+
{"type": "text", "content": "目录项文本"},
|
|
508
|
+
{"type": "index", "ilevel": 1, "content": [...]},
|
|
509
|
+
]
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
ilevel: 缩进等级(0-based)
|
|
514
|
+
elements: 元素列表
|
|
515
|
+
text: 处理后的文本(包含公式标记)
|
|
516
|
+
equations: 公式列表
|
|
517
|
+
"""
|
|
518
|
+
if equations is None:
|
|
519
|
+
equations = []
|
|
520
|
+
if not elements:
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
content_text = self._build_text_with_equations_and_hyperlinks(elements, text, equations)
|
|
524
|
+
content_text = self._normalize_text_block_content(content_text)
|
|
525
|
+
if not content_text:
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
# 情况 1: 首个目录项,创建新的顶层索引块
|
|
529
|
+
if self.pre_index_ilevel == -1:
|
|
530
|
+
index_block = {
|
|
531
|
+
"type": BlockType.INDEX,
|
|
532
|
+
"content": [],
|
|
533
|
+
"ilevel": ilevel,
|
|
534
|
+
}
|
|
535
|
+
self.cur_page.append(index_block)
|
|
536
|
+
self.index_block_stack.append(index_block)
|
|
537
|
+
|
|
538
|
+
index_item = {
|
|
539
|
+
"type": BlockType.TEXT,
|
|
540
|
+
"content": content_text,
|
|
541
|
+
}
|
|
542
|
+
if anchor:
|
|
543
|
+
index_item["anchor"] = anchor
|
|
544
|
+
index_block["content"].append(index_item)
|
|
545
|
+
self.pre_index_ilevel = ilevel
|
|
546
|
+
|
|
547
|
+
# 情况 2: 增加缩进,打开子索引块
|
|
548
|
+
elif self.pre_index_ilevel < ilevel:
|
|
549
|
+
if not self.index_block_stack:
|
|
550
|
+
# 防御异常 TOC 状态:栈为空时按新的目录块恢复,避免单个坏层级阻断解析。
|
|
551
|
+
logger.debug(
|
|
552
|
+
"Recovering DOCX index stack before adding TOC item at level {}",
|
|
553
|
+
ilevel,
|
|
554
|
+
)
|
|
555
|
+
self.pre_index_ilevel = -1
|
|
556
|
+
self._add_index_item(
|
|
557
|
+
ilevel=ilevel,
|
|
558
|
+
elements=elements,
|
|
559
|
+
text=text,
|
|
560
|
+
equations=equations,
|
|
561
|
+
anchor=anchor,
|
|
562
|
+
)
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
child_index_block = {
|
|
566
|
+
"type": BlockType.INDEX,
|
|
567
|
+
"content": [],
|
|
568
|
+
"ilevel": ilevel,
|
|
569
|
+
}
|
|
570
|
+
parent_index_block = self.index_block_stack[-1]
|
|
571
|
+
parent_index_block["content"].append(child_index_block)
|
|
572
|
+
self.index_block_stack.append(child_index_block)
|
|
573
|
+
|
|
574
|
+
index_item = {
|
|
575
|
+
"type": BlockType.TEXT,
|
|
576
|
+
"content": content_text,
|
|
577
|
+
}
|
|
578
|
+
if anchor:
|
|
579
|
+
index_item["anchor"] = anchor
|
|
580
|
+
child_index_block["content"].append(index_item)
|
|
581
|
+
self.pre_index_ilevel = ilevel
|
|
582
|
+
|
|
583
|
+
# 情况 3: 减少缩进,关闭子索引块
|
|
584
|
+
elif ilevel < self.pre_index_ilevel:
|
|
585
|
+
while self.index_block_stack:
|
|
586
|
+
top_block = self.index_block_stack[-1]
|
|
587
|
+
if top_block["ilevel"] == ilevel:
|
|
588
|
+
break
|
|
589
|
+
self.index_block_stack.pop()
|
|
590
|
+
if self.index_block_stack:
|
|
591
|
+
index_block = self.index_block_stack[-1]
|
|
592
|
+
index_item = {
|
|
593
|
+
"type": BlockType.TEXT,
|
|
594
|
+
"content": content_text,
|
|
595
|
+
}
|
|
596
|
+
if anchor:
|
|
597
|
+
index_item["anchor"] = anchor
|
|
598
|
+
index_block["content"].append(index_item)
|
|
599
|
+
self.pre_index_ilevel = ilevel
|
|
600
|
+
|
|
601
|
+
# 情况 4: 同级目录项
|
|
602
|
+
else:
|
|
603
|
+
if self.index_block_stack:
|
|
604
|
+
index_block = self.index_block_stack[-1]
|
|
605
|
+
index_item = {
|
|
606
|
+
"type": BlockType.TEXT,
|
|
607
|
+
"content": content_text,
|
|
608
|
+
}
|
|
609
|
+
if anchor:
|
|
610
|
+
index_item["anchor"] = anchor
|
|
611
|
+
index_block["content"].append(index_item)
|
|
612
|
+
|
|
613
|
+
def _extract_paragraph_bookmark(self, paragraph_element: BaseOxmlElement) -> Optional[str]:
|
|
614
|
+
"""Extract a bookmark name from a paragraph, prioritizing TOC bookmarks."""
|
|
615
|
+
names = self._paragraph_bookmark_names(paragraph_element)
|
|
616
|
+
if not names:
|
|
617
|
+
return None
|
|
618
|
+
toc_names = [name for name in names if name.startswith("_Toc")]
|
|
619
|
+
if toc_names:
|
|
620
|
+
# Prefer anchors that are actually referenced by TOC hyperlinks.
|
|
621
|
+
for name in toc_names:
|
|
622
|
+
if name in self.toc_anchor_set:
|
|
623
|
+
return self._canonical_toc_anchor(name)
|
|
624
|
+
return self._canonical_toc_anchor(toc_names[0])
|
|
625
|
+
return names[0]
|
|
626
|
+
|
|
627
|
+
def _extract_toc_target_anchor(self, paragraph_element: BaseOxmlElement) -> Optional[str]:
|
|
628
|
+
"""从真实超链接或复杂域中提取 TOC 段落的内部 bookmark。"""
|
|
629
|
+
anchor_attr = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}anchor"
|
|
630
|
+
anchors = []
|
|
631
|
+
for hl in paragraph_element.findall(".//w:hyperlink", namespaces=_DocxConstants._BLIP_NAMESPACES):
|
|
632
|
+
anchor = hl.get(anchor_attr, "").strip()
|
|
633
|
+
if anchor:
|
|
634
|
+
anchors.append(anchor)
|
|
635
|
+
for anchor in anchors:
|
|
636
|
+
if anchor.startswith("_Toc"):
|
|
637
|
+
return self._canonical_toc_anchor(anchor)
|
|
638
|
+
if anchors:
|
|
639
|
+
return anchors[0]
|
|
640
|
+
|
|
641
|
+
field_anchors: list[str] = []
|
|
642
|
+
for instruction in self._complex_field_instructions(
|
|
643
|
+
paragraph_element,
|
|
644
|
+
):
|
|
645
|
+
target, is_internal = self._complex_field_hyperlink_target(instruction)
|
|
646
|
+
anchor = target.removeprefix("#") if target and is_internal else ""
|
|
647
|
+
if anchor:
|
|
648
|
+
field_anchors.append(anchor)
|
|
649
|
+
for anchor in field_anchors:
|
|
650
|
+
if anchor.startswith("_Toc"):
|
|
651
|
+
return self._canonical_toc_anchor(anchor)
|
|
652
|
+
return field_anchors[0] if field_anchors else None
|
|
653
|
+
|
|
654
|
+
def _handle_plain_toc_paragraph_as_index(
|
|
655
|
+
self,
|
|
656
|
+
*,
|
|
657
|
+
paragraph: Paragraph,
|
|
658
|
+
paragraph_element: BaseOxmlElement,
|
|
659
|
+
paragraph_elements: list,
|
|
660
|
+
text: str,
|
|
661
|
+
equations: list,
|
|
662
|
+
) -> bool:
|
|
663
|
+
"""将未包裹在 SDT 中的普通目录段落转换为 INDEX 项。"""
|
|
664
|
+
toc_level = self._get_toc_item_level(paragraph)
|
|
665
|
+
if toc_level is None:
|
|
666
|
+
return False
|
|
667
|
+
if not text:
|
|
668
|
+
return True
|
|
669
|
+
|
|
670
|
+
target_anchor = self._extract_toc_target_anchor(paragraph_element)
|
|
671
|
+
# 只有已经进入目录序列后才允许无锚点条目,避免误收复用 TOC 样式的封面文本。
|
|
672
|
+
if not target_anchor and self.pre_index_ilevel == -1:
|
|
673
|
+
return False
|
|
674
|
+
if target_anchor and target_anchor.startswith("_Toc"):
|
|
675
|
+
self.toc_anchor_set.add(target_anchor)
|
|
676
|
+
|
|
677
|
+
if self.plain_toc_base_level is None:
|
|
678
|
+
self.plain_toc_base_level = toc_level
|
|
679
|
+
normalized_level = max(0, toc_level - self.plain_toc_base_level)
|
|
680
|
+
corrected_level = self._correct_toc_level_by_text(normalized_level, text)
|
|
681
|
+
self._add_index_item(
|
|
682
|
+
ilevel=corrected_level,
|
|
683
|
+
elements=paragraph_elements,
|
|
684
|
+
text=text,
|
|
685
|
+
equations=equations,
|
|
686
|
+
anchor=target_anchor,
|
|
687
|
+
)
|
|
688
|
+
return True
|
|
689
|
+
|
|
690
|
+
def _handle_sdt_as_index(self, sdt_content: BaseOxmlElement) -> None:
|
|
691
|
+
"""
|
|
692
|
+
处理目录SDT内容,将其转换为层级化的INDEX块。
|
|
693
|
+
|
|
694
|
+
两阶段处理:
|
|
695
|
+
1. 收集所有段落及其层级;
|
|
696
|
+
2. 检测目录类型(常规目录 vs 扁平列表),对层级进行修正后写入索引块。
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
sdt_content: w:sdtContent XML元素
|
|
700
|
+
"""
|
|
701
|
+
paragraphs = sdt_content.findall(".//w:p", namespaces=_DocxConstants._BLIP_NAMESPACES)
|
|
702
|
+
|
|
703
|
+
# --- 第一阶段:收集所有条目 ---
|
|
704
|
+
toc_items: list[tuple[int, str, list, list, Optional[str]]] = []
|
|
705
|
+
for p in paragraphs:
|
|
706
|
+
try:
|
|
707
|
+
p_obj = Paragraph(p, self.docx_obj)
|
|
708
|
+
paragraph_elements = self._get_paragraph_elements(p_obj)
|
|
709
|
+
text, equations = self._handle_equations_in_text(
|
|
710
|
+
element=p,
|
|
711
|
+
text=p_obj.text,
|
|
712
|
+
part=p_obj.part,
|
|
713
|
+
)
|
|
714
|
+
target_anchor = self._extract_toc_target_anchor(p)
|
|
715
|
+
if target_anchor and target_anchor.startswith("_Toc"):
|
|
716
|
+
self.toc_anchor_set.add(target_anchor)
|
|
717
|
+
if text is None:
|
|
718
|
+
continue
|
|
719
|
+
text = text.strip()
|
|
720
|
+
if not text:
|
|
721
|
+
continue
|
|
722
|
+
|
|
723
|
+
toc_level = self._get_toc_item_level(p_obj)
|
|
724
|
+
if toc_level is None:
|
|
725
|
+
toc_level = 0
|
|
726
|
+
|
|
727
|
+
toc_items.append((toc_level, text, paragraph_elements, equations, target_anchor))
|
|
728
|
+
except Exception as e:
|
|
729
|
+
logger.debug(f"Error collecting TOC paragraph: {e}")
|
|
730
|
+
continue
|
|
731
|
+
|
|
732
|
+
# --- 第二阶段:修正层级并写入索引块 ---
|
|
733
|
+
is_flat = self._is_flat_list_toc(toc_items)
|
|
734
|
+
|
|
735
|
+
# 重置索引状态,开始新的目录块
|
|
736
|
+
self._reset_index_state()
|
|
737
|
+
|
|
738
|
+
for toc_level, text, elements, equations, target_anchor in toc_items:
|
|
739
|
+
if is_flat:
|
|
740
|
+
# 插图/列表清单:强制全部扁平(层级 0)
|
|
741
|
+
corrected_level = 0
|
|
742
|
+
else:
|
|
743
|
+
# 常规目录:依据文本编号深度修正层级,解决 docx 跳级问题
|
|
744
|
+
corrected_level = self._correct_toc_level_by_text(toc_level, text)
|
|
745
|
+
|
|
746
|
+
self._add_index_item(
|
|
747
|
+
ilevel=corrected_level,
|
|
748
|
+
elements=elements,
|
|
749
|
+
text=text,
|
|
750
|
+
equations=equations,
|
|
751
|
+
anchor=target_anchor,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# 处理完成后重置索引状态
|
|
755
|
+
self._reset_index_state()
|
|
756
|
+
|
|
757
|
+
def _get_heading_and_level(self, style_label: str) -> tuple[str, Optional[int]]:
|
|
758
|
+
"""
|
|
759
|
+
从样式标签获取标题和层级。
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
style_label: 样式标签
|
|
763
|
+
|
|
764
|
+
Returns:
|
|
765
|
+
tuple[str, Optional[int]]: (标签字符串, 层级) 元组
|
|
766
|
+
"""
|
|
767
|
+
parts = self._split_text_and_number(style_label)
|
|
768
|
+
|
|
769
|
+
if len(parts) == 2:
|
|
770
|
+
parts.sort()
|
|
771
|
+
label_str: str = ""
|
|
772
|
+
label_level: Optional[int] = 0
|
|
773
|
+
if parts[0].strip().lower() == "heading":
|
|
774
|
+
label_str = "Heading"
|
|
775
|
+
label_level = self._str_to_int(parts[1], None)
|
|
776
|
+
if parts[1].strip().lower() == "heading":
|
|
777
|
+
label_str = "Heading"
|
|
778
|
+
label_level = self._str_to_int(parts[0], None)
|
|
779
|
+
return label_str, label_level
|
|
780
|
+
|
|
781
|
+
return style_label, None
|
|
782
|
+
|
|
783
|
+
def _split_text_and_number(self, input_string: str) -> list[str]:
|
|
784
|
+
"""
|
|
785
|
+
分割字符串中的文本和数字部分。
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
input_string: 输入字符串
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
list[str]: 分割后的部分列表
|
|
792
|
+
"""
|
|
793
|
+
match = re.match(r"(\D+)(\d+)$|^(\d+)(\D+)", input_string)
|
|
794
|
+
if match:
|
|
795
|
+
parts = list(filter(None, match.groups()))
|
|
796
|
+
return parts
|
|
797
|
+
else:
|
|
798
|
+
return [input_string]
|
|
799
|
+
|
|
800
|
+
def _str_to_int(self, s: Optional[str], default: Optional[int] = 0) -> Optional[int]:
|
|
801
|
+
"""
|
|
802
|
+
将字符串转换为整数。
|
|
803
|
+
|
|
804
|
+
Args:
|
|
805
|
+
s: 要转换的字符串
|
|
806
|
+
default: 默认值,转换失败时返回
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
Optional[int]: 转换后的整数,转换失败时返回默认值
|
|
810
|
+
"""
|
|
811
|
+
if s is None:
|
|
812
|
+
return None
|
|
813
|
+
try:
|
|
814
|
+
return int(s)
|
|
815
|
+
except ValueError:
|
|
816
|
+
return default
|