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,708 @@
|
|
|
1
|
+
"""把 typed RTF 语义文档转换为 DocVortex 单逻辑页 raw model-list。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from html import escape
|
|
7
|
+
from typing import Any, BinaryIO, Iterable
|
|
8
|
+
|
|
9
|
+
from .....schema import BlockType
|
|
10
|
+
from .....content.spans import append_equation_span, extend_inline_spans, inline_span_plain_text, strip_span_dicts, text_spans
|
|
11
|
+
from ..image import ensure_bmp_header, is_valid_vector_image_payload, is_vector_image_part, serialize_office_image
|
|
12
|
+
from ..equation.image import OfficeImageEquationDecoder
|
|
13
|
+
from ..rich_text import OfficeRichTextSegment, build_rich_text_from_segments
|
|
14
|
+
from .models import (
|
|
15
|
+
RtfAnchor,
|
|
16
|
+
RtfBlock,
|
|
17
|
+
RtfDisplayEquation,
|
|
18
|
+
RtfDocument,
|
|
19
|
+
RtfImage,
|
|
20
|
+
RtfInline,
|
|
21
|
+
RtfInlineEquation,
|
|
22
|
+
RtfLineBreak,
|
|
23
|
+
RtfNoteReference,
|
|
24
|
+
RtfParagraph,
|
|
25
|
+
RtfTable,
|
|
26
|
+
RtfTableCell,
|
|
27
|
+
RtfTextRun,
|
|
28
|
+
)
|
|
29
|
+
from .parser import MAX_RTF_LIST_DEPTH, parse_rtf, parse_rtf_prelude, read_rtf_bytes
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class _GridOrigin:
|
|
34
|
+
"""保存 HTML table 网格中一个 origin cell 的 span 状态。"""
|
|
35
|
+
|
|
36
|
+
row: int
|
|
37
|
+
col: int
|
|
38
|
+
cell: RtfTableCell
|
|
39
|
+
row_span: int = 1
|
|
40
|
+
col_span: int = 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True)
|
|
44
|
+
class _HtmlListNode:
|
|
45
|
+
"""保存表格单元格内列表段落的临时层级树。"""
|
|
46
|
+
|
|
47
|
+
paragraph: RtfParagraph
|
|
48
|
+
children: list[_HtmlListNode] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _style_names(run: RtfTextRun) -> list[str]:
|
|
52
|
+
"""把 RTF 字符属性映射为现有 Office 富文本样式名。"""
|
|
53
|
+
result: list[str] = []
|
|
54
|
+
if run.style.bold:
|
|
55
|
+
result.append("bold")
|
|
56
|
+
if run.style.italic:
|
|
57
|
+
result.append("italic")
|
|
58
|
+
if run.style.underline:
|
|
59
|
+
result.append("underline")
|
|
60
|
+
if run.style.strike:
|
|
61
|
+
result.append("strikethrough")
|
|
62
|
+
if run.style.superscript:
|
|
63
|
+
result.append("superscript")
|
|
64
|
+
elif run.style.subscript:
|
|
65
|
+
result.append("subscript")
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _paragraph_anchor(paragraph: RtfParagraph) -> str | None:
|
|
70
|
+
"""返回段落内第一个非空 bookmark 名。"""
|
|
71
|
+
for inline in paragraph.inlines:
|
|
72
|
+
if isinstance(inline, RtfAnchor) and inline.name.strip():
|
|
73
|
+
return inline.name.strip()
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _iter_nested_blocks(blocks: Iterable[RtfBlock]) -> Iterable[RtfBlock]:
|
|
78
|
+
"""按深度优先顺序遍历正文及嵌套表格单元格块。"""
|
|
79
|
+
pending = list(reversed(list(blocks)))
|
|
80
|
+
while pending:
|
|
81
|
+
block = pending.pop()
|
|
82
|
+
yield block
|
|
83
|
+
if isinstance(block, RtfTable):
|
|
84
|
+
for row in reversed(block.rows):
|
|
85
|
+
for cell in reversed(row.cells):
|
|
86
|
+
pending.extend(reversed(cell.blocks))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _title_anchors(document: RtfDocument) -> set[str]:
|
|
90
|
+
"""收集 schema 能公开的标题 bookmark,普通段落 bookmark 不生成悬空链接。"""
|
|
91
|
+
result: set[str] = set()
|
|
92
|
+
for block in _iter_nested_blocks(document.blocks):
|
|
93
|
+
if not isinstance(block, RtfParagraph):
|
|
94
|
+
continue
|
|
95
|
+
if not block.is_title and block.outline_level is None:
|
|
96
|
+
continue
|
|
97
|
+
anchor = _paragraph_anchor(block)
|
|
98
|
+
if anchor:
|
|
99
|
+
result.add(anchor)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _note_numbers(document: RtfDocument) -> dict[str, int]:
|
|
104
|
+
"""按第一次引用顺序编号 note,未引用 note 稳定追加在末尾。"""
|
|
105
|
+
numbers: dict[str, int] = {}
|
|
106
|
+
|
|
107
|
+
def visit_blocks(blocks: Iterable[RtfBlock]) -> None:
|
|
108
|
+
"""扫描一组块中的 note reference。"""
|
|
109
|
+
for block in _iter_nested_blocks(blocks):
|
|
110
|
+
if not isinstance(block, RtfParagraph):
|
|
111
|
+
continue
|
|
112
|
+
for inline in block.inlines:
|
|
113
|
+
if isinstance(inline, RtfNoteReference) and inline.note_id not in numbers:
|
|
114
|
+
numbers[inline.note_id] = len(numbers) + 1
|
|
115
|
+
|
|
116
|
+
visit_blocks(document.blocks)
|
|
117
|
+
visit_blocks(document.headers)
|
|
118
|
+
visit_blocks(document.footers)
|
|
119
|
+
for note in document.notes:
|
|
120
|
+
visit_blocks(note.blocks)
|
|
121
|
+
for note in document.notes:
|
|
122
|
+
numbers.setdefault(note.id, len(numbers) + 1)
|
|
123
|
+
return numbers
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _plain_inlines(inlines: Iterable[RtfInline], note_numbers: dict[str, int]) -> str:
|
|
127
|
+
"""提取行内节点可见文本,供代码块、alt 和脚注降级使用。"""
|
|
128
|
+
parts: list[str] = []
|
|
129
|
+
for inline in inlines:
|
|
130
|
+
if isinstance(inline, RtfTextRun):
|
|
131
|
+
parts.append(inline.text)
|
|
132
|
+
elif isinstance(inline, RtfInlineEquation):
|
|
133
|
+
parts.append(inline.latex)
|
|
134
|
+
elif isinstance(inline, RtfImage):
|
|
135
|
+
parts.append(inline.alt)
|
|
136
|
+
elif isinstance(inline, RtfNoteReference):
|
|
137
|
+
number = note_numbers.get(inline.note_id)
|
|
138
|
+
if number is not None:
|
|
139
|
+
parts.append(f"[{number}]")
|
|
140
|
+
elif isinstance(inline, RtfLineBreak):
|
|
141
|
+
parts.append("\n")
|
|
142
|
+
return "".join(parts)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class RtfConverter:
|
|
146
|
+
"""把 RTF typed IR 投影为现有 Office raw-block 协议。"""
|
|
147
|
+
|
|
148
|
+
def __init__(self) -> None:
|
|
149
|
+
"""初始化空输出和每文档图片公式 decoder。"""
|
|
150
|
+
self.pages: list[list[dict[str, Any]]] = []
|
|
151
|
+
self.document: RtfDocument | None = None
|
|
152
|
+
self._note_numbers: dict[str, int] = {}
|
|
153
|
+
self._title_anchors: set[str] = set()
|
|
154
|
+
self._image_equations = OfficeImageEquationDecoder()
|
|
155
|
+
|
|
156
|
+
def convert(self, file_binary: BinaryIO) -> None:
|
|
157
|
+
"""解析 RTF 二进制流并生成固定单逻辑页 model-list。"""
|
|
158
|
+
document = parse_rtf(file_binary)
|
|
159
|
+
self.document = document
|
|
160
|
+
self._note_numbers = _note_numbers(document)
|
|
161
|
+
self._title_anchors = _title_anchors(document)
|
|
162
|
+
page = self._document_blocks(document.blocks)
|
|
163
|
+
page.extend(self._auxiliary_blocks(document.headers, BlockType.HEADER))
|
|
164
|
+
page.extend(self._auxiliary_blocks(document.footers, BlockType.FOOTER))
|
|
165
|
+
page.extend(self._note_blocks(document))
|
|
166
|
+
self.pages = [page]
|
|
167
|
+
|
|
168
|
+
def _resolved_hyperlink(self, target: str | None) -> str | None:
|
|
169
|
+
"""只保留指向可公开标题 bookmark 的内部链接。"""
|
|
170
|
+
if not target:
|
|
171
|
+
return None
|
|
172
|
+
if target.startswith("#") and target[1:] not in self._title_anchors:
|
|
173
|
+
return None
|
|
174
|
+
return target
|
|
175
|
+
|
|
176
|
+
def _rich_text(self, inlines: Iterable[RtfInline]) -> list[dict[str, Any]]:
|
|
177
|
+
"""把非图片行内节点直接转换为结构化 Span。"""
|
|
178
|
+
spans: list[dict[str, Any]] = []
|
|
179
|
+
segments: list[OfficeRichTextSegment] = []
|
|
180
|
+
|
|
181
|
+
def flush() -> None:
|
|
182
|
+
"""在公式边界前输出累计普通富文本。"""
|
|
183
|
+
if not segments:
|
|
184
|
+
return
|
|
185
|
+
extend_inline_spans(spans, build_rich_text_from_segments(segments, trim_plain_edges=not spans))
|
|
186
|
+
segments.clear()
|
|
187
|
+
|
|
188
|
+
for inline in inlines:
|
|
189
|
+
if isinstance(inline, RtfTextRun):
|
|
190
|
+
hyperlink = self._resolved_hyperlink(inline.hyperlink)
|
|
191
|
+
segments.append(
|
|
192
|
+
OfficeRichTextSegment(
|
|
193
|
+
text=inline.text,
|
|
194
|
+
style=_style_names(inline),
|
|
195
|
+
hyperlink=hyperlink,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
elif isinstance(inline, RtfInlineEquation):
|
|
199
|
+
flush()
|
|
200
|
+
append_equation_span(spans, inline.latex)
|
|
201
|
+
elif isinstance(inline, RtfNoteReference):
|
|
202
|
+
number = self._note_numbers.get(inline.note_id)
|
|
203
|
+
if number is not None:
|
|
204
|
+
segments.append(
|
|
205
|
+
OfficeRichTextSegment(
|
|
206
|
+
text=f"[{number}]",
|
|
207
|
+
style="superscript",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
elif isinstance(inline, RtfLineBreak):
|
|
211
|
+
segments.append(OfficeRichTextSegment(text="\n"))
|
|
212
|
+
flush()
|
|
213
|
+
return strip_span_dicts(spans)
|
|
214
|
+
|
|
215
|
+
def _image_payload(self, image: RtfImage) -> tuple[bytes, str, str] | None:
|
|
216
|
+
"""规范 DIB 载荷并返回图片数据、part name 和 content type。"""
|
|
217
|
+
if image.part_name.lower().endswith(".dib"):
|
|
218
|
+
return ensure_bmp_header(image.data), "pict.bmp", "image/bmp"
|
|
219
|
+
if is_vector_image_part(image.part_name, image.content_type) and not is_valid_vector_image_payload(
|
|
220
|
+
image.data,
|
|
221
|
+
part_name=image.part_name,
|
|
222
|
+
content_type=image.content_type,
|
|
223
|
+
):
|
|
224
|
+
return None
|
|
225
|
+
return image.data, image.part_name, image.content_type
|
|
226
|
+
|
|
227
|
+
def _image_block(self, image: RtfImage) -> dict[str, Any] | None:
|
|
228
|
+
"""优先恢复图片 MTEF 公式,否则序列化为安全图片 data URI。"""
|
|
229
|
+
normalized = self._image_payload(image)
|
|
230
|
+
if normalized is None:
|
|
231
|
+
if image.alt.strip():
|
|
232
|
+
return {"type": BlockType.TEXT, "content": text_spans(image.alt.strip())}
|
|
233
|
+
return None
|
|
234
|
+
payload, part_name, content_type = normalized
|
|
235
|
+
latex = self._image_equations.decode(
|
|
236
|
+
payload,
|
|
237
|
+
part_name=part_name,
|
|
238
|
+
content_type=content_type,
|
|
239
|
+
)
|
|
240
|
+
if latex:
|
|
241
|
+
return {"type": BlockType.EQUATION, "content": latex}
|
|
242
|
+
image_base64 = serialize_office_image(
|
|
243
|
+
payload,
|
|
244
|
+
part_name=part_name,
|
|
245
|
+
content_type=content_type,
|
|
246
|
+
)
|
|
247
|
+
if image_base64 is None:
|
|
248
|
+
if image.alt.strip():
|
|
249
|
+
return {"type": BlockType.TEXT, "content": text_spans(image.alt.strip())}
|
|
250
|
+
return None
|
|
251
|
+
block: dict[str, Any] = {
|
|
252
|
+
"type": BlockType.IMAGE,
|
|
253
|
+
"content": "",
|
|
254
|
+
"image_base64": image_base64,
|
|
255
|
+
}
|
|
256
|
+
if image.alt.strip():
|
|
257
|
+
block["sub_type"] = image.alt.strip()
|
|
258
|
+
return block
|
|
259
|
+
|
|
260
|
+
def _paragraph_text_block(
|
|
261
|
+
self,
|
|
262
|
+
paragraph: RtfParagraph,
|
|
263
|
+
inlines: list[RtfInline],
|
|
264
|
+
*,
|
|
265
|
+
allow_title: bool,
|
|
266
|
+
) -> dict[str, Any] | None:
|
|
267
|
+
"""把一个不含图片的段落片段投影为标题、代码或正文 raw block。"""
|
|
268
|
+
if paragraph.block_style == "code":
|
|
269
|
+
content = _plain_inlines(inlines, self._note_numbers).strip("\n")
|
|
270
|
+
return {"type": BlockType.CODE, "content": content} if content else None
|
|
271
|
+
content = self._rich_text(inlines)
|
|
272
|
+
if not content:
|
|
273
|
+
return None
|
|
274
|
+
if allow_title and paragraph.is_title:
|
|
275
|
+
block: dict[str, Any] = {
|
|
276
|
+
"type": BlockType.DOC_TITLE,
|
|
277
|
+
"level": 1,
|
|
278
|
+
"content": content,
|
|
279
|
+
}
|
|
280
|
+
elif allow_title and paragraph.outline_level is not None:
|
|
281
|
+
block = {
|
|
282
|
+
"type": BlockType.PARAGRAPH_TITLE,
|
|
283
|
+
"level": min(max(paragraph.outline_level + 2, 2), 6),
|
|
284
|
+
"is_numbered_style": False,
|
|
285
|
+
"content": content,
|
|
286
|
+
}
|
|
287
|
+
else:
|
|
288
|
+
block = {"type": BlockType.TEXT, "content": content}
|
|
289
|
+
if block["type"] in {BlockType.DOC_TITLE, BlockType.PARAGRAPH_TITLE}:
|
|
290
|
+
anchor = _paragraph_anchor(paragraph)
|
|
291
|
+
if anchor:
|
|
292
|
+
block["anchor"] = anchor
|
|
293
|
+
return block
|
|
294
|
+
|
|
295
|
+
def _paragraph_blocks(self, paragraph: RtfParagraph) -> list[dict[str, Any]]:
|
|
296
|
+
"""按行内图片位置拆分段落,并只让首个文本片段继承标题类型。"""
|
|
297
|
+
if paragraph.list_info is not None and (paragraph.is_title or paragraph.outline_level is not None):
|
|
298
|
+
label = paragraph.list_info.label if paragraph.list_info.ordered else None
|
|
299
|
+
if label:
|
|
300
|
+
paragraph = RtfParagraph(
|
|
301
|
+
inlines=[RtfTextRun(f"{label} "), *paragraph.inlines],
|
|
302
|
+
style_name=paragraph.style_name,
|
|
303
|
+
outline_level=paragraph.outline_level,
|
|
304
|
+
is_title=paragraph.is_title,
|
|
305
|
+
block_style=paragraph.block_style,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
non_image = [inline for inline in paragraph.inlines if not isinstance(inline, (RtfImage, RtfAnchor))]
|
|
309
|
+
equations = [inline for inline in non_image if isinstance(inline, RtfInlineEquation)]
|
|
310
|
+
ordinary = [
|
|
311
|
+
inline
|
|
312
|
+
for inline in non_image
|
|
313
|
+
if not isinstance(inline, (RtfInlineEquation, RtfLineBreak, RtfNoteReference))
|
|
314
|
+
and (not isinstance(inline, RtfTextRun) or bool(inline.text.strip()))
|
|
315
|
+
]
|
|
316
|
+
has_note_reference = any(isinstance(inline, RtfNoteReference) for inline in non_image)
|
|
317
|
+
if equations and not ordinary and not has_note_reference:
|
|
318
|
+
return [{"type": BlockType.EQUATION, "content": equation.latex} for equation in equations]
|
|
319
|
+
|
|
320
|
+
blocks: list[dict[str, Any]] = []
|
|
321
|
+
current: list[RtfInline] = []
|
|
322
|
+
text_emitted = False
|
|
323
|
+
|
|
324
|
+
def flush() -> None:
|
|
325
|
+
"""输出当前图片边界前累计的段落片段。"""
|
|
326
|
+
nonlocal text_emitted
|
|
327
|
+
block = self._paragraph_text_block(
|
|
328
|
+
paragraph,
|
|
329
|
+
current,
|
|
330
|
+
allow_title=not text_emitted,
|
|
331
|
+
)
|
|
332
|
+
current.clear()
|
|
333
|
+
if block is not None:
|
|
334
|
+
blocks.append(block)
|
|
335
|
+
text_emitted = True
|
|
336
|
+
|
|
337
|
+
for inline in paragraph.inlines:
|
|
338
|
+
if isinstance(inline, RtfImage):
|
|
339
|
+
flush()
|
|
340
|
+
image_block = self._image_block(inline)
|
|
341
|
+
if image_block is not None:
|
|
342
|
+
blocks.append(image_block)
|
|
343
|
+
continue
|
|
344
|
+
current.append(inline)
|
|
345
|
+
flush()
|
|
346
|
+
return blocks
|
|
347
|
+
|
|
348
|
+
def _append_list_item(
|
|
349
|
+
self,
|
|
350
|
+
page: list[dict[str, Any]],
|
|
351
|
+
stack: list[dict[str, Any]],
|
|
352
|
+
identity: int | None,
|
|
353
|
+
paragraph: RtfParagraph,
|
|
354
|
+
) -> int:
|
|
355
|
+
"""把一个 RTF 列表段落追加到嵌套 raw list 树。"""
|
|
356
|
+
info = paragraph.list_info
|
|
357
|
+
if info is None:
|
|
358
|
+
return identity or -1
|
|
359
|
+
content = self._rich_text(inline for inline in paragraph.inlines if not isinstance(inline, (RtfImage, RtfAnchor)))
|
|
360
|
+
if identity != info.identity:
|
|
361
|
+
stack.clear()
|
|
362
|
+
identity = info.identity
|
|
363
|
+
level = min(max(info.level, 0), MAX_RTF_LIST_DEPTH)
|
|
364
|
+
while len(stack) > level + 1:
|
|
365
|
+
stack.pop()
|
|
366
|
+
while len(stack) < level + 1:
|
|
367
|
+
list_block: dict[str, Any] = {
|
|
368
|
+
"type": BlockType.LIST,
|
|
369
|
+
"attribute": "ordered" if info.ordered else "unordered",
|
|
370
|
+
"ilevel": len(stack),
|
|
371
|
+
"content": [],
|
|
372
|
+
}
|
|
373
|
+
if info.ordered:
|
|
374
|
+
list_block["start"] = info.start
|
|
375
|
+
if stack:
|
|
376
|
+
stack[-1]["content"].append(list_block)
|
|
377
|
+
else:
|
|
378
|
+
page.append(list_block)
|
|
379
|
+
stack.append(list_block)
|
|
380
|
+
current = stack[level]
|
|
381
|
+
expected = "ordered" if info.ordered else "unordered"
|
|
382
|
+
if current.get("attribute") != expected:
|
|
383
|
+
del stack[level:]
|
|
384
|
+
return self._append_list_item(page, stack, None, paragraph)
|
|
385
|
+
if content:
|
|
386
|
+
leaf: dict[str, Any] = {"type": BlockType.TEXT, "content": content}
|
|
387
|
+
if info.ordered and info.label:
|
|
388
|
+
leaf["list_label"] = info.label
|
|
389
|
+
current["content"].append(leaf)
|
|
390
|
+
return info.identity
|
|
391
|
+
|
|
392
|
+
def _document_blocks(self, blocks: Iterable[RtfBlock]) -> list[dict[str, Any]]:
|
|
393
|
+
"""按源顺序转换正文块,并维护顶层列表连续性。"""
|
|
394
|
+
page: list[dict[str, Any]] = []
|
|
395
|
+
list_stack: list[dict[str, Any]] = []
|
|
396
|
+
list_identity: int | None = None
|
|
397
|
+
source_blocks = list(blocks)
|
|
398
|
+
index = 0
|
|
399
|
+
while index < len(source_blocks):
|
|
400
|
+
block = source_blocks[index]
|
|
401
|
+
if isinstance(block, RtfParagraph) and block.block_style == "code":
|
|
402
|
+
list_stack.clear()
|
|
403
|
+
list_identity = None
|
|
404
|
+
lines: list[str] = []
|
|
405
|
+
images: list[RtfImage] = []
|
|
406
|
+
while index < len(source_blocks):
|
|
407
|
+
candidate = source_blocks[index]
|
|
408
|
+
if not isinstance(candidate, RtfParagraph) or candidate.block_style != "code":
|
|
409
|
+
break
|
|
410
|
+
lines.append(_plain_inlines(candidate.inlines, self._note_numbers).rstrip("\n"))
|
|
411
|
+
images.extend(inline for inline in candidate.inlines if isinstance(inline, RtfImage))
|
|
412
|
+
index += 1
|
|
413
|
+
content = "\n".join(lines).strip("\n")
|
|
414
|
+
if content:
|
|
415
|
+
page.append({"type": BlockType.CODE, "content": content})
|
|
416
|
+
for image in images:
|
|
417
|
+
image_block = self._image_block(image)
|
|
418
|
+
if image_block is not None:
|
|
419
|
+
page.append(image_block)
|
|
420
|
+
continue
|
|
421
|
+
if (
|
|
422
|
+
isinstance(block, RtfParagraph)
|
|
423
|
+
and block.list_info is not None
|
|
424
|
+
and not (block.is_title or block.outline_level is not None)
|
|
425
|
+
):
|
|
426
|
+
list_identity = self._append_list_item(page, list_stack, list_identity, block)
|
|
427
|
+
images = [inline for inline in block.inlines if isinstance(inline, RtfImage)]
|
|
428
|
+
if images:
|
|
429
|
+
list_stack.clear()
|
|
430
|
+
list_identity = None
|
|
431
|
+
for image in images:
|
|
432
|
+
image_block = self._image_block(image)
|
|
433
|
+
if image_block is not None:
|
|
434
|
+
page.append(image_block)
|
|
435
|
+
index += 1
|
|
436
|
+
continue
|
|
437
|
+
list_stack.clear()
|
|
438
|
+
list_identity = None
|
|
439
|
+
if isinstance(block, RtfParagraph):
|
|
440
|
+
page.extend(self._paragraph_blocks(block))
|
|
441
|
+
elif isinstance(block, RtfDisplayEquation):
|
|
442
|
+
if block.latex.strip():
|
|
443
|
+
page.append({"type": BlockType.EQUATION, "content": block.latex.strip()})
|
|
444
|
+
elif isinstance(block, RtfTable):
|
|
445
|
+
page.append({"type": BlockType.TABLE, "content": self._table_html(block)})
|
|
446
|
+
index += 1
|
|
447
|
+
return page
|
|
448
|
+
|
|
449
|
+
def _inline_html(self, inlines: Iterable[RtfInline]) -> str:
|
|
450
|
+
"""把表格单元格行内节点转换为白名单 HTML。"""
|
|
451
|
+
parts: list[str] = []
|
|
452
|
+
for inline in inlines:
|
|
453
|
+
if isinstance(inline, RtfTextRun):
|
|
454
|
+
content = escape(inline.text, quote=False).replace("\n", "<br>")
|
|
455
|
+
if inline.style.code:
|
|
456
|
+
content = f"<code>{content}</code>"
|
|
457
|
+
if inline.style.superscript:
|
|
458
|
+
content = f"<sup>{content}</sup>"
|
|
459
|
+
elif inline.style.subscript:
|
|
460
|
+
content = f"<sub>{content}</sub>"
|
|
461
|
+
if inline.style.underline:
|
|
462
|
+
content = f"<u>{content}</u>"
|
|
463
|
+
if inline.style.bold:
|
|
464
|
+
content = f"<strong>{content}</strong>"
|
|
465
|
+
if inline.style.italic:
|
|
466
|
+
content = f"<em>{content}</em>"
|
|
467
|
+
if inline.style.strike:
|
|
468
|
+
content = f"<s>{content}</s>"
|
|
469
|
+
target = self._resolved_hyperlink(inline.hyperlink)
|
|
470
|
+
if target:
|
|
471
|
+
content = f'<a href="{escape(target, quote=True)}">{content}</a>'
|
|
472
|
+
parts.append(content)
|
|
473
|
+
elif isinstance(inline, RtfInlineEquation):
|
|
474
|
+
parts.append(f"<eq>{escape(inline.latex, quote=False)}</eq>")
|
|
475
|
+
elif isinstance(inline, RtfLineBreak):
|
|
476
|
+
parts.append("<br>")
|
|
477
|
+
elif isinstance(inline, RtfNoteReference):
|
|
478
|
+
number = self._note_numbers.get(inline.note_id)
|
|
479
|
+
if number is not None:
|
|
480
|
+
parts.append(f"<sup>[{number}]</sup>")
|
|
481
|
+
elif isinstance(inline, RtfImage):
|
|
482
|
+
normalized = self._image_payload(inline)
|
|
483
|
+
if normalized is None:
|
|
484
|
+
if inline.alt:
|
|
485
|
+
parts.append(escape(inline.alt, quote=False))
|
|
486
|
+
continue
|
|
487
|
+
payload, part_name, content_type = normalized
|
|
488
|
+
latex = self._image_equations.decode(
|
|
489
|
+
payload,
|
|
490
|
+
part_name=part_name,
|
|
491
|
+
content_type=content_type,
|
|
492
|
+
)
|
|
493
|
+
if latex:
|
|
494
|
+
parts.append(f"<eq>{escape(latex, quote=False)}</eq>")
|
|
495
|
+
continue
|
|
496
|
+
source = serialize_office_image(
|
|
497
|
+
payload,
|
|
498
|
+
part_name=part_name,
|
|
499
|
+
content_type=content_type,
|
|
500
|
+
)
|
|
501
|
+
if source:
|
|
502
|
+
parts.append(f'<img src="{escape(source, quote=True)}" alt="{escape(inline.alt, quote=True)}">')
|
|
503
|
+
elif inline.alt:
|
|
504
|
+
parts.append(escape(inline.alt, quote=False))
|
|
505
|
+
return "".join(parts)
|
|
506
|
+
|
|
507
|
+
def _list_tree(self, paragraphs: list[RtfParagraph]) -> list[_HtmlListNode]:
|
|
508
|
+
"""把连续列表段落构造成单元格 HTML 使用的嵌套树。"""
|
|
509
|
+
roots: list[_HtmlListNode] = []
|
|
510
|
+
stack: list[_HtmlListNode] = []
|
|
511
|
+
for paragraph in paragraphs:
|
|
512
|
+
level = min(paragraph.list_info.level if paragraph.list_info else 0, len(stack))
|
|
513
|
+
while len(stack) > level:
|
|
514
|
+
stack.pop()
|
|
515
|
+
node = _HtmlListNode(paragraph)
|
|
516
|
+
if level > 0 and stack:
|
|
517
|
+
stack[-1].children.append(node)
|
|
518
|
+
else:
|
|
519
|
+
roots.append(node)
|
|
520
|
+
stack.append(node)
|
|
521
|
+
return roots
|
|
522
|
+
|
|
523
|
+
def _list_nodes_html(self, nodes: list[_HtmlListNode]) -> str:
|
|
524
|
+
"""递归序列化一层单元格列表节点。"""
|
|
525
|
+
if not nodes:
|
|
526
|
+
return ""
|
|
527
|
+
info = nodes[0].paragraph.list_info
|
|
528
|
+
ordered = bool(info and info.ordered)
|
|
529
|
+
tag = "ol" if ordered else "ul"
|
|
530
|
+
start = f' start="{max(info.start, 0)}"' if ordered and info else ""
|
|
531
|
+
items: list[str] = []
|
|
532
|
+
for node in nodes:
|
|
533
|
+
content = self._inline_html(node.paragraph.inlines)
|
|
534
|
+
nested = self._list_nodes_html(node.children)
|
|
535
|
+
items.append(f"<li>{content}{nested}</li>")
|
|
536
|
+
return f"<{tag}{start}>{''.join(items)}</{tag}>"
|
|
537
|
+
|
|
538
|
+
def _blocks_html(self, blocks: list[RtfBlock]) -> str:
|
|
539
|
+
"""序列化 table cell 内允许的段落、列表、代码、引用和嵌套表格。"""
|
|
540
|
+
parts: list[str] = []
|
|
541
|
+
index = 0
|
|
542
|
+
while index < len(blocks):
|
|
543
|
+
block = blocks[index]
|
|
544
|
+
if isinstance(block, RtfParagraph) and block.list_info is not None:
|
|
545
|
+
run: list[RtfParagraph] = []
|
|
546
|
+
identity = block.list_info.identity
|
|
547
|
+
while index < len(blocks):
|
|
548
|
+
candidate = blocks[index]
|
|
549
|
+
if not isinstance(candidate, RtfParagraph) or candidate.list_info is None:
|
|
550
|
+
break
|
|
551
|
+
if candidate.list_info.identity != identity:
|
|
552
|
+
break
|
|
553
|
+
run.append(candidate)
|
|
554
|
+
index += 1
|
|
555
|
+
parts.append(self._list_nodes_html(self._list_tree(run)))
|
|
556
|
+
continue
|
|
557
|
+
if isinstance(block, RtfParagraph):
|
|
558
|
+
content = self._inline_html(block.inlines)
|
|
559
|
+
if block.block_style == "code":
|
|
560
|
+
parts.append(f"<pre><code>{escape(_plain_inlines(block.inlines, self._note_numbers))}</code></pre>")
|
|
561
|
+
elif block.block_style == "quote":
|
|
562
|
+
parts.append(f"<blockquote>{content}</blockquote>")
|
|
563
|
+
else:
|
|
564
|
+
parts.append(f"<p>{content}</p>")
|
|
565
|
+
elif isinstance(block, RtfDisplayEquation):
|
|
566
|
+
parts.append(f"<p><eq>{escape(block.latex, quote=False)}</eq></p>")
|
|
567
|
+
elif isinstance(block, RtfTable):
|
|
568
|
+
parts.append(self._table_html(block))
|
|
569
|
+
index += 1
|
|
570
|
+
return "".join(parts)
|
|
571
|
+
|
|
572
|
+
def _table_grid(self, table: RtfTable) -> list[list[_GridOrigin | None]]:
|
|
573
|
+
"""解析横向与纵向 merge continuation,生成 exactly-once origin 网格。"""
|
|
574
|
+
boundaries = sorted(
|
|
575
|
+
{cell.right_boundary for row in table.rows for cell in row.cells if cell.right_boundary is not None}
|
|
576
|
+
)
|
|
577
|
+
width = len(boundaries) or max((len(row.cells) for row in table.rows), default=0)
|
|
578
|
+
boundary_index = {value: index for index, value in enumerate(boundaries)}
|
|
579
|
+
grid: list[list[_GridOrigin | None]] = []
|
|
580
|
+
for row_index, row in enumerate(table.rows):
|
|
581
|
+
slots: list[_GridOrigin | None] = [None] * width
|
|
582
|
+
previous_end = -1
|
|
583
|
+
for fallback_col, cell in enumerate(row.cells):
|
|
584
|
+
right_boundary = cell.right_boundary
|
|
585
|
+
end_col = boundary_index.get(right_boundary, fallback_col) if right_boundary is not None else fallback_col
|
|
586
|
+
start_col = previous_end + 1
|
|
587
|
+
if end_col < start_col:
|
|
588
|
+
end_col = start_col
|
|
589
|
+
end_col = min(end_col, width - 1)
|
|
590
|
+
previous_end = end_col
|
|
591
|
+
above = grid[row_index - 1][start_col] if row_index > 0 and start_col < width else None
|
|
592
|
+
left = slots[start_col - 1] if start_col > 0 else None
|
|
593
|
+
has_content = bool(self._blocks_plain_text(cell.blocks).strip())
|
|
594
|
+
if cell.vertical_merge == "continue" and above is not None and not has_content:
|
|
595
|
+
origin = above
|
|
596
|
+
origin.row_span = max(origin.row_span, row_index - origin.row + 1)
|
|
597
|
+
elif cell.horizontal_merge == "continue" and left is not None and not has_content:
|
|
598
|
+
origin = left
|
|
599
|
+
origin.col_span = max(origin.col_span, end_col - origin.col + 1)
|
|
600
|
+
else:
|
|
601
|
+
origin = _GridOrigin(
|
|
602
|
+
row_index,
|
|
603
|
+
start_col,
|
|
604
|
+
cell,
|
|
605
|
+
col_span=max(end_col - start_col + 1, 1),
|
|
606
|
+
)
|
|
607
|
+
for col_index in range(start_col, end_col + 1):
|
|
608
|
+
slots[col_index] = origin
|
|
609
|
+
for col_index, origin in enumerate(slots):
|
|
610
|
+
if origin is None:
|
|
611
|
+
slots[col_index] = _GridOrigin(row_index, col_index, RtfTableCell())
|
|
612
|
+
grid.append(slots)
|
|
613
|
+
return grid
|
|
614
|
+
|
|
615
|
+
def _table_html(self, table: RtfTable) -> str:
|
|
616
|
+
"""把 RTF 表格输出为带 rowspan/colspan 的安全 HTML。"""
|
|
617
|
+
grid = self._table_grid(table)
|
|
618
|
+
rows: list[str] = []
|
|
619
|
+
for row_index, slots in enumerate(grid):
|
|
620
|
+
tag = "th" if row_index < len(table.rows) and table.rows[row_index].header else "td"
|
|
621
|
+
cells: list[str] = []
|
|
622
|
+
for col_index, origin in enumerate(slots):
|
|
623
|
+
if origin is None or origin.row != row_index or origin.col != col_index:
|
|
624
|
+
continue
|
|
625
|
+
attributes: list[str] = []
|
|
626
|
+
if origin.row_span > 1:
|
|
627
|
+
attributes.append(f'rowspan="{origin.row_span}"')
|
|
628
|
+
if origin.col_span > 1:
|
|
629
|
+
attributes.append(f'colspan="{origin.col_span}"')
|
|
630
|
+
suffix = f" {' '.join(attributes)}" if attributes else ""
|
|
631
|
+
cells.append(f"<{tag}{suffix}>{self._blocks_html(origin.cell.blocks)}</{tag}>")
|
|
632
|
+
rows.append(f"<tr>{''.join(cells)}</tr>")
|
|
633
|
+
return f"<table>{''.join(rows)}</table>"
|
|
634
|
+
|
|
635
|
+
def _auxiliary_blocks(self, blocks: list[RtfBlock], block_type: BlockType) -> list[dict[str, Any]]:
|
|
636
|
+
"""把页眉页脚段落去重后投影为页面辅助块。"""
|
|
637
|
+
result: list[dict[str, Any]] = []
|
|
638
|
+
seen: set[str] = set()
|
|
639
|
+
for block in blocks:
|
|
640
|
+
if isinstance(block, RtfParagraph):
|
|
641
|
+
content = self._rich_text(block.inlines)
|
|
642
|
+
elif isinstance(block, RtfDisplayEquation):
|
|
643
|
+
content = []
|
|
644
|
+
append_equation_span(content, block.latex)
|
|
645
|
+
elif isinstance(block, RtfTable):
|
|
646
|
+
content = text_spans(self._table_plain_text(block))
|
|
647
|
+
else:
|
|
648
|
+
continue
|
|
649
|
+
visible = inline_span_plain_text(content)
|
|
650
|
+
if content and not visible.isdigit() and visible not in seen:
|
|
651
|
+
seen.add(visible)
|
|
652
|
+
result.append({"type": block_type, "content": content})
|
|
653
|
+
return result
|
|
654
|
+
|
|
655
|
+
def _table_plain_text(self, table: RtfTable) -> str:
|
|
656
|
+
"""把表格可见文本压平,供注释和辅助块无损降级。"""
|
|
657
|
+
rows: list[str] = []
|
|
658
|
+
for row in table.rows:
|
|
659
|
+
cells = [self._blocks_plain_text(cell.blocks) for cell in row.cells]
|
|
660
|
+
rows.append(" | ".join(cells))
|
|
661
|
+
return "\n".join(rows)
|
|
662
|
+
|
|
663
|
+
def _blocks_plain_text(self, blocks: Iterable[RtfBlock]) -> str:
|
|
664
|
+
"""提取块列表可见文本,保持段落和表格行边界。"""
|
|
665
|
+
parts: list[str] = []
|
|
666
|
+
for block in blocks:
|
|
667
|
+
if isinstance(block, RtfParagraph):
|
|
668
|
+
parts.append(_plain_inlines(block.inlines, self._note_numbers))
|
|
669
|
+
elif isinstance(block, RtfDisplayEquation):
|
|
670
|
+
parts.append(block.latex)
|
|
671
|
+
elif isinstance(block, RtfTable):
|
|
672
|
+
parts.append(self._table_plain_text(block))
|
|
673
|
+
return "\n".join(part for part in parts if part.strip())
|
|
674
|
+
|
|
675
|
+
def _note_blocks(self, document: RtfDocument) -> list[dict[str, Any]]:
|
|
676
|
+
"""按公开编号输出脚注与尾注正文。"""
|
|
677
|
+
result: list[dict[str, Any]] = []
|
|
678
|
+
ordered = sorted(
|
|
679
|
+
document.notes,
|
|
680
|
+
key=lambda note: self._note_numbers[note.id] if note.id in self._note_numbers else 2**31 - 1,
|
|
681
|
+
)
|
|
682
|
+
for note in ordered:
|
|
683
|
+
number = self._note_numbers.get(note.id)
|
|
684
|
+
content = self._blocks_plain_text(note.blocks).strip()
|
|
685
|
+
if number is None or not content:
|
|
686
|
+
continue
|
|
687
|
+
result.append(
|
|
688
|
+
{
|
|
689
|
+
"type": BlockType.PAGE_FOOTNOTE,
|
|
690
|
+
"content": text_spans(f"[{number}] {content}"),
|
|
691
|
+
}
|
|
692
|
+
)
|
|
693
|
+
return result
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def extract_rtf_metadata(file_binary: BinaryIO) -> dict[str, str | None]:
|
|
697
|
+
"""有界读取 RTF,仅解析 info destination 并返回 doclib 字段。"""
|
|
698
|
+
data = read_rtf_bytes(file_binary)
|
|
699
|
+
metadata = parse_rtf_prelude(data).metadata
|
|
700
|
+
return {
|
|
701
|
+
"title": metadata.title,
|
|
702
|
+
"author": metadata.author,
|
|
703
|
+
"subject": metadata.subject,
|
|
704
|
+
"keywords": metadata.keywords,
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
__all__ = ["RtfConverter", "extract_rtf_metadata"]
|