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,98 @@
|
|
|
1
|
+
"""保存及读取不依赖源文件、网络或宿主缓存的结果包。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..assets import AssetStore
|
|
12
|
+
from ..codecs.json import load_middle, load_model
|
|
13
|
+
from ..foundation.image_payload import INLINE_IMAGE_DATA_URI_RE, parse_image_data_uri_strict
|
|
14
|
+
from ..result import Diagnostic, DocumentResult, ExportResult
|
|
15
|
+
from .files import materialize_middle, validate_materialized_assets
|
|
16
|
+
from .middle import _commit_export_files, _resolve_export_target, _validate_export_path_relationships
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _externalize_model(value: Any, assets: AssetStore) -> Any:
|
|
20
|
+
"""在原始分析副本中外置图片及 HTML 内嵌素材,保持可独立重放。"""
|
|
21
|
+
if isinstance(value, list):
|
|
22
|
+
return [_externalize_model(item, assets) for item in value]
|
|
23
|
+
if isinstance(value, dict):
|
|
24
|
+
result = {key: _externalize_model(item, assets) for key, item in value.items()}
|
|
25
|
+
image = value.get("image_base64")
|
|
26
|
+
if isinstance(image, str):
|
|
27
|
+
result["image_path"] = _add_image(image, assets)
|
|
28
|
+
result.pop("image_base64", None)
|
|
29
|
+
return result
|
|
30
|
+
if isinstance(value, str) and "data:image/" in value and not value.startswith("data:image/"):
|
|
31
|
+
return INLINE_IMAGE_DATA_URI_RE.sub(lambda match: _add_image(match.group(0), assets), value)
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _add_image(data_uri: str, assets: AssetStore) -> str:
|
|
36
|
+
"""按内容摘要登记分析阶段素材,避免产生不稳定文件名。"""
|
|
37
|
+
payload, extension = parse_image_data_uri_strict(data_uri)
|
|
38
|
+
path = f"images/{sha256(payload).hexdigest()}.{extension}"
|
|
39
|
+
assets.add(path, payload)
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_bundle(result: DocumentResult, path: Path, *, overwrite: bool = False) -> ExportResult:
|
|
44
|
+
"""将中间协议与素材清单放入同一文件事务。"""
|
|
45
|
+
middle, assets = materialize_middle(result.middle_json, result.assets)
|
|
46
|
+
validate_materialized_assets(middle, assets)
|
|
47
|
+
files: dict[str, bytes] = {"middle.json": middle.to_json(skip_defaults=False).encode("utf-8")}
|
|
48
|
+
manifest: dict[str, Any] = {
|
|
49
|
+
"schema": "docvortex.bundle",
|
|
50
|
+
"schema_version": "2.0",
|
|
51
|
+
"middle": "middle.json",
|
|
52
|
+
"diagnostics": [asdict(item) for item in result.diagnostics],
|
|
53
|
+
}
|
|
54
|
+
if result.model_json is not None:
|
|
55
|
+
model = _externalize_model(result.model_json.to_dict(skip_defaults=False), assets)
|
|
56
|
+
files["model.json"] = json.dumps(model, ensure_ascii=False, indent=2).encode("utf-8")
|
|
57
|
+
manifest["model"] = "model.json"
|
|
58
|
+
manifest["assets"] = [
|
|
59
|
+
{"path": name, "size": len(payload), "sha256": sha256(payload).hexdigest()} for name, payload in sorted(assets.items())
|
|
60
|
+
]
|
|
61
|
+
if {"manifest.json", "middle.json", "model.json"}.intersection(assets):
|
|
62
|
+
raise ValueError("Bundle assets conflict with reserved document files")
|
|
63
|
+
files.update(assets)
|
|
64
|
+
files["manifest.json"] = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")
|
|
65
|
+
_validate_export_path_relationships(list(files))
|
|
66
|
+
path = path.absolute()
|
|
67
|
+
_commit_export_files({_resolve_export_target(path, name): value for name, value in files.items()}, overwrite=overwrite)
|
|
68
|
+
return ExportResult(path / "manifest.json", tuple(path / name for name in assets))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_bundle(path: str | Path) -> DocumentResult:
|
|
72
|
+
"""校验结果包版本、路径及素材摘要后恢复可渲染结果。"""
|
|
73
|
+
root = Path(path).absolute()
|
|
74
|
+
manifest = json.loads(_resolve_export_target(root, "manifest.json").read_text(encoding="utf-8"))
|
|
75
|
+
if manifest.get("schema") != "docvortex.bundle" or manifest.get("schema_version") != "2.0":
|
|
76
|
+
raise ValueError("Unsupported DocVortex bundle schema")
|
|
77
|
+
if manifest.get("middle") != "middle.json" or manifest.get("model") not in {None, "model.json"}:
|
|
78
|
+
raise ValueError("Invalid bundle document paths")
|
|
79
|
+
assets = AssetStore()
|
|
80
|
+
for item in manifest["assets"]:
|
|
81
|
+
if item["path"] in {"manifest.json", "middle.json", "model.json"}:
|
|
82
|
+
raise ValueError("Bundle asset uses a reserved document path")
|
|
83
|
+
payload = _resolve_export_target(root, item["path"]).read_bytes()
|
|
84
|
+
if len(payload) != item["size"] or sha256(payload).hexdigest() != item["sha256"]:
|
|
85
|
+
raise ValueError(f"Bundle asset integrity mismatch: {item['path']}")
|
|
86
|
+
assets.add(item["path"], payload)
|
|
87
|
+
middle = load_middle(json.loads(_resolve_export_target(root, "middle.json").read_text(encoding="utf-8")))
|
|
88
|
+
validate_materialized_assets(middle, assets)
|
|
89
|
+
model = (
|
|
90
|
+
load_model(json.loads(_resolve_export_target(root, "model.json").read_text(encoding="utf-8")))
|
|
91
|
+
if manifest.get("model")
|
|
92
|
+
else None
|
|
93
|
+
)
|
|
94
|
+
diagnostics = tuple(Diagnostic(**item) for item in manifest.get("diagnostics", []))
|
|
95
|
+
return DocumentResult(middle_json=middle, assets=assets, model_json=model, diagnostics=diagnostics)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["save_bundle", "load_bundle"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""独立渲染文件和文档素材的统一写出。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..assets import AssetStore
|
|
8
|
+
from ..content.tree import iter_child_blocks
|
|
9
|
+
from ..schema import MiddleJson
|
|
10
|
+
from ..result import ExportResult, RenderArtifact
|
|
11
|
+
from .middle import _commit_export_files, _prepare_export_copy, _resolve_export_target, _validate_export_path_relationships
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def materialize_middle(middle_json: MiddleJson, assets: AssetStore | None = None) -> tuple[MiddleJson, AssetStore]:
|
|
15
|
+
"""在文档副本上外置所有内嵌图片,保留调用者提供的外部素材。"""
|
|
16
|
+
document, embedded = _prepare_export_copy(middle_json)
|
|
17
|
+
result = assets.copy() if assets is not None else AssetStore()
|
|
18
|
+
for path, payload in embedded.items():
|
|
19
|
+
result.add(path, payload)
|
|
20
|
+
pending = [block for page in document.pages for block in page.blocks]
|
|
21
|
+
while pending:
|
|
22
|
+
block = pending.pop()
|
|
23
|
+
pending.extend(iter_child_blocks(block))
|
|
24
|
+
image_path = getattr(block, "image_path", None)
|
|
25
|
+
if image_path and image_path in result and getattr(block, "image_url", None):
|
|
26
|
+
block.image_url = None
|
|
27
|
+
return document, result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_materialized_assets(middle_json: MiddleJson, assets: AssetStore) -> None:
|
|
31
|
+
"""拒绝依赖外部文件或网络的未物化图片,保证结果包能离线重渲染。"""
|
|
32
|
+
from bs4 import BeautifulSoup
|
|
33
|
+
|
|
34
|
+
pending = [block for page in middle_json.pages for block in page.blocks]
|
|
35
|
+
while pending:
|
|
36
|
+
block = pending.pop()
|
|
37
|
+
pending.extend(iter_child_blocks(block))
|
|
38
|
+
image_path = getattr(block, "image_path", None)
|
|
39
|
+
if image_path and image_path not in assets:
|
|
40
|
+
raise ValueError(f"Missing materialized asset: {image_path}")
|
|
41
|
+
if getattr(block, "image_url", None) and not image_path:
|
|
42
|
+
raise ValueError("An external image must be materialized before saving a bundle")
|
|
43
|
+
content = getattr(block, "content", None)
|
|
44
|
+
if str(block.type) not in {"table_body", "chart_body", "image_body"} or not isinstance(content, str):
|
|
45
|
+
continue
|
|
46
|
+
if "<img" not in content.lower():
|
|
47
|
+
continue
|
|
48
|
+
for image in BeautifulSoup(content, "html.parser").find_all("img"):
|
|
49
|
+
reference = image.get("src")
|
|
50
|
+
if reference and reference not in assets:
|
|
51
|
+
raise ValueError(f"Missing materialized HTML image: {reference}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def write_artifact(artifact: RenderArtifact, path: Path, *, overwrite: bool) -> ExportResult:
|
|
55
|
+
"""检查主文件与素材的路径关系后,以同一事务写出。"""
|
|
56
|
+
path = path.absolute()
|
|
57
|
+
relative_files = {path.name: artifact.content, **dict(artifact.assets)}
|
|
58
|
+
if path.name in artifact.assets:
|
|
59
|
+
raise ValueError("Main file conflicts with an asset")
|
|
60
|
+
_validate_export_path_relationships(list(relative_files))
|
|
61
|
+
targets = {_resolve_export_target(path.parent, name): payload for name, payload in relative_files.items()}
|
|
62
|
+
_commit_export_files(targets, overwrite=overwrite)
|
|
63
|
+
return ExportResult(path=path, asset_paths=tuple(path.parent / name for name in artifact.assets))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["materialize_middle", "validate_materialized_assets", "write_artifact"]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""语义文档与图片旁文件的原子导出。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from tempfile import NamedTemporaryFile
|
|
8
|
+
from typing import Any, Sequence
|
|
9
|
+
from ..schema import MiddleJson, BlockBase, _iter_child_blocks
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class MiddleJsonExportResult:
|
|
14
|
+
"""Middle JSON 图片外置后的对象副本与实际文件路径。"""
|
|
15
|
+
|
|
16
|
+
middle_json: MiddleJson
|
|
17
|
+
json_path: Path
|
|
18
|
+
image_paths: tuple[Path, ...]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iter_page_blocks(blocks: Sequence[BlockBase]) -> list[BlockBase]:
|
|
22
|
+
"""按深度优先顺序展开页面 block 树,供图片外置统一遍历。"""
|
|
23
|
+
result: list[BlockBase] = []
|
|
24
|
+
pending: list[BlockBase] = list(reversed(blocks))
|
|
25
|
+
while pending:
|
|
26
|
+
block = pending.pop()
|
|
27
|
+
result.append(block)
|
|
28
|
+
pending.extend(reversed(_iter_child_blocks(block)))
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _register_export_file(files: dict[str, bytes], relative_path: str, payload: bytes) -> None:
|
|
33
|
+
"""登记待写文件,并在同名内容冲突时立即终止导出。"""
|
|
34
|
+
existing = files.get(relative_path)
|
|
35
|
+
if existing is not None and existing != payload:
|
|
36
|
+
raise ValueError(f"Conflicting image payload for path: {relative_path}")
|
|
37
|
+
files[relative_path] = payload
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _prepare_export_copy(middle_json: MiddleJson) -> tuple[MiddleJson, dict[str, bytes]]:
|
|
41
|
+
"""复制对象、解析直接及 HTML 图片,并回填副本中的相对路径。"""
|
|
42
|
+
from ..foundation.image_payload import (
|
|
43
|
+
INLINE_IMAGE_DATA_URI_RE,
|
|
44
|
+
parse_image_data_uri_strict,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
exported = middle_json.model_copy(deep=True)
|
|
48
|
+
image_files: dict[str, bytes] = {}
|
|
49
|
+
for page in exported.pages:
|
|
50
|
+
for block in _iter_page_blocks(page.blocks):
|
|
51
|
+
data_uri = getattr(block, "image_base64", None)
|
|
52
|
+
if data_uri is not None:
|
|
53
|
+
if block.index is None:
|
|
54
|
+
raise ValueError(f"Image carrier requires index: page_idx={page.page_idx}, type={block.type}")
|
|
55
|
+
image_bytes, extension = parse_image_data_uri_strict(data_uri)
|
|
56
|
+
relative_path = f"images/page_{page.page_idx}_{block.type}_{block.index}.{extension}"
|
|
57
|
+
_register_export_file(image_files, relative_path, image_bytes)
|
|
58
|
+
block.image_path = relative_path # type: ignore[attr-defined]
|
|
59
|
+
block.image_base64 = None # type: ignore[attr-defined]
|
|
60
|
+
|
|
61
|
+
content = getattr(block, "content", None)
|
|
62
|
+
if not isinstance(content, str) or "data:image/" not in content:
|
|
63
|
+
continue
|
|
64
|
+
if block.index is None:
|
|
65
|
+
raise ValueError(f"HTML image carrier requires index: page_idx={page.page_idx}, type={block.type}")
|
|
66
|
+
ordinal = 0
|
|
67
|
+
|
|
68
|
+
def _replace_data_uri(match: Any) -> str:
|
|
69
|
+
"""将当前 HTML data URI 登记为 sidecar,并返回确定性相对路径。"""
|
|
70
|
+
nonlocal ordinal
|
|
71
|
+
ordinal += 1
|
|
72
|
+
image_bytes, extension = parse_image_data_uri_strict(match.group(0))
|
|
73
|
+
relative_path = f"images/page_{page.page_idx}_{block.type}_{block.index}_{ordinal}.{extension}"
|
|
74
|
+
_register_export_file(image_files, relative_path, image_bytes)
|
|
75
|
+
return relative_path
|
|
76
|
+
|
|
77
|
+
block.content = INLINE_IMAGE_DATA_URI_RE.sub(_replace_data_uri, content) # type: ignore[assignment]
|
|
78
|
+
return exported, image_files
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_export_target(output_root: Path, relative_path: str) -> Path:
|
|
82
|
+
"""校验导出相对路径并确保解析后的目标仍位于文档输出目录内。"""
|
|
83
|
+
from ..foundation.image_payload import validate_image_sidecar_path
|
|
84
|
+
|
|
85
|
+
safe_path = validate_image_sidecar_path(relative_path)
|
|
86
|
+
if output_root.is_symlink():
|
|
87
|
+
raise ValueError(f"Export directory must not be a symlink: {output_root}")
|
|
88
|
+
if output_root.exists() and not output_root.is_dir():
|
|
89
|
+
raise ValueError(f"Export directory must be a directory: {output_root}")
|
|
90
|
+
root = output_root.resolve()
|
|
91
|
+
target = root / safe_path
|
|
92
|
+
current_parent = root
|
|
93
|
+
for path_part in Path(safe_path).parts[:-1]:
|
|
94
|
+
current_parent /= path_part
|
|
95
|
+
if current_parent.is_symlink():
|
|
96
|
+
raise ValueError(f"Export path contains a symlink: {relative_path}")
|
|
97
|
+
if current_parent.exists() and not current_parent.is_dir():
|
|
98
|
+
raise ValueError(f"Export path parent is not a directory: {relative_path}")
|
|
99
|
+
try:
|
|
100
|
+
target.parent.resolve(strict=False).relative_to(root)
|
|
101
|
+
except ValueError as exc:
|
|
102
|
+
raise ValueError(f"Export path escapes output directory: {relative_path}") from exc
|
|
103
|
+
return target
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _commit_export_files(files: dict[Path, bytes], *, overwrite: bool) -> None:
|
|
107
|
+
"""预检冲突后以临时文件提交,并在提交失败时恢复已有文件。"""
|
|
108
|
+
pending: dict[Path, bytes] = {}
|
|
109
|
+
originals: dict[Path, bytes | None] = {}
|
|
110
|
+
for target, payload in files.items():
|
|
111
|
+
if target.is_symlink():
|
|
112
|
+
raise ValueError(f"Export target must not be a symlink: {target}")
|
|
113
|
+
if target.exists():
|
|
114
|
+
if not target.is_file():
|
|
115
|
+
raise ValueError(f"Export target is not a regular file: {target}")
|
|
116
|
+
current = target.read_bytes()
|
|
117
|
+
if current == payload:
|
|
118
|
+
continue
|
|
119
|
+
if not overwrite:
|
|
120
|
+
raise FileExistsError(f"Export target already exists with different content: {target}")
|
|
121
|
+
originals[target] = current
|
|
122
|
+
else:
|
|
123
|
+
originals[target] = None
|
|
124
|
+
pending[target] = payload
|
|
125
|
+
|
|
126
|
+
temp_paths: dict[Path, Path] = {}
|
|
127
|
+
committed: list[Path] = []
|
|
128
|
+
try:
|
|
129
|
+
for target, payload in pending.items():
|
|
130
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
with NamedTemporaryFile(
|
|
132
|
+
mode="wb",
|
|
133
|
+
prefix=".docvortex-export-",
|
|
134
|
+
dir=target.parent,
|
|
135
|
+
delete=False,
|
|
136
|
+
) as temp_file:
|
|
137
|
+
temp_file.write(payload)
|
|
138
|
+
temp_paths[target] = Path(temp_file.name)
|
|
139
|
+
for target, temp_path in temp_paths.items():
|
|
140
|
+
os.replace(temp_path, target)
|
|
141
|
+
committed.append(target)
|
|
142
|
+
except Exception:
|
|
143
|
+
for temp_path in temp_paths.values():
|
|
144
|
+
temp_path.unlink(missing_ok=True)
|
|
145
|
+
for target in reversed(committed):
|
|
146
|
+
original = originals[target]
|
|
147
|
+
if original is None:
|
|
148
|
+
target.unlink(missing_ok=True)
|
|
149
|
+
else:
|
|
150
|
+
target.write_bytes(original)
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _validate_export_path_relationships(relative_paths: list[str]) -> None:
|
|
155
|
+
"""拒绝任一导出文件占用另一文件的父目录,避免提交阶段才产生冲突。"""
|
|
156
|
+
path_parts = {relative_path: Path(relative_path).parts for relative_path in relative_paths}
|
|
157
|
+
for relative_path, parts in path_parts.items():
|
|
158
|
+
for other_path, other_parts in path_parts.items():
|
|
159
|
+
if relative_path == other_path or len(parts) >= len(other_parts):
|
|
160
|
+
continue
|
|
161
|
+
if other_parts[: len(parts)] == parts:
|
|
162
|
+
raise ValueError(f"Export file path conflicts with a required directory: {relative_path} -> {other_path}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _export_middle_json(
|
|
166
|
+
middle_json: MiddleJson,
|
|
167
|
+
output_dir: Path,
|
|
168
|
+
*,
|
|
169
|
+
json_name: str,
|
|
170
|
+
overwrite: bool,
|
|
171
|
+
) -> MiddleJsonExportResult:
|
|
172
|
+
"""构造完整导出事务,保证 JSON 与图片使用同一份规范化对象副本。"""
|
|
173
|
+
from ..foundation.image_payload import validate_image_sidecar_path
|
|
174
|
+
|
|
175
|
+
exported, image_files = _prepare_export_copy(middle_json)
|
|
176
|
+
json_text = exported.to_json(
|
|
177
|
+
exclude_block_fields={"image_base64"},
|
|
178
|
+
)
|
|
179
|
+
if "data:image/" in json_text.lower():
|
|
180
|
+
raise ValueError("Exported Middle JSON still contains an inline image data URI")
|
|
181
|
+
json_bytes = json_text.encode("utf-8")
|
|
182
|
+
safe_json_name = validate_image_sidecar_path(json_name)
|
|
183
|
+
relative_files = dict(image_files)
|
|
184
|
+
if safe_json_name in relative_files:
|
|
185
|
+
raise ValueError(f"JSON path conflicts with an exported image: {safe_json_name}")
|
|
186
|
+
relative_files[safe_json_name] = json_bytes
|
|
187
|
+
_validate_export_path_relationships(list(relative_files))
|
|
188
|
+
absolute_files = {
|
|
189
|
+
_resolve_export_target(output_dir, relative_path): payload for relative_path, payload in relative_files.items()
|
|
190
|
+
}
|
|
191
|
+
_commit_export_files(absolute_files, overwrite=overwrite)
|
|
192
|
+
json_path = _resolve_export_target(output_dir, safe_json_name)
|
|
193
|
+
image_paths = tuple(_resolve_export_target(output_dir, relative_path) for relative_path in sorted(image_files))
|
|
194
|
+
return MiddleJsonExportResult(
|
|
195
|
+
middle_json=exported,
|
|
196
|
+
json_path=json_path,
|
|
197
|
+
image_paths=image_paths,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def export_middle_json(
|
|
202
|
+
middle_json: MiddleJson, output_dir: str | Path, *, json_name: str = "middle_json.json", overwrite: bool = False
|
|
203
|
+
) -> MiddleJsonExportResult:
|
|
204
|
+
"""显式导出语义协议及图片,保持基础 schema 无文件系统依赖。"""
|
|
205
|
+
return _export_middle_json(middle_json, Path(output_dir), json_name=json_name, overwrite=overwrite)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
__all__ = ["MiddleJsonExportResult", "export_middle_json"]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from ..schema import BBox, IntBBox
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize_to_int_bbox(
|
|
9
|
+
box: list[list[float]] | list[float] | BBox | None,
|
|
10
|
+
image_size: tuple[int, int] | None = None,
|
|
11
|
+
) -> IntBBox | None:
|
|
12
|
+
if box is None:
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
arr = np.asarray(box, dtype=np.float64)
|
|
16
|
+
if arr.size == 0:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
if arr.ndim == 2 and arr.shape[-1] == 2:
|
|
20
|
+
xs = arr[:, 0]
|
|
21
|
+
ys = arr[:, 1]
|
|
22
|
+
xmin = float(np.min(xs))
|
|
23
|
+
ymin = float(np.min(ys))
|
|
24
|
+
xmax = float(np.max(xs))
|
|
25
|
+
ymax = float(np.max(ys))
|
|
26
|
+
else:
|
|
27
|
+
flat = arr.reshape(-1)
|
|
28
|
+
if flat.size == 4:
|
|
29
|
+
xmin, ymin, xmax, ymax = [float(v) for v in flat]
|
|
30
|
+
elif flat.size >= 8:
|
|
31
|
+
xs = flat[0::2]
|
|
32
|
+
ys = flat[1::2]
|
|
33
|
+
xmin = float(np.min(xs))
|
|
34
|
+
ymin = float(np.min(ys))
|
|
35
|
+
xmax = float(np.max(xs))
|
|
36
|
+
ymax = float(np.max(ys))
|
|
37
|
+
else:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
xmin = math.floor(xmin)
|
|
41
|
+
ymin = math.floor(ymin)
|
|
42
|
+
xmax = math.ceil(xmax)
|
|
43
|
+
ymax = math.ceil(ymax)
|
|
44
|
+
|
|
45
|
+
if image_size is not None:
|
|
46
|
+
height, width = image_size
|
|
47
|
+
xmin = max(0, min(int(width), xmin))
|
|
48
|
+
ymin = max(0, min(int(height), ymin))
|
|
49
|
+
xmax = max(0, min(int(width), xmax))
|
|
50
|
+
ymax = max(0, min(int(height), ymax))
|
|
51
|
+
|
|
52
|
+
if xmax <= xmin or ymax <= ymin:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
return (int(xmin), int(ymin), int(xmax), int(ymax))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def bbox_relative_pos(bbox1: BBox, bbox2: BBox) -> tuple[bool, bool, bool, bool]:
|
|
59
|
+
"""返回 bbox1 相对 bbox2 的左、右、下、上位置关系。"""
|
|
60
|
+
x1, y1, x1b, y1b = bbox1
|
|
61
|
+
x2, y2, x2b, y2b = bbox2
|
|
62
|
+
left = x2b < x1
|
|
63
|
+
right = x1b < x2
|
|
64
|
+
bottom = y2b < y1
|
|
65
|
+
top = y1b < y2
|
|
66
|
+
return left, right, bottom, top
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def bbox_distance(bbox1: BBox, bbox2: BBox) -> float:
|
|
70
|
+
"""计算两个不相交矩形之间的最短欧氏距离。"""
|
|
71
|
+
x1, y1, x1b, y1b = bbox1
|
|
72
|
+
x2, y2, x2b, y2b = bbox2
|
|
73
|
+
left, right, bottom, top = bbox_relative_pos(bbox1, bbox2)
|
|
74
|
+
if top and left:
|
|
75
|
+
return math.hypot(x1 - x2b, y1b - y2)
|
|
76
|
+
if left and bottom:
|
|
77
|
+
return math.hypot(x1 - x2b, y1 - y2b)
|
|
78
|
+
if bottom and right:
|
|
79
|
+
return math.hypot(x1b - x2, y1 - y2b)
|
|
80
|
+
if right and top:
|
|
81
|
+
return math.hypot(x1b - x2, y1b - y2)
|
|
82
|
+
if left:
|
|
83
|
+
return x1 - x2b
|
|
84
|
+
if right:
|
|
85
|
+
return x2 - x1b
|
|
86
|
+
if bottom:
|
|
87
|
+
return y1 - y2b
|
|
88
|
+
if top:
|
|
89
|
+
return y2 - y1b
|
|
90
|
+
return 0.0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def bbox_center_distance(bbox1: BBox, bbox2: BBox) -> float:
|
|
94
|
+
"""计算两个矩形中心点之间的欧氏距离。"""
|
|
95
|
+
center1 = ((bbox1[0] + bbox1[2]) / 2, (bbox1[1] + bbox1[3]) / 2)
|
|
96
|
+
center2 = ((bbox2[0] + bbox2[2]) / 2, (bbox2[1] + bbox2[3]) / 2)
|
|
97
|
+
return math.hypot(center1[0] - center2[0], center1[1] - center2[1])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def calculate_overlap_area_2_minbox_area_ratio(bbox1: BBox, bbox2: BBox) -> float:
|
|
101
|
+
"""计算交集面积占两个矩形较小面积的比例。"""
|
|
102
|
+
x_left = max(bbox1[0], bbox2[0])
|
|
103
|
+
y_top = max(bbox1[1], bbox2[1])
|
|
104
|
+
x_right = min(bbox1[2], bbox2[2])
|
|
105
|
+
y_bottom = min(bbox1[3], bbox2[3])
|
|
106
|
+
if x_right <= x_left or y_bottom <= y_top:
|
|
107
|
+
return 0.0
|
|
108
|
+
overlap = (x_right - x_left) * (y_bottom - y_top)
|
|
109
|
+
area1 = max(0.0, (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1]))
|
|
110
|
+
area2 = max(0.0, (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1]))
|
|
111
|
+
min_area = min(area1, area2)
|
|
112
|
+
return overlap / min_area if min_area > 0 else 0.0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def calculate_overlap_area_in_bbox1_area_ratio(bbox1: BBox, bbox2: BBox) -> float:
|
|
116
|
+
"""计算两个矩形交集面积占 bbox1 面积的比例。"""
|
|
117
|
+
x_left = max(bbox1[0], bbox2[0])
|
|
118
|
+
y_top = max(bbox1[1], bbox2[1])
|
|
119
|
+
x_right = min(bbox1[2], bbox2[2])
|
|
120
|
+
y_bottom = min(bbox1[3], bbox2[3])
|
|
121
|
+
if x_right <= x_left or y_bottom <= y_top:
|
|
122
|
+
return 0.0
|
|
123
|
+
overlap = (x_right - x_left) * (y_bottom - y_top)
|
|
124
|
+
area1 = max(0.0, (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1]))
|
|
125
|
+
return overlap / area1 if area1 > 0 else 0.0
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""不依赖解析器层的安全超链接目标校验。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Collection
|
|
6
|
+
from typing import Any, Final
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_EXTERNAL_HYPERLINK_SCHEMES: Final = frozenset({"http", "https", "mailto", "tel"})
|
|
11
|
+
OFFICE_EXTERNAL_HYPERLINK_SCHEMES: Final = DEFAULT_EXTERNAL_HYPERLINK_SCHEMES | {"ftp"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sanitize_hyperlink_target(
|
|
15
|
+
value: Any,
|
|
16
|
+
*,
|
|
17
|
+
allowed_schemes: Collection[str] = DEFAULT_EXTERNAL_HYPERLINK_SCHEMES,
|
|
18
|
+
allow_relative: bool = False,
|
|
19
|
+
allow_fragment: bool = False,
|
|
20
|
+
allow_root_relative: bool = False,
|
|
21
|
+
) -> str | None:
|
|
22
|
+
"""按调用方策略保留安全外链、相对链接或文档内 fragment。"""
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
normalized = str(value).strip()
|
|
26
|
+
if (
|
|
27
|
+
not normalized
|
|
28
|
+
or normalized == "."
|
|
29
|
+
or any(ord(char) < 0x20 or ord(char) == 0x7F or 0xD800 <= ord(char) <= 0xDFFF for char in normalized)
|
|
30
|
+
or any(char in normalized for char in ("<", ">", "\\"))
|
|
31
|
+
or normalized.startswith("//")
|
|
32
|
+
):
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
parsed = urlsplit(normalized)
|
|
36
|
+
except ValueError:
|
|
37
|
+
return None
|
|
38
|
+
scheme = parsed.scheme.casefold()
|
|
39
|
+
allowed = {item.casefold() for item in allowed_schemes}
|
|
40
|
+
if scheme:
|
|
41
|
+
if scheme not in allowed:
|
|
42
|
+
return None
|
|
43
|
+
if scheme in {"http", "https", "ftp"}:
|
|
44
|
+
try:
|
|
45
|
+
if not parsed.netloc or parsed.hostname is None:
|
|
46
|
+
return None
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
elif not parsed.path:
|
|
50
|
+
return None
|
|
51
|
+
return normalized
|
|
52
|
+
if parsed.netloc:
|
|
53
|
+
return None
|
|
54
|
+
if normalized.startswith("#"):
|
|
55
|
+
return normalized if allow_fragment and normalized[1:].strip() else None
|
|
56
|
+
if normalized.startswith("/") and not allow_root_relative:
|
|
57
|
+
return None
|
|
58
|
+
return normalized if allow_relative else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"DEFAULT_EXTERNAL_HYPERLINK_SCHEMES",
|
|
63
|
+
"OFFICE_EXTERNAL_HYPERLINK_SCHEMES",
|
|
64
|
+
"sanitize_hyperlink_target",
|
|
65
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""跨模型共享的轻量图像统计与裁剪原语。"""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
from ..schema import BBox
|
|
8
|
+
from .geometry import normalize_to_int_bbox
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def calculate_contrast(img: np.ndarray, img_mode: str) -> float:
|
|
12
|
+
"""
|
|
13
|
+
计算给定图像的对比度。
|
|
14
|
+
:param img: 图像,类型为numpy.ndarray
|
|
15
|
+
:Param img_mode = 图像的色彩通道,'rgb' 或 'bgr'
|
|
16
|
+
:return: 图像的对比度值
|
|
17
|
+
"""
|
|
18
|
+
if img_mode == "rgb":
|
|
19
|
+
# 将RGB图像转换为灰度图
|
|
20
|
+
gray_img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
|
21
|
+
elif img_mode == "bgr":
|
|
22
|
+
# 将BGR图像转换为灰度图
|
|
23
|
+
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
24
|
+
else:
|
|
25
|
+
raise ValueError("Invalid image mode. Please provide 'rgb' or 'bgr'.")
|
|
26
|
+
|
|
27
|
+
# 计算均值和标准差
|
|
28
|
+
mean_value = np.mean(gray_img)
|
|
29
|
+
std_dev = np.std(gray_img)
|
|
30
|
+
# 对比度定义为标准差除以平均值(加上小常数避免除零错误)
|
|
31
|
+
contrast = std_dev / (mean_value + 1e-6)
|
|
32
|
+
# logger.debug(f"contrast: {contrast}")
|
|
33
|
+
return round(float(contrast), 2)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def crop_pil_image(bbox: BBox, image: Image.Image) -> Image.Image:
|
|
37
|
+
"""按 0-1 归一化 bbox 裁剪 Pillow 图像。"""
|
|
38
|
+
width, height = image.size
|
|
39
|
+
scaled_bbox = normalize_to_int_bbox(
|
|
40
|
+
[bbox[0] * width, bbox[1] * height, bbox[2] * width, bbox[3] * height],
|
|
41
|
+
image_size=(height, width),
|
|
42
|
+
)
|
|
43
|
+
if scaled_bbox is None:
|
|
44
|
+
return image.crop((0, 0, 0, 0))
|
|
45
|
+
return image.crop(scaled_bbox)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = ["calculate_contrast", "crop_pil_image"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Flash 各格式复用的轻量图片编码能力。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def image_to_bytes(
|
|
12
|
+
image: Image.Image,
|
|
13
|
+
image_format: str = "JPEG",
|
|
14
|
+
) -> bytes:
|
|
15
|
+
"""按指定格式把 Pillow 图片编码为字节。"""
|
|
16
|
+
with BytesIO() as image_buffer:
|
|
17
|
+
image.save(image_buffer, format=image_format)
|
|
18
|
+
return image_buffer.getvalue()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def image_to_b64str(
|
|
22
|
+
image: Image.Image,
|
|
23
|
+
image_format: str = "JPEG",
|
|
24
|
+
) -> str:
|
|
25
|
+
"""按指定格式把 Pillow 图片编码为 data URI。"""
|
|
26
|
+
image_bytes = image_to_bytes(image, image_format)
|
|
27
|
+
return f"data:image/{image_format.lower()};base64,{base64.b64encode(image_bytes).decode('utf-8')}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["image_to_b64str", "image_to_bytes"]
|