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,1553 @@
|
|
|
1
|
+
"""RTF 1.9.1 常用语义的有界状态机 parser。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
import re
|
|
8
|
+
from typing import BinaryIO, Literal
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from ..._shared.hyperlink import sanitize_hyperlink_target
|
|
13
|
+
from .....document.filetypes import rtf_header_offset
|
|
14
|
+
from ..errors import LegacyOfficeMalformedError, LegacyOfficeResourceLimitError
|
|
15
|
+
from ..limits import MAX_ASSET_TOTAL_BYTES, MAX_ENTRY_BYTES, MAX_GRID_SLOTS
|
|
16
|
+
from .lexer import RtfBinary, RtfClose, RtfControlSymbol, RtfControlWord, RtfHexByte, RtfLexer, RtfOpen, RtfTextBytes
|
|
17
|
+
from .math import parse_rtf_math
|
|
18
|
+
from .models import (
|
|
19
|
+
RtfAnchor,
|
|
20
|
+
RtfBlock,
|
|
21
|
+
RtfDisplayEquation,
|
|
22
|
+
RtfDocument,
|
|
23
|
+
RtfImage,
|
|
24
|
+
RtfInline,
|
|
25
|
+
RtfInlineEquation,
|
|
26
|
+
RtfLineBreak,
|
|
27
|
+
RtfListInfo,
|
|
28
|
+
RtfMetadata,
|
|
29
|
+
RtfNote,
|
|
30
|
+
RtfNoteReference,
|
|
31
|
+
RtfParagraph,
|
|
32
|
+
RtfTable,
|
|
33
|
+
RtfTableCell,
|
|
34
|
+
RtfTableRow,
|
|
35
|
+
RtfTextRun,
|
|
36
|
+
RtfTextStyle,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
MAX_RTF_BYTES = MAX_ENTRY_BYTES
|
|
40
|
+
MAX_RTF_LIST_DEPTH = 8
|
|
41
|
+
MAX_RTF_TABLE_DEPTH = 4
|
|
42
|
+
MAX_RTF_ROMAN_VALUE = 3_999
|
|
43
|
+
MAX_RTF_TABLE_CELLS = 100_000
|
|
44
|
+
|
|
45
|
+
_CHARSET_ENCODINGS = {
|
|
46
|
+
0: "cp1252",
|
|
47
|
+
2: "cp1252",
|
|
48
|
+
77: "mac_roman",
|
|
49
|
+
128: "cp932",
|
|
50
|
+
129: "cp949",
|
|
51
|
+
130: "cp1361",
|
|
52
|
+
134: "gb18030",
|
|
53
|
+
136: "big5",
|
|
54
|
+
161: "cp1253",
|
|
55
|
+
162: "cp1254",
|
|
56
|
+
163: "cp1258",
|
|
57
|
+
177: "cp1255",
|
|
58
|
+
178: "cp1256",
|
|
59
|
+
186: "cp1257",
|
|
60
|
+
204: "cp1251",
|
|
61
|
+
222: "cp874",
|
|
62
|
+
238: "cp1250",
|
|
63
|
+
255: "cp437",
|
|
64
|
+
}
|
|
65
|
+
_NFC_MARKERS = {
|
|
66
|
+
0: "decimal",
|
|
67
|
+
1: "upper_roman",
|
|
68
|
+
2: "lower_roman",
|
|
69
|
+
3: "upper_alpha",
|
|
70
|
+
4: "lower_alpha",
|
|
71
|
+
23: "bullet",
|
|
72
|
+
255: "none",
|
|
73
|
+
}
|
|
74
|
+
_BLOCK_STYLE_NAMES = {
|
|
75
|
+
"html preformatted": "code",
|
|
76
|
+
"preformatted text": "code",
|
|
77
|
+
"source code": "code",
|
|
78
|
+
"block text": "quote",
|
|
79
|
+
"intense quote": "quote",
|
|
80
|
+
"quotation": "quote",
|
|
81
|
+
"quotations": "quote",
|
|
82
|
+
"quote": "quote",
|
|
83
|
+
}
|
|
84
|
+
_TITLE_STYLE_NAMES = {"document title", "title"}
|
|
85
|
+
_HEADER_DESTINATIONS = {"header", "headerf", "headerl", "headerr"}
|
|
86
|
+
_FOOTER_DESTINATIONS = {"footer", "footerf", "footerl", "footerr"}
|
|
87
|
+
_SUPPRESSED_DESTINATIONS = {
|
|
88
|
+
"atnauthor",
|
|
89
|
+
"atnid",
|
|
90
|
+
"background",
|
|
91
|
+
"blipuid",
|
|
92
|
+
"colortbl",
|
|
93
|
+
"colorschememapping",
|
|
94
|
+
"datafield",
|
|
95
|
+
"datastore",
|
|
96
|
+
"defchp",
|
|
97
|
+
"defpap",
|
|
98
|
+
"doccomm",
|
|
99
|
+
"docvar",
|
|
100
|
+
"filetbl",
|
|
101
|
+
"fonttbl",
|
|
102
|
+
"generator",
|
|
103
|
+
"info",
|
|
104
|
+
"latentstyles",
|
|
105
|
+
"listoverridetable",
|
|
106
|
+
"listtable",
|
|
107
|
+
"nonshppict",
|
|
108
|
+
"objdata",
|
|
109
|
+
"operator",
|
|
110
|
+
"panose",
|
|
111
|
+
"revtbl",
|
|
112
|
+
"rsidtbl",
|
|
113
|
+
"stylesheet",
|
|
114
|
+
"themedata",
|
|
115
|
+
"userprops",
|
|
116
|
+
"wgrffmtfilter",
|
|
117
|
+
"xmlnstbl",
|
|
118
|
+
}
|
|
119
|
+
_SPECIAL_WORDS = {
|
|
120
|
+
"bullet": "\u2022",
|
|
121
|
+
"emdash": "\u2014",
|
|
122
|
+
"emspace": " ",
|
|
123
|
+
"endash": "\u2013",
|
|
124
|
+
"enspace": " ",
|
|
125
|
+
"ldblquote": "\u201c",
|
|
126
|
+
"line": "\n",
|
|
127
|
+
"lquote": "\u2018",
|
|
128
|
+
"qmspace": " ",
|
|
129
|
+
"rdblquote": "\u201d",
|
|
130
|
+
"rquote": "\u2019",
|
|
131
|
+
"tab": " ",
|
|
132
|
+
}
|
|
133
|
+
_HYPERLINK_RE = re.compile(
|
|
134
|
+
r"\bHYPERLINK\b(?P<local>\s+\\l)?\s+(?:\"(?P<quoted>[^\"]*)\"|(?P<bare>\S+))",
|
|
135
|
+
re.IGNORECASE,
|
|
136
|
+
)
|
|
137
|
+
_CONTROL_RE = re.compile(rb"\\(?P<name>[A-Za-z]+)(?P<param>-?\d+)?(?: )?")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class _TextStyleOverrides:
|
|
142
|
+
"""保存 stylesheet 中可区分缺省与显式关闭的字符样式覆盖。"""
|
|
143
|
+
|
|
144
|
+
bold: bool | None = None
|
|
145
|
+
italic: bool | None = None
|
|
146
|
+
underline: bool | None = None
|
|
147
|
+
strike: bool | None = None
|
|
148
|
+
|
|
149
|
+
def resolve(self, base: RtfTextStyle = RtfTextStyle()) -> RtfTextStyle:
|
|
150
|
+
"""用当前显式值覆盖基样式,缺省字段继续继承。"""
|
|
151
|
+
return RtfTextStyle(
|
|
152
|
+
bold=base.bold if self.bold is None else self.bold,
|
|
153
|
+
italic=base.italic if self.italic is None else self.italic,
|
|
154
|
+
underline=base.underline if self.underline is None else self.underline,
|
|
155
|
+
strike=base.strike if self.strike is None else self.strike,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class _StyleDefinition:
|
|
161
|
+
"""保存 stylesheet 中与语义投影相关的段落样式。"""
|
|
162
|
+
|
|
163
|
+
name: str = ""
|
|
164
|
+
outline_level: int | None = None
|
|
165
|
+
is_title: bool = False
|
|
166
|
+
block_style: Literal["normal", "code", "quote"] = "normal"
|
|
167
|
+
based_on: int | None = None
|
|
168
|
+
text_style: RtfTextStyle = RtfTextStyle()
|
|
169
|
+
text_style_overrides: _TextStyleOverrides = _TextStyleOverrides()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class _ListLevel:
|
|
174
|
+
"""保存 RTF listlevel 的起始值和常用编号格式。"""
|
|
175
|
+
|
|
176
|
+
marker: str = "decimal"
|
|
177
|
+
start: int = 1
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def ordered(self) -> bool:
|
|
181
|
+
"""返回当前 level 是否应按有序列表输出。"""
|
|
182
|
+
return self.marker not in {"bullet", "none"}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True, slots=True)
|
|
186
|
+
class _ListDefinition:
|
|
187
|
+
"""保存一个 list override 对应的列表定义。"""
|
|
188
|
+
|
|
189
|
+
identity: int
|
|
190
|
+
levels: tuple[_ListLevel, ...]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(slots=True)
|
|
194
|
+
class _Prelude:
|
|
195
|
+
"""保存正文解析前从 RTF header tables 收敛出的定义。"""
|
|
196
|
+
|
|
197
|
+
default_encoding: str = "cp1252"
|
|
198
|
+
font_encodings: dict[int, str] = field(default_factory=dict)
|
|
199
|
+
styles: dict[int, _StyleDefinition] = field(default_factory=dict)
|
|
200
|
+
lists: dict[int, _ListDefinition] = field(default_factory=dict)
|
|
201
|
+
metadata: RtfMetadata = field(default_factory=RtfMetadata)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(slots=True)
|
|
205
|
+
class _State:
|
|
206
|
+
"""保存随 RTF group 继承和恢复的字符、段落及 destination 状态。"""
|
|
207
|
+
|
|
208
|
+
style: RtfTextStyle = RtfTextStyle()
|
|
209
|
+
font_id: int | None = None
|
|
210
|
+
uc_skip: int = 1
|
|
211
|
+
hidden: bool = False
|
|
212
|
+
paragraph_style_id: int | None = None
|
|
213
|
+
outline_level: int | None = None
|
|
214
|
+
list_override_id: int | None = None
|
|
215
|
+
list_level: int = 0
|
|
216
|
+
in_table: bool = False
|
|
217
|
+
table_depth: int = 0
|
|
218
|
+
destination: str = "body"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(slots=True)
|
|
222
|
+
class _CellDefinition:
|
|
223
|
+
"""保存 cellx 处冻结的单元格合并属性。"""
|
|
224
|
+
|
|
225
|
+
horizontal_merge: Literal["none", "start", "continue"] = "none"
|
|
226
|
+
vertical_merge: Literal["none", "start", "continue"] = "none"
|
|
227
|
+
right_boundary: int | None = None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class _TableBuilder:
|
|
231
|
+
"""把 RTF row/cell 控制按当前 table depth 组装为语义表格。"""
|
|
232
|
+
|
|
233
|
+
def __init__(self) -> None:
|
|
234
|
+
"""初始化空表格和空 row/cell 状态。"""
|
|
235
|
+
self.rows: list[RtfTableRow] = []
|
|
236
|
+
self._definitions: list[_CellDefinition] = []
|
|
237
|
+
self._cells: list[RtfTableCell] = []
|
|
238
|
+
self._cell_blocks: list[RtfBlock] = []
|
|
239
|
+
self._row_header = False
|
|
240
|
+
self._pending_horizontal: Literal["none", "start", "continue"] = "none"
|
|
241
|
+
self._pending_vertical: Literal["none", "start", "continue"] = "none"
|
|
242
|
+
self._row_open = False
|
|
243
|
+
self._total_slots = 0
|
|
244
|
+
|
|
245
|
+
def _check_cell_budget(self, row_cell_count: int) -> None:
|
|
246
|
+
"""在保存定义或物化 cell 前校验当前表格对象预算。"""
|
|
247
|
+
limit = min(MAX_GRID_SLOTS, MAX_RTF_TABLE_CELLS)
|
|
248
|
+
if self._total_slots + row_cell_count > limit:
|
|
249
|
+
raise LegacyOfficeResourceLimitError(f"RTF table exceeds max_grid_slots={limit}")
|
|
250
|
+
|
|
251
|
+
def start_row(self) -> None:
|
|
252
|
+
"""开始新行;若上一行未显式结束则先安全收束。"""
|
|
253
|
+
if self._row_open:
|
|
254
|
+
if self._cells or self._cell_blocks:
|
|
255
|
+
# 嵌套表格的 nesttableprops 位于 cell 内容之后,此处只补写定义。
|
|
256
|
+
self._definitions = []
|
|
257
|
+
return
|
|
258
|
+
self.end_row()
|
|
259
|
+
self._definitions = []
|
|
260
|
+
self._cells = []
|
|
261
|
+
self._cell_blocks = []
|
|
262
|
+
self._row_header = False
|
|
263
|
+
self._pending_horizontal = "none"
|
|
264
|
+
self._pending_vertical = "none"
|
|
265
|
+
self._row_open = True
|
|
266
|
+
|
|
267
|
+
def set_header(self) -> None:
|
|
268
|
+
"""把当前行标记为重复表头行。"""
|
|
269
|
+
self._row_header = True
|
|
270
|
+
|
|
271
|
+
def set_horizontal_merge(self, value: Literal["start", "continue"]) -> None:
|
|
272
|
+
"""记录下一个 cellx 使用的横向合并属性。"""
|
|
273
|
+
self._pending_horizontal = value
|
|
274
|
+
|
|
275
|
+
def set_vertical_merge(self, value: Literal["start", "continue"]) -> None:
|
|
276
|
+
"""记录下一个 cellx 使用的纵向合并属性。"""
|
|
277
|
+
self._pending_vertical = value
|
|
278
|
+
|
|
279
|
+
def add_definition(self, right_boundary: int | None) -> None:
|
|
280
|
+
"""在 cellx 边界冻结当前单元格定义。"""
|
|
281
|
+
if not self._row_open:
|
|
282
|
+
self.start_row()
|
|
283
|
+
self._check_cell_budget(max(len(self._definitions) + 1, len(self._cells)))
|
|
284
|
+
self._definitions.append(
|
|
285
|
+
_CellDefinition(
|
|
286
|
+
horizontal_merge=self._pending_horizontal,
|
|
287
|
+
vertical_merge=self._pending_vertical,
|
|
288
|
+
right_boundary=right_boundary,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
definition_index = len(self._definitions) - 1
|
|
292
|
+
if definition_index < len(self._cells):
|
|
293
|
+
definition = self._definitions[definition_index]
|
|
294
|
+
existing = self._cells[definition_index]
|
|
295
|
+
existing.horizontal_merge = definition.horizontal_merge
|
|
296
|
+
existing.vertical_merge = definition.vertical_merge
|
|
297
|
+
existing.right_boundary = definition.right_boundary
|
|
298
|
+
self._pending_horizontal = "none"
|
|
299
|
+
self._pending_vertical = "none"
|
|
300
|
+
|
|
301
|
+
def add_block(self, block: RtfBlock) -> None:
|
|
302
|
+
"""向当前尚未结束的单元格追加语义块。"""
|
|
303
|
+
if not self._row_open:
|
|
304
|
+
self.start_row()
|
|
305
|
+
self._cell_blocks.append(block)
|
|
306
|
+
|
|
307
|
+
def end_cell(self) -> None:
|
|
308
|
+
"""结束当前单元格,并按同位置 cellx 定义附加合并属性。"""
|
|
309
|
+
if not self._row_open:
|
|
310
|
+
self.start_row()
|
|
311
|
+
self._check_cell_budget(max(len(self._definitions), len(self._cells) + 1))
|
|
312
|
+
definition_index = len(self._cells)
|
|
313
|
+
definition = self._definitions[definition_index] if definition_index < len(self._definitions) else _CellDefinition()
|
|
314
|
+
self._cells.append(
|
|
315
|
+
RtfTableCell(
|
|
316
|
+
blocks=self._cell_blocks,
|
|
317
|
+
horizontal_merge=definition.horizontal_merge,
|
|
318
|
+
vertical_merge=definition.vertical_merge,
|
|
319
|
+
right_boundary=definition.right_boundary,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
self._cell_blocks = []
|
|
323
|
+
|
|
324
|
+
def end_row(self) -> None:
|
|
325
|
+
"""补齐当前行定义的空单元格并写入表格。"""
|
|
326
|
+
if not self._row_open:
|
|
327
|
+
return
|
|
328
|
+
if self._cell_blocks:
|
|
329
|
+
self.end_cell()
|
|
330
|
+
target_cells = max(len(self._definitions), len(self._cells))
|
|
331
|
+
self._check_cell_budget(target_cells)
|
|
332
|
+
while len(self._cells) < target_cells:
|
|
333
|
+
self.end_cell()
|
|
334
|
+
if target_cells:
|
|
335
|
+
self.rows.append(RtfTableRow(cells=self._cells, header=self._row_header))
|
|
336
|
+
self._total_slots += target_cells
|
|
337
|
+
self._definitions = []
|
|
338
|
+
self._cells = []
|
|
339
|
+
self._cell_blocks = []
|
|
340
|
+
self._row_open = False
|
|
341
|
+
|
|
342
|
+
def finish(self) -> RtfTable | None:
|
|
343
|
+
"""结束未闭合行并返回非空表格。"""
|
|
344
|
+
self.end_row()
|
|
345
|
+
return RtfTable(rows=self.rows) if self.rows else None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass(slots=True)
|
|
349
|
+
class _OutputContext:
|
|
350
|
+
"""隔离正文、脚注及页眉页脚各自的块和表格状态。"""
|
|
351
|
+
|
|
352
|
+
blocks: list[RtfBlock] = field(default_factory=list)
|
|
353
|
+
inlines: list[RtfInline] = field(default_factory=list)
|
|
354
|
+
text_fragments: list[str] = field(default_factory=list)
|
|
355
|
+
text_style: RtfTextStyle | None = None
|
|
356
|
+
pending_list_label: str | None = None
|
|
357
|
+
tables: dict[int, _TableBuilder] = field(default_factory=dict)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass(slots=True)
|
|
361
|
+
class _PictCapture:
|
|
362
|
+
"""保存 pict destination 的格式、hex 和 bin 数据。"""
|
|
363
|
+
|
|
364
|
+
content_type: str = "application/octet-stream"
|
|
365
|
+
extension: str = "bin"
|
|
366
|
+
hex_data: bytearray = field(default_factory=bytearray)
|
|
367
|
+
binary: bytes | None = None
|
|
368
|
+
alt: str = ""
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass(slots=True)
|
|
372
|
+
class _GroupFrame:
|
|
373
|
+
"""保存一个 group 的父状态和需要在右花括号处收束的 destination。"""
|
|
374
|
+
|
|
375
|
+
previous_state: _State
|
|
376
|
+
start: int
|
|
377
|
+
ignorable: bool = False
|
|
378
|
+
destination: str | None = None
|
|
379
|
+
parent_context: _OutputContext | None = None
|
|
380
|
+
inline_start: int = 0
|
|
381
|
+
capture_text: list[str] = field(default_factory=list)
|
|
382
|
+
instruction: list[str] = field(default_factory=list)
|
|
383
|
+
pict: _PictCapture | None = None
|
|
384
|
+
note_kind: Literal["footnote", "endnote", "annotation"] = "footnote"
|
|
385
|
+
upr_child_count: int = 0
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@dataclass(frozen=True, slots=True)
|
|
389
|
+
class _GroupSpan:
|
|
390
|
+
"""用原始 RTF buffer 上的起止位置表示 group,避免嵌套切片复制。"""
|
|
391
|
+
|
|
392
|
+
data: bytes
|
|
393
|
+
start: int
|
|
394
|
+
end: int
|
|
395
|
+
|
|
396
|
+
def materialize(self) -> bytes:
|
|
397
|
+
"""仅在实际解析匹配 destination 时物化当前 group。"""
|
|
398
|
+
return self.data[self.start : self.end]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _lookup_encoding(code_page: int, fallback: str = "cp1252") -> str:
|
|
402
|
+
"""把 RTF code page 规范为 Python codec,不支持时返回稳定 fallback。"""
|
|
403
|
+
candidate = f"cp{code_page}"
|
|
404
|
+
try:
|
|
405
|
+
codecs.lookup(candidate)
|
|
406
|
+
except LookupError:
|
|
407
|
+
logger.warning("Unsupported RTF code page {}, falling back to {}", code_page, fallback)
|
|
408
|
+
return fallback
|
|
409
|
+
return candidate
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _default_encoding(data: bytes) -> str:
|
|
413
|
+
"""按 RTF header 控制确定默认字符编码。"""
|
|
414
|
+
match = re.search(rb"\\ansicpg(?P<code>\d+)", data[:65536], re.IGNORECASE)
|
|
415
|
+
if match is not None:
|
|
416
|
+
return _lookup_encoding(int(match.group("code")))
|
|
417
|
+
if re.search(rb"\\mac(?:\D|$)", data[:1024], re.IGNORECASE):
|
|
418
|
+
return "mac_roman"
|
|
419
|
+
if re.search(rb"\\pca(?:\D|$)", data[:1024], re.IGNORECASE):
|
|
420
|
+
return "cp850"
|
|
421
|
+
if re.search(rb"\\pc(?:\D|$)", data[:1024], re.IGNORECASE):
|
|
422
|
+
return "cp437"
|
|
423
|
+
return "cp1252"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _group_spans(data: bytes, *, direct_only: bool = False) -> list[_GroupSpan]:
|
|
427
|
+
"""按二进制安全 token 边界返回共享原始 buffer 的子 group 范围。"""
|
|
428
|
+
depth = 0
|
|
429
|
+
starts: list[int] = []
|
|
430
|
+
result: list[_GroupSpan] = []
|
|
431
|
+
for token in RtfLexer(data):
|
|
432
|
+
if isinstance(token, RtfOpen):
|
|
433
|
+
depth += 1
|
|
434
|
+
starts.append(token.start)
|
|
435
|
+
elif isinstance(token, RtfClose) and starts:
|
|
436
|
+
start = starts.pop()
|
|
437
|
+
if (direct_only and depth == 2) or (not direct_only and depth >= 2):
|
|
438
|
+
result.append(_GroupSpan(data, start, token.end))
|
|
439
|
+
depth = max(depth - 1, 0)
|
|
440
|
+
return result
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _group_destination(group: _GroupSpan) -> str | None:
|
|
444
|
+
"""读取 group 开头最多两个 control word 以识别 destination。"""
|
|
445
|
+
prefix = group.data[group.start : min(group.end, group.start + 256)]
|
|
446
|
+
controls = list(_CONTROL_RE.finditer(prefix))
|
|
447
|
+
for match in controls[:3]:
|
|
448
|
+
name = match.group("name").decode("ascii").lower()
|
|
449
|
+
if name not in {"rtf", "ansi", "deff"}:
|
|
450
|
+
return name
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _named_groups(data: bytes, destination: str) -> list[_GroupSpan]:
|
|
455
|
+
"""返回全部匹配 destination 的零拷贝 group 范围。"""
|
|
456
|
+
return [group for group in _group_spans(data) if _group_destination(group) == destination]
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _decode_group_text(data: bytes, encoding: str) -> str:
|
|
460
|
+
"""解码定义表或 metadata group 中的可见文本,不解释正文结构。"""
|
|
461
|
+
parts: list[str] = []
|
|
462
|
+
byte_buffer = bytearray()
|
|
463
|
+
uc_skip = 1
|
|
464
|
+
fallback_skip = 0
|
|
465
|
+
pending_high: int | None = None
|
|
466
|
+
|
|
467
|
+
def flush_byte_buffer() -> None:
|
|
468
|
+
"""按当前代码页整体解码连续字节,保留多字节字符边界。"""
|
|
469
|
+
if not byte_buffer:
|
|
470
|
+
return
|
|
471
|
+
parts.append(bytes(byte_buffer).decode(encoding, errors="replace"))
|
|
472
|
+
byte_buffer.clear()
|
|
473
|
+
|
|
474
|
+
for token in RtfLexer(data):
|
|
475
|
+
if not isinstance(token, (RtfHexByte, RtfTextBytes)):
|
|
476
|
+
flush_byte_buffer()
|
|
477
|
+
if isinstance(token, RtfControlWord):
|
|
478
|
+
if token.name == "uc":
|
|
479
|
+
uc_skip = max(token.param or 0, 0)
|
|
480
|
+
elif token.name == "u" and token.param is not None:
|
|
481
|
+
unit = (token.param + 65536 if token.param < 0 else token.param) & 0xFFFF
|
|
482
|
+
if 0xD800 <= unit <= 0xDBFF:
|
|
483
|
+
pending_high = unit
|
|
484
|
+
elif 0xDC00 <= unit <= 0xDFFF and pending_high is not None:
|
|
485
|
+
parts.append(chr(0x10000 + ((pending_high - 0xD800) << 10) + unit - 0xDC00))
|
|
486
|
+
pending_high = None
|
|
487
|
+
else:
|
|
488
|
+
if pending_high is not None:
|
|
489
|
+
parts.append("\ufffd")
|
|
490
|
+
pending_high = None
|
|
491
|
+
parts.append(chr(unit) if not 0xD800 <= unit <= 0xDFFF else "\ufffd")
|
|
492
|
+
fallback_skip = uc_skip
|
|
493
|
+
elif token.name in _SPECIAL_WORDS:
|
|
494
|
+
parts.append(_SPECIAL_WORDS[token.name])
|
|
495
|
+
elif isinstance(token, RtfHexByte):
|
|
496
|
+
if fallback_skip:
|
|
497
|
+
fallback_skip -= 1
|
|
498
|
+
else:
|
|
499
|
+
byte_buffer.append(token.value)
|
|
500
|
+
elif isinstance(token, RtfTextBytes):
|
|
501
|
+
raw = token.data.replace(b"\r", b"").replace(b"\n", b"")
|
|
502
|
+
if fallback_skip:
|
|
503
|
+
skipped = min(fallback_skip, len(raw))
|
|
504
|
+
raw = raw[skipped:]
|
|
505
|
+
fallback_skip -= skipped
|
|
506
|
+
if raw:
|
|
507
|
+
byte_buffer.extend(raw)
|
|
508
|
+
elif isinstance(token, RtfControlSymbol):
|
|
509
|
+
if token.symbol in {"\\", "{", "}"}:
|
|
510
|
+
parts.append(token.symbol)
|
|
511
|
+
elif token.symbol == "~":
|
|
512
|
+
parts.append("\u00a0")
|
|
513
|
+
elif token.symbol == "_":
|
|
514
|
+
parts.append("-")
|
|
515
|
+
flush_byte_buffer()
|
|
516
|
+
if pending_high is not None:
|
|
517
|
+
parts.append("\ufffd")
|
|
518
|
+
return "".join(parts)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _parse_fonts(data: bytes, default: str) -> dict[int, str]:
|
|
522
|
+
"""解析 fonttbl 中 font id 到字符编码的映射。"""
|
|
523
|
+
groups = _named_groups(data, "fonttbl")
|
|
524
|
+
if not groups:
|
|
525
|
+
return {}
|
|
526
|
+
result: dict[int, str] = {}
|
|
527
|
+
font_table = groups[0].materialize()
|
|
528
|
+
for font_span in _group_spans(font_table, direct_only=True):
|
|
529
|
+
font_group = font_span.materialize()
|
|
530
|
+
font_match = re.search(rb"\\f(?P<id>\d+)(?:\D|$)", font_group)
|
|
531
|
+
if font_match is None:
|
|
532
|
+
continue
|
|
533
|
+
font_id = int(font_match.group("id"))
|
|
534
|
+
cpg_match = re.search(rb"\\cpg(?P<code>\d+)", font_group)
|
|
535
|
+
charset_match = re.search(rb"\\fcharset(?P<charset>\d+)", font_group)
|
|
536
|
+
if cpg_match is not None:
|
|
537
|
+
result[font_id] = _lookup_encoding(int(cpg_match.group("code")), default)
|
|
538
|
+
elif charset_match is not None:
|
|
539
|
+
result[font_id] = _CHARSET_ENCODINGS.get(int(charset_match.group("charset")), default)
|
|
540
|
+
else:
|
|
541
|
+
result[font_id] = default
|
|
542
|
+
return result
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _style_control_value(data: bytes, name: str) -> bool | None:
|
|
546
|
+
"""读取样式 on/off control 的三态值,缺省时返回空。"""
|
|
547
|
+
pattern = re.compile(
|
|
548
|
+
rb"\\" + name.encode("ascii") + rb"(?P<param>-?\d+)?(?=[^A-Za-z]|$)",
|
|
549
|
+
re.IGNORECASE,
|
|
550
|
+
)
|
|
551
|
+
matches = list(pattern.finditer(data))
|
|
552
|
+
if not matches:
|
|
553
|
+
return None
|
|
554
|
+
raw_param = matches[-1].group("param")
|
|
555
|
+
return raw_param is None or int(raw_param) != 0
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _parse_styles(data: bytes, encoding: str) -> dict[int, _StyleDefinition]:
|
|
559
|
+
"""解析 stylesheet 的标题、outline、代码与引用样式。"""
|
|
560
|
+
groups = _named_groups(data, "stylesheet")
|
|
561
|
+
if not groups:
|
|
562
|
+
return {}
|
|
563
|
+
result: dict[int, _StyleDefinition] = {}
|
|
564
|
+
stylesheet = groups[0].materialize()
|
|
565
|
+
for style_span in _group_spans(stylesheet, direct_only=True):
|
|
566
|
+
style_group = style_span.materialize()
|
|
567
|
+
style_match = re.search(rb"\\s(?P<id>-?\d+)(?:\D|$)", style_group)
|
|
568
|
+
if style_match is None:
|
|
569
|
+
continue
|
|
570
|
+
style_id = int(style_match.group("id"))
|
|
571
|
+
name = _decode_group_text(style_group, encoding).strip().rstrip(";").strip()
|
|
572
|
+
normalized = name.casefold()
|
|
573
|
+
outline_match = re.search(rb"\\outlinelevel(?P<level>\d+)", style_group)
|
|
574
|
+
outline = int(outline_match.group("level")) if outline_match is not None else None
|
|
575
|
+
if outline is None:
|
|
576
|
+
heading_name = re.fullmatch(r"heading\s+([1-9])", normalized)
|
|
577
|
+
if heading_name is not None:
|
|
578
|
+
outline = int(heading_name.group(1)) - 1
|
|
579
|
+
based_on_match = re.search(rb"\\sbasedon(?P<id>-?\d+)", style_group)
|
|
580
|
+
based_on = int(based_on_match.group("id")) if based_on_match is not None else None
|
|
581
|
+
if "code" in normalized or normalized in {"macro", "macro text"}:
|
|
582
|
+
block_style = "code"
|
|
583
|
+
elif "quote" in normalized:
|
|
584
|
+
block_style = "quote"
|
|
585
|
+
else:
|
|
586
|
+
block_style = _BLOCK_STYLE_NAMES.get(normalized, "normal")
|
|
587
|
+
text_style_overrides = _TextStyleOverrides(
|
|
588
|
+
bold=_style_control_value(style_group, "b"),
|
|
589
|
+
italic=_style_control_value(style_group, "i"),
|
|
590
|
+
underline=_style_control_value(style_group, "ul"),
|
|
591
|
+
strike=_style_control_value(style_group, "strike"),
|
|
592
|
+
)
|
|
593
|
+
result[style_id] = _StyleDefinition(
|
|
594
|
+
name=name,
|
|
595
|
+
outline_level=outline,
|
|
596
|
+
is_title=normalized in _TITLE_STYLE_NAMES,
|
|
597
|
+
block_style=block_style, # type: ignore[arg-type]
|
|
598
|
+
based_on=based_on,
|
|
599
|
+
text_style=text_style_overrides.resolve(),
|
|
600
|
+
text_style_overrides=text_style_overrides,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
resolved: dict[int, _StyleDefinition] = {}
|
|
604
|
+
|
|
605
|
+
def resolve(style_id: int, visiting: set[int]) -> _StyleDefinition:
|
|
606
|
+
"""递归合并 based-on 样式,循环引用时保留当前显式属性。"""
|
|
607
|
+
if style_id in resolved:
|
|
608
|
+
return resolved[style_id]
|
|
609
|
+
current = result.get(style_id, _StyleDefinition())
|
|
610
|
+
if current.based_on is None or current.based_on in visiting:
|
|
611
|
+
resolved[style_id] = current
|
|
612
|
+
return current
|
|
613
|
+
base = resolve(current.based_on, {*visiting, style_id})
|
|
614
|
+
merged_style = current.text_style_overrides.resolve(base.text_style)
|
|
615
|
+
merged = _StyleDefinition(
|
|
616
|
+
name=current.name,
|
|
617
|
+
outline_level=current.outline_level if current.outline_level is not None else base.outline_level,
|
|
618
|
+
is_title=current.is_title or base.is_title,
|
|
619
|
+
block_style=current.block_style if current.block_style != "normal" else base.block_style,
|
|
620
|
+
based_on=current.based_on,
|
|
621
|
+
text_style=merged_style,
|
|
622
|
+
text_style_overrides=current.text_style_overrides,
|
|
623
|
+
)
|
|
624
|
+
resolved[style_id] = merged
|
|
625
|
+
return merged
|
|
626
|
+
|
|
627
|
+
for style_id in result:
|
|
628
|
+
resolve(style_id, set())
|
|
629
|
+
return resolved
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _parse_lists(data: bytes) -> dict[int, _ListDefinition]:
|
|
633
|
+
"""解析 listtable/listoverridetable 的常见编号格式与 override identity。"""
|
|
634
|
+
by_list_id: dict[int, tuple[_ListLevel, ...]] = {}
|
|
635
|
+
list_tables = _named_groups(data, "listtable")
|
|
636
|
+
if list_tables:
|
|
637
|
+
list_table = list_tables[0].materialize()
|
|
638
|
+
for list_span in _named_groups(list_table, "list"):
|
|
639
|
+
list_group = list_span.materialize()
|
|
640
|
+
id_match = re.search(rb"\\listid(?P<id>-?\d+)", list_group)
|
|
641
|
+
if id_match is None:
|
|
642
|
+
continue
|
|
643
|
+
levels: list[_ListLevel] = []
|
|
644
|
+
for level_span in _named_groups(list_group, "listlevel")[: MAX_RTF_LIST_DEPTH + 1]:
|
|
645
|
+
level_group = level_span.materialize()
|
|
646
|
+
nfc_match = re.search(rb"\\levelnfc(?P<nfc>\d+)", level_group)
|
|
647
|
+
start_match = re.search(rb"\\levelstartat(?P<start>-?\d+)", level_group)
|
|
648
|
+
nfc = int(nfc_match.group("nfc")) if nfc_match is not None else 0
|
|
649
|
+
start = int(start_match.group("start")) if start_match is not None else 1
|
|
650
|
+
levels.append(_ListLevel(marker=_NFC_MARKERS.get(nfc, "decimal"), start=max(start, 0)))
|
|
651
|
+
if not levels:
|
|
652
|
+
levels.append(_ListLevel())
|
|
653
|
+
by_list_id[int(id_match.group("id"))] = tuple(levels)
|
|
654
|
+
|
|
655
|
+
result: dict[int, _ListDefinition] = {}
|
|
656
|
+
override_tables = _named_groups(data, "listoverridetable")
|
|
657
|
+
if override_tables:
|
|
658
|
+
override_table = override_tables[0].materialize()
|
|
659
|
+
for override_span in _named_groups(override_table, "listoverride"):
|
|
660
|
+
override_group = override_span.materialize()
|
|
661
|
+
id_match = re.search(rb"\\listid(?P<id>-?\d+)", override_group)
|
|
662
|
+
ls_match = re.search(rb"\\ls(?P<ls>\d+)", override_group)
|
|
663
|
+
if id_match is None or ls_match is None:
|
|
664
|
+
continue
|
|
665
|
+
list_id = int(id_match.group("id"))
|
|
666
|
+
ls = int(ls_match.group("ls"))
|
|
667
|
+
levels = list(by_list_id.get(list_id, (_ListLevel(),)))
|
|
668
|
+
for level_index, level_span in enumerate(_named_groups(override_group, "lfolevel")):
|
|
669
|
+
level_override = level_span.materialize()
|
|
670
|
+
while len(levels) <= level_index:
|
|
671
|
+
levels.append(_ListLevel())
|
|
672
|
+
current = levels[level_index]
|
|
673
|
+
start_match = re.search(rb"\\levelstartat(?P<start>-?\d+)", level_override)
|
|
674
|
+
nfc_match = re.search(rb"\\levelnfc(?P<nfc>\d+)", level_override)
|
|
675
|
+
start = max(int(start_match.group("start")), 0) if start_match is not None else current.start
|
|
676
|
+
marker = (
|
|
677
|
+
_NFC_MARKERS.get(int(nfc_match.group("nfc")), current.marker) if nfc_match is not None else current.marker
|
|
678
|
+
)
|
|
679
|
+
levels[level_index] = _ListLevel(marker=marker, start=start)
|
|
680
|
+
result[ls] = _ListDefinition(identity=ls, levels=tuple(levels))
|
|
681
|
+
return result
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _parse_metadata(data: bytes, encoding: str) -> RtfMetadata:
|
|
685
|
+
"""解析 info destination 中允许公开的四个字符串字段。"""
|
|
686
|
+
info_groups = _named_groups(data, "info")
|
|
687
|
+
if not info_groups:
|
|
688
|
+
return RtfMetadata()
|
|
689
|
+
info_group = info_groups[0].materialize()
|
|
690
|
+
values: dict[str, str | None] = {}
|
|
691
|
+
for name in ("title", "author", "subject", "keywords"):
|
|
692
|
+
groups = _named_groups(info_group, name)
|
|
693
|
+
value = _decode_group_text(groups[0].materialize(), encoding).strip() if groups else ""
|
|
694
|
+
values[name] = value or None
|
|
695
|
+
return RtfMetadata(**values)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def parse_rtf_prelude(data: bytes) -> _Prelude:
|
|
699
|
+
"""解析 RTF header tables、列表和 metadata,供正文 parser 与 doclib 共用。"""
|
|
700
|
+
offset = rtf_header_offset(data[:128])
|
|
701
|
+
if offset is None:
|
|
702
|
+
raise LegacyOfficeMalformedError("not an RTF document")
|
|
703
|
+
normalized = data[offset:]
|
|
704
|
+
default = _default_encoding(normalized)
|
|
705
|
+
return _Prelude(
|
|
706
|
+
default_encoding=default,
|
|
707
|
+
font_encodings=_parse_fonts(normalized, default),
|
|
708
|
+
styles=_parse_styles(normalized, default),
|
|
709
|
+
lists=_parse_lists(normalized),
|
|
710
|
+
metadata=_parse_metadata(normalized, default),
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _roman(value: int) -> str:
|
|
715
|
+
"""把受支持正整数格式化为 Roman 编号,越界时安全回退十进制。"""
|
|
716
|
+
if value <= 0 or value > MAX_RTF_ROMAN_VALUE:
|
|
717
|
+
return str(value)
|
|
718
|
+
pairs = (
|
|
719
|
+
(1000, "M"),
|
|
720
|
+
(900, "CM"),
|
|
721
|
+
(500, "D"),
|
|
722
|
+
(400, "CD"),
|
|
723
|
+
(100, "C"),
|
|
724
|
+
(90, "XC"),
|
|
725
|
+
(50, "L"),
|
|
726
|
+
(40, "XL"),
|
|
727
|
+
(10, "X"),
|
|
728
|
+
(9, "IX"),
|
|
729
|
+
(5, "V"),
|
|
730
|
+
(4, "IV"),
|
|
731
|
+
(1, "I"),
|
|
732
|
+
)
|
|
733
|
+
parts: list[str] = []
|
|
734
|
+
remaining = value
|
|
735
|
+
for number, label in pairs:
|
|
736
|
+
while remaining >= number:
|
|
737
|
+
parts.append(label)
|
|
738
|
+
remaining -= number
|
|
739
|
+
return "".join(parts)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _alpha(value: int) -> str:
|
|
743
|
+
"""把正整数格式化为 Excel 风格字母编号。"""
|
|
744
|
+
if value <= 0:
|
|
745
|
+
return str(value)
|
|
746
|
+
parts: list[str] = []
|
|
747
|
+
remaining = value
|
|
748
|
+
while remaining:
|
|
749
|
+
remaining, digit = divmod(remaining - 1, 26)
|
|
750
|
+
parts.append(chr(ord("A") + digit))
|
|
751
|
+
return "".join(reversed(parts))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _format_marker(marker: str, value: int) -> str:
|
|
755
|
+
"""按常见 RTF levelnfc marker 格式化一个编号。"""
|
|
756
|
+
if marker == "upper_roman":
|
|
757
|
+
return _roman(value)
|
|
758
|
+
if marker == "lower_roman":
|
|
759
|
+
return _roman(value).lower()
|
|
760
|
+
if marker == "upper_alpha":
|
|
761
|
+
return _alpha(value)
|
|
762
|
+
if marker == "lower_alpha":
|
|
763
|
+
return _alpha(value).lower()
|
|
764
|
+
return str(value)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _capture_picture_group(data: bytes) -> _PictCapture | None:
|
|
768
|
+
"""从独立 pict group 中提取 direct hex/bin,用于 Office Math 图片 fallback。"""
|
|
769
|
+
capture = _PictCapture()
|
|
770
|
+
depth = 0
|
|
771
|
+
for token in RtfLexer(data):
|
|
772
|
+
if isinstance(token, RtfOpen):
|
|
773
|
+
depth += 1
|
|
774
|
+
continue
|
|
775
|
+
if isinstance(token, RtfClose):
|
|
776
|
+
depth = max(depth - 1, 0)
|
|
777
|
+
continue
|
|
778
|
+
if isinstance(token, RtfControlWord) and depth == 1:
|
|
779
|
+
if token.name == "pngblip":
|
|
780
|
+
capture.content_type, capture.extension = "image/png", "png"
|
|
781
|
+
elif token.name == "jpegblip":
|
|
782
|
+
capture.content_type, capture.extension = "image/jpeg", "jpg"
|
|
783
|
+
elif token.name == "emfblip":
|
|
784
|
+
capture.content_type, capture.extension = "image/x-emf", "emf"
|
|
785
|
+
elif token.name == "wmetafile":
|
|
786
|
+
capture.content_type, capture.extension = "image/x-wmf", "wmf"
|
|
787
|
+
elif token.name in {"dibitmap", "wbitmap"}:
|
|
788
|
+
capture.content_type, capture.extension = "image/bmp", "dib"
|
|
789
|
+
elif isinstance(token, RtfBinary) and depth == 1:
|
|
790
|
+
capture.binary = token.data
|
|
791
|
+
elif isinstance(token, RtfHexByte) and depth == 1:
|
|
792
|
+
capture.hex_data.extend(f"{token.value:02x}".encode("ascii"))
|
|
793
|
+
elif isinstance(token, RtfTextBytes) and depth == 1:
|
|
794
|
+
capture.hex_data.extend(token.data)
|
|
795
|
+
if capture.binary is None and not capture.hex_data:
|
|
796
|
+
return None
|
|
797
|
+
return capture
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class RtfParser:
|
|
801
|
+
"""把一个 RTF 字节串解析为无布局、单逻辑页的 typed document。"""
|
|
802
|
+
|
|
803
|
+
def __init__(self, data: bytes, prelude: _Prelude | None = None) -> None:
|
|
804
|
+
"""校验输入大小和根组,并初始化所有每文档状态。"""
|
|
805
|
+
if len(data) > MAX_RTF_BYTES:
|
|
806
|
+
raise LegacyOfficeResourceLimitError(f"RTF input exceeds max_bytes={MAX_RTF_BYTES}")
|
|
807
|
+
offset = rtf_header_offset(data[:128])
|
|
808
|
+
if offset is None:
|
|
809
|
+
raise LegacyOfficeMalformedError("not an RTF document")
|
|
810
|
+
self.data = data[offset:]
|
|
811
|
+
self.prelude = prelude or parse_rtf_prelude(data)
|
|
812
|
+
self.state = _State()
|
|
813
|
+
self.frames: list[_GroupFrame] = []
|
|
814
|
+
self.context = _OutputContext()
|
|
815
|
+
self.document = RtfDocument(metadata=self.prelude.metadata)
|
|
816
|
+
self._byte_buffer = bytearray()
|
|
817
|
+
self._fallback_skip = 0
|
|
818
|
+
self._pending_high_surrogate: int | None = None
|
|
819
|
+
self._list_counters: dict[tuple[int, int], int] = {}
|
|
820
|
+
self._asset_total = 0
|
|
821
|
+
self._recovered = False
|
|
822
|
+
|
|
823
|
+
def parse(self) -> RtfDocument:
|
|
824
|
+
"""运行状态机,恢复未闭合组并返回 typed RTF 文档。"""
|
|
825
|
+
for token in RtfLexer(self.data):
|
|
826
|
+
if isinstance(token, RtfOpen):
|
|
827
|
+
self._open_group(token)
|
|
828
|
+
elif isinstance(token, RtfClose):
|
|
829
|
+
self._close_group(token)
|
|
830
|
+
elif isinstance(token, RtfControlSymbol):
|
|
831
|
+
self._control_symbol(token)
|
|
832
|
+
elif isinstance(token, RtfControlWord):
|
|
833
|
+
self._control_word(token)
|
|
834
|
+
elif isinstance(token, RtfHexByte):
|
|
835
|
+
self._hex_byte(token)
|
|
836
|
+
elif isinstance(token, RtfTextBytes):
|
|
837
|
+
self._text_bytes(token)
|
|
838
|
+
elif isinstance(token, RtfBinary):
|
|
839
|
+
self._binary(token)
|
|
840
|
+
|
|
841
|
+
self._flush_bytes()
|
|
842
|
+
while self.frames:
|
|
843
|
+
self._recovered = True
|
|
844
|
+
frame = self.frames.pop()
|
|
845
|
+
self._finish_destination(frame, len(self.data))
|
|
846
|
+
self.state = frame.previous_state
|
|
847
|
+
self._finalize_context(self.context)
|
|
848
|
+
self.document.blocks = self.context.blocks
|
|
849
|
+
if self._recovered:
|
|
850
|
+
logger.warning("Recovered unbalanced RTF groups")
|
|
851
|
+
return self.document
|
|
852
|
+
|
|
853
|
+
def _open_group(self, token: RtfOpen) -> None:
|
|
854
|
+
"""压入当前状态,新的 group 初始继承所有属性。"""
|
|
855
|
+
self._flush_bytes()
|
|
856
|
+
parent = self._current_frame()
|
|
857
|
+
self.frames.append(_GroupFrame(previous_state=replace(self.state), start=token.start))
|
|
858
|
+
if parent is not None and parent.destination == "upr":
|
|
859
|
+
parent.upr_child_count += 1
|
|
860
|
+
if parent.upr_child_count == 2:
|
|
861
|
+
self.state.destination = "upr_unicode"
|
|
862
|
+
else:
|
|
863
|
+
self.state.destination = "suppressed"
|
|
864
|
+
|
|
865
|
+
def _close_group(self, token: RtfClose) -> None:
|
|
866
|
+
"""收束当前 destination 并恢复父 group 状态。"""
|
|
867
|
+
self._flush_bytes()
|
|
868
|
+
if not self.frames:
|
|
869
|
+
self._recovered = True
|
|
870
|
+
return
|
|
871
|
+
frame = self.frames.pop()
|
|
872
|
+
self._finish_destination(frame, token.end)
|
|
873
|
+
self.state = frame.previous_state
|
|
874
|
+
|
|
875
|
+
def _current_frame(self) -> _GroupFrame | None:
|
|
876
|
+
"""返回当前最内层 group frame。"""
|
|
877
|
+
return self.frames[-1] if self.frames else None
|
|
878
|
+
|
|
879
|
+
def _nearest_frame(self, destination: str) -> _GroupFrame | None:
|
|
880
|
+
"""从内向外查找负责指定 destination 的 frame。"""
|
|
881
|
+
return next((frame for frame in reversed(self.frames) if frame.destination == destination), None)
|
|
882
|
+
|
|
883
|
+
def _start_destination(self, name: str) -> bool:
|
|
884
|
+
"""识别 group destination 并初始化其隔离输出或捕获状态。"""
|
|
885
|
+
frame = self._current_frame()
|
|
886
|
+
if frame is None or frame.destination is not None:
|
|
887
|
+
return False
|
|
888
|
+
if name == "ud" and self.state.destination == "upr_unicode":
|
|
889
|
+
upr_frame = self._nearest_frame("upr")
|
|
890
|
+
if upr_frame is not None:
|
|
891
|
+
frame.destination = "ud"
|
|
892
|
+
self.state.destination = upr_frame.previous_state.destination
|
|
893
|
+
return True
|
|
894
|
+
if self.state.destination in {"math", "pict", "suppressed"}:
|
|
895
|
+
return False
|
|
896
|
+
if name == "upr":
|
|
897
|
+
frame.destination = "upr"
|
|
898
|
+
self.state.destination = "upr"
|
|
899
|
+
return True
|
|
900
|
+
if name in _SUPPRESSED_DESTINATIONS:
|
|
901
|
+
frame.destination = name
|
|
902
|
+
self.state.destination = "suppressed"
|
|
903
|
+
return True
|
|
904
|
+
if name == "field":
|
|
905
|
+
self._flush_text_run()
|
|
906
|
+
frame.destination = "field"
|
|
907
|
+
frame.inline_start = len(self.context.inlines)
|
|
908
|
+
return True
|
|
909
|
+
if name == "object":
|
|
910
|
+
frame.destination = "object"
|
|
911
|
+
self.state.destination = "object"
|
|
912
|
+
return True
|
|
913
|
+
if name == "result" and self.state.destination == "object":
|
|
914
|
+
frame.destination = "result"
|
|
915
|
+
self.state.destination = "body"
|
|
916
|
+
return True
|
|
917
|
+
if name == "fldinst":
|
|
918
|
+
frame.destination = "fldinst"
|
|
919
|
+
self.state.destination = "field_instruction"
|
|
920
|
+
return True
|
|
921
|
+
if name == "fldrslt":
|
|
922
|
+
frame.destination = "fldrslt"
|
|
923
|
+
self.state.destination = "body"
|
|
924
|
+
return True
|
|
925
|
+
if name in {"footnote", "endnote", "annotation"}:
|
|
926
|
+
frame.destination = "note"
|
|
927
|
+
frame.note_kind = "endnote" if name == "endnote" else "annotation" if name == "annotation" else "footnote"
|
|
928
|
+
frame.parent_context = self.context
|
|
929
|
+
self.context = _OutputContext()
|
|
930
|
+
self.state.destination = "body"
|
|
931
|
+
if name == "annotation":
|
|
932
|
+
self.state.hidden = False
|
|
933
|
+
return True
|
|
934
|
+
if name in _HEADER_DESTINATIONS | _FOOTER_DESTINATIONS:
|
|
935
|
+
frame.destination = "header" if name in _HEADER_DESTINATIONS else "footer"
|
|
936
|
+
frame.parent_context = self.context
|
|
937
|
+
self.context = _OutputContext()
|
|
938
|
+
self.state.destination = "body"
|
|
939
|
+
return True
|
|
940
|
+
if name == "pict":
|
|
941
|
+
frame.destination = "pict"
|
|
942
|
+
frame.pict = _PictCapture()
|
|
943
|
+
self.state.destination = "pict"
|
|
944
|
+
return True
|
|
945
|
+
if name == "mmath":
|
|
946
|
+
frame.destination = "math"
|
|
947
|
+
self.state.destination = "math"
|
|
948
|
+
return True
|
|
949
|
+
if name in {"listtext", "pntext"}:
|
|
950
|
+
frame.destination = "listtext"
|
|
951
|
+
self.state.destination = "listtext"
|
|
952
|
+
return True
|
|
953
|
+
if name == "bkmkstart":
|
|
954
|
+
frame.destination = "bookmark"
|
|
955
|
+
self.state.destination = "bookmark"
|
|
956
|
+
return True
|
|
957
|
+
if name == "bkmkend":
|
|
958
|
+
frame.destination = "bookmark_end"
|
|
959
|
+
self.state.destination = "suppressed"
|
|
960
|
+
return True
|
|
961
|
+
if name == "nonshppict":
|
|
962
|
+
frame.destination = name
|
|
963
|
+
self.state.destination = "suppressed"
|
|
964
|
+
return True
|
|
965
|
+
if name == "shppict":
|
|
966
|
+
frame.destination = name
|
|
967
|
+
return True
|
|
968
|
+
if name == "nesttableprops":
|
|
969
|
+
frame.destination = name
|
|
970
|
+
return True
|
|
971
|
+
if name == "pn":
|
|
972
|
+
frame.destination = name
|
|
973
|
+
self.state.list_override_id = self.state.list_override_id or -1
|
|
974
|
+
return True
|
|
975
|
+
if frame.ignorable:
|
|
976
|
+
frame.destination = "unknown"
|
|
977
|
+
self.state.destination = "suppressed"
|
|
978
|
+
return True
|
|
979
|
+
return False
|
|
980
|
+
|
|
981
|
+
def _finish_destination(self, frame: _GroupFrame, end: int) -> None:
|
|
982
|
+
"""在 group 结束处物化 field、note、pict、math 和捕获文本。"""
|
|
983
|
+
destination = frame.destination
|
|
984
|
+
if destination == "field":
|
|
985
|
+
self._finish_field(frame)
|
|
986
|
+
elif destination == "note":
|
|
987
|
+
self._finish_note(frame)
|
|
988
|
+
elif destination in {"header", "footer"}:
|
|
989
|
+
self._finish_auxiliary(frame, destination)
|
|
990
|
+
elif destination == "pict":
|
|
991
|
+
self._finish_picture(frame)
|
|
992
|
+
elif destination == "math":
|
|
993
|
+
self._finish_math(frame, end)
|
|
994
|
+
elif destination == "listtext":
|
|
995
|
+
label = "".join(frame.capture_text).replace("\t", " ").strip()
|
|
996
|
+
if label:
|
|
997
|
+
self.context.pending_list_label = label
|
|
998
|
+
elif destination == "bookmark":
|
|
999
|
+
name = "".join(frame.capture_text).strip()
|
|
1000
|
+
if name:
|
|
1001
|
+
self._append_inline(RtfAnchor(name))
|
|
1002
|
+
|
|
1003
|
+
def _finish_field(self, frame: _GroupFrame) -> None:
|
|
1004
|
+
"""把安全 HYPERLINK field result 包装回行内 run。"""
|
|
1005
|
+
self._flush_text_run()
|
|
1006
|
+
instruction = "".join(frame.instruction).strip()
|
|
1007
|
+
match = _HYPERLINK_RE.search(instruction)
|
|
1008
|
+
if match is None:
|
|
1009
|
+
return
|
|
1010
|
+
raw_target = match.group("quoted") or match.group("bare") or ""
|
|
1011
|
+
candidate = f"#{raw_target.lstrip('#')}" if match.group("local") else raw_target
|
|
1012
|
+
target = sanitize_hyperlink_target(candidate, allow_relative=True, allow_fragment=True)
|
|
1013
|
+
if target is None:
|
|
1014
|
+
return
|
|
1015
|
+
start = min(frame.inline_start, len(self.context.inlines))
|
|
1016
|
+
for index in range(start, len(self.context.inlines)):
|
|
1017
|
+
inline = self.context.inlines[index]
|
|
1018
|
+
if isinstance(inline, RtfTextRun):
|
|
1019
|
+
self.context.inlines[index] = replace(inline, hyperlink=target)
|
|
1020
|
+
|
|
1021
|
+
def _finish_note(self, frame: _GroupFrame) -> None:
|
|
1022
|
+
"""结束隔离 note context,登记正文并按类型决定是否插入引用。"""
|
|
1023
|
+
note_context = self.context
|
|
1024
|
+
self._finalize_context(note_context)
|
|
1025
|
+
parent = frame.parent_context or _OutputContext()
|
|
1026
|
+
self.context = parent
|
|
1027
|
+
if not note_context.blocks:
|
|
1028
|
+
return
|
|
1029
|
+
note_id = f"rtf{len(self.document.notes)}"
|
|
1030
|
+
self.document.notes.append(RtfNote(note_id, frame.note_kind, note_context.blocks))
|
|
1031
|
+
if frame.note_kind != "annotation":
|
|
1032
|
+
self._append_inline(RtfNoteReference(note_id))
|
|
1033
|
+
|
|
1034
|
+
def _finish_auxiliary(self, frame: _GroupFrame, destination: str) -> None:
|
|
1035
|
+
"""结束页眉页脚隔离 context,并恢复父正文。"""
|
|
1036
|
+
auxiliary_context = self.context
|
|
1037
|
+
self._finalize_context(auxiliary_context)
|
|
1038
|
+
self.context = frame.parent_context or _OutputContext()
|
|
1039
|
+
target = self.document.headers if destination == "header" else self.document.footers
|
|
1040
|
+
target.extend(auxiliary_context.blocks)
|
|
1041
|
+
|
|
1042
|
+
def _finish_picture(self, frame: _GroupFrame) -> None:
|
|
1043
|
+
"""校验 pict 大小并向当前行内流追加图片。"""
|
|
1044
|
+
capture = frame.pict
|
|
1045
|
+
if capture is None:
|
|
1046
|
+
return
|
|
1047
|
+
image = self._materialize_picture(capture)
|
|
1048
|
+
if image is not None:
|
|
1049
|
+
self._append_inline(image)
|
|
1050
|
+
|
|
1051
|
+
def _materialize_picture(self, capture: _PictCapture) -> RtfImage | None:
|
|
1052
|
+
"""把已捕获 pict 转成有界图片载荷,并累计文档素材预算。"""
|
|
1053
|
+
if capture.binary is not None:
|
|
1054
|
+
payload = capture.binary
|
|
1055
|
+
else:
|
|
1056
|
+
compact = bytes(character for character in capture.hex_data if chr(character).strip())
|
|
1057
|
+
if len(compact) % 2:
|
|
1058
|
+
logger.warning("Skipping RTF pict with odd hex length")
|
|
1059
|
+
return
|
|
1060
|
+
try:
|
|
1061
|
+
payload = bytes.fromhex(compact.decode("ascii"))
|
|
1062
|
+
except (UnicodeDecodeError, ValueError):
|
|
1063
|
+
logger.warning("Skipping malformed RTF pict hex payload")
|
|
1064
|
+
return
|
|
1065
|
+
if not payload:
|
|
1066
|
+
return
|
|
1067
|
+
if len(payload) > MAX_ENTRY_BYTES:
|
|
1068
|
+
raise LegacyOfficeResourceLimitError(f"RTF pict exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
|
|
1069
|
+
if self._asset_total + len(payload) > MAX_ASSET_TOTAL_BYTES:
|
|
1070
|
+
raise LegacyOfficeResourceLimitError(f"RTF pict assets exceed max_asset_total_bytes={MAX_ASSET_TOTAL_BYTES}")
|
|
1071
|
+
self._asset_total += len(payload)
|
|
1072
|
+
return RtfImage(
|
|
1073
|
+
data=payload,
|
|
1074
|
+
content_type=capture.content_type,
|
|
1075
|
+
part_name=f"pict.{capture.extension}",
|
|
1076
|
+
alt=capture.alt,
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
def _finish_math(self, frame: _GroupFrame, end: int) -> None:
|
|
1080
|
+
"""把 math group 转换为行内或行间 LaTeX,失败时静默保留其余正文。"""
|
|
1081
|
+
formulas, display = parse_rtf_math(
|
|
1082
|
+
self.data[frame.start : end],
|
|
1083
|
+
encoding=self._current_encoding(),
|
|
1084
|
+
)
|
|
1085
|
+
if not formulas:
|
|
1086
|
+
for group in _named_groups(self.data[frame.start : end], "pict"):
|
|
1087
|
+
capture = _capture_picture_group(group.materialize())
|
|
1088
|
+
if capture is None:
|
|
1089
|
+
continue
|
|
1090
|
+
image = self._materialize_picture(capture)
|
|
1091
|
+
if image is not None:
|
|
1092
|
+
self._append_inline(image)
|
|
1093
|
+
break
|
|
1094
|
+
return
|
|
1095
|
+
if display:
|
|
1096
|
+
self._end_paragraph()
|
|
1097
|
+
self._flush_tables()
|
|
1098
|
+
self.context.blocks.extend(RtfDisplayEquation(formula) for formula in formulas)
|
|
1099
|
+
else:
|
|
1100
|
+
for formula in formulas:
|
|
1101
|
+
self._append_inline(RtfInlineEquation(formula))
|
|
1102
|
+
|
|
1103
|
+
def _control_symbol(self, token: RtfControlSymbol) -> None:
|
|
1104
|
+
"""处理 ignorable marker、转义结构字符和特殊空白。"""
|
|
1105
|
+
self._flush_bytes()
|
|
1106
|
+
frame = self._current_frame()
|
|
1107
|
+
if token.symbol == "*" and frame is not None:
|
|
1108
|
+
frame.ignorable = True
|
|
1109
|
+
return
|
|
1110
|
+
if self._fallback_skip:
|
|
1111
|
+
self._fallback_skip -= 1
|
|
1112
|
+
return
|
|
1113
|
+
if token.symbol == "\n":
|
|
1114
|
+
self._end_paragraph()
|
|
1115
|
+
elif token.symbol in {"\\", "{", "}"}:
|
|
1116
|
+
self._append_text(token.symbol)
|
|
1117
|
+
elif token.symbol == "~":
|
|
1118
|
+
self._append_text("\u00a0")
|
|
1119
|
+
elif token.symbol == "_":
|
|
1120
|
+
self._append_text("-")
|
|
1121
|
+
|
|
1122
|
+
def _control_word(self, token: RtfControlWord) -> None:
|
|
1123
|
+
"""按 destination、文本、表格和列表的固定顺序解释 control word。"""
|
|
1124
|
+
self._flush_bytes()
|
|
1125
|
+
if self._start_destination(token.name):
|
|
1126
|
+
return
|
|
1127
|
+
if token.name == "ftnalt":
|
|
1128
|
+
note_frame = self._nearest_frame("note")
|
|
1129
|
+
if note_frame is not None:
|
|
1130
|
+
note_frame.note_kind = "endnote"
|
|
1131
|
+
return
|
|
1132
|
+
if self.state.destination == "pict":
|
|
1133
|
+
self._pict_control(token)
|
|
1134
|
+
return
|
|
1135
|
+
if self.state.destination in {"math", "suppressed"}:
|
|
1136
|
+
return
|
|
1137
|
+
if token.name == "u":
|
|
1138
|
+
self._unicode(token.param)
|
|
1139
|
+
return
|
|
1140
|
+
if token.name == "uc":
|
|
1141
|
+
self.state.uc_skip = max(token.param or 0, 0)
|
|
1142
|
+
return
|
|
1143
|
+
if token.name == "f":
|
|
1144
|
+
self.state.font_id = token.param
|
|
1145
|
+
return
|
|
1146
|
+
if token.name == "s" and token.param is not None:
|
|
1147
|
+
self.state.paragraph_style_id = token.param
|
|
1148
|
+
definition = self.prelude.styles.get(token.param)
|
|
1149
|
+
if definition is not None and definition.outline_level is not None:
|
|
1150
|
+
self.state.outline_level = definition.outline_level
|
|
1151
|
+
if definition is not None:
|
|
1152
|
+
self.state.style = definition.text_style
|
|
1153
|
+
return
|
|
1154
|
+
if token.name == "outlinelevel" and token.param is not None:
|
|
1155
|
+
self.state.outline_level = max(token.param, 0)
|
|
1156
|
+
return
|
|
1157
|
+
if token.name == "b":
|
|
1158
|
+
self.state.style = replace(self.state.style, bold=token.param != 0)
|
|
1159
|
+
return
|
|
1160
|
+
if token.name == "i":
|
|
1161
|
+
self.state.style = replace(self.state.style, italic=token.param != 0)
|
|
1162
|
+
return
|
|
1163
|
+
if token.name in {"ul", "uld", "uldash", "uldb", "ulth", "ulw"}:
|
|
1164
|
+
self.state.style = replace(self.state.style, underline=token.param != 0)
|
|
1165
|
+
return
|
|
1166
|
+
if token.name in {"ulnone", "ul0"}:
|
|
1167
|
+
self.state.style = replace(self.state.style, underline=False)
|
|
1168
|
+
return
|
|
1169
|
+
if token.name in {"strike", "striked"}:
|
|
1170
|
+
self.state.style = replace(self.state.style, strike=token.param != 0)
|
|
1171
|
+
return
|
|
1172
|
+
if token.name == "super":
|
|
1173
|
+
self.state.style = replace(self.state.style, superscript=True, subscript=False)
|
|
1174
|
+
return
|
|
1175
|
+
if token.name == "sub":
|
|
1176
|
+
self.state.style = replace(self.state.style, superscript=False, subscript=True)
|
|
1177
|
+
return
|
|
1178
|
+
if token.name == "nosupersub":
|
|
1179
|
+
self.state.style = replace(self.state.style, superscript=False, subscript=False)
|
|
1180
|
+
return
|
|
1181
|
+
if token.name == "v":
|
|
1182
|
+
self.state.hidden = token.param != 0
|
|
1183
|
+
return
|
|
1184
|
+
if token.name == "plain":
|
|
1185
|
+
self.state.style = RtfTextStyle()
|
|
1186
|
+
self.state.hidden = False
|
|
1187
|
+
return
|
|
1188
|
+
if token.name == "pard":
|
|
1189
|
+
if self.state.table_depth > 1:
|
|
1190
|
+
self._set_table_depth(self.state.table_depth - 1)
|
|
1191
|
+
self.state.table_depth = 0
|
|
1192
|
+
self.state.in_table = False
|
|
1193
|
+
self.state.paragraph_style_id = None
|
|
1194
|
+
self.state.outline_level = None
|
|
1195
|
+
self.state.list_override_id = None
|
|
1196
|
+
self.state.list_level = 0
|
|
1197
|
+
return
|
|
1198
|
+
if token.name in {"par", "sect"}:
|
|
1199
|
+
self._end_paragraph()
|
|
1200
|
+
return
|
|
1201
|
+
if token.name in {"page", "column", "lbr"}:
|
|
1202
|
+
self._append_inline(RtfLineBreak())
|
|
1203
|
+
return
|
|
1204
|
+
if token.name in _SPECIAL_WORDS:
|
|
1205
|
+
self._append_text(_SPECIAL_WORDS[token.name])
|
|
1206
|
+
return
|
|
1207
|
+
if token.name == "chftn":
|
|
1208
|
+
return
|
|
1209
|
+
if self._table_control(token):
|
|
1210
|
+
return
|
|
1211
|
+
self._list_control(token)
|
|
1212
|
+
|
|
1213
|
+
def _table_control(self, token: RtfControlWord) -> bool:
|
|
1214
|
+
"""解释常见 row/cell/table-depth 与合并控制。"""
|
|
1215
|
+
name = token.name
|
|
1216
|
+
if name == "itap":
|
|
1217
|
+
self._set_table_depth(max(token.param or 0, 0))
|
|
1218
|
+
return True
|
|
1219
|
+
depth = max(self.state.table_depth, 1)
|
|
1220
|
+
if name == "trowd":
|
|
1221
|
+
self.state.in_table = True
|
|
1222
|
+
self.state.list_override_id = None
|
|
1223
|
+
self.state.list_level = 0
|
|
1224
|
+
self.context.pending_list_label = None
|
|
1225
|
+
if self.state.table_depth == 0:
|
|
1226
|
+
self.state.table_depth = 1
|
|
1227
|
+
self._table_builder(depth).start_row()
|
|
1228
|
+
return True
|
|
1229
|
+
if name == "intbl":
|
|
1230
|
+
self.state.in_table = token.param != 0
|
|
1231
|
+
if self.state.in_table and self.state.table_depth == 0:
|
|
1232
|
+
self.state.table_depth = 1
|
|
1233
|
+
return True
|
|
1234
|
+
if name == "trhdr":
|
|
1235
|
+
self._table_builder(depth).set_header()
|
|
1236
|
+
return True
|
|
1237
|
+
if name == "clmgf":
|
|
1238
|
+
self._table_builder(depth).set_horizontal_merge("start")
|
|
1239
|
+
return True
|
|
1240
|
+
if name == "clmrg":
|
|
1241
|
+
self._table_builder(depth).set_horizontal_merge("continue")
|
|
1242
|
+
return True
|
|
1243
|
+
if name == "clvmgf":
|
|
1244
|
+
self._table_builder(depth).set_vertical_merge("start")
|
|
1245
|
+
return True
|
|
1246
|
+
if name == "clvmrg":
|
|
1247
|
+
self._table_builder(depth).set_vertical_merge("continue")
|
|
1248
|
+
return True
|
|
1249
|
+
if name == "cellx":
|
|
1250
|
+
self._table_builder(depth).add_definition(token.param)
|
|
1251
|
+
return True
|
|
1252
|
+
if name in {"cell", "nestcell"}:
|
|
1253
|
+
self._end_paragraph()
|
|
1254
|
+
self._table_builder(depth).end_cell()
|
|
1255
|
+
return True
|
|
1256
|
+
if name in {"row", "nestrow"}:
|
|
1257
|
+
self._end_paragraph()
|
|
1258
|
+
self._table_builder(depth).end_row()
|
|
1259
|
+
return True
|
|
1260
|
+
return False
|
|
1261
|
+
|
|
1262
|
+
def _list_control(self, token: RtfControlWord) -> bool:
|
|
1263
|
+
"""记录现代 ls/ilvl 及常见 legacy pn marker。"""
|
|
1264
|
+
if token.name == "ls" and token.param is not None:
|
|
1265
|
+
self.state.list_override_id = token.param
|
|
1266
|
+
return True
|
|
1267
|
+
if token.name == "ilvl" and token.param is not None:
|
|
1268
|
+
self.state.list_level = min(max(token.param, 0), MAX_RTF_LIST_DEPTH)
|
|
1269
|
+
return True
|
|
1270
|
+
if token.name == "pnlvlblt":
|
|
1271
|
+
self.state.list_override_id = self.state.list_override_id or -1
|
|
1272
|
+
return True
|
|
1273
|
+
if token.name in {"pndec", "pnucrm", "pnlcrm", "pnucltr", "pnlcltr"}:
|
|
1274
|
+
self.state.list_override_id = self.state.list_override_id or -2
|
|
1275
|
+
return True
|
|
1276
|
+
return False
|
|
1277
|
+
|
|
1278
|
+
def _pict_control(self, token: RtfControlWord) -> None:
|
|
1279
|
+
"""记录 pict 格式;尺寸与裁剪控制不影响无布局语义。"""
|
|
1280
|
+
frame = self._nearest_frame("pict")
|
|
1281
|
+
capture = frame.pict if frame is not None else None
|
|
1282
|
+
if capture is None:
|
|
1283
|
+
return
|
|
1284
|
+
if token.name == "pngblip":
|
|
1285
|
+
capture.content_type, capture.extension = "image/png", "png"
|
|
1286
|
+
elif token.name == "jpegblip":
|
|
1287
|
+
capture.content_type, capture.extension = "image/jpeg", "jpg"
|
|
1288
|
+
elif token.name == "emfblip":
|
|
1289
|
+
capture.content_type, capture.extension = "image/x-emf", "emf"
|
|
1290
|
+
elif token.name == "wmetafile":
|
|
1291
|
+
capture.content_type, capture.extension = "image/x-wmf", "wmf"
|
|
1292
|
+
elif token.name in {"dibitmap", "wbitmap"}:
|
|
1293
|
+
capture.content_type, capture.extension = "image/bmp", "dib"
|
|
1294
|
+
|
|
1295
|
+
def _hex_byte(self, token: RtfHexByte) -> None:
|
|
1296
|
+
"""把 hex byte 送入 pict 或当前代码页缓冲。"""
|
|
1297
|
+
if self.state.destination == "pict":
|
|
1298
|
+
frame = self._nearest_frame("pict")
|
|
1299
|
+
if frame is not None and frame is self._current_frame() and frame.pict is not None:
|
|
1300
|
+
frame.pict.hex_data.extend(f"{token.value:02x}".encode("ascii"))
|
|
1301
|
+
return
|
|
1302
|
+
if self.state.destination in {"math", "suppressed"}:
|
|
1303
|
+
return
|
|
1304
|
+
if self._fallback_skip:
|
|
1305
|
+
self._fallback_skip -= 1
|
|
1306
|
+
return
|
|
1307
|
+
self._byte_buffer.append(token.value)
|
|
1308
|
+
|
|
1309
|
+
def _text_bytes(self, token: RtfTextBytes) -> None:
|
|
1310
|
+
"""规范源换行后把文本字节送入 destination 或代码页缓冲。"""
|
|
1311
|
+
if self.state.destination == "pict":
|
|
1312
|
+
frame = self._nearest_frame("pict")
|
|
1313
|
+
if frame is not None and frame is self._current_frame() and frame.pict is not None:
|
|
1314
|
+
frame.pict.hex_data.extend(token.data)
|
|
1315
|
+
return
|
|
1316
|
+
if self.state.destination in {"math", "suppressed"}:
|
|
1317
|
+
return
|
|
1318
|
+
raw = token.data.replace(b"\r", b"").replace(b"\n", b"")
|
|
1319
|
+
if self._fallback_skip:
|
|
1320
|
+
skipped = min(self._fallback_skip, len(raw))
|
|
1321
|
+
raw = raw[skipped:]
|
|
1322
|
+
self._fallback_skip -= skipped
|
|
1323
|
+
self._byte_buffer.extend(raw)
|
|
1324
|
+
|
|
1325
|
+
def _binary(self, token: RtfBinary) -> None:
|
|
1326
|
+
"""只允许 pict destination 消费 bin 载荷,其他二进制内容直接跳过。"""
|
|
1327
|
+
if self.state.destination != "pict":
|
|
1328
|
+
return
|
|
1329
|
+
frame = self._nearest_frame("pict")
|
|
1330
|
+
if frame is not None and frame is self._current_frame() and frame.pict is not None:
|
|
1331
|
+
frame.pict.binary = token.data
|
|
1332
|
+
|
|
1333
|
+
def _unicode(self, value: int | None) -> None:
|
|
1334
|
+
"""解码有符号 UTF-16 code unit,合并代理对并启动 fallback skip。"""
|
|
1335
|
+
if value is None:
|
|
1336
|
+
return
|
|
1337
|
+
unit = (value + 65536 if value < 0 else value) & 0xFFFF
|
|
1338
|
+
if 0xD800 <= unit <= 0xDBFF:
|
|
1339
|
+
if self._pending_high_surrogate is not None:
|
|
1340
|
+
self._append_text("\ufffd")
|
|
1341
|
+
self._pending_high_surrogate = unit
|
|
1342
|
+
elif 0xDC00 <= unit <= 0xDFFF and self._pending_high_surrogate is not None:
|
|
1343
|
+
codepoint = 0x10000 + ((self._pending_high_surrogate - 0xD800) << 10) + unit - 0xDC00
|
|
1344
|
+
self._append_text(chr(codepoint))
|
|
1345
|
+
self._pending_high_surrogate = None
|
|
1346
|
+
else:
|
|
1347
|
+
if self._pending_high_surrogate is not None:
|
|
1348
|
+
self._append_text("\ufffd")
|
|
1349
|
+
self._pending_high_surrogate = None
|
|
1350
|
+
self._append_text(chr(unit) if not 0xD800 <= unit <= 0xDFFF else "\ufffd")
|
|
1351
|
+
self._fallback_skip = self.state.uc_skip
|
|
1352
|
+
|
|
1353
|
+
def _current_encoding(self) -> str:
|
|
1354
|
+
"""返回当前 font 的代码页或文档默认代码页。"""
|
|
1355
|
+
if self.state.font_id is None:
|
|
1356
|
+
return self.prelude.default_encoding
|
|
1357
|
+
return self.prelude.font_encodings.get(self.state.font_id, self.prelude.default_encoding)
|
|
1358
|
+
|
|
1359
|
+
def _flush_bytes(self) -> None:
|
|
1360
|
+
"""使用当前 font code page 解码累计字节并写入当前 destination。"""
|
|
1361
|
+
if not self._byte_buffer:
|
|
1362
|
+
return
|
|
1363
|
+
payload = bytes(self._byte_buffer)
|
|
1364
|
+
self._byte_buffer.clear()
|
|
1365
|
+
self._append_text(payload.decode(self._current_encoding(), errors="replace"))
|
|
1366
|
+
|
|
1367
|
+
def _append_text(self, text: str) -> None:
|
|
1368
|
+
"""按当前 destination 把文本写入 field、bookmark、list label 或正文。"""
|
|
1369
|
+
if not text or self.state.hidden:
|
|
1370
|
+
return
|
|
1371
|
+
if self.state.destination == "field_instruction":
|
|
1372
|
+
field_frame = self._nearest_frame("field")
|
|
1373
|
+
if field_frame is not None:
|
|
1374
|
+
field_frame.instruction.append(text)
|
|
1375
|
+
return
|
|
1376
|
+
if self.state.destination in {"bookmark", "listtext"}:
|
|
1377
|
+
frame = self._nearest_frame(self.state.destination)
|
|
1378
|
+
if frame is not None:
|
|
1379
|
+
frame.capture_text.append(text)
|
|
1380
|
+
return
|
|
1381
|
+
if self.state.destination != "body":
|
|
1382
|
+
return
|
|
1383
|
+
if self.context.text_fragments and self.context.text_style != self.state.style:
|
|
1384
|
+
self._flush_text_run()
|
|
1385
|
+
self.context.text_style = self.state.style
|
|
1386
|
+
self.context.text_fragments.append(text)
|
|
1387
|
+
|
|
1388
|
+
def _flush_text_run(self) -> None:
|
|
1389
|
+
"""把当前 context 的相邻文本片段一次性合并为 typed run。"""
|
|
1390
|
+
if not self.context.text_fragments:
|
|
1391
|
+
return
|
|
1392
|
+
style = self.context.text_style if self.context.text_style is not None else RtfTextStyle()
|
|
1393
|
+
self.context.inlines.append(RtfTextRun(text="".join(self.context.text_fragments), style=style))
|
|
1394
|
+
self.context.text_fragments.clear()
|
|
1395
|
+
self.context.text_style = None
|
|
1396
|
+
|
|
1397
|
+
def _append_inline(self, inline: RtfInline) -> None:
|
|
1398
|
+
"""先冻结累计文本,再追加公式、图片、锚点等结构行内节点。"""
|
|
1399
|
+
self._flush_text_run()
|
|
1400
|
+
self.context.inlines.append(inline)
|
|
1401
|
+
|
|
1402
|
+
def _resolve_list_info(self) -> RtfListInfo | None:
|
|
1403
|
+
"""把 paragraph 的 ls/ilvl 和精确 listtext 收敛为显式列表信息。"""
|
|
1404
|
+
identity = self.state.list_override_id
|
|
1405
|
+
label = self.context.pending_list_label
|
|
1406
|
+
if identity is None and not label:
|
|
1407
|
+
return None
|
|
1408
|
+
level = min(max(self.state.list_level, 0), MAX_RTF_LIST_DEPTH)
|
|
1409
|
+
definition = self.prelude.lists.get(identity or 0)
|
|
1410
|
+
if definition is not None:
|
|
1411
|
+
level_def = definition.levels[min(level, len(definition.levels) - 1)]
|
|
1412
|
+
resolved_identity = definition.identity
|
|
1413
|
+
else:
|
|
1414
|
+
marker = "bullet" if label and any(char in label for char in ("\u2022", "\u00b7", "\uf0b7")) else "decimal"
|
|
1415
|
+
level_def = _ListLevel(marker=marker)
|
|
1416
|
+
resolved_identity = identity or 0
|
|
1417
|
+
counter_key = (resolved_identity, level)
|
|
1418
|
+
current = self._list_counters.get(counter_key, level_def.start - 1) + 1
|
|
1419
|
+
self._list_counters[counter_key] = current
|
|
1420
|
+
ordered = level_def.ordered
|
|
1421
|
+
if label:
|
|
1422
|
+
stripped_label = label.strip()
|
|
1423
|
+
if stripped_label in {"\u2022", "\u00b7", "\uf0b7", "o"}:
|
|
1424
|
+
ordered = False
|
|
1425
|
+
elif ordered:
|
|
1426
|
+
label = f"{_format_marker(level_def.marker, current)}."
|
|
1427
|
+
return RtfListInfo(
|
|
1428
|
+
identity=resolved_identity,
|
|
1429
|
+
level=level,
|
|
1430
|
+
ordered=ordered,
|
|
1431
|
+
marker=level_def.marker,
|
|
1432
|
+
start=level_def.start,
|
|
1433
|
+
label=label,
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
def _end_paragraph(self) -> None:
|
|
1437
|
+
"""把当前行内流冻结为段落,并路由到正文或当前表格单元格。"""
|
|
1438
|
+
self._flush_bytes()
|
|
1439
|
+
if self._pending_high_surrogate is not None:
|
|
1440
|
+
self._append_text("\ufffd")
|
|
1441
|
+
self._pending_high_surrogate = None
|
|
1442
|
+
self._flush_text_run()
|
|
1443
|
+
inlines = self.context.inlines
|
|
1444
|
+
self.context.inlines = []
|
|
1445
|
+
list_info = self._resolve_list_info()
|
|
1446
|
+
self.context.pending_list_label = None
|
|
1447
|
+
has_visible = any(
|
|
1448
|
+
not isinstance(inline, (RtfTextRun, RtfAnchor)) or (isinstance(inline, RtfTextRun) and bool(inline.text.strip()))
|
|
1449
|
+
for inline in inlines
|
|
1450
|
+
)
|
|
1451
|
+
if not has_visible and not any(isinstance(inline, RtfAnchor) for inline in inlines):
|
|
1452
|
+
return
|
|
1453
|
+
style_definition = self.prelude.styles.get(self.state.paragraph_style_id or -1, _StyleDefinition())
|
|
1454
|
+
outline = self.state.outline_level
|
|
1455
|
+
if outline is None:
|
|
1456
|
+
outline = style_definition.outline_level
|
|
1457
|
+
paragraph = RtfParagraph(
|
|
1458
|
+
inlines=inlines,
|
|
1459
|
+
style_name=style_definition.name,
|
|
1460
|
+
outline_level=outline,
|
|
1461
|
+
is_title=style_definition.is_title,
|
|
1462
|
+
block_style=style_definition.block_style,
|
|
1463
|
+
list_info=list_info,
|
|
1464
|
+
)
|
|
1465
|
+
active_depth = max(self.state.table_depth, 1)
|
|
1466
|
+
if self.state.in_table:
|
|
1467
|
+
self._table_builder(active_depth).add_block(paragraph)
|
|
1468
|
+
return
|
|
1469
|
+
self._flush_tables()
|
|
1470
|
+
self.context.blocks.append(paragraph)
|
|
1471
|
+
|
|
1472
|
+
def _table_builder(self, depth: int) -> _TableBuilder:
|
|
1473
|
+
"""返回指定 depth 的 builder,并拒绝超出渲染能力的嵌套。"""
|
|
1474
|
+
if depth < 1 or depth > MAX_RTF_TABLE_DEPTH:
|
|
1475
|
+
raise LegacyOfficeResourceLimitError(f"RTF table nesting exceeds max_table_depth={MAX_RTF_TABLE_DEPTH}")
|
|
1476
|
+
return self.context.tables.setdefault(depth, _TableBuilder())
|
|
1477
|
+
|
|
1478
|
+
def _set_table_depth(self, depth: int) -> None:
|
|
1479
|
+
"""切换 table depth,并把已结束的深层表格挂回父 cell。"""
|
|
1480
|
+
normalized = min(max(depth, 0), MAX_RTF_TABLE_DEPTH)
|
|
1481
|
+
if depth > MAX_RTF_TABLE_DEPTH:
|
|
1482
|
+
raise LegacyOfficeResourceLimitError(f"RTF table nesting exceeds max_table_depth={MAX_RTF_TABLE_DEPTH}")
|
|
1483
|
+
for current_depth in sorted(
|
|
1484
|
+
[value for value in self.context.tables if value > normalized],
|
|
1485
|
+
reverse=True,
|
|
1486
|
+
):
|
|
1487
|
+
table = self.context.tables.pop(current_depth).finish()
|
|
1488
|
+
if table is None:
|
|
1489
|
+
continue
|
|
1490
|
+
if current_depth > 1:
|
|
1491
|
+
self._table_builder(current_depth - 1).add_block(table)
|
|
1492
|
+
else:
|
|
1493
|
+
self.context.blocks.append(table)
|
|
1494
|
+
self.state.table_depth = normalized
|
|
1495
|
+
|
|
1496
|
+
def _flush_tables(self) -> None:
|
|
1497
|
+
"""从深到浅结束当前 context 的全部 table builder。"""
|
|
1498
|
+
for depth in sorted(self.context.tables, reverse=True):
|
|
1499
|
+
table = self.context.tables[depth].finish()
|
|
1500
|
+
if table is None:
|
|
1501
|
+
continue
|
|
1502
|
+
if depth > 1:
|
|
1503
|
+
self._table_builder(depth - 1).add_block(table)
|
|
1504
|
+
else:
|
|
1505
|
+
self.context.blocks.append(table)
|
|
1506
|
+
self.context.tables.clear()
|
|
1507
|
+
|
|
1508
|
+
def _finalize_context(self, context: _OutputContext) -> None:
|
|
1509
|
+
"""结束一个输出 context 的残留段落和表格。"""
|
|
1510
|
+
if context is not self.context:
|
|
1511
|
+
current = self.context
|
|
1512
|
+
self.context = context
|
|
1513
|
+
self._end_paragraph()
|
|
1514
|
+
self._flush_tables()
|
|
1515
|
+
self.context = current
|
|
1516
|
+
return
|
|
1517
|
+
self._end_paragraph()
|
|
1518
|
+
self._flush_tables()
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def read_rtf_bytes(file_binary: BinaryIO) -> bytes:
|
|
1522
|
+
"""从二进制流头部读取有界 RTF 输入,并恢复调用前流位置。"""
|
|
1523
|
+
try:
|
|
1524
|
+
original_position = file_binary.tell()
|
|
1525
|
+
except (AttributeError, OSError):
|
|
1526
|
+
original_position = None
|
|
1527
|
+
if original_position is not None:
|
|
1528
|
+
file_binary.seek(0)
|
|
1529
|
+
data = file_binary.read(MAX_RTF_BYTES + 1)
|
|
1530
|
+
if original_position is not None:
|
|
1531
|
+
file_binary.seek(original_position)
|
|
1532
|
+
if len(data) > MAX_RTF_BYTES:
|
|
1533
|
+
raise LegacyOfficeResourceLimitError(f"RTF input exceeds max_bytes={MAX_RTF_BYTES}")
|
|
1534
|
+
return data
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
def parse_rtf(file_binary: BinaryIO) -> RtfDocument:
|
|
1538
|
+
"""读取一个 RTF 二进制流并返回单逻辑页 typed document。"""
|
|
1539
|
+
data = read_rtf_bytes(file_binary)
|
|
1540
|
+
prelude = parse_rtf_prelude(data)
|
|
1541
|
+
return RtfParser(data, prelude).parse()
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
__all__ = [
|
|
1545
|
+
"MAX_RTF_BYTES",
|
|
1546
|
+
"MAX_RTF_LIST_DEPTH",
|
|
1547
|
+
"MAX_RTF_TABLE_CELLS",
|
|
1548
|
+
"MAX_RTF_TABLE_DEPTH",
|
|
1549
|
+
"RtfParser",
|
|
1550
|
+
"parse_rtf",
|
|
1551
|
+
"parse_rtf_prelude",
|
|
1552
|
+
"read_rtf_bytes",
|
|
1553
|
+
]
|