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,478 @@
|
|
|
1
|
+
"""HTML renderer 使用的嵌入片段与 URL 安全处理。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
8
|
+
|
|
9
|
+
import nh3
|
|
10
|
+
from bs4 import BeautifulSoup, NavigableString, Tag
|
|
11
|
+
|
|
12
|
+
from ....foundation.image_payload import extract_generated_svg_fallback, parse_image_data_uri_strict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_ALLOWED_TAGS = {
|
|
16
|
+
"a",
|
|
17
|
+
"b",
|
|
18
|
+
"blockquote",
|
|
19
|
+
"br",
|
|
20
|
+
"caption",
|
|
21
|
+
"code",
|
|
22
|
+
"col",
|
|
23
|
+
"colgroup",
|
|
24
|
+
"div",
|
|
25
|
+
"em",
|
|
26
|
+
"eq",
|
|
27
|
+
"i",
|
|
28
|
+
"img",
|
|
29
|
+
"li",
|
|
30
|
+
"ol",
|
|
31
|
+
"p",
|
|
32
|
+
"pre",
|
|
33
|
+
"s",
|
|
34
|
+
"span",
|
|
35
|
+
"strong",
|
|
36
|
+
"sub",
|
|
37
|
+
"sup",
|
|
38
|
+
"table",
|
|
39
|
+
"tbody",
|
|
40
|
+
"td",
|
|
41
|
+
"tfoot",
|
|
42
|
+
"th",
|
|
43
|
+
"thead",
|
|
44
|
+
"tr",
|
|
45
|
+
"u",
|
|
46
|
+
"ul",
|
|
47
|
+
}
|
|
48
|
+
_ACTIVE_CONTENT_TAGS = {
|
|
49
|
+
"audio",
|
|
50
|
+
"button",
|
|
51
|
+
"canvas",
|
|
52
|
+
"embed",
|
|
53
|
+
"form",
|
|
54
|
+
"iframe",
|
|
55
|
+
"input",
|
|
56
|
+
"math",
|
|
57
|
+
"noscript",
|
|
58
|
+
"object",
|
|
59
|
+
"script",
|
|
60
|
+
"select",
|
|
61
|
+
"style",
|
|
62
|
+
"svg",
|
|
63
|
+
"template",
|
|
64
|
+
"textarea",
|
|
65
|
+
"video",
|
|
66
|
+
}
|
|
67
|
+
_ALLOWED_ATTRIBUTES = {
|
|
68
|
+
"a": {"href", "title"},
|
|
69
|
+
"col": {"span"},
|
|
70
|
+
"colgroup": {"span"},
|
|
71
|
+
"img": {"alt", "src", "title"},
|
|
72
|
+
"li": {"value"},
|
|
73
|
+
"ol": {"start"},
|
|
74
|
+
"td": {"colspan", "rowspan"},
|
|
75
|
+
"th": {"colspan", "rowspan", "scope"},
|
|
76
|
+
}
|
|
77
|
+
_ALLOWED_URL_SCHEMES = {"data", "http", "https", "mailto", "tel"}
|
|
78
|
+
_LINK_SCHEMES = {"http", "https", "mailto", "tel"}
|
|
79
|
+
_EQ_PLACEHOLDER_TAG = "docvortex-eq-placeholder"
|
|
80
|
+
_DATA_IMAGE_RE = re.compile(
|
|
81
|
+
r"\Adata:(?P<mime>image/[a-z0-9.+-]+);base64,(?P<payload>[a-z0-9+/]*={0,2})\Z",
|
|
82
|
+
re.IGNORECASE,
|
|
83
|
+
)
|
|
84
|
+
_SCHEME_RE = re.compile(r"\A[a-z][a-z0-9+.-]*:", re.IGNORECASE)
|
|
85
|
+
_INTEGER_RE = re.compile(r"\A[+-]?\d+\Z")
|
|
86
|
+
_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f\ud800-\udfff]")
|
|
87
|
+
_INVALID_HTML_TEXT_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\ud800-\udfff]")
|
|
88
|
+
_HTML_TAG_TOKEN_RE = re.compile(
|
|
89
|
+
r"<\s*(?P<closing>/)?\s*(?P<name>[A-Za-z][A-Za-z0-9:-]*)\b(?P<attrs>[^>]*)>",
|
|
90
|
+
re.DOTALL,
|
|
91
|
+
)
|
|
92
|
+
_VOID_MARKUP_TAGS = {"br"}
|
|
93
|
+
_SOURCE_MARKUP_TAGS = _ALLOWED_TAGS | _ACTIVE_CONTENT_TAGS
|
|
94
|
+
_PHRASING_CONTAINER_TAGS = {"a", "b", "code", "em", "i", "p", "s", "span", "strong", "sub", "sup", "u"}
|
|
95
|
+
_TABLE_STRUCTURE_TAGS = {"colgroup", "table", "tbody", "tfoot", "thead", "tr"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_supported_html_markup(content: str) -> bool:
|
|
99
|
+
"""判断内容是否包含需要交给安全层处理的 HTML 标签。
|
|
100
|
+
|
|
101
|
+
只有白名单标签或必须整体删除的活动标签才算 HTML,避免把
|
|
102
|
+
``<local_dir>`` 和数学不等式这类普通文本误判为标记。
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(content, str):
|
|
105
|
+
raise TypeError("content must be a string")
|
|
106
|
+
if "<" not in content or ">" not in content:
|
|
107
|
+
return False
|
|
108
|
+
tokens = list(_HTML_TAG_TOKEN_RE.finditer(content))
|
|
109
|
+
closing_names = {
|
|
110
|
+
match.group("name").lower()
|
|
111
|
+
for match in tokens
|
|
112
|
+
if match.group("closing") and match.group("name").lower() in _SOURCE_MARKUP_TAGS
|
|
113
|
+
}
|
|
114
|
+
for match in tokens:
|
|
115
|
+
if match.group("closing"):
|
|
116
|
+
continue
|
|
117
|
+
name = match.group("name").lower()
|
|
118
|
+
if name not in _SOURCE_MARKUP_TAGS:
|
|
119
|
+
continue
|
|
120
|
+
if name in _VOID_MARKUP_TAGS or name in closing_names:
|
|
121
|
+
return True
|
|
122
|
+
if name in {"img", "embed"} and re.search(r"\bsrc\s*=", match.group("attrs"), re.IGNORECASE):
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def sanitize_link_url(url: str) -> str | None:
|
|
128
|
+
"""校验链接地址,仅保留安全的相对地址和显式允许的协议。"""
|
|
129
|
+
normalized = _normalize_url_text(url, parameter_name="url")
|
|
130
|
+
if normalized is None:
|
|
131
|
+
return None
|
|
132
|
+
if normalized.startswith(("//", "\\")):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
scheme_match = _SCHEME_RE.match(normalized)
|
|
136
|
+
if scheme_match is None:
|
|
137
|
+
return _quote_document_url(normalized)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
parsed = urlsplit(normalized)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return None
|
|
143
|
+
scheme = parsed.scheme.lower()
|
|
144
|
+
if scheme not in _LINK_SCHEMES:
|
|
145
|
+
return None
|
|
146
|
+
if scheme in {"http", "https"}:
|
|
147
|
+
try:
|
|
148
|
+
if not parsed.netloc or parsed.hostname is None:
|
|
149
|
+
return None
|
|
150
|
+
except ValueError:
|
|
151
|
+
return None
|
|
152
|
+
elif not parsed.path:
|
|
153
|
+
return None
|
|
154
|
+
return _quote_document_url(normalized)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def sanitize_image_source(source: str, *, asset_base_url: str = "") -> str | None:
|
|
158
|
+
"""校验图片来源,并为安全的相对 sidecar 路径添加资源根地址。"""
|
|
159
|
+
normalized = _normalize_url_text(source, parameter_name="source")
|
|
160
|
+
if normalized is None:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
data_source = _sanitize_raster_data_uri(normalized)
|
|
164
|
+
if data_source is not None:
|
|
165
|
+
return data_source
|
|
166
|
+
if normalized.lower().startswith("data:"):
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
absolute_source = _sanitize_absolute_image_url(normalized)
|
|
170
|
+
if absolute_source is not None:
|
|
171
|
+
return _quote_image_url(absolute_source)
|
|
172
|
+
if _SCHEME_RE.match(normalized) is not None or normalized.startswith(("//", "\\", "#", "?")):
|
|
173
|
+
return None
|
|
174
|
+
if not _is_safe_image_path(normalized):
|
|
175
|
+
return None
|
|
176
|
+
if normalized.startswith("/") or not asset_base_url:
|
|
177
|
+
return _quote_image_url(normalized)
|
|
178
|
+
|
|
179
|
+
safe_base = _sanitize_asset_base_url(asset_base_url)
|
|
180
|
+
if safe_base is None:
|
|
181
|
+
return None
|
|
182
|
+
return _quote_image_url(f"{safe_base.rstrip('/')}/{normalized.lstrip('/')}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def sanitize_html_fragment(markup: str, *, asset_base_url: str = "") -> str:
|
|
186
|
+
"""清洗嵌入 HTML,保留表格语义、安全媒体与待渲染的 ``eq`` 标签。"""
|
|
187
|
+
if not isinstance(markup, str):
|
|
188
|
+
raise TypeError("markup must be a string")
|
|
189
|
+
if not isinstance(asset_base_url, str):
|
|
190
|
+
raise TypeError("asset_base_url must be a string")
|
|
191
|
+
|
|
192
|
+
soup = BeautifulSoup(_INVALID_HTML_TEXT_RE.sub("\ufffd", markup), "html.parser")
|
|
193
|
+
_remove_active_content(soup)
|
|
194
|
+
_normalize_fragment_elements(soup, asset_base_url=asset_base_url)
|
|
195
|
+
_protect_equation_elements(soup)
|
|
196
|
+
prepared = str(soup)
|
|
197
|
+
cleaned = nh3.clean(
|
|
198
|
+
prepared,
|
|
199
|
+
tags=_ALLOWED_TAGS | {_EQ_PLACEHOLDER_TAG},
|
|
200
|
+
clean_content_tags=_ACTIVE_CONTENT_TAGS,
|
|
201
|
+
attributes=_ALLOWED_ATTRIBUTES,
|
|
202
|
+
attribute_filter=_filter_sanitized_attribute,
|
|
203
|
+
link_rel=None,
|
|
204
|
+
url_schemes=_ALLOWED_URL_SCHEMES,
|
|
205
|
+
)
|
|
206
|
+
restored = cleaned.replace(f"<{_EQ_PLACEHOLDER_TAG}>", "<eq>").replace(f"</{_EQ_PLACEHOLDER_TAG}>", "</eq>")
|
|
207
|
+
cleaned_soup = BeautifulSoup(restored, "html.parser")
|
|
208
|
+
_normalize_cleaned_content_models(cleaned_soup)
|
|
209
|
+
return str(cleaned_soup)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _normalize_url_text(value: str, *, parameter_name: str) -> str | None:
|
|
213
|
+
"""解码 HTML 实体并拒绝可用于混淆协议的控制字符。"""
|
|
214
|
+
if not isinstance(value, str):
|
|
215
|
+
raise TypeError(f"{parameter_name} must be a string")
|
|
216
|
+
normalized = value.strip()
|
|
217
|
+
for _ in range(8):
|
|
218
|
+
decoded = html.unescape(normalized)
|
|
219
|
+
if decoded == normalized:
|
|
220
|
+
break
|
|
221
|
+
normalized = decoded
|
|
222
|
+
normalized = normalized.strip()
|
|
223
|
+
if not normalized or _CONTROL_RE.search(normalized) is not None:
|
|
224
|
+
return None
|
|
225
|
+
return normalized
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _sanitize_absolute_image_url(source: str) -> str | None:
|
|
229
|
+
"""只允许带有有效主机名的 HTTP(S) 图片地址。"""
|
|
230
|
+
if source.startswith(("//", "\\")):
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
parsed = urlsplit(source)
|
|
234
|
+
except ValueError:
|
|
235
|
+
return None
|
|
236
|
+
if parsed.scheme.lower() not in {"http", "https"}:
|
|
237
|
+
return None
|
|
238
|
+
try:
|
|
239
|
+
if not parsed.netloc or parsed.hostname is None:
|
|
240
|
+
return None
|
|
241
|
+
except ValueError:
|
|
242
|
+
return None
|
|
243
|
+
return source
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _sanitize_raster_data_uri(source: str) -> str | None:
|
|
247
|
+
"""校验栅格图或 renderer 生成的安全 SVG data URI。"""
|
|
248
|
+
match = _DATA_IMAGE_RE.fullmatch(source)
|
|
249
|
+
if match is None:
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
payload, extension = parse_image_data_uri_strict(source)
|
|
253
|
+
except ValueError:
|
|
254
|
+
return None
|
|
255
|
+
if extension == "svg":
|
|
256
|
+
try:
|
|
257
|
+
extract_generated_svg_fallback(payload)
|
|
258
|
+
except ValueError:
|
|
259
|
+
return None
|
|
260
|
+
return source
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _quote_image_url(source: str) -> str:
|
|
264
|
+
"""按现有 render 资源规则编码空格与括号,同时保留 URL 结构字符。"""
|
|
265
|
+
return quote(source, safe="/:#?&=%@+~,;!$'*-._")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _quote_document_url(source: str) -> str:
|
|
269
|
+
"""编码链接中的空格、反斜杠与括号,同时保留 URL 结构字符。"""
|
|
270
|
+
return quote(source, safe="/:#?&=%@+~,;!$'*-._")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _is_safe_image_path(source: str) -> bool:
|
|
274
|
+
"""判断相对或根相对图片路径是否不会逃逸 sidecar 根目录。"""
|
|
275
|
+
if "\\" in source:
|
|
276
|
+
return False
|
|
277
|
+
try:
|
|
278
|
+
parsed = urlsplit(source)
|
|
279
|
+
except ValueError:
|
|
280
|
+
return False
|
|
281
|
+
if parsed.scheme or parsed.netloc or not parsed.path:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
return not _has_unsafe_path_segment(parsed.path)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _sanitize_asset_base_url(asset_base_url: str) -> str | None:
|
|
288
|
+
"""校验用于拼接 sidecar 的资源根地址。"""
|
|
289
|
+
normalized = _normalize_url_text(asset_base_url, parameter_name="asset_base_url")
|
|
290
|
+
if normalized is None or normalized.startswith(("//", "\\", "#", "?")):
|
|
291
|
+
return None
|
|
292
|
+
absolute_url = _sanitize_absolute_image_url(normalized)
|
|
293
|
+
if absolute_url is not None:
|
|
294
|
+
parsed = urlsplit(absolute_url)
|
|
295
|
+
if parsed.query or parsed.fragment or _has_unsafe_path_segment(parsed.path):
|
|
296
|
+
return None
|
|
297
|
+
return absolute_url
|
|
298
|
+
if _SCHEME_RE.match(normalized) is not None:
|
|
299
|
+
return None
|
|
300
|
+
if not _is_safe_image_path(normalized):
|
|
301
|
+
return None
|
|
302
|
+
parsed = urlsplit(normalized)
|
|
303
|
+
return None if parsed.query or parsed.fragment else normalized
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _has_unsafe_path_segment(path: str) -> bool:
|
|
307
|
+
"""递归解码 URL 路径,识别控制字符、反斜杠和父目录逃逸。"""
|
|
308
|
+
decoded_path = path
|
|
309
|
+
for _ in range(8):
|
|
310
|
+
next_path = unquote(decoded_path)
|
|
311
|
+
if next_path == decoded_path:
|
|
312
|
+
break
|
|
313
|
+
decoded_path = next_path
|
|
314
|
+
if "\\" in decoded_path or _CONTROL_RE.search(decoded_path) is not None:
|
|
315
|
+
return True
|
|
316
|
+
return ".." in decoded_path.split("/")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _remove_active_content(soup: BeautifulSoup) -> None:
|
|
320
|
+
"""在通用白名单清洗前,删除活动标签及其全部内容。"""
|
|
321
|
+
for tag in list(soup.find_all(_ACTIVE_CONTENT_TAGS)):
|
|
322
|
+
if tag.parent is not None:
|
|
323
|
+
tag.decompose()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _normalize_fragment_elements(soup: BeautifulSoup, *, asset_base_url: str) -> None:
|
|
327
|
+
"""在 nh3 前重写图片、链接和有边界的数值属性。"""
|
|
328
|
+
for tag in list(soup.find_all(True)):
|
|
329
|
+
if tag.parent is None:
|
|
330
|
+
continue
|
|
331
|
+
if tag.name == "a":
|
|
332
|
+
if not _normalize_link_element(tag):
|
|
333
|
+
tag.unwrap()
|
|
334
|
+
continue
|
|
335
|
+
if tag.name == "img":
|
|
336
|
+
_normalize_image_element(tag, asset_base_url=asset_base_url)
|
|
337
|
+
continue
|
|
338
|
+
tag.attrs = _normalized_non_url_attributes(tag)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _protect_equation_elements(soup: BeautifulSoup) -> None:
|
|
342
|
+
"""使用临时自定义标签保护 ``eq``,规避 nh3 对该历史标签的特殊解析。"""
|
|
343
|
+
for forged_placeholder in list(soup.find_all(_EQ_PLACEHOLDER_TAG)):
|
|
344
|
+
forged_placeholder.unwrap()
|
|
345
|
+
for equation in soup.find_all("eq"):
|
|
346
|
+
latex = equation.get_text()
|
|
347
|
+
equation.clear()
|
|
348
|
+
equation.append(NavigableString(latex))
|
|
349
|
+
equation.name = _EQ_PLACEHOLDER_TAG
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _normalize_cleaned_content_models(soup: BeautifulSoup) -> None:
|
|
353
|
+
"""修复 allowlist 清洗后仍可能违反 HTML content model 的列表、colgroup 与图片。"""
|
|
354
|
+
for image in soup.find_all("img"):
|
|
355
|
+
if not image.has_attr("alt"):
|
|
356
|
+
image["alt"] = ""
|
|
357
|
+
|
|
358
|
+
for colgroup in soup.find_all("colgroup"):
|
|
359
|
+
if colgroup.find("col", recursive=False) is not None:
|
|
360
|
+
colgroup.attrs.pop("span", None)
|
|
361
|
+
|
|
362
|
+
for list_tag in soup.find_all(("ul", "ol")):
|
|
363
|
+
for child in list(list_tag.children):
|
|
364
|
+
if isinstance(child, NavigableString):
|
|
365
|
+
if not str(child).strip():
|
|
366
|
+
continue
|
|
367
|
+
item = soup.new_tag("li")
|
|
368
|
+
child.replace_with(item)
|
|
369
|
+
item.append(child)
|
|
370
|
+
elif isinstance(child, Tag) and child.name != "li":
|
|
371
|
+
item = soup.new_tag("li")
|
|
372
|
+
child.replace_with(item)
|
|
373
|
+
item.append(child)
|
|
374
|
+
|
|
375
|
+
for item in list(soup.find_all("li")):
|
|
376
|
+
parent = item.parent
|
|
377
|
+
if isinstance(parent, Tag) and parent.name in {"ul", "ol"}:
|
|
378
|
+
continue
|
|
379
|
+
while isinstance(parent, Tag) and parent.name in _PHRASING_CONTAINER_TAGS:
|
|
380
|
+
parent.unwrap()
|
|
381
|
+
parent = item.parent
|
|
382
|
+
wrapper = soup.new_tag("ul")
|
|
383
|
+
if isinstance(parent, Tag) and parent.name in _TABLE_STRUCTURE_TAGS:
|
|
384
|
+
boundary = parent
|
|
385
|
+
while isinstance(boundary.parent, Tag) and boundary.parent.name in _TABLE_STRUCTURE_TAGS:
|
|
386
|
+
boundary = boundary.parent
|
|
387
|
+
boundary.insert_before(wrapper)
|
|
388
|
+
item.extract()
|
|
389
|
+
wrapper.append(item)
|
|
390
|
+
else:
|
|
391
|
+
item.replace_with(wrapper)
|
|
392
|
+
wrapper.append(item)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _normalize_link_element(tag: Tag) -> bool:
|
|
396
|
+
"""清理链接属性,危险或缺失的 href 由调用方展开为普通文本。"""
|
|
397
|
+
href = sanitize_link_url(_attribute_text(tag.get("href")))
|
|
398
|
+
if href is None:
|
|
399
|
+
return False
|
|
400
|
+
attributes: dict[str, str] = {"href": href}
|
|
401
|
+
title = _attribute_text(tag.get("title"))
|
|
402
|
+
if title:
|
|
403
|
+
attributes["title"] = title
|
|
404
|
+
tag.attrs = attributes
|
|
405
|
+
return True
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _normalize_image_element(tag: Tag, *, asset_base_url: str) -> None:
|
|
409
|
+
"""清理图片属性,危险图片替换为可见且会被转义的 alt 文本。"""
|
|
410
|
+
alt = _attribute_text(tag.get("alt"))
|
|
411
|
+
source = sanitize_image_source(_attribute_text(tag.get("src")), asset_base_url=asset_base_url)
|
|
412
|
+
if source is None:
|
|
413
|
+
tag.replace_with(NavigableString(alt))
|
|
414
|
+
return
|
|
415
|
+
attributes: dict[str, str] = {"alt": alt, "src": source}
|
|
416
|
+
title = _attribute_text(tag.get("title"))
|
|
417
|
+
if title:
|
|
418
|
+
attributes["title"] = title
|
|
419
|
+
tag.attrs = attributes
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _normalized_non_url_attributes(tag: Tag) -> dict[str, str]:
|
|
423
|
+
"""仅保留表格与列表语义需要的有界整数属性。"""
|
|
424
|
+
attributes: dict[str, str] = {}
|
|
425
|
+
if tag.name in {"td", "th"}:
|
|
426
|
+
for name in ("colspan", "rowspan"):
|
|
427
|
+
value = _bounded_integer(_attribute_text(tag.get(name)), minimum=1, maximum=1000)
|
|
428
|
+
if value is not None:
|
|
429
|
+
attributes[name] = value
|
|
430
|
+
if tag.name == "th" and _attribute_text(tag.get("scope")) in {"col", "colgroup", "row", "rowgroup"}:
|
|
431
|
+
attributes["scope"] = _attribute_text(tag.get("scope"))
|
|
432
|
+
elif tag.name in {"col", "colgroup"}:
|
|
433
|
+
value = _bounded_integer(_attribute_text(tag.get("span")), minimum=1, maximum=1000)
|
|
434
|
+
if value is not None:
|
|
435
|
+
attributes["span"] = value
|
|
436
|
+
elif tag.name == "ol":
|
|
437
|
+
value = _bounded_integer(_attribute_text(tag.get("start")), minimum=-1_000_000, maximum=1_000_000)
|
|
438
|
+
if value is not None:
|
|
439
|
+
attributes["start"] = value
|
|
440
|
+
elif tag.name == "li":
|
|
441
|
+
value = _bounded_integer(_attribute_text(tag.get("value")), minimum=-1_000_000, maximum=1_000_000)
|
|
442
|
+
if value is not None:
|
|
443
|
+
attributes["value"] = value
|
|
444
|
+
return attributes
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _bounded_integer(value: str, *, minimum: int, maximum: int) -> str | None:
|
|
448
|
+
"""解析并规范化指定闭区间内的十进制整数。"""
|
|
449
|
+
if _INTEGER_RE.fullmatch(value) is None:
|
|
450
|
+
return None
|
|
451
|
+
number = int(value)
|
|
452
|
+
return str(number) if minimum <= number <= maximum else None
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _attribute_text(value: object) -> str:
|
|
456
|
+
"""将 BeautifulSoup 属性值稳定地转为字符串。"""
|
|
457
|
+
if value is None:
|
|
458
|
+
return ""
|
|
459
|
+
if isinstance(value, list):
|
|
460
|
+
return " ".join(str(item) for item in value)
|
|
461
|
+
return str(value)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _filter_sanitized_attribute(tag: str, attribute: str, value: str) -> str | None:
|
|
465
|
+
"""在 nh3 重新解析后再次校验 URL,防止解析差异导致属性绕过。"""
|
|
466
|
+
if tag == "a" and attribute == "href":
|
|
467
|
+
return sanitize_link_url(value)
|
|
468
|
+
if tag == "img" and attribute == "src":
|
|
469
|
+
return sanitize_image_source(value)
|
|
470
|
+
return value
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
__all__ = [
|
|
474
|
+
"is_supported_html_markup",
|
|
475
|
+
"sanitize_html_fragment",
|
|
476
|
+
"sanitize_image_source",
|
|
477
|
+
"sanitize_link_url",
|
|
478
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""HTML renderer 使用的严格 GFM pipe table 转换。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ....schema import TextSpan
|
|
8
|
+
from .inline import HtmlInlineResult, render_inline_content_html
|
|
9
|
+
|
|
10
|
+
_SEPARATOR_CELL_RE = re.compile(r"^:?-{3,}:?$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def looks_like_gfm_table(content: str) -> bool:
|
|
14
|
+
"""判断文本是否具有 GFM pipe table 的表头与分隔行外形。"""
|
|
15
|
+
lines = [line.strip() for line in content.strip().splitlines() if line.strip()]
|
|
16
|
+
if len(lines) < 2:
|
|
17
|
+
return False
|
|
18
|
+
if not _contains_unescaped_pipe(lines[0]) or not _contains_unescaped_pipe(lines[1]):
|
|
19
|
+
return False
|
|
20
|
+
separator_cells = _split_pipe_row(lines[1])
|
|
21
|
+
return bool(separator_cells and all(_SEPARATOR_CELL_RE.fullmatch(cell.strip()) for cell in separator_cells))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_gfm_table_html(content: str) -> HtmlInlineResult | None:
|
|
25
|
+
"""把严格、等宽的简单 GFM pipe table 转换为语义 HTML。"""
|
|
26
|
+
lines = [line.strip() for line in content.strip().splitlines() if line.strip()]
|
|
27
|
+
if len(lines) < 2:
|
|
28
|
+
return None
|
|
29
|
+
if not _contains_unescaped_pipe(lines[0]) or not _contains_unescaped_pipe(lines[1]):
|
|
30
|
+
return None
|
|
31
|
+
header = _split_pipe_row(lines[0])
|
|
32
|
+
separators = _split_pipe_row(lines[1])
|
|
33
|
+
if not header or len(header) != len(separators):
|
|
34
|
+
return None
|
|
35
|
+
if not all(_SEPARATOR_CELL_RE.fullmatch(cell.strip()) for cell in separators):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
rows = [_split_pipe_row(line) for line in lines[2:]]
|
|
39
|
+
if any(len(row) != len(header) for row in rows):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
alignments = [_separator_alignment(cell.strip()) for cell in separators]
|
|
43
|
+
has_math = False
|
|
44
|
+
|
|
45
|
+
def _render_row(cells: list[str], cell_tag: str) -> str:
|
|
46
|
+
"""渲染一行等宽单元格,并累积公式存在标记。"""
|
|
47
|
+
nonlocal has_math
|
|
48
|
+
rendered_cells: list[str] = []
|
|
49
|
+
for index, cell in enumerate(cells):
|
|
50
|
+
cell_text = _unescape_gfm_cell(cell.strip())
|
|
51
|
+
rendered = (
|
|
52
|
+
render_inline_content_html([TextSpan(type="text", content=cell_text)]) if cell_text else HtmlInlineResult("")
|
|
53
|
+
)
|
|
54
|
+
has_math = rendered.has_math or has_math
|
|
55
|
+
alignment = alignments[index]
|
|
56
|
+
class_attr = f' class="docvortex-align-{alignment}"' if alignment else ""
|
|
57
|
+
rendered_cells.append(f"<{cell_tag}{class_attr}>{rendered.html}</{cell_tag}>")
|
|
58
|
+
return f"<tr>{''.join(rendered_cells)}</tr>"
|
|
59
|
+
|
|
60
|
+
table = "".join(
|
|
61
|
+
[
|
|
62
|
+
'<table class="docvortex-chart-table">',
|
|
63
|
+
f"<thead>{_render_row(header, 'th')}</thead>",
|
|
64
|
+
f"<tbody>{''.join(_render_row(row, 'td') for row in rows)}</tbody>",
|
|
65
|
+
"</table>",
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
return HtmlInlineResult(table, has_math)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _split_pipe_row(line: str) -> list[str]:
|
|
72
|
+
"""按未被奇数个反斜杠转义的竖线切分 GFM 表格行。"""
|
|
73
|
+
normalized = line.strip()
|
|
74
|
+
if normalized.startswith("|"):
|
|
75
|
+
normalized = normalized[1:]
|
|
76
|
+
if normalized.endswith("|") and not _is_escaped(normalized, len(normalized) - 1):
|
|
77
|
+
normalized = normalized[:-1]
|
|
78
|
+
|
|
79
|
+
cells: list[str] = []
|
|
80
|
+
start = 0
|
|
81
|
+
for index, char in enumerate(normalized):
|
|
82
|
+
if char == "|" and not _is_escaped(normalized, index):
|
|
83
|
+
cells.append(normalized[start:index])
|
|
84
|
+
start = index + 1
|
|
85
|
+
cells.append(normalized[start:])
|
|
86
|
+
return cells
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_escaped(content: str, index: int) -> bool:
|
|
90
|
+
"""按 markdown-it table 规则判断 pipe 是否紧邻任意反斜杠。"""
|
|
91
|
+
return index > 0 and content[index - 1] == "\\"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _contains_unescaped_pipe(content: str) -> bool:
|
|
95
|
+
"""判断行内是否至少包含一个真正的 GFM 列分隔符。"""
|
|
96
|
+
return any(char == "|" and not _is_escaped(content, index) for index, char in enumerate(content))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _unescape_gfm_cell(content: str) -> str:
|
|
100
|
+
"""按既有 2n+1 编码逆向恢复 GFM 单元格中的原始反斜杠与竖线。"""
|
|
101
|
+
|
|
102
|
+
def _replace(match: re.Match[str]) -> str:
|
|
103
|
+
"""把竖线前 2n+1 个 Markdown 反斜杠还原为 n 个。"""
|
|
104
|
+
slash_count = len(match.group("slashes"))
|
|
105
|
+
return "\\" * ((slash_count - 1) // 2) + "|"
|
|
106
|
+
|
|
107
|
+
return re.sub(r"(?P<slashes>\\+)\|", _replace, content)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _separator_alignment(cell: str) -> str | None:
|
|
111
|
+
"""从 GFM 分隔单元格解析 left、center 或 right 对齐。"""
|
|
112
|
+
if cell.startswith(":") and cell.endswith(":"):
|
|
113
|
+
return "center"
|
|
114
|
+
if cell.endswith(":"):
|
|
115
|
+
return "right"
|
|
116
|
+
if cell.startswith(":"):
|
|
117
|
+
return "left"
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["looks_like_gfm_table", "render_gfm_table_html"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""LaTeX renderer 的纯路径 sidecar 图片解析。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from ....schema import ImagePayloadBlock
|
|
9
|
+
from ....foundation.image_payload import validate_image_sidecar_path
|
|
10
|
+
|
|
11
|
+
_SUPPORTED_IMAGE_EXTENSIONS = {".jpeg", ".jpg", ".pdf", ".png"}
|
|
12
|
+
_FORBIDDEN_TEX_PATH_CHARACTERS = {"%", "{", "}"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_asset_base_path(asset_base_path: str) -> str:
|
|
16
|
+
"""把调用方路径前缀规范化为 TeX 可跨平台读取的正斜杠形式。"""
|
|
17
|
+
if not isinstance(asset_base_path, str):
|
|
18
|
+
raise TypeError("asset_base_path must be a string")
|
|
19
|
+
if any(character == "\x00" or character in "\r\n" for character in asset_base_path):
|
|
20
|
+
raise ValueError("asset_base_path must not contain control characters")
|
|
21
|
+
normalized = asset_base_path.replace("\\", "/")
|
|
22
|
+
if normalized == "/" or (len(normalized) == 3 and normalized[1:] == ":/"):
|
|
23
|
+
return normalized
|
|
24
|
+
return normalized.rstrip("/")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_block_image_path(block: ImagePayloadBlock, asset_base_path: str) -> str | None:
|
|
28
|
+
"""只从 block 的安全 sidecar 路径解析 XeLaTeX 可直接读取的图片。"""
|
|
29
|
+
if block.image_path is None:
|
|
30
|
+
return None
|
|
31
|
+
return resolve_relative_image_path(block.image_path, asset_base_path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_html_image_path(source: str, asset_base_path: str) -> str | None:
|
|
35
|
+
"""解析 HTML img 的安全相对路径,拒绝 data URI 与远程地址。"""
|
|
36
|
+
if not isinstance(source, str):
|
|
37
|
+
return None
|
|
38
|
+
normalized = source.strip()
|
|
39
|
+
if not normalized:
|
|
40
|
+
return None
|
|
41
|
+
parsed = urlsplit(normalized)
|
|
42
|
+
if parsed.scheme or parsed.netloc or normalized.startswith(("/", "#")):
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return resolve_relative_image_path(normalized, asset_base_path)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_relative_image_path(relative_path: str, asset_base_path: str) -> str | None:
|
|
51
|
+
"""拼接安全相对路径,并限制为 XeLaTeX 原生支持的图片扩展名。"""
|
|
52
|
+
safe_path = validate_image_sidecar_path(relative_path)
|
|
53
|
+
if PurePosixPath(safe_path).suffix.casefold() not in _SUPPORTED_IMAGE_EXTENSIONS:
|
|
54
|
+
return None
|
|
55
|
+
if not asset_base_path:
|
|
56
|
+
resolved = safe_path
|
|
57
|
+
elif asset_base_path.endswith("/"):
|
|
58
|
+
resolved = f"{asset_base_path}{safe_path}"
|
|
59
|
+
else:
|
|
60
|
+
resolved = f"{asset_base_path}/{safe_path}"
|
|
61
|
+
if any(character in _FORBIDDEN_TEX_PATH_CHARACTERS for character in resolved):
|
|
62
|
+
return None
|
|
63
|
+
if any(ord(character) < 0x20 or ord(character) == 0x7F for character in resolved):
|
|
64
|
+
return None
|
|
65
|
+
return resolved
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def remote_block_image_url(block: ImagePayloadBlock) -> str | None:
|
|
69
|
+
"""返回 block 已由严格模型校验过的远程图片 URL。"""
|
|
70
|
+
return block.image_url
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tex_image_path(path: str) -> str:
|
|
74
|
+
"""使用 detokenize 包装已校验路径,保留空格及 TeX 保留字符。"""
|
|
75
|
+
return rf"\detokenize{{{path}}}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = [
|
|
79
|
+
"normalize_asset_base_path",
|
|
80
|
+
"remote_block_image_url",
|
|
81
|
+
"resolve_block_image_path",
|
|
82
|
+
"resolve_html_image_path",
|
|
83
|
+
"resolve_relative_image_path",
|
|
84
|
+
"tex_image_path",
|
|
85
|
+
]
|