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,1895 @@
|
|
|
1
|
+
"""PowerPoint 97–2003 二进制文档的分页语义解析器。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
import struct
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
import unicodedata
|
|
9
|
+
import zlib
|
|
10
|
+
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
from ..._shared.hyperlink import sanitize_hyperlink_target
|
|
14
|
+
from ..errors import LegacyOfficeEncryptedError, LegacyOfficeMalformedError, LegacyOfficeResourceLimitError
|
|
15
|
+
from ..legacy.binary import get_i16, get_u16, get_u32
|
|
16
|
+
from ..limits import MAX_ASSET_TOTAL_BYTES, MAX_ENTRY_BYTES, MAX_GRID_SLOTS, MAX_PICTURE_RECORDS, MAX_USER_EDIT_CHAIN
|
|
17
|
+
from ..equation.mtef import decode_equation_object
|
|
18
|
+
from ..legacy.ole import BoundedOleReader
|
|
19
|
+
from ..legacy.officeart import OfficeArtRecord, OfficeImagePayload, decode_blip as decode_officeart_blip
|
|
20
|
+
from ..image import serialize_office_image
|
|
21
|
+
from ..equation.image import OfficeImageEquationDecoder
|
|
22
|
+
from ..xls.embedded_chart import extract_embedded_chart_html_from_storage
|
|
23
|
+
|
|
24
|
+
from .models import (
|
|
25
|
+
PptChartElement,
|
|
26
|
+
PptEquationElement,
|
|
27
|
+
PptImageElement,
|
|
28
|
+
PptParagraph,
|
|
29
|
+
PptPresentation,
|
|
30
|
+
PptSlide,
|
|
31
|
+
PptTableCell,
|
|
32
|
+
PptTableElement,
|
|
33
|
+
PptTextElement,
|
|
34
|
+
PptTextRun,
|
|
35
|
+
)
|
|
36
|
+
from .records import CONTAINER_VERSION, PptRecord, RecordBudget, iter_descendants, iter_records, record_at, utf16_text
|
|
37
|
+
from .style_text import CharacterRun, MasterLevel, ParagraphRun, StyleRuns, parse_master_style, parse_style_text
|
|
38
|
+
|
|
39
|
+
# MS-PPT records.
|
|
40
|
+
RT_DOCUMENT = 0x03E8
|
|
41
|
+
RT_DOCUMENT_ATOM = 0x03E9
|
|
42
|
+
RT_SLIDE = 0x03EE
|
|
43
|
+
RT_SLIDE_ATOM = 0x03EF
|
|
44
|
+
RT_NOTES = 0x03F0
|
|
45
|
+
RT_NOTES_ATOM = 0x03F1
|
|
46
|
+
RT_SLIDE_PERSIST_ATOM = 0x03F3
|
|
47
|
+
RT_MAIN_MASTER = 0x03F8
|
|
48
|
+
RT_SLIDE_SHOW_SLIDE_INFO_ATOM = 0x03F9
|
|
49
|
+
RT_TEXT_HEADER_ATOM = 0x0F9F
|
|
50
|
+
RT_TEXT_CHARS_ATOM = 0x0FA0
|
|
51
|
+
RT_STYLE_TEXT_PROP_ATOM = 0x0FA1
|
|
52
|
+
RT_TEXT_MASTER_STYLE_ATOM = 0x0FA3
|
|
53
|
+
RT_TEXT_BYTES_ATOM = 0x0FA8
|
|
54
|
+
RT_CSTRING = 0x0FBA
|
|
55
|
+
RT_TEXT_INTERACTIVE_INFO_ATOM = 0x0FDF
|
|
56
|
+
RT_EXTERNAL_HYPERLINK = 0x0FD7
|
|
57
|
+
RT_EXTERNAL_HYPERLINK_ATOM = 0x0FD3
|
|
58
|
+
RT_SLIDE_LIST_WITH_TEXT = 0x0FF0
|
|
59
|
+
RT_INTERACTIVE_INFO = 0x0FF2
|
|
60
|
+
RT_INTERACTIVE_INFO_ATOM = 0x0FF3
|
|
61
|
+
RT_USER_EDIT_ATOM = 0x0FF5
|
|
62
|
+
RT_OUTLINE_TEXT_REF_ATOM = 0x0F9E
|
|
63
|
+
RT_PERSIST_DIRECTORY_ATOM = 0x1772
|
|
64
|
+
RT_CRYPT_SESSION10_CONTAINER = 0x2F14
|
|
65
|
+
RT_EXTERNAL_OBJECT_REF_ATOM = 0x0BC1
|
|
66
|
+
RT_EXTERNAL_OLE_OBJECT_ATOM = 0x0FC3
|
|
67
|
+
RT_EXTERNAL_OLE_OBJECT_STG = 0x1011
|
|
68
|
+
|
|
69
|
+
# OfficeArt records and properties.
|
|
70
|
+
RT_OFFICEART_DGG_CONTAINER = 0xF000
|
|
71
|
+
RT_OFFICEART_BSTORE_CONTAINER = 0xF001
|
|
72
|
+
RT_OFFICEART_SPGR_CONTAINER = 0xF003
|
|
73
|
+
RT_OFFICEART_SP_CONTAINER = 0xF004
|
|
74
|
+
RT_OFFICEART_BSE = 0xF007
|
|
75
|
+
RT_OFFICEART_FSPGR = 0xF009
|
|
76
|
+
RT_OFFICEART_FSP = 0xF00A
|
|
77
|
+
RT_OFFICEART_FOPT = 0xF00B
|
|
78
|
+
RT_OFFICEART_CLIENT_TEXTBOX = 0xF00D
|
|
79
|
+
RT_OFFICEART_CHILD_ANCHOR = 0xF00F
|
|
80
|
+
RT_OFFICEART_CLIENT_ANCHOR = 0xF010
|
|
81
|
+
RT_OFFICEART_CLIENT_DATA = 0xF011
|
|
82
|
+
RT_OFFICEART_TERTIARY_FOPT = 0xF122
|
|
83
|
+
RT_OE_PLACEHOLDER_ATOM = 0x0BC3
|
|
84
|
+
|
|
85
|
+
FOPT_PIB = 0x0104
|
|
86
|
+
FOPT_TABLE_PROPERTIES = 0x039F
|
|
87
|
+
FOPT_TABLE_ROW_PROPERTIES = 0x03A0
|
|
88
|
+
|
|
89
|
+
DEFAULT_SLIDE_WIDTH = 5760
|
|
90
|
+
DEFAULT_SLIDE_HEIGHT = 4320
|
|
91
|
+
_ALLOWED_LINK_SCHEMES = frozenset({"http", "https", "mailto"})
|
|
92
|
+
_CFB_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
|
93
|
+
_ZLIB_SYNC_FLUSH_SUFFIX = b"\x00\x00\xff\xff"
|
|
94
|
+
_OLE_SUBTYPE_GRAPH = 0x0000_0004
|
|
95
|
+
_OLE_SUBTYPE_EQUATION = 0x0000_0006
|
|
96
|
+
_OLE_SUBTYPE_EXCEL_CHART = 0x0000_000E
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class _TextContent:
|
|
101
|
+
"""一个 TextHeaderAtom 对应的完整段落集合。"""
|
|
102
|
+
|
|
103
|
+
paragraphs: tuple[PptParagraph, ...]
|
|
104
|
+
text_type: int
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class _PersistLayout:
|
|
109
|
+
"""最新 UserEdit 解析出的文档与 persist 映射。"""
|
|
110
|
+
|
|
111
|
+
document: PptRecord
|
|
112
|
+
persist: dict[int, int]
|
|
113
|
+
recovered: bool = False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class _EmbeddedOleObject:
|
|
118
|
+
"""一个已按 exObjId 绑定并解压的 PPT OLE 对象。"""
|
|
119
|
+
|
|
120
|
+
subtype: int
|
|
121
|
+
storage: bytes
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class _GroupSpace:
|
|
126
|
+
"""嵌套 OfficeArt group 到幻灯片坐标的线性映射。"""
|
|
127
|
+
|
|
128
|
+
coord_left: int
|
|
129
|
+
coord_top: int
|
|
130
|
+
coord_right: int
|
|
131
|
+
coord_bottom: int
|
|
132
|
+
abs_left: float
|
|
133
|
+
abs_top: float
|
|
134
|
+
abs_right: float
|
|
135
|
+
abs_bottom: float
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class _ShapeInfo:
|
|
140
|
+
"""带唯一遍历路径、坐标和 z-order 的 OfficeArt shape。"""
|
|
141
|
+
|
|
142
|
+
key: tuple[int, ...]
|
|
143
|
+
record: PptRecord
|
|
144
|
+
space: _GroupSpace | None
|
|
145
|
+
bbox: tuple[float, float, float, float]
|
|
146
|
+
order: int
|
|
147
|
+
group_key: tuple[int, ...] | None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class _TableGroup:
|
|
152
|
+
"""一个由 fIsTable 标记的 group 及其所有叶子 shape。"""
|
|
153
|
+
|
|
154
|
+
key: tuple[int, ...]
|
|
155
|
+
group_shape: PptRecord
|
|
156
|
+
shapes: tuple[_ShapeInfo, ...]
|
|
157
|
+
order: int
|
|
158
|
+
authoritative: bool
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_ImagePayload = OfficeImagePayload
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True, slots=True)
|
|
165
|
+
class _NumberingStyle:
|
|
166
|
+
"""一个 pp9rt 槽位的自动编号覆盖。"""
|
|
167
|
+
|
|
168
|
+
enabled: bool | None
|
|
169
|
+
start: int | None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class _ShapeCollection:
|
|
174
|
+
"""一次遍历得到的叶子 shape 与表格 group。"""
|
|
175
|
+
|
|
176
|
+
shapes: tuple[_ShapeInfo, ...]
|
|
177
|
+
table_groups: tuple[_TableGroup, ...]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _direct_children(record: PptRecord, budget: RecordBudget) -> list[PptRecord]:
|
|
181
|
+
"""返回容器的直接子记录,普通 atom 返回空列表。"""
|
|
182
|
+
|
|
183
|
+
if record.version != CONTAINER_VERSION:
|
|
184
|
+
return []
|
|
185
|
+
return list(iter_records(record.payload, budget=budget))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _find_latest_user_edit_offset(data: bytes, current_user: bytes) -> int | None:
|
|
189
|
+
"""从 Current User stream 读取最新 UserEditAtom 偏移。"""
|
|
190
|
+
|
|
191
|
+
offset = get_u32(current_user, 16)
|
|
192
|
+
return int(offset) if offset else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _merge_persist_directory(
|
|
196
|
+
mapping: dict[int, int],
|
|
197
|
+
payload: bytes,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""合并一个 PersistDirectoryAtom;调用顺序保证较新记录优先。"""
|
|
200
|
+
|
|
201
|
+
cursor = 0
|
|
202
|
+
while cursor < len(payload):
|
|
203
|
+
head = get_u32(payload, cursor)
|
|
204
|
+
if head is None:
|
|
205
|
+
raise LegacyOfficeMalformedError("PowerPoint persist directory is truncated")
|
|
206
|
+
cursor += 4
|
|
207
|
+
first_id = head & 0x000F_FFFF
|
|
208
|
+
count = head >> 20
|
|
209
|
+
if count <= 0 or cursor + count * 4 > len(payload):
|
|
210
|
+
raise LegacyOfficeMalformedError("PowerPoint persist directory entry is invalid")
|
|
211
|
+
for index in range(count):
|
|
212
|
+
offset = get_u32(payload, cursor)
|
|
213
|
+
if offset is None:
|
|
214
|
+
raise LegacyOfficeMalformedError("PowerPoint persist offset is truncated")
|
|
215
|
+
mapping.setdefault(first_id + index, int(offset))
|
|
216
|
+
cursor += 4
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _latest_document_fallback(data: bytes, budget: RecordBudget) -> PptRecord | None:
|
|
220
|
+
"""在 persist 链损坏时选择最后一个完整 DocumentContainer。"""
|
|
221
|
+
|
|
222
|
+
candidates = [
|
|
223
|
+
record
|
|
224
|
+
for record in iter_records(data, budget=budget)
|
|
225
|
+
if record.record_type == RT_DOCUMENT and record.version == CONTAINER_VERSION
|
|
226
|
+
]
|
|
227
|
+
return candidates[-1] if candidates else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _locate_document(
|
|
231
|
+
data: bytes,
|
|
232
|
+
current_user: bytes,
|
|
233
|
+
budget: RecordBudget,
|
|
234
|
+
) -> _PersistLayout:
|
|
235
|
+
"""解析最新 UserEdit 链,失败时回退到顶层 DocumentContainer。"""
|
|
236
|
+
|
|
237
|
+
edit_offset = _find_latest_user_edit_offset(data, current_user)
|
|
238
|
+
persist: dict[int, int] = {}
|
|
239
|
+
document_persist_id: int | None = None
|
|
240
|
+
seen: set[int] = set()
|
|
241
|
+
try:
|
|
242
|
+
for _ in range(MAX_USER_EDIT_CHAIN):
|
|
243
|
+
if not edit_offset:
|
|
244
|
+
break
|
|
245
|
+
if edit_offset in seen:
|
|
246
|
+
raise LegacyOfficeMalformedError("PowerPoint UserEdit chain is cyclic")
|
|
247
|
+
seen.add(edit_offset)
|
|
248
|
+
edit = record_at(data, edit_offset, strict=True, budget=budget)
|
|
249
|
+
if edit is None or edit.record_type != RT_USER_EDIT_ATOM:
|
|
250
|
+
raise LegacyOfficeMalformedError("PowerPoint UserEditAtom is invalid")
|
|
251
|
+
if document_persist_id is None:
|
|
252
|
+
document_persist_id = get_u32(edit.payload, 16)
|
|
253
|
+
directory_offset = get_u32(edit.payload, 12)
|
|
254
|
+
if directory_offset is None:
|
|
255
|
+
raise LegacyOfficeMalformedError("PowerPoint persist directory offset is missing")
|
|
256
|
+
directory = record_at(data, directory_offset, strict=True, budget=budget)
|
|
257
|
+
if directory is None or directory.record_type != RT_PERSIST_DIRECTORY_ATOM:
|
|
258
|
+
raise LegacyOfficeMalformedError("PowerPoint PersistDirectoryAtom is invalid")
|
|
259
|
+
_merge_persist_directory(persist, directory.payload)
|
|
260
|
+
previous = get_u32(edit.payload, 8)
|
|
261
|
+
if previous == edit_offset:
|
|
262
|
+
raise LegacyOfficeMalformedError("PowerPoint UserEdit chain points to itself")
|
|
263
|
+
edit_offset = int(previous or 0)
|
|
264
|
+
else:
|
|
265
|
+
raise LegacyOfficeResourceLimitError(f"UserEdit chain exceeds max_user_edit_chain={MAX_USER_EDIT_CHAIN}")
|
|
266
|
+
if document_persist_id is not None:
|
|
267
|
+
document_offset = persist.get(int(document_persist_id))
|
|
268
|
+
if document_offset is not None:
|
|
269
|
+
document = record_at(data, document_offset, strict=True, budget=budget)
|
|
270
|
+
if document is not None and document.record_type == RT_DOCUMENT:
|
|
271
|
+
return _PersistLayout(document=document, persist=persist)
|
|
272
|
+
except (LegacyOfficeMalformedError, LegacyOfficeResourceLimitError) as exc:
|
|
273
|
+
if isinstance(exc, LegacyOfficeResourceLimitError):
|
|
274
|
+
raise
|
|
275
|
+
logger.warning(f"PPT_PERSIST_RECOVERY: {exc}")
|
|
276
|
+
|
|
277
|
+
document = _latest_document_fallback(data, budget)
|
|
278
|
+
if document is None:
|
|
279
|
+
raise LegacyOfficeMalformedError("PowerPoint DocumentContainer is missing")
|
|
280
|
+
# 顶层 persist 记录仍可帮助恢复同一保存版本中的 slide。
|
|
281
|
+
recovered_persist: dict[int, int] = {}
|
|
282
|
+
for record in iter_records(data, budget=budget):
|
|
283
|
+
if record.record_type == RT_PERSIST_DIRECTORY_ATOM:
|
|
284
|
+
try:
|
|
285
|
+
_merge_persist_directory(recovered_persist, record.payload)
|
|
286
|
+
except LegacyOfficeMalformedError:
|
|
287
|
+
continue
|
|
288
|
+
return _PersistLayout(document=document, persist=recovered_persist, recovered=True)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _slide_entries(document: PptRecord, budget: RecordBudget) -> list[tuple[int, int]]:
|
|
292
|
+
"""按 SlideListWithText 的保存顺序返回 persist 引用与稳定 slide id。"""
|
|
293
|
+
|
|
294
|
+
entries: list[tuple[int, int]] = []
|
|
295
|
+
for container in iter_descendants(document, budget=budget):
|
|
296
|
+
if container.record_type != RT_SLIDE_LIST_WITH_TEXT or container.instance != 0:
|
|
297
|
+
continue
|
|
298
|
+
for record in iter_records(container.payload, budget=budget):
|
|
299
|
+
if record.record_type != RT_SLIDE_PERSIST_ATOM:
|
|
300
|
+
continue
|
|
301
|
+
reference = get_u32(record.payload, 0)
|
|
302
|
+
slide_id = get_u32(record.payload, 12)
|
|
303
|
+
if reference:
|
|
304
|
+
entries.append((int(reference), int(slide_id or 0)))
|
|
305
|
+
break
|
|
306
|
+
return entries
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _master_entries(document: PptRecord, budget: RecordBudget) -> list[tuple[int, int]]:
|
|
310
|
+
"""返回 master persist 引用与 master id。"""
|
|
311
|
+
|
|
312
|
+
entries: list[tuple[int, int]] = []
|
|
313
|
+
for container in iter_descendants(document, budget=budget):
|
|
314
|
+
if container.record_type != RT_SLIDE_LIST_WITH_TEXT or container.instance != 1:
|
|
315
|
+
continue
|
|
316
|
+
for record in iter_records(container.payload, budget=budget):
|
|
317
|
+
if record.record_type != RT_SLIDE_PERSIST_ATOM:
|
|
318
|
+
continue
|
|
319
|
+
reference = get_u32(record.payload, 0)
|
|
320
|
+
master_id = get_u32(record.payload, 12)
|
|
321
|
+
if reference and master_id:
|
|
322
|
+
entries.append((int(reference), int(master_id)))
|
|
323
|
+
break
|
|
324
|
+
return entries
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _notes_entries(document: PptRecord, budget: RecordBudget) -> list[int]:
|
|
328
|
+
"""返回 notes list 中的 persist 引用。"""
|
|
329
|
+
|
|
330
|
+
references: list[int] = []
|
|
331
|
+
for container in iter_descendants(document, budget=budget):
|
|
332
|
+
if container.record_type != RT_SLIDE_LIST_WITH_TEXT or container.instance != 2:
|
|
333
|
+
continue
|
|
334
|
+
for record in iter_records(container.payload, budget=budget):
|
|
335
|
+
if record.record_type == RT_SLIDE_PERSIST_ATOM:
|
|
336
|
+
reference = get_u32(record.payload, 0)
|
|
337
|
+
if reference:
|
|
338
|
+
references.append(int(reference))
|
|
339
|
+
break
|
|
340
|
+
return references
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _slide_master_id(slide: PptRecord, budget: RecordBudget) -> int | None:
|
|
344
|
+
"""读取 SlideAtom.masterIdRef。"""
|
|
345
|
+
|
|
346
|
+
for child in iter_descendants(slide, budget=budget):
|
|
347
|
+
if child.record_type == RT_SLIDE_ATOM:
|
|
348
|
+
master_id = get_u32(child.payload, 12)
|
|
349
|
+
return int(master_id) if master_id else None
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _presentation_size(document: PptRecord, budget: RecordBudget) -> tuple[int, int]:
|
|
354
|
+
"""从 DocumentAtom 读取页面 master units 尺寸。"""
|
|
355
|
+
|
|
356
|
+
for child in iter_descendants(document, budget=budget):
|
|
357
|
+
if child.record_type != RT_DOCUMENT_ATOM or len(child.payload) < 8:
|
|
358
|
+
continue
|
|
359
|
+
width = get_u32(child.payload, 0)
|
|
360
|
+
height = get_u32(child.payload, 4)
|
|
361
|
+
if width and height and width < 100_000 and height < 100_000:
|
|
362
|
+
return int(width), int(height)
|
|
363
|
+
return DEFAULT_SLIDE_WIDTH, DEFAULT_SLIDE_HEIGHT
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _decode_text_atom(record: PptRecord) -> str | None:
|
|
367
|
+
"""解码 TextCharsAtom 或 TextBytesAtom。"""
|
|
368
|
+
|
|
369
|
+
if record.record_type == RT_TEXT_CHARS_ATOM:
|
|
370
|
+
return utf16_text(record.payload)
|
|
371
|
+
if record.record_type == RT_TEXT_BYTES_ATOM:
|
|
372
|
+
return record.payload.decode("cp1252", "replace").rstrip("\x00")
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _hyperlink_targets(document: PptRecord, budget: RecordBudget) -> dict[int, str]:
|
|
377
|
+
"""建立 ExHyperlinkId 到安全外链目标的映射。"""
|
|
378
|
+
|
|
379
|
+
result: dict[int, str] = {}
|
|
380
|
+
for container in iter_descendants(document, budget=budget):
|
|
381
|
+
if container.record_type != RT_EXTERNAL_HYPERLINK or container.version != CONTAINER_VERSION:
|
|
382
|
+
continue
|
|
383
|
+
link_id = None
|
|
384
|
+
strings: list[str] = []
|
|
385
|
+
for child in iter_records(container.payload, budget=budget):
|
|
386
|
+
if child.record_type == RT_EXTERNAL_HYPERLINK_ATOM:
|
|
387
|
+
link_id = get_u32(child.payload, 0)
|
|
388
|
+
elif child.record_type == RT_CSTRING:
|
|
389
|
+
strings.append(utf16_text(child.payload))
|
|
390
|
+
if link_id is None or not strings:
|
|
391
|
+
continue
|
|
392
|
+
safe_target = sanitize_hyperlink_target(strings[-1], allowed_schemes=_ALLOWED_LINK_SCHEMES)
|
|
393
|
+
if safe_target is None:
|
|
394
|
+
logger.warning(f"PPT_UNSAFE_HYPERLINK: hyperlink id={link_id} was downgraded")
|
|
395
|
+
continue
|
|
396
|
+
result[int(link_id)] = safe_target
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _interactive_spans(
|
|
401
|
+
related_records: Iterable[PptRecord],
|
|
402
|
+
hyperlinks: dict[int, str],
|
|
403
|
+
budget: RecordBudget,
|
|
404
|
+
) -> list[tuple[int, int, str]]:
|
|
405
|
+
"""解析一段文本之后的 InteractiveInfo 与 UTF-16 范围。"""
|
|
406
|
+
|
|
407
|
+
flattened: list[PptRecord] = []
|
|
408
|
+
for record in related_records:
|
|
409
|
+
flattened.append(record)
|
|
410
|
+
if record.version == CONTAINER_VERSION:
|
|
411
|
+
flattened.extend(iter_descendants(record, budget=budget))
|
|
412
|
+
spans: list[tuple[int, int, str]] = []
|
|
413
|
+
pending_id: int | None = None
|
|
414
|
+
for record in flattened:
|
|
415
|
+
if record.record_type == RT_INTERACTIVE_INFO_ATOM:
|
|
416
|
+
pending_id = get_u32(record.payload, 4)
|
|
417
|
+
continue
|
|
418
|
+
if record.record_type != RT_TEXT_INTERACTIVE_INFO_ATOM or pending_id is None:
|
|
419
|
+
continue
|
|
420
|
+
start = get_u32(record.payload, 0)
|
|
421
|
+
end = get_u32(record.payload, 4)
|
|
422
|
+
target = hyperlinks.get(int(pending_id))
|
|
423
|
+
if start is not None and end is not None and target and end > start:
|
|
424
|
+
spans.append((int(start), int(end), target))
|
|
425
|
+
pending_id = None
|
|
426
|
+
return spans
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _utf16_width(text: str) -> int:
|
|
430
|
+
"""返回字符串占用的 UTF-16 code unit 数。"""
|
|
431
|
+
|
|
432
|
+
return len(text.encode("utf-16-le", "surrogatepass")) // 2
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _master_level(levels: list[MasterLevel], depth: int) -> MasterLevel:
|
|
436
|
+
"""按深度取得母版默认值,超界时回退最后一个可用层级。"""
|
|
437
|
+
|
|
438
|
+
if not levels:
|
|
439
|
+
return MasterLevel()
|
|
440
|
+
return levels[min(max(depth, 0), len(levels) - 1)]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _paragraph_run_at(runs: list[ParagraphRun], offset: int) -> ParagraphRun:
|
|
444
|
+
"""返回覆盖指定 UTF-16 偏移的段落 run。"""
|
|
445
|
+
|
|
446
|
+
cursor = 0
|
|
447
|
+
for run in runs:
|
|
448
|
+
if cursor <= offset < cursor + run.count:
|
|
449
|
+
return run
|
|
450
|
+
cursor += run.count
|
|
451
|
+
return ParagraphRun(count=0, depth=0)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _character_run_at(runs: list[CharacterRun], offset: int) -> CharacterRun:
|
|
455
|
+
"""返回覆盖指定 UTF-16 偏移的字符 run。"""
|
|
456
|
+
|
|
457
|
+
cursor = 0
|
|
458
|
+
for run in runs:
|
|
459
|
+
if cursor <= offset < cursor + run.count:
|
|
460
|
+
return run
|
|
461
|
+
cursor += run.count
|
|
462
|
+
return CharacterRun(count=0)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _hyperlink_at(spans: list[tuple[int, int, str]], offset: int) -> str | None:
|
|
466
|
+
"""返回覆盖当前 UTF-16 偏移的超链接目标。"""
|
|
467
|
+
|
|
468
|
+
return next((target for start, end, target in spans if start <= offset < end), None)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _resolve_run(
|
|
472
|
+
text: str,
|
|
473
|
+
explicit: CharacterRun,
|
|
474
|
+
master: MasterLevel,
|
|
475
|
+
hyperlink: str | None,
|
|
476
|
+
) -> PptTextRun:
|
|
477
|
+
"""把字符异常属性覆盖到母版默认值上。"""
|
|
478
|
+
|
|
479
|
+
return PptTextRun(
|
|
480
|
+
text=text,
|
|
481
|
+
bold=explicit.bold if explicit.bold is not None else bool(master.bold),
|
|
482
|
+
italic=explicit.italic if explicit.italic is not None else bool(master.italic),
|
|
483
|
+
underline=(explicit.underline if explicit.underline is not None else bool(master.underline) or hyperlink is not None),
|
|
484
|
+
strike=bool(explicit.strike),
|
|
485
|
+
baseline=explicit.baseline if explicit.baseline is not None else master.baseline,
|
|
486
|
+
hyperlink=hyperlink,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _flush_text_run(
|
|
491
|
+
output: list[PptTextRun],
|
|
492
|
+
text: str,
|
|
493
|
+
style: PptTextRun | None,
|
|
494
|
+
) -> None:
|
|
495
|
+
"""追加非空文本,并合并相邻同样式 run。"""
|
|
496
|
+
|
|
497
|
+
if not text or style is None:
|
|
498
|
+
return
|
|
499
|
+
candidate = replace(style, text=text)
|
|
500
|
+
if output and replace(output[-1], text="") == replace(candidate, text=""):
|
|
501
|
+
output[-1] = replace(output[-1], text=f"{output[-1].text}{text}")
|
|
502
|
+
return
|
|
503
|
+
output.append(candidate)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _build_paragraphs(
|
|
507
|
+
text: str,
|
|
508
|
+
styles: StyleRuns,
|
|
509
|
+
master_levels: list[MasterLevel],
|
|
510
|
+
hyperlinks: list[tuple[int, int, str]],
|
|
511
|
+
) -> tuple[PptParagraph, ...]:
|
|
512
|
+
"""把 UTF-16 属性范围转换为带样式 run 的段落。"""
|
|
513
|
+
|
|
514
|
+
paragraphs: list[PptParagraph] = []
|
|
515
|
+
current_runs: list[PptTextRun] = []
|
|
516
|
+
run_text: list[str] = []
|
|
517
|
+
active_style: PptTextRun | None = None
|
|
518
|
+
utf16_offset = 0
|
|
519
|
+
paragraph_start = 0
|
|
520
|
+
|
|
521
|
+
def flush_run() -> None:
|
|
522
|
+
"""把当前相同样式字符提交到段落。"""
|
|
523
|
+
|
|
524
|
+
nonlocal run_text
|
|
525
|
+
_flush_text_run(current_runs, "".join(run_text), active_style)
|
|
526
|
+
run_text = []
|
|
527
|
+
|
|
528
|
+
def flush_paragraph() -> None:
|
|
529
|
+
"""完成当前段落并解析列表属性。"""
|
|
530
|
+
|
|
531
|
+
flush_run()
|
|
532
|
+
paragraph_style = _paragraph_run_at(styles.paragraphs, paragraph_start)
|
|
533
|
+
character_style = _character_run_at(styles.characters, paragraph_start)
|
|
534
|
+
master = _master_level(master_levels, paragraph_style.depth)
|
|
535
|
+
bullet = paragraph_style.bullet if paragraph_style.bullet is not None else bool(master.bullet)
|
|
536
|
+
visible = any(run.text.strip() for run in current_runs)
|
|
537
|
+
if visible:
|
|
538
|
+
paragraphs.append(
|
|
539
|
+
PptParagraph(
|
|
540
|
+
runs=tuple(current_runs),
|
|
541
|
+
depth=max(0, int(paragraph_style.depth)),
|
|
542
|
+
list_kind="unordered" if bullet else None,
|
|
543
|
+
pp9rt=character_style.pp9rt,
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
current_runs.clear()
|
|
547
|
+
|
|
548
|
+
for character in text:
|
|
549
|
+
paragraph_style = _paragraph_run_at(styles.paragraphs, utf16_offset)
|
|
550
|
+
master = _master_level(master_levels, paragraph_style.depth)
|
|
551
|
+
explicit = _character_run_at(styles.characters, utf16_offset)
|
|
552
|
+
hyperlink = _hyperlink_at(hyperlinks, utf16_offset)
|
|
553
|
+
style = _resolve_run("", explicit, master, hyperlink)
|
|
554
|
+
if active_style is None or replace(active_style, text="") != replace(style, text=""):
|
|
555
|
+
flush_run()
|
|
556
|
+
active_style = style
|
|
557
|
+
if character == "\r":
|
|
558
|
+
flush_paragraph()
|
|
559
|
+
paragraph_start = utf16_offset + 1
|
|
560
|
+
active_style = None
|
|
561
|
+
elif character in {"\x0b", "\n"}:
|
|
562
|
+
run_text.append("\n")
|
|
563
|
+
elif not unicodedata.category(character).startswith("C") or character == "\t":
|
|
564
|
+
run_text.append(character)
|
|
565
|
+
utf16_offset += _utf16_width(character)
|
|
566
|
+
if run_text or current_runs:
|
|
567
|
+
flush_paragraph()
|
|
568
|
+
return tuple(paragraphs)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _parse_text_contents(
|
|
572
|
+
records: list[PptRecord],
|
|
573
|
+
master_styles: dict[int, list[MasterLevel]],
|
|
574
|
+
hyperlinks: dict[int, str],
|
|
575
|
+
budget: RecordBudget,
|
|
576
|
+
) -> list[_TextContent]:
|
|
577
|
+
"""从一组相邻记录中解析所有文本形状内容。"""
|
|
578
|
+
|
|
579
|
+
result: list[_TextContent] = []
|
|
580
|
+
text_type = 4
|
|
581
|
+
for index, record in enumerate(records):
|
|
582
|
+
if record.record_type == RT_TEXT_HEADER_ATOM:
|
|
583
|
+
text_type = int(get_u32(record.payload, 0) or 4)
|
|
584
|
+
continue
|
|
585
|
+
text = _decode_text_atom(record)
|
|
586
|
+
if text is None:
|
|
587
|
+
continue
|
|
588
|
+
tail = records[index + 1 :]
|
|
589
|
+
next_text_index = next(
|
|
590
|
+
(
|
|
591
|
+
position
|
|
592
|
+
for position, candidate in enumerate(tail)
|
|
593
|
+
if candidate.record_type in {RT_TEXT_CHARS_ATOM, RT_TEXT_BYTES_ATOM, RT_SLIDE_PERSIST_ATOM}
|
|
594
|
+
),
|
|
595
|
+
len(tail),
|
|
596
|
+
)
|
|
597
|
+
related = tail[:next_text_index]
|
|
598
|
+
style_atom = next(
|
|
599
|
+
(candidate for candidate in related if candidate.record_type == RT_STYLE_TEXT_PROP_ATOM),
|
|
600
|
+
None,
|
|
601
|
+
)
|
|
602
|
+
styles = parse_style_text(style_atom.payload, _utf16_width(text)) if style_atom is not None else StyleRuns()
|
|
603
|
+
spans = _interactive_spans(related, hyperlinks, budget)
|
|
604
|
+
paragraphs = _build_paragraphs(
|
|
605
|
+
text,
|
|
606
|
+
styles,
|
|
607
|
+
master_styles.get(text_type, []),
|
|
608
|
+
spans,
|
|
609
|
+
)
|
|
610
|
+
result.append(_TextContent(paragraphs=paragraphs, text_type=text_type))
|
|
611
|
+
return result
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _external_text_records(
|
|
615
|
+
document: PptRecord,
|
|
616
|
+
budget: RecordBudget,
|
|
617
|
+
) -> dict[int, list[PptRecord]]:
|
|
618
|
+
"""按 slide persist 引用收集 SlideListWithText 中的外置文本记录。"""
|
|
619
|
+
|
|
620
|
+
grouped: dict[int, list[PptRecord]] = {}
|
|
621
|
+
for container in iter_descendants(document, budget=budget):
|
|
622
|
+
if container.record_type != RT_SLIDE_LIST_WITH_TEXT or container.instance != 0:
|
|
623
|
+
continue
|
|
624
|
+
current_reference: int | None = None
|
|
625
|
+
for record in iter_records(container.payload, budget=budget):
|
|
626
|
+
if record.record_type == RT_SLIDE_PERSIST_ATOM:
|
|
627
|
+
reference = get_u32(record.payload, 0)
|
|
628
|
+
current_reference = int(reference) if reference else None
|
|
629
|
+
if current_reference is not None:
|
|
630
|
+
grouped.setdefault(current_reference, [])
|
|
631
|
+
continue
|
|
632
|
+
if current_reference is not None:
|
|
633
|
+
grouped[current_reference].append(record)
|
|
634
|
+
break
|
|
635
|
+
return grouped
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _master_styles(record: PptRecord, budget: RecordBudget) -> dict[int, list[MasterLevel]]:
|
|
639
|
+
"""解析一个 master container 的逐文本类型默认样式。"""
|
|
640
|
+
|
|
641
|
+
result: dict[int, list[MasterLevel]] = {}
|
|
642
|
+
for child in iter_descendants(record, budget=budget):
|
|
643
|
+
if child.record_type != RT_TEXT_MASTER_STYLE_ATOM:
|
|
644
|
+
continue
|
|
645
|
+
result.setdefault(
|
|
646
|
+
int(child.instance),
|
|
647
|
+
parse_master_style(child.payload, int(child.instance)),
|
|
648
|
+
)
|
|
649
|
+
return result
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _collect_masters(
|
|
653
|
+
layout: _PersistLayout,
|
|
654
|
+
data: bytes,
|
|
655
|
+
budget: RecordBudget,
|
|
656
|
+
) -> tuple[dict[int, tuple[PptRecord, dict[int, list[MasterLevel]]]], tuple[PptRecord, dict[int, list[MasterLevel]]] | None]:
|
|
657
|
+
"""按 master id 建立容器与样式映射,并返回确定性 fallback。"""
|
|
658
|
+
|
|
659
|
+
masters: dict[int, tuple[PptRecord, dict[int, list[MasterLevel]]]] = {}
|
|
660
|
+
for reference, master_id in _master_entries(layout.document, budget):
|
|
661
|
+
offset = layout.persist.get(reference)
|
|
662
|
+
if offset is None:
|
|
663
|
+
continue
|
|
664
|
+
record = record_at(data, offset, budget=budget)
|
|
665
|
+
if record is None or record.record_type not in {RT_MAIN_MASTER, RT_SLIDE}:
|
|
666
|
+
continue
|
|
667
|
+
masters.setdefault(master_id, (record, _master_styles(record, budget)))
|
|
668
|
+
|
|
669
|
+
if not masters:
|
|
670
|
+
for root in iter_records(data, budget=budget):
|
|
671
|
+
if root.record_type != RT_MAIN_MASTER:
|
|
672
|
+
continue
|
|
673
|
+
masters.setdefault(0, (root, _master_styles(root, budget)))
|
|
674
|
+
fallback = next(iter(masters.values()), None)
|
|
675
|
+
return masters, fallback
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _fopt_properties(record: PptRecord) -> dict[int, int]:
|
|
679
|
+
"""读取 OfficeArt FOPT 的简单属性,重复属性以后者覆盖。"""
|
|
680
|
+
|
|
681
|
+
count = int(record.instance)
|
|
682
|
+
properties: dict[int, int] = {}
|
|
683
|
+
for index in range(count):
|
|
684
|
+
offset = index * 6
|
|
685
|
+
if offset + 6 > len(record.payload):
|
|
686
|
+
break
|
|
687
|
+
opid, value = struct.unpack_from("<HI", record.payload, offset)
|
|
688
|
+
properties[opid & 0x3FFF] = int(value)
|
|
689
|
+
return properties
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _fopt_complex_properties(record: PptRecord) -> dict[int, bytes]:
|
|
693
|
+
"""读取 OfficeArt FOPT 中紧随属性数组的复杂载荷。"""
|
|
694
|
+
|
|
695
|
+
count = int(record.instance)
|
|
696
|
+
cursor = count * 6
|
|
697
|
+
result: dict[int, bytes] = {}
|
|
698
|
+
entries: list[tuple[int, int, bool]] = []
|
|
699
|
+
for index in range(count):
|
|
700
|
+
offset = index * 6
|
|
701
|
+
if offset + 6 > len(record.payload):
|
|
702
|
+
break
|
|
703
|
+
opid, value = struct.unpack_from("<HI", record.payload, offset)
|
|
704
|
+
entries.append((opid & 0x3FFF, int(value), bool(opid & 0x8000)))
|
|
705
|
+
for property_id, size, is_complex in entries:
|
|
706
|
+
if not is_complex:
|
|
707
|
+
continue
|
|
708
|
+
end = cursor + size
|
|
709
|
+
if size < 0 or end < cursor or end > len(record.payload):
|
|
710
|
+
break
|
|
711
|
+
result[property_id] = record.payload[cursor:end]
|
|
712
|
+
cursor = end
|
|
713
|
+
return result
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _shape_properties(shape: PptRecord, budget: RecordBudget) -> dict[int, int]:
|
|
717
|
+
"""合并 shape 的 primary 与 tertiary FOPT 属性。"""
|
|
718
|
+
|
|
719
|
+
properties: dict[int, int] = {}
|
|
720
|
+
for child in _direct_children(shape, budget):
|
|
721
|
+
if child.record_type in {RT_OFFICEART_FOPT, RT_OFFICEART_TERTIARY_FOPT}:
|
|
722
|
+
properties.update(_fopt_properties(child))
|
|
723
|
+
return properties
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _shape_external_object_id(shape: PptRecord, budget: RecordBudget) -> int | None:
|
|
727
|
+
"""从 OfficeArtClientData 读取 ExObjRefAtom 的外部对象 id。"""
|
|
728
|
+
|
|
729
|
+
for child in _direct_children(shape, budget):
|
|
730
|
+
if child.record_type != RT_OFFICEART_CLIENT_DATA:
|
|
731
|
+
continue
|
|
732
|
+
candidates: Iterable[PptRecord]
|
|
733
|
+
if child.version == CONTAINER_VERSION:
|
|
734
|
+
candidates = iter_descendants(child, budget=budget)
|
|
735
|
+
else:
|
|
736
|
+
candidates = iter_records(child.payload, budget=budget)
|
|
737
|
+
for candidate in candidates:
|
|
738
|
+
if candidate.record_type != RT_EXTERNAL_OBJECT_REF_ATOM:
|
|
739
|
+
continue
|
|
740
|
+
reference = get_u32(candidate.payload, 0)
|
|
741
|
+
return int(reference) if reference else None
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _embedded_object_references(
|
|
746
|
+
document: PptRecord,
|
|
747
|
+
budget: RecordBudget,
|
|
748
|
+
) -> dict[int, tuple[int, int]]:
|
|
749
|
+
"""收集支持的嵌入 OLE subtype、exObjId 与 persistIdRef。"""
|
|
750
|
+
|
|
751
|
+
references: dict[int, tuple[int, int]] = {}
|
|
752
|
+
for atom in iter_descendants(document, budget=budget):
|
|
753
|
+
if atom.record_type != RT_EXTERNAL_OLE_OBJECT_ATOM or len(atom.payload) < 24:
|
|
754
|
+
continue
|
|
755
|
+
object_type = get_u32(atom.payload, 4)
|
|
756
|
+
object_id = get_u32(atom.payload, 8)
|
|
757
|
+
object_subtype = get_u32(atom.payload, 12)
|
|
758
|
+
persist_id = get_u32(atom.payload, 16)
|
|
759
|
+
if (
|
|
760
|
+
object_type == 0
|
|
761
|
+
and object_subtype
|
|
762
|
+
in {
|
|
763
|
+
_OLE_SUBTYPE_GRAPH,
|
|
764
|
+
_OLE_SUBTYPE_EQUATION,
|
|
765
|
+
_OLE_SUBTYPE_EXCEL_CHART,
|
|
766
|
+
}
|
|
767
|
+
and object_id
|
|
768
|
+
and persist_id
|
|
769
|
+
):
|
|
770
|
+
references.setdefault(
|
|
771
|
+
int(object_id),
|
|
772
|
+
(int(object_subtype), int(persist_id)),
|
|
773
|
+
)
|
|
774
|
+
return references
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _decompress_ole_storage(record: PptRecord) -> bytes | None:
|
|
778
|
+
"""按 ExOleObjStg instance 有界恢复独立 CFB 字节。"""
|
|
779
|
+
|
|
780
|
+
if record.record_type != RT_EXTERNAL_OLE_OBJECT_STG:
|
|
781
|
+
return None
|
|
782
|
+
if record.instance == 0:
|
|
783
|
+
if len(record.payload) > MAX_ENTRY_BYTES:
|
|
784
|
+
raise LegacyOfficeResourceLimitError(f"OLE object exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
|
|
785
|
+
storage = record.payload
|
|
786
|
+
if not storage.startswith(_CFB_MAGIC):
|
|
787
|
+
return None
|
|
788
|
+
try:
|
|
789
|
+
with BoundedOleReader(storage):
|
|
790
|
+
pass
|
|
791
|
+
except ValueError:
|
|
792
|
+
return None
|
|
793
|
+
return storage
|
|
794
|
+
if record.instance != 1 or len(record.payload) < 4:
|
|
795
|
+
return None
|
|
796
|
+
declared_size = int(get_u32(record.payload, 0) or 0)
|
|
797
|
+
if declared_size <= 0:
|
|
798
|
+
return None
|
|
799
|
+
if declared_size > MAX_ENTRY_BYTES:
|
|
800
|
+
raise LegacyOfficeResourceLimitError(f"OLE object exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
|
|
801
|
+
try:
|
|
802
|
+
inflater = zlib.decompressobj(zlib.MAX_WBITS)
|
|
803
|
+
storage = inflater.decompress(record.payload[4:], MAX_ENTRY_BYTES + 1)
|
|
804
|
+
if len(storage) > MAX_ENTRY_BYTES:
|
|
805
|
+
raise LegacyOfficeResourceLimitError(f"OLE object exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
|
|
806
|
+
storage += inflater.flush(MAX_ENTRY_BYTES + 1 - len(storage))
|
|
807
|
+
except (ValueError, zlib.error):
|
|
808
|
+
return None
|
|
809
|
+
if len(storage) > MAX_ENTRY_BYTES:
|
|
810
|
+
raise LegacyOfficeResourceLimitError(f"OLE object exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
|
|
811
|
+
if len(storage) != declared_size or inflater.unconsumed_tail:
|
|
812
|
+
return None
|
|
813
|
+
if not inflater.eof:
|
|
814
|
+
if not record.payload[4:].endswith(_ZLIB_SYNC_FLUSH_SUFFIX):
|
|
815
|
+
return None
|
|
816
|
+
elif inflater.unused_data:
|
|
817
|
+
return None
|
|
818
|
+
if not storage.startswith(_CFB_MAGIC):
|
|
819
|
+
return None
|
|
820
|
+
try:
|
|
821
|
+
with BoundedOleReader(storage):
|
|
822
|
+
pass
|
|
823
|
+
except ValueError:
|
|
824
|
+
return None
|
|
825
|
+
return storage
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _embedded_object_map(
|
|
829
|
+
layout: _PersistLayout,
|
|
830
|
+
data: bytes,
|
|
831
|
+
budget: RecordBudget,
|
|
832
|
+
) -> dict[int, _EmbeddedOleObject]:
|
|
833
|
+
"""统一解压受支持的 PPT persist OLE storages 并共享资源预算。"""
|
|
834
|
+
|
|
835
|
+
objects: dict[int, _EmbeddedOleObject] = {}
|
|
836
|
+
asset_total = 0
|
|
837
|
+
for object_id, (subtype, persist_id) in _embedded_object_references(
|
|
838
|
+
layout.document,
|
|
839
|
+
budget,
|
|
840
|
+
).items():
|
|
841
|
+
offset = layout.persist.get(persist_id)
|
|
842
|
+
record = record_at(data, offset, budget=budget) if offset is not None else None
|
|
843
|
+
if record is None:
|
|
844
|
+
logger.warning(
|
|
845
|
+
"PPT_OLE_FALLBACK: exObjId={} persistIdRef={} is missing",
|
|
846
|
+
object_id,
|
|
847
|
+
persist_id,
|
|
848
|
+
)
|
|
849
|
+
continue
|
|
850
|
+
storage = _decompress_ole_storage(record)
|
|
851
|
+
if storage is None:
|
|
852
|
+
logger.warning(
|
|
853
|
+
"PPT_OLE_FALLBACK: exObjId={} has an invalid OLE storage",
|
|
854
|
+
object_id,
|
|
855
|
+
)
|
|
856
|
+
continue
|
|
857
|
+
asset_total += len(storage)
|
|
858
|
+
if asset_total > MAX_ASSET_TOTAL_BYTES:
|
|
859
|
+
raise LegacyOfficeResourceLimitError(f"embedded assets exceed max_asset_total_bytes={MAX_ASSET_TOTAL_BYTES}")
|
|
860
|
+
objects[object_id] = _EmbeddedOleObject(subtype=subtype, storage=storage)
|
|
861
|
+
return objects
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _equation_map(objects: dict[int, _EmbeddedOleObject]) -> dict[int, str]:
|
|
865
|
+
"""从共享 OLE 对象集合解析 Equation Native。"""
|
|
866
|
+
|
|
867
|
+
equations: dict[int, str] = {}
|
|
868
|
+
for object_id, embedded in objects.items():
|
|
869
|
+
if embedded.subtype != _OLE_SUBTYPE_EQUATION:
|
|
870
|
+
continue
|
|
871
|
+
latex = decode_equation_object(embedded.storage)
|
|
872
|
+
if latex is None:
|
|
873
|
+
logger.warning(
|
|
874
|
+
"PPT_MTEF_FALLBACK: exObjId={} has an invalid or unsupported Equation Native stream",
|
|
875
|
+
object_id,
|
|
876
|
+
)
|
|
877
|
+
continue
|
|
878
|
+
equations[object_id] = latex
|
|
879
|
+
return equations
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _chart_map(objects: dict[int, _EmbeddedOleObject]) -> dict[int, str]:
|
|
883
|
+
"""从共享 OLE 对象集合解析 Excel.Chart 与 MSGraph.Chart 数据表。"""
|
|
884
|
+
|
|
885
|
+
charts: dict[int, str] = {}
|
|
886
|
+
for object_id, embedded in objects.items():
|
|
887
|
+
if embedded.subtype not in {_OLE_SUBTYPE_GRAPH, _OLE_SUBTYPE_EXCEL_CHART}:
|
|
888
|
+
continue
|
|
889
|
+
content = extract_embedded_chart_html_from_storage(embedded.storage)
|
|
890
|
+
if content is None:
|
|
891
|
+
logger.warning(
|
|
892
|
+
"PPT_CHART_FALLBACK: exObjId={} has no supported chart datasheet",
|
|
893
|
+
object_id,
|
|
894
|
+
)
|
|
895
|
+
continue
|
|
896
|
+
charts[object_id] = content
|
|
897
|
+
return charts
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _shape_complex_properties(shape: PptRecord, budget: RecordBudget) -> dict[int, bytes]:
|
|
901
|
+
"""合并 shape 的复杂 FOPT 属性。"""
|
|
902
|
+
|
|
903
|
+
properties: dict[int, bytes] = {}
|
|
904
|
+
for child in _direct_children(shape, budget):
|
|
905
|
+
if child.record_type in {RT_OFFICEART_FOPT, RT_OFFICEART_TERTIARY_FOPT}:
|
|
906
|
+
properties.update(_fopt_complex_properties(child))
|
|
907
|
+
return properties
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _shape_type(shape: PptRecord, budget: RecordBudget) -> int | None:
|
|
911
|
+
"""返回 OfficeArtFSP header 中的 MSOSPT 类型。"""
|
|
912
|
+
|
|
913
|
+
for child in _direct_children(shape, budget):
|
|
914
|
+
if child.record_type == RT_OFFICEART_FSP:
|
|
915
|
+
return int(child.instance)
|
|
916
|
+
return None
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _is_background_shape(shape: PptRecord, budget: RecordBudget) -> bool:
|
|
920
|
+
"""判断 FSP 标志是否把 shape 标记为背景。"""
|
|
921
|
+
|
|
922
|
+
for child in _direct_children(shape, budget):
|
|
923
|
+
if child.record_type != RT_OFFICEART_FSP or len(child.payload) < 8:
|
|
924
|
+
continue
|
|
925
|
+
return bool(int(get_u32(child.payload, 4) or 0) & 0x0000_0400)
|
|
926
|
+
return False
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _is_placeholder(shape: PptRecord, budget: RecordBudget) -> bool:
|
|
930
|
+
"""判断 shape 的 ClientData 是否包含 OEPlaceholderAtom。"""
|
|
931
|
+
|
|
932
|
+
for child in _direct_children(shape, budget):
|
|
933
|
+
if child.record_type != RT_OFFICEART_CLIENT_DATA:
|
|
934
|
+
continue
|
|
935
|
+
if child.version == CONTAINER_VERSION:
|
|
936
|
+
candidates: Iterable[PptRecord] = iter_descendants(child, budget=budget)
|
|
937
|
+
else:
|
|
938
|
+
candidates = iter_records(child.payload, budget=budget)
|
|
939
|
+
if any(candidate.record_type == RT_OE_PLACEHOLDER_ATOM for candidate in candidates):
|
|
940
|
+
return True
|
|
941
|
+
return False
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _client_rect(payload: bytes) -> tuple[float, float, float, float] | None:
|
|
945
|
+
"""把 OfficeArtClientAnchor 转成 left/top/right/bottom。"""
|
|
946
|
+
|
|
947
|
+
if len(payload) < 8:
|
|
948
|
+
return None
|
|
949
|
+
top, left, right, bottom = struct.unpack_from("<4h", payload)
|
|
950
|
+
return float(left), float(top), float(right), float(bottom)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _child_rect(payload: bytes) -> tuple[float, float, float, float] | None:
|
|
954
|
+
"""把 OfficeArtChildAnchor 转成 left/top/right/bottom。"""
|
|
955
|
+
|
|
956
|
+
if len(payload) < 16:
|
|
957
|
+
return None
|
|
958
|
+
left, top, right, bottom = struct.unpack_from("<4i", payload)
|
|
959
|
+
return float(left), float(top), float(right), float(bottom)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _map_group_rect(
|
|
963
|
+
rect: tuple[float, float, float, float],
|
|
964
|
+
space: _GroupSpace,
|
|
965
|
+
) -> tuple[float, float, float, float]:
|
|
966
|
+
"""把 group 子坐标线性映射到幻灯片坐标。"""
|
|
967
|
+
|
|
968
|
+
coord_width = max(1.0, float(space.coord_right - space.coord_left))
|
|
969
|
+
coord_height = max(1.0, float(space.coord_bottom - space.coord_top))
|
|
970
|
+
scale_x = (space.abs_right - space.abs_left) / coord_width
|
|
971
|
+
scale_y = (space.abs_bottom - space.abs_top) / coord_height
|
|
972
|
+
left, top, right, bottom = rect
|
|
973
|
+
return (
|
|
974
|
+
space.abs_left + (left - space.coord_left) * scale_x,
|
|
975
|
+
space.abs_top + (top - space.coord_top) * scale_y,
|
|
976
|
+
space.abs_left + (right - space.coord_left) * scale_x,
|
|
977
|
+
space.abs_top + (bottom - space.coord_top) * scale_y,
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _shape_bbox(
|
|
982
|
+
shape: PptRecord,
|
|
983
|
+
space: _GroupSpace | None,
|
|
984
|
+
budget: RecordBudget,
|
|
985
|
+
) -> tuple[float, float, float, float] | None:
|
|
986
|
+
"""解析 shape anchor,并应用父 group 坐标映射。"""
|
|
987
|
+
|
|
988
|
+
children = _direct_children(shape, budget)
|
|
989
|
+
for child in children:
|
|
990
|
+
if child.record_type == RT_OFFICEART_CLIENT_ANCHOR:
|
|
991
|
+
rect = _client_rect(child.payload)
|
|
992
|
+
if rect is not None:
|
|
993
|
+
return rect
|
|
994
|
+
for child in children:
|
|
995
|
+
if child.record_type != RT_OFFICEART_CHILD_ANCHOR:
|
|
996
|
+
continue
|
|
997
|
+
rect = _child_rect(child.payload)
|
|
998
|
+
if rect is None:
|
|
999
|
+
continue
|
|
1000
|
+
if space is not None:
|
|
1001
|
+
return _map_group_rect(rect, space)
|
|
1002
|
+
if max(abs(value) for value in rect) > 100_000:
|
|
1003
|
+
return tuple(value * 576.0 / 914_400.0 for value in rect) # type: ignore[return-value]
|
|
1004
|
+
return rect
|
|
1005
|
+
return None
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _group_space(
|
|
1009
|
+
group_shape: PptRecord,
|
|
1010
|
+
parent: _GroupSpace | None,
|
|
1011
|
+
budget: RecordBudget,
|
|
1012
|
+
) -> _GroupSpace | None:
|
|
1013
|
+
"""解析 group 自身坐标系与它在父坐标系中的外框。"""
|
|
1014
|
+
|
|
1015
|
+
children = _direct_children(group_shape, budget)
|
|
1016
|
+
fspgr = next(
|
|
1017
|
+
(child for child in children if child.record_type == RT_OFFICEART_FSPGR and len(child.payload) >= 16),
|
|
1018
|
+
None,
|
|
1019
|
+
)
|
|
1020
|
+
if fspgr is None:
|
|
1021
|
+
return parent
|
|
1022
|
+
coord_left, coord_top, coord_right, coord_bottom = struct.unpack_from("<4i", fspgr.payload)
|
|
1023
|
+
if coord_right <= coord_left or coord_bottom <= coord_top:
|
|
1024
|
+
return parent
|
|
1025
|
+
raw_rect = None
|
|
1026
|
+
for child in children:
|
|
1027
|
+
if child.record_type == RT_OFFICEART_CLIENT_ANCHOR:
|
|
1028
|
+
raw_rect = _client_rect(child.payload)
|
|
1029
|
+
break
|
|
1030
|
+
if child.record_type == RT_OFFICEART_CHILD_ANCHOR:
|
|
1031
|
+
raw_rect = _child_rect(child.payload)
|
|
1032
|
+
break
|
|
1033
|
+
if raw_rect is None:
|
|
1034
|
+
return parent
|
|
1035
|
+
rect = _map_group_rect(raw_rect, parent) if parent is not None else raw_rect
|
|
1036
|
+
left, top, right, bottom = rect
|
|
1037
|
+
return _GroupSpace(
|
|
1038
|
+
coord_left=int(coord_left),
|
|
1039
|
+
coord_top=int(coord_top),
|
|
1040
|
+
coord_right=int(coord_right),
|
|
1041
|
+
coord_bottom=int(coord_bottom),
|
|
1042
|
+
abs_left=left,
|
|
1043
|
+
abs_top=top,
|
|
1044
|
+
abs_right=right,
|
|
1045
|
+
abs_bottom=bottom,
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _is_table_group(group_shape: PptRecord, budget: RecordBudget) -> bool:
|
|
1050
|
+
"""读取 tableProperties.fIsTable 标志。"""
|
|
1051
|
+
|
|
1052
|
+
return bool(_shape_properties(group_shape, budget).get(FOPT_TABLE_PROPERTIES, 0) & 0x1)
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _collect_shapes(slide: PptRecord, budget: RecordBudget) -> _ShapeCollection:
|
|
1056
|
+
"""单次遍历收集叶子 shape,并保留 table group 的成员边界。"""
|
|
1057
|
+
|
|
1058
|
+
shapes: list[_ShapeInfo] = []
|
|
1059
|
+
groups: dict[tuple[int, ...], tuple[PptRecord, list[_ShapeInfo], int, bool]] = {}
|
|
1060
|
+
order = 0
|
|
1061
|
+
|
|
1062
|
+
def walk(
|
|
1063
|
+
record: PptRecord,
|
|
1064
|
+
space: _GroupSpace | None,
|
|
1065
|
+
path: tuple[int, ...],
|
|
1066
|
+
active_table: tuple[int, ...] | None,
|
|
1067
|
+
) -> None:
|
|
1068
|
+
"""递归遍历 OfficeArt 容器并传播 group 空间与 table 身份。"""
|
|
1069
|
+
|
|
1070
|
+
nonlocal order
|
|
1071
|
+
if record.record_type == RT_OFFICEART_SPGR_CONTAINER and record.version == CONTAINER_VERSION:
|
|
1072
|
+
children = _direct_children(record, budget)
|
|
1073
|
+
if not children:
|
|
1074
|
+
return
|
|
1075
|
+
group_shape = children[0]
|
|
1076
|
+
group_key = path + (0,)
|
|
1077
|
+
nested_space = _group_space(group_shape, space, budget)
|
|
1078
|
+
authoritative = _is_table_group(group_shape, budget)
|
|
1079
|
+
groups[group_key] = (group_shape, [], order, authoritative)
|
|
1080
|
+
table_key = group_key
|
|
1081
|
+
for index, child in enumerate(children[1:], start=1):
|
|
1082
|
+
walk(child, nested_space, path + (index,), table_key)
|
|
1083
|
+
return
|
|
1084
|
+
if record.record_type == RT_OFFICEART_SP_CONTAINER:
|
|
1085
|
+
bbox = _shape_bbox(record, space, budget)
|
|
1086
|
+
if bbox is None or bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
|
|
1087
|
+
return
|
|
1088
|
+
info = _ShapeInfo(
|
|
1089
|
+
key=path,
|
|
1090
|
+
record=record,
|
|
1091
|
+
space=space,
|
|
1092
|
+
bbox=bbox,
|
|
1093
|
+
order=order,
|
|
1094
|
+
group_key=active_table,
|
|
1095
|
+
)
|
|
1096
|
+
order += 1
|
|
1097
|
+
shapes.append(info)
|
|
1098
|
+
if active_table in groups:
|
|
1099
|
+
groups[active_table][1].append(info)
|
|
1100
|
+
return
|
|
1101
|
+
if record.version == CONTAINER_VERSION:
|
|
1102
|
+
for index, child in enumerate(_direct_children(record, budget)):
|
|
1103
|
+
walk(child, space, path + (index,), active_table)
|
|
1104
|
+
|
|
1105
|
+
walk(slide, None, (), None)
|
|
1106
|
+
table_groups = tuple(
|
|
1107
|
+
_TableGroup(
|
|
1108
|
+
key=key,
|
|
1109
|
+
group_shape=group_shape,
|
|
1110
|
+
shapes=tuple(members),
|
|
1111
|
+
order=group_order,
|
|
1112
|
+
authoritative=authoritative,
|
|
1113
|
+
)
|
|
1114
|
+
for key, (group_shape, members, group_order, authoritative) in groups.items()
|
|
1115
|
+
)
|
|
1116
|
+
return _ShapeCollection(shapes=tuple(shapes), table_groups=table_groups)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _shape_text_content(
|
|
1120
|
+
shape: _ShapeInfo,
|
|
1121
|
+
external_text: list[_TextContent],
|
|
1122
|
+
master_styles: dict[int, list[MasterLevel]],
|
|
1123
|
+
hyperlinks: dict[int, str],
|
|
1124
|
+
budget: RecordBudget,
|
|
1125
|
+
) -> _TextContent | None:
|
|
1126
|
+
"""解析 shape 的 ClientTextbox,必要时回退到 OutlineTextRefAtom。"""
|
|
1127
|
+
|
|
1128
|
+
textbox = next(
|
|
1129
|
+
(child for child in _direct_children(shape.record, budget) if child.record_type == RT_OFFICEART_CLIENT_TEXTBOX),
|
|
1130
|
+
None,
|
|
1131
|
+
)
|
|
1132
|
+
if textbox is None:
|
|
1133
|
+
return None
|
|
1134
|
+
textbox_records = list(iter_descendants(textbox, budget=budget))
|
|
1135
|
+
contents = _parse_text_contents(textbox_records, master_styles, hyperlinks, budget)
|
|
1136
|
+
if contents:
|
|
1137
|
+
return _apply_shape_numbering(contents[0], shape.record, budget)
|
|
1138
|
+
reference = next(
|
|
1139
|
+
(child for child in textbox_records if child.record_type == RT_OUTLINE_TEXT_REF_ATOM and len(child.payload) >= 4),
|
|
1140
|
+
None,
|
|
1141
|
+
)
|
|
1142
|
+
if reference is None:
|
|
1143
|
+
return None
|
|
1144
|
+
index = int(get_u32(reference.payload, 0) or 0)
|
|
1145
|
+
for candidate in (index, index - 1) if index else (0,):
|
|
1146
|
+
if 0 <= candidate < len(external_text):
|
|
1147
|
+
return _apply_shape_numbering(external_text[candidate], shape.record, budget)
|
|
1148
|
+
return None
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _skip_style_text9_cf(payload: bytes, position: int) -> int | None:
|
|
1152
|
+
"""跳过 TextCFException9;未知扩展位时停止该 atom 的解析。"""
|
|
1153
|
+
|
|
1154
|
+
mask = get_u32(payload, position)
|
|
1155
|
+
if mask is None:
|
|
1156
|
+
return None
|
|
1157
|
+
position += 4
|
|
1158
|
+
if mask == 0:
|
|
1159
|
+
return position
|
|
1160
|
+
# 当前只需要自动编号,复杂 CF9 不影响已解析文本样式,安全终止后续槽位。
|
|
1161
|
+
return None
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _skip_style_text9_si(payload: bytes, position: int) -> int | None:
|
|
1165
|
+
"""跳过 TextSIException 中固定长度的语言与拼写字段。"""
|
|
1166
|
+
|
|
1167
|
+
mask = get_u32(payload, position)
|
|
1168
|
+
if mask is None:
|
|
1169
|
+
return None
|
|
1170
|
+
position += 4
|
|
1171
|
+
for bit, size in ((0, 2), (1, 2), (2, 2), (5, 4), (6, 2)):
|
|
1172
|
+
if mask & (1 << bit):
|
|
1173
|
+
position += size
|
|
1174
|
+
if mask & (1 << 9) or position > len(payload):
|
|
1175
|
+
return None
|
|
1176
|
+
return position
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _parse_style_text9_numbering(payload: bytes) -> dict[int, _NumberingStyle]:
|
|
1180
|
+
"""解析 StyleTextProp9 数组中与自动编号有关的三个字段。"""
|
|
1181
|
+
|
|
1182
|
+
result: dict[int, _NumberingStyle] = {}
|
|
1183
|
+
position = 0
|
|
1184
|
+
slot = 0
|
|
1185
|
+
while position + 12 <= len(payload) and slot < 16:
|
|
1186
|
+
mask = get_u32(payload, position)
|
|
1187
|
+
if mask is None or mask & ~0x0380_0000:
|
|
1188
|
+
break
|
|
1189
|
+
position += 4
|
|
1190
|
+
if mask & 0x0080_0000:
|
|
1191
|
+
position += 2
|
|
1192
|
+
enabled = None
|
|
1193
|
+
if mask & 0x0200_0000:
|
|
1194
|
+
value = get_i16(payload, position)
|
|
1195
|
+
if value is None:
|
|
1196
|
+
break
|
|
1197
|
+
enabled = value == 1
|
|
1198
|
+
position += 2
|
|
1199
|
+
start = None
|
|
1200
|
+
if mask & 0x0100_0000:
|
|
1201
|
+
scheme = get_u16(payload, position)
|
|
1202
|
+
start_value = get_u16(payload, position + 2)
|
|
1203
|
+
if scheme is None or start_value is None:
|
|
1204
|
+
break
|
|
1205
|
+
start = max(0, int(start_value))
|
|
1206
|
+
position += 4
|
|
1207
|
+
position = _skip_style_text9_cf(payload, position) or -1
|
|
1208
|
+
if position < 0:
|
|
1209
|
+
break
|
|
1210
|
+
position = _skip_style_text9_si(payload, position) or -1
|
|
1211
|
+
if position < 0:
|
|
1212
|
+
break
|
|
1213
|
+
result[slot] = _NumberingStyle(enabled=enabled, start=start)
|
|
1214
|
+
slot += 1
|
|
1215
|
+
return result
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _shape_numbering_styles(
|
|
1219
|
+
shape: PptRecord,
|
|
1220
|
+
budget: RecordBudget,
|
|
1221
|
+
) -> dict[int, _NumberingStyle]:
|
|
1222
|
+
"""从 PP9ShapeBinaryTagExtension 取出 StyleTextProp9 自动编号。"""
|
|
1223
|
+
|
|
1224
|
+
for tag in iter_descendants(shape, budget=budget):
|
|
1225
|
+
if tag.record_type != 0x138A or tag.version != CONTAINER_VERSION:
|
|
1226
|
+
continue
|
|
1227
|
+
children = list(iter_records(tag.payload, budget=budget))
|
|
1228
|
+
marker = next(
|
|
1229
|
+
(child for child in children if child.record_type == RT_CSTRING and utf16_text(child.payload) == "___PPT9"),
|
|
1230
|
+
None,
|
|
1231
|
+
)
|
|
1232
|
+
blob = next((child for child in children if child.record_type == 0x138B), None)
|
|
1233
|
+
if marker is None or blob is None:
|
|
1234
|
+
continue
|
|
1235
|
+
atom = record_at(blob.payload, 0, budget=budget)
|
|
1236
|
+
if atom is not None and atom.record_type == 0x0FAC:
|
|
1237
|
+
return _parse_style_text9_numbering(atom.payload)
|
|
1238
|
+
return {}
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _apply_shape_numbering(
|
|
1242
|
+
content: _TextContent,
|
|
1243
|
+
shape: PptRecord,
|
|
1244
|
+
budget: RecordBudget,
|
|
1245
|
+
) -> _TextContent:
|
|
1246
|
+
"""按 paragraph 起始字符的 pp9rt 槽位覆盖列表类型和起始编号。"""
|
|
1247
|
+
|
|
1248
|
+
numbering = _shape_numbering_styles(shape, budget)
|
|
1249
|
+
if not numbering:
|
|
1250
|
+
return content
|
|
1251
|
+
paragraphs: list[PptParagraph] = []
|
|
1252
|
+
for paragraph in content.paragraphs:
|
|
1253
|
+
style = numbering.get(paragraph.pp9rt)
|
|
1254
|
+
if style is None or style.enabled is None:
|
|
1255
|
+
paragraphs.append(paragraph)
|
|
1256
|
+
continue
|
|
1257
|
+
paragraphs.append(
|
|
1258
|
+
replace(
|
|
1259
|
+
paragraph,
|
|
1260
|
+
list_kind="ordered" if style.enabled else None,
|
|
1261
|
+
start=style.start if style.enabled else None,
|
|
1262
|
+
)
|
|
1263
|
+
)
|
|
1264
|
+
return replace(content, paragraphs=tuple(paragraphs))
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _cluster_coordinates(values: list[float], tolerance: float = 8.0) -> list[float]:
|
|
1268
|
+
"""把生产器舍入误差导致的近邻坐标合并为稳定边界。"""
|
|
1269
|
+
|
|
1270
|
+
if not values:
|
|
1271
|
+
return []
|
|
1272
|
+
clusters: list[list[float]] = []
|
|
1273
|
+
for value in sorted(values):
|
|
1274
|
+
if clusters and abs(value - sum(clusters[-1]) / len(clusters[-1])) <= tolerance:
|
|
1275
|
+
clusters[-1].append(value)
|
|
1276
|
+
else:
|
|
1277
|
+
clusters.append([value])
|
|
1278
|
+
return [sum(cluster) / len(cluster) for cluster in clusters]
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _boundary_index(boundaries: list[float], value: float, tolerance: float = 16.0) -> int | None:
|
|
1282
|
+
"""返回与坐标最近的边界索引,偏差过大时拒绝映射。"""
|
|
1283
|
+
|
|
1284
|
+
if not boundaries:
|
|
1285
|
+
return None
|
|
1286
|
+
index = min(range(len(boundaries)), key=lambda candidate: abs(boundaries[candidate] - value))
|
|
1287
|
+
return index if abs(boundaries[index] - value) <= tolerance else None
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def _table_from_group(
|
|
1291
|
+
group: _TableGroup,
|
|
1292
|
+
external_text: list[_TextContent],
|
|
1293
|
+
master_styles: dict[int, list[MasterLevel]],
|
|
1294
|
+
hyperlinks: dict[int, str],
|
|
1295
|
+
budget: RecordBudget,
|
|
1296
|
+
) -> PptTableElement | None:
|
|
1297
|
+
"""从 fIsTable group 的矩形单元格恢复含合并信息的完整网格。"""
|
|
1298
|
+
|
|
1299
|
+
cell_shapes: list[tuple[_ShapeInfo, _TextContent | None]] = []
|
|
1300
|
+
x_values: list[float] = []
|
|
1301
|
+
y_values: list[float] = []
|
|
1302
|
+
for shape in group.shapes:
|
|
1303
|
+
shape_type = _shape_type(shape.record, budget)
|
|
1304
|
+
left, top, right, bottom = shape.bbox
|
|
1305
|
+
width = right - left
|
|
1306
|
+
height = bottom - top
|
|
1307
|
+
# 线条只贡献边界,不成为单元格。
|
|
1308
|
+
if shape_type in {20, 32, 33, 34, 35, 36, 37, 38, 39, 40} or width <= 2 or height <= 2:
|
|
1309
|
+
if width <= 2:
|
|
1310
|
+
x_values.extend((left, right))
|
|
1311
|
+
if height <= 2:
|
|
1312
|
+
y_values.extend((top, bottom))
|
|
1313
|
+
continue
|
|
1314
|
+
if shape_type != 1:
|
|
1315
|
+
continue
|
|
1316
|
+
content = _shape_text_content(
|
|
1317
|
+
shape,
|
|
1318
|
+
external_text,
|
|
1319
|
+
master_styles,
|
|
1320
|
+
hyperlinks,
|
|
1321
|
+
budget,
|
|
1322
|
+
)
|
|
1323
|
+
cell_shapes.append((shape, content))
|
|
1324
|
+
x_values.extend((left, right))
|
|
1325
|
+
y_values.extend((top, bottom))
|
|
1326
|
+
if len(cell_shapes) < 4:
|
|
1327
|
+
return None
|
|
1328
|
+
|
|
1329
|
+
x_boundaries = _cluster_coordinates(x_values)
|
|
1330
|
+
y_boundaries = _cluster_coordinates(y_values)
|
|
1331
|
+
cols = len(x_boundaries) - 1
|
|
1332
|
+
rows = len(y_boundaries) - 1
|
|
1333
|
+
if rows < 2 or cols < 2 or rows * cols > MAX_GRID_SLOTS:
|
|
1334
|
+
return None
|
|
1335
|
+
|
|
1336
|
+
coverage: dict[tuple[int, int], tuple[int, int]] = {}
|
|
1337
|
+
cells: list[PptTableCell] = []
|
|
1338
|
+
for shape, content in cell_shapes:
|
|
1339
|
+
left, top, right, bottom = shape.bbox
|
|
1340
|
+
col_start = _boundary_index(x_boundaries, left)
|
|
1341
|
+
col_end = _boundary_index(x_boundaries, right)
|
|
1342
|
+
row_start = _boundary_index(y_boundaries, top)
|
|
1343
|
+
row_end = _boundary_index(y_boundaries, bottom)
|
|
1344
|
+
if (
|
|
1345
|
+
col_start is None
|
|
1346
|
+
or col_end is None
|
|
1347
|
+
or row_start is None
|
|
1348
|
+
or row_end is None
|
|
1349
|
+
or col_end <= col_start
|
|
1350
|
+
or row_end <= row_start
|
|
1351
|
+
):
|
|
1352
|
+
return None
|
|
1353
|
+
for row in range(row_start, row_end):
|
|
1354
|
+
for col in range(col_start, col_end):
|
|
1355
|
+
if (row, col) in coverage:
|
|
1356
|
+
return None
|
|
1357
|
+
coverage[(row, col)] = (row_start, col_start)
|
|
1358
|
+
cells.append(
|
|
1359
|
+
PptTableCell(
|
|
1360
|
+
row=row_start,
|
|
1361
|
+
col=col_start,
|
|
1362
|
+
row_span=row_end - row_start,
|
|
1363
|
+
col_span=col_end - col_start,
|
|
1364
|
+
paragraphs=content.paragraphs if content is not None else (),
|
|
1365
|
+
)
|
|
1366
|
+
)
|
|
1367
|
+
if len(coverage) != rows * cols:
|
|
1368
|
+
return None
|
|
1369
|
+
|
|
1370
|
+
cells.sort(key=lambda cell: (cell.row, cell.col))
|
|
1371
|
+
return PptTableElement(
|
|
1372
|
+
rows=rows,
|
|
1373
|
+
cols=cols,
|
|
1374
|
+
cells=tuple(cells),
|
|
1375
|
+
bbox=(x_boundaries[0], y_boundaries[0], x_boundaries[-1], y_boundaries[-1]),
|
|
1376
|
+
order=group.order,
|
|
1377
|
+
shape_offsets=frozenset(shape.key for shape in group.shapes),
|
|
1378
|
+
)
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _decode_blip(record: PptRecord) -> _ImagePayload | None:
|
|
1382
|
+
"""通过 legacy-office 共享层解码一个 OfficeArt BLIP。"""
|
|
1383
|
+
|
|
1384
|
+
return decode_officeart_blip(
|
|
1385
|
+
OfficeArtRecord(
|
|
1386
|
+
offset=record.offset,
|
|
1387
|
+
version=record.version,
|
|
1388
|
+
instance=record.instance,
|
|
1389
|
+
record_type=record.record_type,
|
|
1390
|
+
payload=record.payload,
|
|
1391
|
+
)
|
|
1392
|
+
)
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _decode_bse_body(body: bytes, budget: RecordBudget) -> _ImagePayload | None:
|
|
1396
|
+
"""从 FBSE body 的可选内嵌 BLIP 中提取图片。"""
|
|
1397
|
+
|
|
1398
|
+
if len(body) < 36:
|
|
1399
|
+
return None
|
|
1400
|
+
name_length = body[33]
|
|
1401
|
+
inner_offset = 36 + int(name_length)
|
|
1402
|
+
inner = record_at(body, inner_offset, budget=budget)
|
|
1403
|
+
return _decode_blip(inner) if inner is not None else None
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def _picture_map(
|
|
1407
|
+
document: PptRecord,
|
|
1408
|
+
pictures: bytes,
|
|
1409
|
+
budget: RecordBudget,
|
|
1410
|
+
) -> dict[int, _ImagePayload]:
|
|
1411
|
+
"""按 BStore 中 1-based BSE 序号建立图片资源映射。"""
|
|
1412
|
+
|
|
1413
|
+
result: dict[int, _ImagePayload] = {}
|
|
1414
|
+
asset_total = 0
|
|
1415
|
+
bse_records = [record for record in iter_descendants(document, budget=budget) if record.record_type == RT_OFFICEART_BSE]
|
|
1416
|
+
for index, bse in enumerate(bse_records[:MAX_PICTURE_RECORDS], start=1):
|
|
1417
|
+
decoded = None
|
|
1418
|
+
picture_offset = get_u32(bse.payload, 28)
|
|
1419
|
+
if picture_offset is not None and picture_offset < len(pictures):
|
|
1420
|
+
picture_record = record_at(pictures, int(picture_offset), budget=budget)
|
|
1421
|
+
if picture_record is not None:
|
|
1422
|
+
if picture_record.record_type == RT_OFFICEART_BSE:
|
|
1423
|
+
decoded = _decode_bse_body(picture_record.payload, budget)
|
|
1424
|
+
else:
|
|
1425
|
+
decoded = _decode_blip(picture_record)
|
|
1426
|
+
if decoded is None:
|
|
1427
|
+
decoded = _decode_bse_body(bse.payload, budget)
|
|
1428
|
+
if decoded is None:
|
|
1429
|
+
continue
|
|
1430
|
+
asset_total += len(decoded.data)
|
|
1431
|
+
if asset_total > MAX_ASSET_TOTAL_BYTES:
|
|
1432
|
+
raise LegacyOfficeResourceLimitError(f"embedded assets exceed max_asset_total_bytes={MAX_ASSET_TOTAL_BYTES}")
|
|
1433
|
+
result[index] = decoded
|
|
1434
|
+
if len(bse_records) > MAX_PICTURE_RECORDS:
|
|
1435
|
+
logger.warning(f"PPT_PICTURE_LIMIT: ignored BSE records after {MAX_PICTURE_RECORDS}")
|
|
1436
|
+
return result
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _image_from_shape(
|
|
1440
|
+
shape: _ShapeInfo,
|
|
1441
|
+
image_map: dict[int, _ImagePayload],
|
|
1442
|
+
equation_decoder: OfficeImageEquationDecoder,
|
|
1443
|
+
budget: RecordBudget,
|
|
1444
|
+
) -> PptImageElement | PptEquationElement | None:
|
|
1445
|
+
"""把 shape 的 pib 属性解析为图片或 comment 内公式。"""
|
|
1446
|
+
|
|
1447
|
+
reference = _shape_properties(shape.record, budget).get(FOPT_PIB)
|
|
1448
|
+
if reference is None:
|
|
1449
|
+
return None
|
|
1450
|
+
image = image_map.get(int(reference))
|
|
1451
|
+
if image is None:
|
|
1452
|
+
logger.warning(f"PPT_IMAGE_REFERENCE_MISSING: shape={shape.key}, pib={reference}")
|
|
1453
|
+
return None
|
|
1454
|
+
latex = equation_decoder.decode(
|
|
1455
|
+
image.data,
|
|
1456
|
+
part_name=f"picture.{image.extension}",
|
|
1457
|
+
content_type=image.content_type,
|
|
1458
|
+
)
|
|
1459
|
+
if latex:
|
|
1460
|
+
return PptEquationElement(
|
|
1461
|
+
latex=latex,
|
|
1462
|
+
bbox=shape.bbox,
|
|
1463
|
+
order=shape.order,
|
|
1464
|
+
shape_offset=shape.order,
|
|
1465
|
+
)
|
|
1466
|
+
data_uri = serialize_office_image(
|
|
1467
|
+
image.data,
|
|
1468
|
+
part_name=f"picture.{image.extension}",
|
|
1469
|
+
content_type=image.content_type,
|
|
1470
|
+
render_size_emu=image.render_size_emu,
|
|
1471
|
+
)
|
|
1472
|
+
if not data_uri:
|
|
1473
|
+
logger.warning(f"PPT_IMAGE_UNSUPPORTED: shape={shape.key}, type={image.content_type}")
|
|
1474
|
+
return None
|
|
1475
|
+
return PptImageElement(
|
|
1476
|
+
image_base64=data_uri,
|
|
1477
|
+
bbox=shape.bbox,
|
|
1478
|
+
order=shape.order,
|
|
1479
|
+
shape_offset=shape.order,
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _is_small_picture(
|
|
1484
|
+
bbox: tuple[float, float, float, float],
|
|
1485
|
+
slide_width: int,
|
|
1486
|
+
slide_height: int,
|
|
1487
|
+
) -> bool:
|
|
1488
|
+
"""复用现代 PPTX 的尺寸阈值过滤装饰性小图。"""
|
|
1489
|
+
|
|
1490
|
+
width = bbox[2] - bbox[0]
|
|
1491
|
+
height = bbox[3] - bbox[1]
|
|
1492
|
+
if width <= 0 or height <= 0 or slide_width <= 0 or slide_height <= 0:
|
|
1493
|
+
return False
|
|
1494
|
+
if width < 0.1 * slide_width or height < 0.1 * slide_height:
|
|
1495
|
+
return True
|
|
1496
|
+
return width * height / float(slide_width * slide_height) < 0.01
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def _slide_elements(
|
|
1500
|
+
slide: PptRecord,
|
|
1501
|
+
external_text: list[_TextContent],
|
|
1502
|
+
master_styles: dict[int, list[MasterLevel]],
|
|
1503
|
+
hyperlinks: dict[int, str],
|
|
1504
|
+
image_map: dict[int, _ImagePayload],
|
|
1505
|
+
equation_map: dict[int, str],
|
|
1506
|
+
chart_map: dict[int, str],
|
|
1507
|
+
image_equation_decoder: OfficeImageEquationDecoder,
|
|
1508
|
+
slide_width: int,
|
|
1509
|
+
slide_height: int,
|
|
1510
|
+
budget: RecordBudget,
|
|
1511
|
+
) -> list[PptTextElement | PptImageElement | PptEquationElement | PptChartElement | PptTableElement]:
|
|
1512
|
+
"""把一张 slide 的 shapes 转换为文本、表格、chart 和图片元素。"""
|
|
1513
|
+
|
|
1514
|
+
collection = _collect_shapes(slide, budget)
|
|
1515
|
+
tables: list[PptTableElement] = []
|
|
1516
|
+
consumed_keys: set[tuple[int, ...]] = set()
|
|
1517
|
+
for group in sorted(
|
|
1518
|
+
collection.table_groups,
|
|
1519
|
+
key=lambda candidate: (not candidate.authoritative, candidate.order),
|
|
1520
|
+
):
|
|
1521
|
+
if any(shape.key in consumed_keys for shape in group.shapes):
|
|
1522
|
+
continue
|
|
1523
|
+
table = _table_from_group(
|
|
1524
|
+
group,
|
|
1525
|
+
external_text,
|
|
1526
|
+
master_styles,
|
|
1527
|
+
hyperlinks,
|
|
1528
|
+
budget,
|
|
1529
|
+
)
|
|
1530
|
+
if table is None and group.authoritative:
|
|
1531
|
+
logger.warning(f"PPT_TABLE_RECOVERY_FAILED: group={group.key}")
|
|
1532
|
+
continue
|
|
1533
|
+
if table is None:
|
|
1534
|
+
continue
|
|
1535
|
+
tables.append(table)
|
|
1536
|
+
consumed_keys.update(shape.key for shape in group.shapes)
|
|
1537
|
+
|
|
1538
|
+
elements: list[PptTextElement | PptImageElement | PptEquationElement | PptChartElement | PptTableElement] = list(tables)
|
|
1539
|
+
for shape in collection.shapes:
|
|
1540
|
+
if shape.key in consumed_keys or _is_background_shape(shape.record, budget):
|
|
1541
|
+
continue
|
|
1542
|
+
external_object_id = _shape_external_object_id(shape.record, budget)
|
|
1543
|
+
chart = chart_map.get(external_object_id or 0)
|
|
1544
|
+
if chart:
|
|
1545
|
+
preview = _image_from_shape(
|
|
1546
|
+
shape,
|
|
1547
|
+
image_map,
|
|
1548
|
+
image_equation_decoder,
|
|
1549
|
+
budget,
|
|
1550
|
+
)
|
|
1551
|
+
elements.append(
|
|
1552
|
+
PptChartElement(
|
|
1553
|
+
content=chart,
|
|
1554
|
+
image_base64=(preview.image_base64 if isinstance(preview, PptImageElement) else None),
|
|
1555
|
+
bbox=shape.bbox,
|
|
1556
|
+
order=shape.order,
|
|
1557
|
+
shape_offset=shape.order,
|
|
1558
|
+
)
|
|
1559
|
+
)
|
|
1560
|
+
continue
|
|
1561
|
+
equation = equation_map.get(external_object_id or 0)
|
|
1562
|
+
if equation:
|
|
1563
|
+
elements.append(
|
|
1564
|
+
PptEquationElement(
|
|
1565
|
+
latex=equation,
|
|
1566
|
+
bbox=shape.bbox,
|
|
1567
|
+
order=shape.order,
|
|
1568
|
+
shape_offset=shape.order,
|
|
1569
|
+
)
|
|
1570
|
+
)
|
|
1571
|
+
continue
|
|
1572
|
+
content = _shape_text_content(
|
|
1573
|
+
shape,
|
|
1574
|
+
external_text,
|
|
1575
|
+
master_styles,
|
|
1576
|
+
hyperlinks,
|
|
1577
|
+
budget,
|
|
1578
|
+
)
|
|
1579
|
+
if content is not None and content.paragraphs:
|
|
1580
|
+
elements.append(
|
|
1581
|
+
PptTextElement(
|
|
1582
|
+
paragraphs=content.paragraphs,
|
|
1583
|
+
text_type=content.text_type,
|
|
1584
|
+
bbox=shape.bbox,
|
|
1585
|
+
order=shape.order,
|
|
1586
|
+
shape_offset=shape.order,
|
|
1587
|
+
is_placeholder=_is_placeholder(shape.record, budget),
|
|
1588
|
+
)
|
|
1589
|
+
)
|
|
1590
|
+
image_or_equation = _image_from_shape(
|
|
1591
|
+
shape,
|
|
1592
|
+
image_map,
|
|
1593
|
+
image_equation_decoder,
|
|
1594
|
+
budget,
|
|
1595
|
+
)
|
|
1596
|
+
if isinstance(image_or_equation, PptEquationElement):
|
|
1597
|
+
elements.append(image_or_equation)
|
|
1598
|
+
elif image_or_equation is not None and not _is_small_picture(
|
|
1599
|
+
image_or_equation.bbox,
|
|
1600
|
+
slide_width,
|
|
1601
|
+
slide_height,
|
|
1602
|
+
):
|
|
1603
|
+
elements.append(image_or_equation)
|
|
1604
|
+
if not any(isinstance(element, PptTextElement) for element in elements):
|
|
1605
|
+
# Handmade、早期生产器或恢复路径可能把文本直接放在 SlideContainer 中。
|
|
1606
|
+
raw_contents = _parse_text_contents(
|
|
1607
|
+
list(iter_descendants(slide, budget=budget)),
|
|
1608
|
+
master_styles,
|
|
1609
|
+
hyperlinks,
|
|
1610
|
+
budget,
|
|
1611
|
+
)
|
|
1612
|
+
for offset, content in enumerate(raw_contents):
|
|
1613
|
+
if not content.paragraphs:
|
|
1614
|
+
continue
|
|
1615
|
+
elements.append(
|
|
1616
|
+
PptTextElement(
|
|
1617
|
+
paragraphs=content.paragraphs,
|
|
1618
|
+
text_type=content.text_type,
|
|
1619
|
+
bbox=(
|
|
1620
|
+
288.0,
|
|
1621
|
+
288.0 + offset * 432.0,
|
|
1622
|
+
float(max(slide_width - 288, 576)),
|
|
1623
|
+
576.0 + offset * 432.0,
|
|
1624
|
+
),
|
|
1625
|
+
order=len(elements) + offset,
|
|
1626
|
+
shape_offset=len(elements) + offset,
|
|
1627
|
+
)
|
|
1628
|
+
)
|
|
1629
|
+
return elements
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _slide_hidden(slide: PptRecord, budget: RecordBudget) -> bool:
|
|
1633
|
+
"""读取 SlideShowSlideInfoAtom 的隐藏标志。"""
|
|
1634
|
+
|
|
1635
|
+
for child in iter_descendants(slide, budget=budget):
|
|
1636
|
+
if child.record_type != RT_SLIDE_SHOW_SLIDE_INFO_ATOM or len(child.payload) < 12:
|
|
1637
|
+
continue
|
|
1638
|
+
flags = get_u16(child.payload, 10)
|
|
1639
|
+
return bool(int(flags or 0) & 0x0004)
|
|
1640
|
+
return False
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
def _plain_paragraph_text(paragraph: PptParagraph) -> str:
|
|
1644
|
+
"""返回一个内部段落的纯文本。"""
|
|
1645
|
+
|
|
1646
|
+
return "".join(run.text for run in paragraph.runs)
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def _note_paragraphs(
|
|
1650
|
+
note_record: PptRecord,
|
|
1651
|
+
master_styles: dict[int, list[MasterLevel]],
|
|
1652
|
+
hyperlinks: dict[int, str],
|
|
1653
|
+
budget: RecordBudget,
|
|
1654
|
+
) -> list[PptParagraph]:
|
|
1655
|
+
"""提取 notes container 中的正文,排除占位符字段。"""
|
|
1656
|
+
|
|
1657
|
+
paragraphs: list[PptParagraph] = []
|
|
1658
|
+
collection = _collect_shapes(note_record, budget)
|
|
1659
|
+
for shape in collection.shapes:
|
|
1660
|
+
content = _shape_text_content(shape, [], master_styles, hyperlinks, budget)
|
|
1661
|
+
if content is None:
|
|
1662
|
+
continue
|
|
1663
|
+
for paragraph in content.paragraphs:
|
|
1664
|
+
text = _plain_paragraph_text(paragraph).strip()
|
|
1665
|
+
if text and text != "*":
|
|
1666
|
+
paragraphs.append(paragraph)
|
|
1667
|
+
if paragraphs:
|
|
1668
|
+
return paragraphs
|
|
1669
|
+
|
|
1670
|
+
# 少数生产器不把 notes 文本包在 OfficeArtClientTextbox 中。
|
|
1671
|
+
all_records = list(iter_descendants(note_record, budget=budget))
|
|
1672
|
+
for content in _parse_text_contents(all_records, master_styles, hyperlinks, budget):
|
|
1673
|
+
for paragraph in content.paragraphs:
|
|
1674
|
+
text = _plain_paragraph_text(paragraph).strip()
|
|
1675
|
+
if text and text != "*":
|
|
1676
|
+
paragraphs.append(paragraph)
|
|
1677
|
+
return paragraphs
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _notes_by_slide_id(
|
|
1681
|
+
layout: _PersistLayout,
|
|
1682
|
+
data: bytes,
|
|
1683
|
+
fallback_master_styles: dict[int, list[MasterLevel]],
|
|
1684
|
+
hyperlinks: dict[int, str],
|
|
1685
|
+
budget: RecordBudget,
|
|
1686
|
+
) -> tuple[dict[int, list[PptParagraph]], list[list[PptParagraph]]]:
|
|
1687
|
+
"""按 NotesAtom.slideIdRef 绑定备注,并保留无主备注的顺序。"""
|
|
1688
|
+
|
|
1689
|
+
bound: dict[int, list[PptParagraph]] = {}
|
|
1690
|
+
unbound: list[list[PptParagraph]] = []
|
|
1691
|
+
for reference in _notes_entries(layout.document, budget):
|
|
1692
|
+
offset = layout.persist.get(reference)
|
|
1693
|
+
if offset is None:
|
|
1694
|
+
continue
|
|
1695
|
+
note = record_at(data, offset, budget=budget)
|
|
1696
|
+
if note is None or note.record_type != RT_NOTES:
|
|
1697
|
+
continue
|
|
1698
|
+
slide_id = None
|
|
1699
|
+
for child in iter_descendants(note, budget=budget):
|
|
1700
|
+
if child.record_type == RT_NOTES_ATOM:
|
|
1701
|
+
slide_id = get_u32(child.payload, 0)
|
|
1702
|
+
break
|
|
1703
|
+
paragraphs = _note_paragraphs(
|
|
1704
|
+
note,
|
|
1705
|
+
fallback_master_styles,
|
|
1706
|
+
hyperlinks,
|
|
1707
|
+
budget,
|
|
1708
|
+
)
|
|
1709
|
+
if not paragraphs:
|
|
1710
|
+
continue
|
|
1711
|
+
if slide_id:
|
|
1712
|
+
bound[int(slide_id)] = paragraphs
|
|
1713
|
+
else:
|
|
1714
|
+
unbound.append(paragraphs)
|
|
1715
|
+
return bound, unbound
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def _root_slide_records(data: bytes, budget: RecordBudget) -> list[PptRecord]:
|
|
1719
|
+
"""恢复 persist 不可用时直接位于文档 stream 顶层的 slide records。"""
|
|
1720
|
+
|
|
1721
|
+
return [
|
|
1722
|
+
record
|
|
1723
|
+
for record in iter_records(data, budget=budget)
|
|
1724
|
+
if record.record_type == RT_SLIDE and record.version == CONTAINER_VERSION
|
|
1725
|
+
]
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _fallback_slide_from_text(
|
|
1729
|
+
data: bytes,
|
|
1730
|
+
master_styles: dict[int, list[MasterLevel]],
|
|
1731
|
+
hyperlinks: dict[int, str],
|
|
1732
|
+
budget: RecordBudget,
|
|
1733
|
+
) -> PptSlide | None:
|
|
1734
|
+
"""没有可靠 slide 边界时,把可恢复文本放入单个逻辑页。"""
|
|
1735
|
+
|
|
1736
|
+
contents = _parse_text_contents(
|
|
1737
|
+
list(iter_records(data, budget=budget)),
|
|
1738
|
+
master_styles,
|
|
1739
|
+
hyperlinks,
|
|
1740
|
+
budget,
|
|
1741
|
+
)
|
|
1742
|
+
elements: list[PptTextElement] = []
|
|
1743
|
+
for order, content in enumerate(contents):
|
|
1744
|
+
if not content.paragraphs:
|
|
1745
|
+
continue
|
|
1746
|
+
elements.append(
|
|
1747
|
+
PptTextElement(
|
|
1748
|
+
paragraphs=content.paragraphs,
|
|
1749
|
+
text_type=content.text_type,
|
|
1750
|
+
bbox=(288.0, 288.0 + order * 432.0, 5472.0, 576.0 + order * 432.0),
|
|
1751
|
+
order=order,
|
|
1752
|
+
shape_offset=order,
|
|
1753
|
+
)
|
|
1754
|
+
)
|
|
1755
|
+
return PptSlide(slide_id=None, elements=list(elements)) if elements else None
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def parse_ppt_document(
|
|
1759
|
+
powerpoint_document: bytes,
|
|
1760
|
+
*,
|
|
1761
|
+
current_user: bytes = b"",
|
|
1762
|
+
pictures: bytes = b"",
|
|
1763
|
+
) -> PptPresentation:
|
|
1764
|
+
"""把三个核心 PPT streams 解析为分页内部语义模型。"""
|
|
1765
|
+
|
|
1766
|
+
if current_user:
|
|
1767
|
+
record_type = get_u16(current_user, 2)
|
|
1768
|
+
if record_type not in {None, 0x0FF6}:
|
|
1769
|
+
raise LegacyOfficeMalformedError("PowerPoint 95 or earlier Current User stream is unsupported")
|
|
1770
|
+
if get_u32(current_user, 12) == 0xF3D1_C4DF:
|
|
1771
|
+
raise LegacyOfficeEncryptedError("password-protected PPT is unsupported")
|
|
1772
|
+
|
|
1773
|
+
budget = RecordBudget()
|
|
1774
|
+
# 先完整验证可递归记录深度,避免无 Document 的攻击形状被误报为普通坏文件。
|
|
1775
|
+
for root_record in iter_records(
|
|
1776
|
+
powerpoint_document,
|
|
1777
|
+
budget=budget,
|
|
1778
|
+
strict_first=True,
|
|
1779
|
+
):
|
|
1780
|
+
for _ in iter_descendants(root_record, budget=budget):
|
|
1781
|
+
pass
|
|
1782
|
+
layout = _locate_document(powerpoint_document, current_user, budget)
|
|
1783
|
+
if any(child.record_type == RT_CRYPT_SESSION10_CONTAINER for child in iter_descendants(layout.document, budget=budget)):
|
|
1784
|
+
raise LegacyOfficeEncryptedError("encrypted PPT record stream is unsupported")
|
|
1785
|
+
|
|
1786
|
+
width, height = _presentation_size(layout.document, budget)
|
|
1787
|
+
hyperlinks = _hyperlink_targets(layout.document, budget)
|
|
1788
|
+
image_map = _picture_map(layout.document, pictures, budget)
|
|
1789
|
+
image_equation_decoder = OfficeImageEquationDecoder()
|
|
1790
|
+
embedded_objects = _embedded_object_map(layout, powerpoint_document, budget)
|
|
1791
|
+
native_equations = _equation_map(embedded_objects)
|
|
1792
|
+
native_charts = _chart_map(embedded_objects)
|
|
1793
|
+
master_map, fallback_master = _collect_masters(
|
|
1794
|
+
layout,
|
|
1795
|
+
powerpoint_document,
|
|
1796
|
+
budget,
|
|
1797
|
+
)
|
|
1798
|
+
fallback_styles = fallback_master[1] if fallback_master is not None else {}
|
|
1799
|
+
external_records = _external_text_records(layout.document, budget)
|
|
1800
|
+
bound_notes, unbound_notes = _notes_by_slide_id(
|
|
1801
|
+
layout,
|
|
1802
|
+
powerpoint_document,
|
|
1803
|
+
fallback_styles,
|
|
1804
|
+
hyperlinks,
|
|
1805
|
+
budget,
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1808
|
+
slides: list[PptSlide] = []
|
|
1809
|
+
resolved_offsets: set[int] = set()
|
|
1810
|
+
resolved_slide_count = 0
|
|
1811
|
+
unbound_note_index = 0
|
|
1812
|
+
for reference, slide_id in _slide_entries(layout.document, budget):
|
|
1813
|
+
offset = layout.persist.get(reference)
|
|
1814
|
+
notes = bound_notes.get(slide_id)
|
|
1815
|
+
if notes is None and unbound_note_index < len(unbound_notes):
|
|
1816
|
+
notes = unbound_notes[unbound_note_index]
|
|
1817
|
+
unbound_note_index += 1
|
|
1818
|
+
if offset is None or offset in resolved_offsets:
|
|
1819
|
+
logger.warning(f"PPT_SLIDE_MISSING: persist_ref={reference}, slide_id={slide_id}")
|
|
1820
|
+
slides.append(PptSlide(slide_id=slide_id or None, notes=list(notes or [])))
|
|
1821
|
+
continue
|
|
1822
|
+
slide = record_at(powerpoint_document, offset, budget=budget)
|
|
1823
|
+
if slide is None or slide.record_type != RT_SLIDE:
|
|
1824
|
+
logger.warning(f"PPT_SLIDE_MALFORMED: persist_ref={reference}, slide_id={slide_id}")
|
|
1825
|
+
slides.append(PptSlide(slide_id=slide_id or None, notes=list(notes or [])))
|
|
1826
|
+
continue
|
|
1827
|
+
resolved_offsets.add(offset)
|
|
1828
|
+
resolved_slide_count += 1
|
|
1829
|
+
master = master_map.get(_slide_master_id(slide, budget) or -1, fallback_master)
|
|
1830
|
+
master_styles = master[1] if master is not None else {}
|
|
1831
|
+
external_text = _parse_text_contents(
|
|
1832
|
+
external_records.get(reference, []),
|
|
1833
|
+
master_styles,
|
|
1834
|
+
hyperlinks,
|
|
1835
|
+
budget,
|
|
1836
|
+
)
|
|
1837
|
+
slides.append(
|
|
1838
|
+
PptSlide(
|
|
1839
|
+
slide_id=slide_id or None,
|
|
1840
|
+
elements=_slide_elements(
|
|
1841
|
+
slide,
|
|
1842
|
+
external_text,
|
|
1843
|
+
master_styles,
|
|
1844
|
+
hyperlinks,
|
|
1845
|
+
image_map,
|
|
1846
|
+
native_equations,
|
|
1847
|
+
native_charts,
|
|
1848
|
+
image_equation_decoder,
|
|
1849
|
+
width,
|
|
1850
|
+
height,
|
|
1851
|
+
budget,
|
|
1852
|
+
),
|
|
1853
|
+
notes=list(notes or []),
|
|
1854
|
+
hidden=_slide_hidden(slide, budget),
|
|
1855
|
+
)
|
|
1856
|
+
)
|
|
1857
|
+
|
|
1858
|
+
if resolved_slide_count == 0:
|
|
1859
|
+
slides = []
|
|
1860
|
+
logger.warning("PPT_SLIDE_RECOVERY: persist mapping did not resolve slide records")
|
|
1861
|
+
for slide in _root_slide_records(powerpoint_document, budget):
|
|
1862
|
+
master = master_map.get(_slide_master_id(slide, budget) or -1, fallback_master)
|
|
1863
|
+
master_styles = master[1] if master is not None else {}
|
|
1864
|
+
slides.append(
|
|
1865
|
+
PptSlide(
|
|
1866
|
+
slide_id=None,
|
|
1867
|
+
elements=_slide_elements(
|
|
1868
|
+
slide,
|
|
1869
|
+
[],
|
|
1870
|
+
master_styles,
|
|
1871
|
+
hyperlinks,
|
|
1872
|
+
image_map,
|
|
1873
|
+
native_equations,
|
|
1874
|
+
native_charts,
|
|
1875
|
+
image_equation_decoder,
|
|
1876
|
+
width,
|
|
1877
|
+
height,
|
|
1878
|
+
budget,
|
|
1879
|
+
),
|
|
1880
|
+
hidden=_slide_hidden(slide, budget),
|
|
1881
|
+
)
|
|
1882
|
+
)
|
|
1883
|
+
if not slides:
|
|
1884
|
+
fallback_slide = _fallback_slide_from_text(
|
|
1885
|
+
powerpoint_document,
|
|
1886
|
+
fallback_styles,
|
|
1887
|
+
hyperlinks,
|
|
1888
|
+
budget,
|
|
1889
|
+
)
|
|
1890
|
+
if fallback_slide is not None:
|
|
1891
|
+
logger.warning("PPT_SINGLE_PAGE_RECOVERY: slide boundaries were not recoverable")
|
|
1892
|
+
slides.append(fallback_slide)
|
|
1893
|
+
if not slides:
|
|
1894
|
+
raise LegacyOfficeMalformedError("PPT contains no recoverable slides or text")
|
|
1895
|
+
return PptPresentation(slides=slides, width=width, height=height)
|