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,197 @@
|
|
|
1
|
+
"""PDF renderer 的离线图片解析、签名校验与格式准备。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from pathlib import PurePosixPath
|
|
8
|
+
import re
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from PIL import Image, UnidentifiedImageError
|
|
12
|
+
|
|
13
|
+
from ...contracts import AssetResolver
|
|
14
|
+
from ....schema import ImagePayloadBlock
|
|
15
|
+
from ....foundation.image_payload import (
|
|
16
|
+
MAX_IMAGE_PAYLOAD_BYTES,
|
|
17
|
+
extract_generated_svg_fallback,
|
|
18
|
+
parse_image_data_uri_strict,
|
|
19
|
+
validate_decoded_raster_size,
|
|
20
|
+
validate_image_sidecar_path,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_PIL_FORMAT_EXTENSIONS = {
|
|
24
|
+
"BMP": "bmp",
|
|
25
|
+
"GIF": "gif",
|
|
26
|
+
"JPEG": "jpg",
|
|
27
|
+
"PNG": "png",
|
|
28
|
+
"TIFF": "tiff",
|
|
29
|
+
"WEBP": "webp",
|
|
30
|
+
}
|
|
31
|
+
_EXTENSION_ALIASES = {"jpeg": "jpg", "jpe": "jpg", "tif": "tiff"}
|
|
32
|
+
_SVG_START_RE = re.compile(rb"^(?:\xef\xbb\xbf)?\s*(?:<\?xml\b.*?\?>\s*)?<svg\b", re.IGNORECASE | re.DOTALL)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PdfAssetError(ValueError):
|
|
36
|
+
"""表示 PDF renderer 无法离线安全加载或识别图片素材。"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class PreparedImage:
|
|
41
|
+
"""保存可直接交给 ReportLab 的图片字节与像素尺寸。"""
|
|
42
|
+
|
|
43
|
+
data: bytes
|
|
44
|
+
extension: str
|
|
45
|
+
width_px: int
|
|
46
|
+
height_px: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def prepare_block_image(
|
|
50
|
+
block: ImagePayloadBlock,
|
|
51
|
+
asset_resolver: AssetResolver | None = None,
|
|
52
|
+
) -> PreparedImage:
|
|
53
|
+
"""按 sidecar、data URI 的固定优先级加载一个图片载荷 block。"""
|
|
54
|
+
if not isinstance(block, ImagePayloadBlock):
|
|
55
|
+
raise TypeError("block must be an ImagePayloadBlock")
|
|
56
|
+
if block.image_path is not None:
|
|
57
|
+
return _prepare_relative_asset(block.image_path, asset_resolver)
|
|
58
|
+
if block.image_base64 is not None:
|
|
59
|
+
return _prepare_data_uri(block.image_base64)
|
|
60
|
+
if block.image_url is not None:
|
|
61
|
+
raise PdfAssetError(f"Remote image is not downloaded: {block.image_url}")
|
|
62
|
+
raise PdfAssetError("Image block does not contain an image source")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def prepare_html_image(
|
|
66
|
+
source: str,
|
|
67
|
+
asset_resolver: AssetResolver | None = None,
|
|
68
|
+
) -> PreparedImage:
|
|
69
|
+
"""从 HTML img 的 data URI 或安全相对 sidecar 加载图片。"""
|
|
70
|
+
if not isinstance(source, str):
|
|
71
|
+
raise TypeError("HTML image source must be a string")
|
|
72
|
+
normalized = source.strip()
|
|
73
|
+
if not normalized:
|
|
74
|
+
raise PdfAssetError("HTML image source must not be empty")
|
|
75
|
+
if normalized.lower().startswith("data:"):
|
|
76
|
+
if not normalized.lower().startswith("data:image/"):
|
|
77
|
+
raise PdfAssetError("Only image data URIs are supported")
|
|
78
|
+
return _prepare_data_uri(normalized)
|
|
79
|
+
parsed = urlsplit(normalized)
|
|
80
|
+
if parsed.scheme or parsed.netloc:
|
|
81
|
+
raise PdfAssetError(f"Remote HTML image is not downloaded: {normalized}")
|
|
82
|
+
return _prepare_relative_asset(normalized, asset_resolver)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def prepare_image_bytes(data: bytes, *, declared_extension: str | None = None) -> PreparedImage:
|
|
86
|
+
"""严格解码图片字节,并把 WebP 与安全 SVG fallback 转成 PNG。"""
|
|
87
|
+
if not isinstance(data, bytes):
|
|
88
|
+
raise PdfAssetError("Image resolver must return bytes")
|
|
89
|
+
if not data:
|
|
90
|
+
raise PdfAssetError("Image payload must not be empty")
|
|
91
|
+
if len(data) > MAX_IMAGE_PAYLOAD_BYTES:
|
|
92
|
+
raise PdfAssetError("Image payload exceeds its byte limit")
|
|
93
|
+
expected_extension = _normalize_extension(declared_extension)
|
|
94
|
+
if expected_extension == "svg" or _looks_like_svg(data):
|
|
95
|
+
try:
|
|
96
|
+
fallback, logical_width, logical_height = extract_generated_svg_fallback(data)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise PdfAssetError("SVG images require a validated DocVortex PNG fallback") from exc
|
|
99
|
+
prepared = prepare_image_bytes(fallback, declared_extension="png")
|
|
100
|
+
return PreparedImage(
|
|
101
|
+
data=prepared.data,
|
|
102
|
+
extension="png",
|
|
103
|
+
width_px=logical_width,
|
|
104
|
+
height_px=logical_height,
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
with Image.open(BytesIO(data)) as image:
|
|
108
|
+
detected_extension = _PIL_FORMAT_EXTENSIONS.get((image.format or "").upper())
|
|
109
|
+
if detected_extension is None:
|
|
110
|
+
raise PdfAssetError(f"Unsupported image format: {image.format or 'unknown'}")
|
|
111
|
+
width_px, height_px = image.size
|
|
112
|
+
try:
|
|
113
|
+
validate_decoded_raster_size(width_px, height_px)
|
|
114
|
+
except ValueError as exc:
|
|
115
|
+
raise PdfAssetError(str(exc)) from exc
|
|
116
|
+
image.load()
|
|
117
|
+
if expected_extension is not None and expected_extension != detected_extension:
|
|
118
|
+
raise PdfAssetError(
|
|
119
|
+
f"Image bytes do not match declared format: expected {expected_extension}, detected {detected_extension}"
|
|
120
|
+
)
|
|
121
|
+
if detected_extension == "webp":
|
|
122
|
+
return _convert_webp_to_png(image, width_px, height_px)
|
|
123
|
+
except PdfAssetError:
|
|
124
|
+
raise
|
|
125
|
+
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
|
126
|
+
raise PdfAssetError("Invalid or corrupted image payload") from exc
|
|
127
|
+
return PreparedImage(
|
|
128
|
+
data=data,
|
|
129
|
+
extension=detected_extension,
|
|
130
|
+
width_px=width_px,
|
|
131
|
+
height_px=height_px,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _prepare_data_uri(data_uri: str) -> PreparedImage:
|
|
136
|
+
"""严格解析图片 data URI 并校验 MIME、签名与完整载荷。"""
|
|
137
|
+
try:
|
|
138
|
+
data, extension = parse_image_data_uri_strict(data_uri)
|
|
139
|
+
except ValueError as exc:
|
|
140
|
+
raise PdfAssetError(str(exc)) from exc
|
|
141
|
+
return prepare_image_bytes(data, declared_extension=extension)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _prepare_relative_asset(image_path: str, asset_resolver: AssetResolver | None) -> PreparedImage:
|
|
145
|
+
"""校验相对 sidecar 路径,并仅通过显式 resolver 获取字节。"""
|
|
146
|
+
parsed = urlsplit(image_path)
|
|
147
|
+
if parsed.scheme or parsed.netloc:
|
|
148
|
+
raise PdfAssetError(f"Remote or scheme-based image source is not supported: {image_path}")
|
|
149
|
+
try:
|
|
150
|
+
safe_path = validate_image_sidecar_path(image_path)
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
raise PdfAssetError(str(exc)) from exc
|
|
153
|
+
if asset_resolver is None:
|
|
154
|
+
raise PdfAssetError(f"asset_resolver is required for image_path: {safe_path}")
|
|
155
|
+
try:
|
|
156
|
+
data = asset_resolver(safe_path)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
raise PdfAssetError(f"Failed to resolve image asset: {safe_path}") from exc
|
|
159
|
+
extension = PurePosixPath(safe_path).suffix.removeprefix(".") or None
|
|
160
|
+
return prepare_image_bytes(data, declared_extension=extension)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _normalize_extension(extension: str | None) -> str | None:
|
|
164
|
+
"""规范化可识别扩展名,未知扩展不作为签名声明。"""
|
|
165
|
+
if extension is None:
|
|
166
|
+
return None
|
|
167
|
+
normalized = extension.lower().lstrip(".").split("+", 1)[0]
|
|
168
|
+
normalized = _EXTENSION_ALIASES.get(normalized, normalized)
|
|
169
|
+
if normalized == "svg":
|
|
170
|
+
return normalized
|
|
171
|
+
return normalized if normalized in set(_PIL_FORMAT_EXTENSIONS.values()) else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _looks_like_svg(data: bytes) -> bool:
|
|
175
|
+
"""识别带可选 BOM 与 XML 声明的 SVG 字节。"""
|
|
176
|
+
return _SVG_START_RE.match(data[:4096]) is not None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _convert_webp_to_png(image: Image.Image, width_px: int, height_px: int) -> PreparedImage:
|
|
180
|
+
"""在内存中把已解码 WebP 转成 ReportLab 可稳定读取的 PNG。"""
|
|
181
|
+
output = BytesIO()
|
|
182
|
+
image.save(output, format="PNG")
|
|
183
|
+
return PreparedImage(
|
|
184
|
+
data=output.getvalue(),
|
|
185
|
+
extension="png",
|
|
186
|
+
width_px=width_px,
|
|
187
|
+
height_px=height_px,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = [
|
|
192
|
+
"PdfAssetError",
|
|
193
|
+
"PreparedImage",
|
|
194
|
+
"prepare_block_image",
|
|
195
|
+
"prepare_html_image",
|
|
196
|
+
"prepare_image_bytes",
|
|
197
|
+
]
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""ZiaMath LaTeX 到受控 ReportLab 矢量路径的转换。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import re
|
|
7
|
+
from threading import RLock
|
|
8
|
+
from typing import Any
|
|
9
|
+
from xml.etree import ElementTree
|
|
10
|
+
|
|
11
|
+
from fontTools.pens.basePen import BasePen
|
|
12
|
+
from fontTools.svgLib.path import parse_path
|
|
13
|
+
from reportlab.graphics.shapes import Drawing, Group, Path, Rect
|
|
14
|
+
from reportlab.lib.colors import Color, toColor
|
|
15
|
+
from reportlab.platypus import Flowable
|
|
16
|
+
import ziamath
|
|
17
|
+
|
|
18
|
+
MAX_FORMULA_CHARACTERS = 20_000
|
|
19
|
+
MAX_CACHED_FORMULAS = 512
|
|
20
|
+
_MAX_SVG_NODES = 20_000
|
|
21
|
+
_MAX_SVG_PATH_CHARACTERS = 20_000_000
|
|
22
|
+
_SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
|
23
|
+
_ZIAMATH_LOCK = RLock()
|
|
24
|
+
_SVG_PATH_COMMAND_RE = re.compile(r"[A-DF-Za-df-z]")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PdfFormulaError(ValueError):
|
|
28
|
+
"""表示 LaTeX 或 ZiaMath SVG 无法安全转换为 PDF 矢量对象。"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class FormulaVector:
|
|
33
|
+
"""保存一个公式的 ReportLab Drawing 与基线几何。"""
|
|
34
|
+
|
|
35
|
+
drawing: Drawing
|
|
36
|
+
width: float
|
|
37
|
+
height: float
|
|
38
|
+
ascent: float
|
|
39
|
+
descent: float
|
|
40
|
+
|
|
41
|
+
def scaled(self, factor: float) -> FormulaVector:
|
|
42
|
+
"""返回仅调整展示几何、不复制 Drawing 的等比公式对象。"""
|
|
43
|
+
return FormulaVector(
|
|
44
|
+
drawing=self.drawing,
|
|
45
|
+
width=self.width * factor,
|
|
46
|
+
height=self.height * factor,
|
|
47
|
+
ascent=self.ascent * factor,
|
|
48
|
+
descent=self.descent * factor,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class InlineFormulaImage:
|
|
54
|
+
"""作为 ReportLab Paragraph 行内图片占位的矢量公式代理。"""
|
|
55
|
+
|
|
56
|
+
vector: FormulaVector
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FormulaRenderer:
|
|
60
|
+
"""维护单份 PDF 文档内有界且无跨文档状态的公式缓存。"""
|
|
61
|
+
|
|
62
|
+
def __init__(self) -> None:
|
|
63
|
+
"""初始化最多缓存 512 个唯一公式的文档级缓存。"""
|
|
64
|
+
self._cache: dict[tuple[str, bool, float, str], FormulaVector] = {}
|
|
65
|
+
|
|
66
|
+
def render(
|
|
67
|
+
self,
|
|
68
|
+
latex: str,
|
|
69
|
+
*,
|
|
70
|
+
inline: bool,
|
|
71
|
+
font_size: float,
|
|
72
|
+
color: str = "#1f2937",
|
|
73
|
+
) -> FormulaVector:
|
|
74
|
+
"""把一个裸 LaTeX 公式转换为行内或行间矢量对象。"""
|
|
75
|
+
if not isinstance(latex, str) or not latex.strip():
|
|
76
|
+
raise PdfFormulaError("formula must contain non-blank LaTeX")
|
|
77
|
+
if not isinstance(font_size, (int, float)) or isinstance(font_size, bool) or font_size <= 0:
|
|
78
|
+
raise PdfFormulaError("font_size must be a positive number")
|
|
79
|
+
if len(latex) > MAX_FORMULA_CHARACTERS:
|
|
80
|
+
raise PdfFormulaError(f"formula exceeds max_formula_characters={MAX_FORMULA_CHARACTERS}")
|
|
81
|
+
|
|
82
|
+
key = (latex, inline, float(font_size), color)
|
|
83
|
+
if key in self._cache:
|
|
84
|
+
return self._cache[key]
|
|
85
|
+
|
|
86
|
+
vector = _render_ziamath_formula(latex, inline=inline, font_size=float(font_size), color=color)
|
|
87
|
+
if len(self._cache) < MAX_CACHED_FORMULAS:
|
|
88
|
+
self._cache[key] = vector
|
|
89
|
+
return vector
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DisplayFormulaFlowable(Flowable):
|
|
93
|
+
"""在可用行宽内居中绘制公式,并把可选编号贴到右边界。"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, formula: FormulaVector, tag: FormulaVector | None = None) -> None:
|
|
96
|
+
"""保存公式、可选编号以及延迟到 wrap 阶段计算的缩放参数。"""
|
|
97
|
+
super().__init__()
|
|
98
|
+
self.formula = formula
|
|
99
|
+
self.tag = tag
|
|
100
|
+
self._available_width = formula.width
|
|
101
|
+
self._formula_scale = 1.0
|
|
102
|
+
self._tag_scale = 1.0
|
|
103
|
+
self.width = formula.width
|
|
104
|
+
self.height = formula.height
|
|
105
|
+
|
|
106
|
+
def wrap(self, avail_width: float, _avail_height: float) -> tuple[float, float]:
|
|
107
|
+
"""按正文宽度缩放主公式和编号,避免二者重叠或水平裁切。"""
|
|
108
|
+
self._available_width = max(1.0, avail_width)
|
|
109
|
+
tag_width = self.tag.width if self.tag is not None else 0.0
|
|
110
|
+
tag_gap = 10.0 if self.tag is not None else 0.0
|
|
111
|
+
main_limit = self._available_width if self.tag is None else max(1.0, self._available_width - tag_width - tag_gap)
|
|
112
|
+
self._formula_scale = min(1.0, main_limit / max(self.formula.width, 1.0))
|
|
113
|
+
if self.tag is not None and tag_width > self._available_width * 0.25:
|
|
114
|
+
self._tag_scale = min(1.0, self._available_width * 0.25 / max(tag_width, 1.0))
|
|
115
|
+
main_limit = max(1.0, self._available_width - tag_width * self._tag_scale - tag_gap)
|
|
116
|
+
self._formula_scale = min(self._formula_scale, main_limit / max(self.formula.width, 1.0))
|
|
117
|
+
formula_height = self.formula.height * self._formula_scale
|
|
118
|
+
tag_height = self.tag.height * self._tag_scale if self.tag is not None else 0.0
|
|
119
|
+
self.width = self._available_width
|
|
120
|
+
self.height = max(formula_height, tag_height, 1.0)
|
|
121
|
+
return self.width, self.height
|
|
122
|
+
|
|
123
|
+
def draw(self) -> None:
|
|
124
|
+
"""把缩放后的主公式居中,并在同一垂直中心绘制右侧编号。"""
|
|
125
|
+
formula_width = self.formula.width * self._formula_scale
|
|
126
|
+
formula_height = self.formula.height * self._formula_scale
|
|
127
|
+
formula_x = max(0.0, (self._available_width - formula_width) / 2)
|
|
128
|
+
formula_y = max(0.0, (self.height - formula_height) / 2)
|
|
129
|
+
_draw_vector(self.canv, self.formula, formula_x, formula_y, self._formula_scale)
|
|
130
|
+
if self.tag is not None:
|
|
131
|
+
tag_width = self.tag.width * self._tag_scale
|
|
132
|
+
tag_height = self.tag.height * self._tag_scale
|
|
133
|
+
tag_x = max(0.0, self._available_width - tag_width)
|
|
134
|
+
tag_y = max(0.0, (self.height - tag_height) / 2)
|
|
135
|
+
_draw_vector(self.canv, self.tag, tag_x, tag_y, self._tag_scale)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _ReportLabPathPen(BasePen):
|
|
139
|
+
"""把 FontTools SVG path 回调写入 ReportLab Path。"""
|
|
140
|
+
|
|
141
|
+
def __init__(self) -> None:
|
|
142
|
+
"""创建不依赖 glyphSet 的空 ReportLab 路径。"""
|
|
143
|
+
super().__init__(None)
|
|
144
|
+
self.path = Path()
|
|
145
|
+
|
|
146
|
+
def _moveTo(self, point: tuple[float, float]) -> None:
|
|
147
|
+
"""把 SVG move 命令写入目标路径。"""
|
|
148
|
+
self.path.moveTo(*point)
|
|
149
|
+
|
|
150
|
+
def _lineTo(self, point: tuple[float, float]) -> None:
|
|
151
|
+
"""把 SVG line 命令写入目标路径。"""
|
|
152
|
+
self.path.lineTo(*point)
|
|
153
|
+
|
|
154
|
+
def _curveToOne(
|
|
155
|
+
self,
|
|
156
|
+
point1: tuple[float, float],
|
|
157
|
+
point2: tuple[float, float],
|
|
158
|
+
point3: tuple[float, float],
|
|
159
|
+
) -> None:
|
|
160
|
+
"""把三次曲线写入目标路径,二次曲线由 BasePen 自动转换。"""
|
|
161
|
+
self.path.curveTo(*point1, *point2, *point3)
|
|
162
|
+
|
|
163
|
+
def _closePath(self) -> None:
|
|
164
|
+
"""闭合当前 ReportLab 子路径。"""
|
|
165
|
+
self.path.closePath()
|
|
166
|
+
|
|
167
|
+
def _endPath(self) -> None:
|
|
168
|
+
"""结束不闭合的 SVG 子路径。"""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def split_formula_tag(content: str) -> tuple[str, str | None]:
|
|
172
|
+
"""剥离公式末尾括号平衡的 ``\\tag{...}``,并返回正文与编号。"""
|
|
173
|
+
stripped_end = len(content.rstrip())
|
|
174
|
+
if stripped_end == 0 or content[stripped_end - 1] != "}":
|
|
175
|
+
return content, None
|
|
176
|
+
search_end = stripped_end
|
|
177
|
+
while (tag_start := content.rfind(r"\tag", 0, search_end)) >= 0:
|
|
178
|
+
if not _is_escaped_character(content, tag_start):
|
|
179
|
+
opening_brace = _find_tag_opening_brace(content, tag_start, stripped_end)
|
|
180
|
+
if opening_brace is not None:
|
|
181
|
+
closing_brace = _find_balanced_closing_brace(content, opening_brace, stripped_end)
|
|
182
|
+
if closing_brace == stripped_end - 1:
|
|
183
|
+
return content[:tag_start].rstrip(), content[opening_brace + 1 : closing_brace].strip()
|
|
184
|
+
search_end = tag_start
|
|
185
|
+
return content, None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def draw_inline_formula(
|
|
189
|
+
canvas: Any,
|
|
190
|
+
image: InlineFormulaImage,
|
|
191
|
+
x: float,
|
|
192
|
+
y: float,
|
|
193
|
+
width: float,
|
|
194
|
+
height: float,
|
|
195
|
+
) -> tuple[float, float]:
|
|
196
|
+
"""由自定义 Canvas 在 Paragraph 计算的位置绘制一个行内矢量公式。"""
|
|
197
|
+
vector = image.vector
|
|
198
|
+
scale = min(width / max(vector.width, 1.0), height / max(vector.height, 1.0))
|
|
199
|
+
_draw_vector(canvas, vector, x, y, scale)
|
|
200
|
+
return width, height
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _render_ziamath_formula(latex: str, *, inline: bool, font_size: float, color: str) -> FormulaVector:
|
|
204
|
+
"""在全局锁内临时关闭 SVG2 symbols,并转换单个 ZiaMath 结果。"""
|
|
205
|
+
try:
|
|
206
|
+
with _ZIAMATH_LOCK:
|
|
207
|
+
previous_svg2 = ziamath.config.svg2
|
|
208
|
+
ziamath.config.svg2 = False
|
|
209
|
+
try:
|
|
210
|
+
formula = ziamath.Latex(latex, inline=inline, size=font_size, color=color, margin=0)
|
|
211
|
+
root = formula.svgxml()
|
|
212
|
+
finally:
|
|
213
|
+
ziamath.config.svg2 = previous_svg2
|
|
214
|
+
return _svg_root_to_vector(root)
|
|
215
|
+
except PdfFormulaError:
|
|
216
|
+
raise
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
raise PdfFormulaError(f"LaTeX formula cannot be rendered: {_formula_preview(latex)!r}") from exc
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _svg_root_to_vector(root: ElementTree.Element) -> FormulaVector:
|
|
222
|
+
"""把 ZiaMath 的固定 SVG 子集转换为坐标已翻转的 ReportLab Drawing。"""
|
|
223
|
+
namespace = root.get("xmlns") if root.tag == "svg" else root.tag.removeprefix("{").split("}", 1)[0]
|
|
224
|
+
if _local_name(root.tag) != "svg" or namespace != _SVG_NAMESPACE:
|
|
225
|
+
raise PdfFormulaError("ZiaMath output must contain an SVG root")
|
|
226
|
+
view_box = _parse_number_list(root.get("viewBox"), count=4, field="viewBox")
|
|
227
|
+
min_x, min_y, width, height = view_box
|
|
228
|
+
if width <= 0 or height <= 0:
|
|
229
|
+
raise PdfFormulaError("ZiaMath SVG dimensions must be positive")
|
|
230
|
+
drawing = Drawing(width, height)
|
|
231
|
+
root_group = Group()
|
|
232
|
+
root_group.transform = (1, 0, 0, -1, -min_x, min_y + height)
|
|
233
|
+
node_counter = [0]
|
|
234
|
+
path_budget = [0]
|
|
235
|
+
for child in root:
|
|
236
|
+
_append_svg_element(
|
|
237
|
+
root_group,
|
|
238
|
+
child,
|
|
239
|
+
inherited={},
|
|
240
|
+
node_counter=node_counter,
|
|
241
|
+
path_budget=path_budget,
|
|
242
|
+
)
|
|
243
|
+
drawing.add(root_group)
|
|
244
|
+
ascent = max(0.0, -min_y)
|
|
245
|
+
descent = max(0.0, min_y + height)
|
|
246
|
+
return FormulaVector(drawing=drawing, width=width, height=height, ascent=ascent, descent=descent)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _append_svg_element(
|
|
250
|
+
parent: Group,
|
|
251
|
+
element: ElementTree.Element,
|
|
252
|
+
*,
|
|
253
|
+
inherited: dict[str, str],
|
|
254
|
+
node_counter: list[int],
|
|
255
|
+
path_budget: list[int],
|
|
256
|
+
) -> None:
|
|
257
|
+
"""递归转换 ZiaMath 允许的 group、path 与 rect 节点。"""
|
|
258
|
+
node_counter[0] += 1
|
|
259
|
+
if node_counter[0] > _MAX_SVG_NODES:
|
|
260
|
+
raise PdfFormulaError("ZiaMath SVG exceeds its node limit")
|
|
261
|
+
tag = _local_name(element.tag)
|
|
262
|
+
if tag == "g":
|
|
263
|
+
_reject_unknown_attributes(element, {"fill", "stroke", "stroke-width"})
|
|
264
|
+
group_style = {**inherited, **element.attrib}
|
|
265
|
+
group = Group()
|
|
266
|
+
for child in element:
|
|
267
|
+
_append_svg_element(
|
|
268
|
+
group,
|
|
269
|
+
child,
|
|
270
|
+
inherited=group_style,
|
|
271
|
+
node_counter=node_counter,
|
|
272
|
+
path_budget=path_budget,
|
|
273
|
+
)
|
|
274
|
+
parent.add(group)
|
|
275
|
+
return
|
|
276
|
+
if tag == "path":
|
|
277
|
+
_reject_unknown_attributes(element, {"d", "fill", "stroke", "stroke-width"})
|
|
278
|
+
path_data = element.get("d", "")
|
|
279
|
+
path_budget[0] += len(path_data)
|
|
280
|
+
if not path_data or path_budget[0] > _MAX_SVG_PATH_CHARACTERS:
|
|
281
|
+
raise PdfFormulaError("ZiaMath SVG path data is empty or exceeds its limit")
|
|
282
|
+
commands = set(_SVG_PATH_COMMAND_RE.findall(path_data))
|
|
283
|
+
if not commands.issubset({"M", "L", "Q", "Z"}):
|
|
284
|
+
raise PdfFormulaError(f"Unsupported ZiaMath SVG path commands: {', '.join(sorted(commands))}")
|
|
285
|
+
pen = _ReportLabPathPen()
|
|
286
|
+
try:
|
|
287
|
+
parse_path(path_data, pen)
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
raise PdfFormulaError("ZiaMath SVG path data is invalid") from exc
|
|
290
|
+
_apply_paint(pen.path, element.attrib, inherited)
|
|
291
|
+
parent.add(pen.path)
|
|
292
|
+
return
|
|
293
|
+
if tag == "rect":
|
|
294
|
+
_reject_unknown_attributes(element, {"x", "y", "width", "height", "fill", "stroke", "stroke-width"})
|
|
295
|
+
x, y, width, height = (_parse_number(element.get(name, "0"), field=name) for name in ("x", "y", "width", "height"))
|
|
296
|
+
if width < 0 or height < 0:
|
|
297
|
+
raise PdfFormulaError("ZiaMath SVG rect dimensions must not be negative")
|
|
298
|
+
rectangle = Rect(x, y, width, height)
|
|
299
|
+
_apply_paint(rectangle, element.attrib, inherited)
|
|
300
|
+
parent.add(rectangle)
|
|
301
|
+
return
|
|
302
|
+
raise PdfFormulaError(f"Unsupported ZiaMath SVG element: {tag}")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _apply_paint(shape: Any, attributes: dict[str, str], inherited: dict[str, str]) -> None:
|
|
306
|
+
"""把受控 SVG fill、stroke 与 stroke-width 映射到 ReportLab shape。"""
|
|
307
|
+
fill = attributes.get("fill", inherited.get("fill", "black"))
|
|
308
|
+
stroke = attributes.get("stroke", inherited.get("stroke", "none"))
|
|
309
|
+
shape.fillColor = _parse_color(fill)
|
|
310
|
+
shape.strokeColor = _parse_color(stroke)
|
|
311
|
+
stroke_width = attributes.get("stroke-width", inherited.get("stroke-width", "1"))
|
|
312
|
+
shape.strokeWidth = _parse_number(stroke_width, field="stroke-width")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _parse_color(value: str) -> Color | None:
|
|
316
|
+
"""解析 ZiaMath 生成的静态颜色,none 映射为透明。"""
|
|
317
|
+
if value.strip().casefold() == "none":
|
|
318
|
+
return None
|
|
319
|
+
try:
|
|
320
|
+
return toColor(value)
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
raise PdfFormulaError(f"Unsupported ZiaMath SVG color: {value!r}") from exc
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _parse_number(value: str, *, field: str) -> float:
|
|
326
|
+
"""严格读取不含 CSS 单位的有限 SVG 数值。"""
|
|
327
|
+
try:
|
|
328
|
+
number = float(value)
|
|
329
|
+
except (TypeError, ValueError) as exc:
|
|
330
|
+
raise PdfFormulaError(f"Invalid ZiaMath SVG {field}: {value!r}") from exc
|
|
331
|
+
if number != number or number in (float("inf"), float("-inf")):
|
|
332
|
+
raise PdfFormulaError(f"Invalid ZiaMath SVG {field}: {value!r}")
|
|
333
|
+
return number
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _parse_number_list(value: str | None, *, count: int, field: str) -> tuple[float, ...]:
|
|
337
|
+
"""读取固定长度的空白或逗号分隔 SVG 数值列表。"""
|
|
338
|
+
if value is None:
|
|
339
|
+
raise PdfFormulaError(f"ZiaMath SVG is missing {field}")
|
|
340
|
+
values = tuple(_parse_number(item, field=field) for item in value.replace(",", " ").split())
|
|
341
|
+
if len(values) != count:
|
|
342
|
+
raise PdfFormulaError(f"ZiaMath SVG {field} must contain {count} numbers")
|
|
343
|
+
return values
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _reject_unknown_attributes(element: ElementTree.Element, allowed: set[str]) -> None:
|
|
347
|
+
"""拒绝 ZiaMath 固定子集之外的 SVG 属性。"""
|
|
348
|
+
unexpected = set(element.attrib) - allowed
|
|
349
|
+
if unexpected:
|
|
350
|
+
raise PdfFormulaError(f"Unsupported ZiaMath SVG attributes: {', '.join(sorted(unexpected))}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _local_name(tag: str) -> str:
|
|
354
|
+
"""返回可带 XML namespace 的元素本地名称。"""
|
|
355
|
+
return tag.rsplit("}", 1)[-1]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _formula_preview(latex: str) -> str:
|
|
359
|
+
"""为告警生成有界 LaTeX 摘要,避免超长公式污染日志。"""
|
|
360
|
+
return latex if len(latex) <= 200 else f"{latex[:197]}..."
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _draw_vector(canvas: Any, vector: FormulaVector, x: float, y: float, scale: float) -> None:
|
|
364
|
+
"""在 canvas 上按给定位置和比例绘制公式 Drawing。"""
|
|
365
|
+
canvas.saveState()
|
|
366
|
+
try:
|
|
367
|
+
canvas.translate(x, y)
|
|
368
|
+
canvas.scale(scale, scale)
|
|
369
|
+
vector.drawing.drawOn(canvas, 0, 0)
|
|
370
|
+
finally:
|
|
371
|
+
canvas.restoreState()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _find_tag_opening_brace(content: str, tag_start: int, content_end: int) -> int | None:
|
|
375
|
+
"""查找 tag 命令允许空白后的左花括号。"""
|
|
376
|
+
cursor = tag_start + len(r"\tag")
|
|
377
|
+
while cursor < content_end and content[cursor].isspace():
|
|
378
|
+
cursor += 1
|
|
379
|
+
return cursor if cursor < content_end and content[cursor] == "{" else None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _find_balanced_closing_brace(content: str, opening_brace: int, content_end: int) -> int | None:
|
|
383
|
+
"""查找与 tag 左花括号配对的右花括号,并忽略转义花括号。"""
|
|
384
|
+
depth = 0
|
|
385
|
+
for cursor in range(opening_brace, content_end):
|
|
386
|
+
character = content[cursor]
|
|
387
|
+
if character not in "{}" or _is_escaped_character(content, cursor):
|
|
388
|
+
continue
|
|
389
|
+
depth += 1 if character == "{" else -1
|
|
390
|
+
if depth == 0:
|
|
391
|
+
return cursor
|
|
392
|
+
if depth < 0:
|
|
393
|
+
return None
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _is_escaped_character(content: str, position: int) -> bool:
|
|
398
|
+
"""判断指定字符前是否存在奇数个连续反斜杠。"""
|
|
399
|
+
preceding_backslashes = 0
|
|
400
|
+
cursor = position - 1
|
|
401
|
+
while cursor >= 0 and content[cursor] == "\\":
|
|
402
|
+
preceding_backslashes += 1
|
|
403
|
+
cursor -= 1
|
|
404
|
+
return preceding_backslashes % 2 == 1
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
__all__ = [
|
|
408
|
+
"DisplayFormulaFlowable",
|
|
409
|
+
"FormulaRenderer",
|
|
410
|
+
"FormulaVector",
|
|
411
|
+
"InlineFormulaImage",
|
|
412
|
+
"MAX_CACHED_FORMULAS",
|
|
413
|
+
"MAX_FORMULA_CHARACTERS",
|
|
414
|
+
"PdfFormulaError",
|
|
415
|
+
"draw_inline_formula",
|
|
416
|
+
"split_formula_tag",
|
|
417
|
+
]
|