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,1016 @@
|
|
|
1
|
+
"""Flash Office 文档中的 OOXML 图表解析与表格化渲染。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date, datetime, time
|
|
8
|
+
from html import escape
|
|
9
|
+
from io import BytesIO
|
|
10
|
+
from typing import Any, Final
|
|
11
|
+
|
|
12
|
+
from lxml import etree
|
|
13
|
+
from openpyxl import load_workbook
|
|
14
|
+
from openpyxl.utils.cell import range_to_tuple
|
|
15
|
+
from openpyxl.utils.datetime import MAC_EPOCH, WINDOWS_EPOCH, from_excel
|
|
16
|
+
from openpyxl.workbook.workbook import Workbook
|
|
17
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
18
|
+
|
|
19
|
+
_CHART_NS: Final = "http://schemas.openxmlformats.org/drawingml/2006/chart"
|
|
20
|
+
_DRAWING_NS: Final = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
|
21
|
+
_NS: Final = {"c": _CHART_NS, "a": _DRAWING_NS}
|
|
22
|
+
_MAX_CACHE_INDEX_SPAN: Final = 100_000
|
|
23
|
+
_PLOT_TAGS: Final = (
|
|
24
|
+
"areaChart",
|
|
25
|
+
"area3DChart",
|
|
26
|
+
"barChart",
|
|
27
|
+
"bar3DChart",
|
|
28
|
+
"bubbleChart",
|
|
29
|
+
"doughnutChart",
|
|
30
|
+
"lineChart",
|
|
31
|
+
"line3DChart",
|
|
32
|
+
"ofPieChart",
|
|
33
|
+
"pieChart",
|
|
34
|
+
"pie3DChart",
|
|
35
|
+
"radarChart",
|
|
36
|
+
"scatterChart",
|
|
37
|
+
"stockChart",
|
|
38
|
+
"surfaceChart",
|
|
39
|
+
"surface3DChart",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class SeriesSpec:
|
|
45
|
+
"""保存单个 OOXML 图表序列的公式引用、名称和缓存数据。"""
|
|
46
|
+
|
|
47
|
+
name_formula: str | None = None
|
|
48
|
+
literal_name: str | None = None
|
|
49
|
+
cat_formula: str | None = None
|
|
50
|
+
x_formula: str | None = None
|
|
51
|
+
val_formula: str | None = None
|
|
52
|
+
y_formula: str | None = None
|
|
53
|
+
bubble_size_formula: str | None = None
|
|
54
|
+
cached_categories: list[str] = field(default_factory=list)
|
|
55
|
+
cached_x_values: list[str] = field(default_factory=list)
|
|
56
|
+
cached_values: list[str] = field(default_factory=list)
|
|
57
|
+
cached_bubble_sizes: list[str] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ChartSpec:
|
|
62
|
+
"""保存图表类型、坐标轴信息和全部序列的规范化描述。"""
|
|
63
|
+
|
|
64
|
+
chart_type: str
|
|
65
|
+
plot_kind: str
|
|
66
|
+
title: str = ""
|
|
67
|
+
category_axis_title: str = ""
|
|
68
|
+
value_axis_title: str = ""
|
|
69
|
+
x_axis_title: str = ""
|
|
70
|
+
has_date_axis: bool = False
|
|
71
|
+
date_1904: bool = False
|
|
72
|
+
series: list[SeriesSpec] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def html_table_from_excel_bytes(excel_bytes: bytes) -> str:
|
|
76
|
+
"""将嵌入工作簿中第一个非空工作表转换为 HTML 表格。"""
|
|
77
|
+
if not excel_bytes:
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
workbook = load_workbook(
|
|
82
|
+
filename=BytesIO(excel_bytes),
|
|
83
|
+
data_only=True,
|
|
84
|
+
read_only=True,
|
|
85
|
+
)
|
|
86
|
+
except Exception:
|
|
87
|
+
return ""
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
for worksheet in workbook.worksheets:
|
|
91
|
+
rows = _extract_non_empty_worksheet_rows(worksheet)
|
|
92
|
+
if rows:
|
|
93
|
+
return _render_embedded_workbook_table(rows)
|
|
94
|
+
finally:
|
|
95
|
+
workbook.close()
|
|
96
|
+
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _extract_non_empty_worksheet_rows(worksheet: Worksheet) -> list[list[str]]:
|
|
101
|
+
"""提取工作表中首尾有内容的行,避免空 sheet 或尾部空列撑大兜底表格。"""
|
|
102
|
+
raw_rows: list[list[str]] = []
|
|
103
|
+
for row in worksheet.iter_rows(values_only=True):
|
|
104
|
+
stringified_row = [_stringify_cell_value(value) for value in row]
|
|
105
|
+
raw_rows.append(_trim_trailing_empty_values(stringified_row))
|
|
106
|
+
|
|
107
|
+
first_row_idx = _find_first_non_empty_row(raw_rows)
|
|
108
|
+
if first_row_idx is None:
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
last_row_idx = _find_last_non_empty_row(raw_rows)
|
|
112
|
+
if last_row_idx is None:
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
last_row_exclusive = last_row_idx + 1
|
|
116
|
+
rows = raw_rows[first_row_idx:last_row_exclusive]
|
|
117
|
+
width = max(len(row) for row in rows)
|
|
118
|
+
return [row + [""] * (width - len(row)) for row in rows]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _trim_trailing_empty_values(values: list[str]) -> list[str]:
|
|
122
|
+
"""移除行尾空值,保留中间空单元格的位置。"""
|
|
123
|
+
end = len(values)
|
|
124
|
+
while end > 0 and values[end - 1] == "":
|
|
125
|
+
end -= 1
|
|
126
|
+
return values[:end]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _find_first_non_empty_row(rows: list[list[str]]) -> int | None:
|
|
130
|
+
"""返回第一行非空行的索引,未找到时返回 None。"""
|
|
131
|
+
for idx, row in enumerate(rows):
|
|
132
|
+
if any(value != "" for value in row):
|
|
133
|
+
return idx
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _find_last_non_empty_row(rows: list[list[str]]) -> int | None:
|
|
138
|
+
"""返回最后一行非空行的索引。"""
|
|
139
|
+
for idx in range(len(rows) - 1, -1, -1):
|
|
140
|
+
if any(value != "" for value in rows[idx]):
|
|
141
|
+
return idx
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _render_embedded_workbook_table(rows: list[list[str]]) -> str:
|
|
146
|
+
"""将嵌入 workbook 的二维数据渲染为紧凑 HTML 表格,首行作为表头。"""
|
|
147
|
+
if not rows:
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
headers = rows[0]
|
|
151
|
+
data_rows = rows[1:]
|
|
152
|
+
html_parts = ["<table><thead><tr>"]
|
|
153
|
+
for header in headers:
|
|
154
|
+
html_parts.append(f"<th>{escape(header)}</th>")
|
|
155
|
+
html_parts.append("</tr></thead><tbody>")
|
|
156
|
+
|
|
157
|
+
for row in data_rows:
|
|
158
|
+
html_parts.append("<tr>")
|
|
159
|
+
for value in row:
|
|
160
|
+
html_parts.append(f"<td>{escape(value)}</td>")
|
|
161
|
+
html_parts.append("</tr>")
|
|
162
|
+
|
|
163
|
+
html_parts.append("</tbody></table>")
|
|
164
|
+
return "".join(html_parts)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def extract_chart_html_from_ooxml(chart_xml: bytes, workbook_bytes: bytes | None) -> str:
|
|
168
|
+
"""解析 OOXML 图表并按工作簿、缓存和普通表格的优先级生成 HTML。"""
|
|
169
|
+
spec = parse_chart_spec_from_ooxml(chart_xml)
|
|
170
|
+
if spec is None or not spec.series:
|
|
171
|
+
if workbook_bytes:
|
|
172
|
+
return html_table_from_excel_bytes(workbook_bytes)
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
chart_cache_html = render_chart_html_from_cache(spec)
|
|
176
|
+
|
|
177
|
+
if workbook_bytes:
|
|
178
|
+
chart_html = render_chart_html_from_workbook(spec, workbook_bytes)
|
|
179
|
+
if chart_html:
|
|
180
|
+
return chart_html
|
|
181
|
+
if chart_cache_html:
|
|
182
|
+
return chart_cache_html
|
|
183
|
+
workbook_table_html = html_table_from_excel_bytes(workbook_bytes)
|
|
184
|
+
if workbook_table_html:
|
|
185
|
+
return workbook_table_html
|
|
186
|
+
|
|
187
|
+
return chart_cache_html
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_chart_spec_from_ooxml(chart_xml: bytes) -> ChartSpec | None:
|
|
191
|
+
"""解析图表 XML,生成统一的图表类型、坐标轴和序列描述。"""
|
|
192
|
+
try:
|
|
193
|
+
root = etree.fromstring(
|
|
194
|
+
chart_xml,
|
|
195
|
+
parser=etree.XMLParser(
|
|
196
|
+
load_dtd=False,
|
|
197
|
+
resolve_entities=False,
|
|
198
|
+
no_network=True,
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
except (etree.XMLSyntaxError, TypeError, ValueError):
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
plot_area = root.find(".//c:plotArea", namespaces=_NS)
|
|
205
|
+
if plot_area is None:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
plot_elements = _collect_plot_elements(plot_area)
|
|
209
|
+
if not plot_elements:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
has_date_axis = plot_area.find("c:dateAx", namespaces=_NS) is not None
|
|
213
|
+
plot_kinds = {_plot_kind_from_tag_name(tag_name, has_date_axis) for tag_name, _ in plot_elements}
|
|
214
|
+
if plot_kinds == {"scatter"}:
|
|
215
|
+
plot_kind = "scatter"
|
|
216
|
+
elif plot_kinds == {"bubble"}:
|
|
217
|
+
plot_kind = "bubble"
|
|
218
|
+
elif plot_kinds <= {"category", "date"}:
|
|
219
|
+
plot_kind = "date" if "date" in plot_kinds else "category"
|
|
220
|
+
else:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
category_axis_title = ""
|
|
224
|
+
axis = plot_area.find("c:dateAx", namespaces=_NS)
|
|
225
|
+
if axis is None:
|
|
226
|
+
axis = plot_area.find("c:catAx", namespaces=_NS)
|
|
227
|
+
if axis is not None:
|
|
228
|
+
category_axis_title = _extract_title_text(axis.find("c:title", namespaces=_NS)) # type: ignore
|
|
229
|
+
|
|
230
|
+
x_axis_title = ""
|
|
231
|
+
value_axis_title = ""
|
|
232
|
+
if plot_kind in {"scatter", "bubble"}:
|
|
233
|
+
for axis in plot_area.findall("c:valAx", namespaces=_NS):
|
|
234
|
+
axis_pos = axis.find("c:axPos", namespaces=_NS)
|
|
235
|
+
axis_position = axis_pos.get("val") if axis_pos is not None else ""
|
|
236
|
+
title = _extract_title_text(axis.find("c:title", namespaces=_NS)) # type: ignore
|
|
237
|
+
if axis_position == "b" and not x_axis_title:
|
|
238
|
+
x_axis_title = title
|
|
239
|
+
elif axis_position == "l" and not value_axis_title:
|
|
240
|
+
value_axis_title = title
|
|
241
|
+
if not x_axis_title:
|
|
242
|
+
x_axis_title = category_axis_title
|
|
243
|
+
else:
|
|
244
|
+
axis = plot_area.find("c:valAx", namespaces=_NS)
|
|
245
|
+
if axis is not None:
|
|
246
|
+
value_axis_title = _extract_title_text(axis.find("c:title", namespaces=_NS)) # type: ignore
|
|
247
|
+
|
|
248
|
+
series_specs = []
|
|
249
|
+
for _, plot_element in plot_elements:
|
|
250
|
+
for series_element in plot_element.findall("c:ser", namespaces=_NS):
|
|
251
|
+
series_specs.append(
|
|
252
|
+
SeriesSpec(
|
|
253
|
+
name_formula=_extract_tx_formula(series_element.find("c:tx", namespaces=_NS)), # type: ignore
|
|
254
|
+
literal_name=_extract_tx_text(series_element.find("c:tx", namespaces=_NS)), # type: ignore
|
|
255
|
+
cat_formula=_extract_reference_formula(series_element.find("c:cat", namespaces=_NS)), # type: ignore
|
|
256
|
+
x_formula=_extract_reference_formula(series_element.find("c:xVal", namespaces=_NS)), # type: ignore
|
|
257
|
+
val_formula=_extract_reference_formula(series_element.find("c:val", namespaces=_NS)), # type: ignore
|
|
258
|
+
y_formula=_extract_reference_formula(series_element.find("c:yVal", namespaces=_NS)), # type: ignore
|
|
259
|
+
bubble_size_formula=_extract_reference_formula(series_element.find("c:bubbleSize", namespaces=_NS)), # type: ignore
|
|
260
|
+
cached_categories=_extract_reference_cache(
|
|
261
|
+
series_element.find("c:cat", namespaces=_NS), # type: ignore
|
|
262
|
+
date_hint=has_date_axis,
|
|
263
|
+
date_1904=_chart_uses_date_1904(root),
|
|
264
|
+
),
|
|
265
|
+
cached_x_values=_extract_reference_cache(series_element.find("c:xVal", namespaces=_NS)), # type: ignore
|
|
266
|
+
cached_values=_extract_reference_cache(
|
|
267
|
+
_first_non_none(
|
|
268
|
+
series_element.find("c:val", namespaces=_NS),
|
|
269
|
+
series_element.find("c:yVal", namespaces=_NS),
|
|
270
|
+
) # type: ignore
|
|
271
|
+
),
|
|
272
|
+
cached_bubble_sizes=_extract_reference_cache(series_element.find("c:bubbleSize", namespaces=_NS)), # type: ignore
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return ChartSpec(
|
|
277
|
+
chart_type=(plot_elements[0][0] if len(plot_elements) == 1 else "comboChart"),
|
|
278
|
+
plot_kind=plot_kind,
|
|
279
|
+
title=_extract_title_text(root.find(".//c:chart/c:title", namespaces=_NS)), # type: ignore
|
|
280
|
+
category_axis_title=category_axis_title,
|
|
281
|
+
value_axis_title=value_axis_title,
|
|
282
|
+
x_axis_title=x_axis_title,
|
|
283
|
+
has_date_axis=has_date_axis,
|
|
284
|
+
date_1904=_chart_uses_date_1904(root),
|
|
285
|
+
series=series_specs,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def render_chart_html_from_workbook(spec: ChartSpec, workbook_bytes: bytes) -> str:
|
|
290
|
+
"""根据图表公式从嵌入工作簿读取数据并渲染 HTML 表格。"""
|
|
291
|
+
try:
|
|
292
|
+
workbook = load_workbook(
|
|
293
|
+
filename=BytesIO(workbook_bytes),
|
|
294
|
+
data_only=True,
|
|
295
|
+
read_only=True,
|
|
296
|
+
)
|
|
297
|
+
except Exception:
|
|
298
|
+
return ""
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
if spec.plot_kind in {"category", "date"}:
|
|
302
|
+
return _render_category_like_chart_from_workbook(spec, workbook)
|
|
303
|
+
if spec.plot_kind == "scatter":
|
|
304
|
+
return _render_scatter_like_chart_from_workbook(spec, workbook)
|
|
305
|
+
if spec.plot_kind == "bubble":
|
|
306
|
+
return _render_bubble_chart_from_workbook(spec, workbook)
|
|
307
|
+
return ""
|
|
308
|
+
finally:
|
|
309
|
+
workbook.close()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def render_chart_html_from_cache(spec: ChartSpec) -> str:
|
|
313
|
+
"""在工作簿不可用时使用 OOXML 图表缓存数据渲染 HTML 表格。"""
|
|
314
|
+
if spec.plot_kind in {"category", "date"}:
|
|
315
|
+
categories = []
|
|
316
|
+
for series in spec.series:
|
|
317
|
+
if series.cached_categories:
|
|
318
|
+
categories = series.cached_categories
|
|
319
|
+
break
|
|
320
|
+
|
|
321
|
+
series_names = []
|
|
322
|
+
series_values = []
|
|
323
|
+
for idx, series in enumerate(spec.series, start=1):
|
|
324
|
+
series_names.append(_resolve_series_name(series, idx))
|
|
325
|
+
series_values.append(series.cached_values)
|
|
326
|
+
|
|
327
|
+
row_count = max(
|
|
328
|
+
len(categories),
|
|
329
|
+
max((len(values) for values in series_values), default=0),
|
|
330
|
+
)
|
|
331
|
+
if not series_names or row_count == 0:
|
|
332
|
+
return ""
|
|
333
|
+
|
|
334
|
+
headers = [spec.category_axis_title or ""] + series_names
|
|
335
|
+
columns = [categories] + series_values
|
|
336
|
+
return _render_html_table(headers, columns, row_count)
|
|
337
|
+
|
|
338
|
+
if spec.plot_kind == "scatter":
|
|
339
|
+
return _render_scatter_like_chart_from_cache(spec)
|
|
340
|
+
|
|
341
|
+
if spec.plot_kind == "bubble":
|
|
342
|
+
return _render_bubble_chart_from_cache(spec)
|
|
343
|
+
|
|
344
|
+
return ""
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _render_category_like_chart_from_workbook(spec: ChartSpec, workbook: Workbook) -> str:
|
|
348
|
+
"""从工作簿渲染分类轴或日期轴图表的二维 HTML 表格。"""
|
|
349
|
+
categories = []
|
|
350
|
+
|
|
351
|
+
for series in spec.series:
|
|
352
|
+
if not series.cat_formula:
|
|
353
|
+
continue
|
|
354
|
+
read_result = _read_formula_vector(workbook, series.cat_formula)
|
|
355
|
+
if read_result is None:
|
|
356
|
+
return ""
|
|
357
|
+
_, values = read_result
|
|
358
|
+
categories = values
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
series_names = []
|
|
362
|
+
series_values = []
|
|
363
|
+
for idx, series in enumerate(spec.series, start=1):
|
|
364
|
+
if not series.val_formula:
|
|
365
|
+
return ""
|
|
366
|
+
read_result = _read_formula_vector(workbook, series.val_formula)
|
|
367
|
+
if read_result is None:
|
|
368
|
+
return ""
|
|
369
|
+
_, values = read_result
|
|
370
|
+
series_names.append(_resolve_series_name(series, idx, workbook))
|
|
371
|
+
series_values.append(values)
|
|
372
|
+
|
|
373
|
+
row_count = max(
|
|
374
|
+
len(categories),
|
|
375
|
+
max((len(values) for values in series_values), default=0),
|
|
376
|
+
)
|
|
377
|
+
if not series_names or row_count == 0:
|
|
378
|
+
return ""
|
|
379
|
+
|
|
380
|
+
headers = [spec.category_axis_title or ""] + series_names
|
|
381
|
+
columns = [
|
|
382
|
+
_stringify_series_values(
|
|
383
|
+
categories,
|
|
384
|
+
date_hint=spec.has_date_axis,
|
|
385
|
+
date_1904=spec.date_1904,
|
|
386
|
+
)
|
|
387
|
+
]
|
|
388
|
+
columns.extend(_stringify_series_values(values) for values in series_values)
|
|
389
|
+
return _render_html_table(headers, columns, row_count)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _render_scatter_like_chart_from_workbook(spec: ChartSpec, workbook: Workbook) -> str:
|
|
393
|
+
"""读取工作簿中的散点图横纵轴数据并渲染 HTML 表格。"""
|
|
394
|
+
x_sequences, series_names, series_y_values = _read_scatter_axes_from_workbook(
|
|
395
|
+
spec,
|
|
396
|
+
workbook,
|
|
397
|
+
)
|
|
398
|
+
return _render_scatter_like_chart_table(
|
|
399
|
+
x_sequences,
|
|
400
|
+
series_names,
|
|
401
|
+
series_y_values,
|
|
402
|
+
x_axis_title=spec.x_axis_title,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _render_bubble_chart_from_workbook(spec: ChartSpec, workbook: Workbook) -> str:
|
|
407
|
+
"""读取工作簿中的气泡图横纵轴和尺寸数据并渲染 HTML 表格。"""
|
|
408
|
+
x_sequences, series_names, series_y_values, series_sizes = _read_bubble_axes_from_workbook(
|
|
409
|
+
spec,
|
|
410
|
+
workbook,
|
|
411
|
+
)
|
|
412
|
+
return _render_bubble_chart_table(
|
|
413
|
+
x_sequences,
|
|
414
|
+
series_names,
|
|
415
|
+
series_y_values,
|
|
416
|
+
series_sizes,
|
|
417
|
+
x_axis_title=spec.x_axis_title,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _render_scatter_like_chart_from_cache(spec: ChartSpec) -> str:
|
|
422
|
+
"""使用 OOXML 缓存的横纵轴数据渲染散点图 HTML 表格。"""
|
|
423
|
+
x_sequences = []
|
|
424
|
+
series_names = []
|
|
425
|
+
series_y_values = []
|
|
426
|
+
for idx, series in enumerate(spec.series, start=1):
|
|
427
|
+
if not series.cached_x_values or not series.cached_values:
|
|
428
|
+
return ""
|
|
429
|
+
x_sequences.append(series.cached_x_values)
|
|
430
|
+
series_names.append(_resolve_series_name(series, idx))
|
|
431
|
+
series_y_values.append(series.cached_values)
|
|
432
|
+
|
|
433
|
+
return _render_scatter_like_chart_table(
|
|
434
|
+
x_sequences,
|
|
435
|
+
series_names,
|
|
436
|
+
series_y_values,
|
|
437
|
+
x_axis_title=spec.x_axis_title,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _render_bubble_chart_from_cache(spec: ChartSpec) -> str:
|
|
442
|
+
"""使用 OOXML 缓存的横纵轴和尺寸数据渲染气泡图 HTML 表格。"""
|
|
443
|
+
x_sequences = []
|
|
444
|
+
series_names = []
|
|
445
|
+
series_y_values = []
|
|
446
|
+
series_sizes = []
|
|
447
|
+
for idx, series in enumerate(spec.series, start=1):
|
|
448
|
+
if not series.cached_x_values or not series.cached_values or not series.cached_bubble_sizes:
|
|
449
|
+
return ""
|
|
450
|
+
x_sequences.append(series.cached_x_values)
|
|
451
|
+
series_names.append(_resolve_series_name(series, idx))
|
|
452
|
+
series_y_values.append(series.cached_values)
|
|
453
|
+
series_sizes.append(series.cached_bubble_sizes)
|
|
454
|
+
|
|
455
|
+
return _render_bubble_chart_table(
|
|
456
|
+
x_sequences,
|
|
457
|
+
series_names,
|
|
458
|
+
series_y_values,
|
|
459
|
+
series_sizes,
|
|
460
|
+
x_axis_title=spec.x_axis_title,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _read_scatter_axes_from_workbook(
|
|
465
|
+
spec: ChartSpec, workbook: Workbook
|
|
466
|
+
) -> tuple[list[list[float]] | None, list[str], list[list[float]]]:
|
|
467
|
+
"""按序列公式读取散点图的 X 轴、序列名称和 Y 轴数据。"""
|
|
468
|
+
x_sequences = []
|
|
469
|
+
series_names = []
|
|
470
|
+
series_y_values = []
|
|
471
|
+
|
|
472
|
+
for idx, series in enumerate(spec.series, start=1):
|
|
473
|
+
if not series.x_formula or not series.y_formula:
|
|
474
|
+
return None, [], []
|
|
475
|
+
|
|
476
|
+
x_read = _read_formula_vector(workbook, series.x_formula)
|
|
477
|
+
if x_read is None:
|
|
478
|
+
return None, [], []
|
|
479
|
+
_, x_values = x_read
|
|
480
|
+
x_sequences.append(x_values)
|
|
481
|
+
|
|
482
|
+
y_read = _read_formula_vector(workbook, series.y_formula)
|
|
483
|
+
if y_read is None:
|
|
484
|
+
return None, [], []
|
|
485
|
+
_, y_values = y_read
|
|
486
|
+
series_names.append(_resolve_series_name(series, idx, workbook))
|
|
487
|
+
series_y_values.append(y_values)
|
|
488
|
+
|
|
489
|
+
return x_sequences, series_names, series_y_values
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _read_bubble_axes_from_workbook(
|
|
493
|
+
spec: ChartSpec, workbook: Workbook
|
|
494
|
+
) -> tuple[list[list[float]] | None, list[str], list[list[float]], list[list[float]]]:
|
|
495
|
+
"""按序列公式读取气泡图的 X/Y 轴、名称和气泡尺寸数据。"""
|
|
496
|
+
x_sequences = []
|
|
497
|
+
series_names = []
|
|
498
|
+
series_y_values = []
|
|
499
|
+
series_sizes = []
|
|
500
|
+
|
|
501
|
+
for idx, series in enumerate(spec.series, start=1):
|
|
502
|
+
if not series.x_formula or not series.y_formula or not series.bubble_size_formula:
|
|
503
|
+
return None, [], [], []
|
|
504
|
+
|
|
505
|
+
x_read = _read_formula_vector(workbook, series.x_formula)
|
|
506
|
+
if x_read is None:
|
|
507
|
+
return None, [], [], []
|
|
508
|
+
_, x_values = x_read
|
|
509
|
+
x_sequences.append(x_values)
|
|
510
|
+
|
|
511
|
+
y_read = _read_formula_vector(workbook, series.y_formula)
|
|
512
|
+
bubble_size_read = _read_formula_vector(workbook, series.bubble_size_formula)
|
|
513
|
+
if y_read is None or bubble_size_read is None:
|
|
514
|
+
return None, [], [], []
|
|
515
|
+
|
|
516
|
+
series_names.append(_resolve_series_name(series, idx, workbook))
|
|
517
|
+
series_y_values.append(y_read[1])
|
|
518
|
+
series_sizes.append(bubble_size_read[1])
|
|
519
|
+
|
|
520
|
+
return x_sequences, series_names, series_y_values, series_sizes
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _read_formula_vector(workbook: Workbook, formula: str) -> tuple[str, list[Any]] | None:
|
|
524
|
+
"""解析单行或单列单元格公式,并从工作簿读取对应的一维数据。"""
|
|
525
|
+
parsed = _parse_formula(formula)
|
|
526
|
+
if parsed is None:
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
sheet_name, min_col, min_row, max_col, max_row = parsed
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
worksheet = workbook[sheet_name]
|
|
533
|
+
except KeyError:
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
if min_col != max_col and min_row != max_row:
|
|
537
|
+
return None
|
|
538
|
+
|
|
539
|
+
values = []
|
|
540
|
+
if min_col == max_col:
|
|
541
|
+
for row_idx in range(min_row, max_row + 1):
|
|
542
|
+
values.append(worksheet.cell(row=row_idx, column=min_col).value)
|
|
543
|
+
else:
|
|
544
|
+
for col_idx in range(min_col, max_col + 1):
|
|
545
|
+
values.append(worksheet.cell(row=min_row, column=col_idx).value)
|
|
546
|
+
|
|
547
|
+
return sheet_name, values
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _read_formula_scalar(workbook: Workbook, formula: str) -> str | None:
|
|
551
|
+
"""读取公式引用区域的首个有效值并转换为字符串。"""
|
|
552
|
+
read_result = _read_formula_vector(workbook, formula)
|
|
553
|
+
if read_result is None:
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
_, values = read_result
|
|
557
|
+
if not values:
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
value = values[0]
|
|
561
|
+
if value in (None, ""):
|
|
562
|
+
return None
|
|
563
|
+
return _stringify_cell_value(value)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _parse_formula(formula: str) -> tuple[str, int, int, int, int] | None:
|
|
567
|
+
"""将工作表区域公式解析为工作表名称和规范化单元格边界。"""
|
|
568
|
+
formula = formula.strip()
|
|
569
|
+
if not formula:
|
|
570
|
+
return None
|
|
571
|
+
if formula.startswith("="):
|
|
572
|
+
formula = formula[1:]
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
sheet_name, bounds = range_to_tuple(formula)
|
|
576
|
+
except ValueError:
|
|
577
|
+
return None
|
|
578
|
+
|
|
579
|
+
if not all(isinstance(bound, int) for bound in bounds):
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
min_col, min_row, max_col, max_row = bounds
|
|
583
|
+
return _unescape_formula_sheet_name(sheet_name), min_col, min_row, max_col, max_row
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _unescape_formula_sheet_name(sheet_name: str) -> str:
|
|
587
|
+
"""还原 OOXML 公式中以双单引号转义的工作表名称。"""
|
|
588
|
+
return sheet_name.replace("''", "'")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _extract_reference_formula(container: etree._Element) -> str | None:
|
|
592
|
+
"""从字符串、数字或多级字符串引用容器中提取公式文本。"""
|
|
593
|
+
ref_element = _find_reference_element(container)
|
|
594
|
+
if ref_element is None:
|
|
595
|
+
return None
|
|
596
|
+
formula_element = ref_element.find("c:f", namespaces=_NS)
|
|
597
|
+
if formula_element is None or formula_element.text is None:
|
|
598
|
+
return None
|
|
599
|
+
return formula_element.text.strip()
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _extract_reference_cache(
|
|
603
|
+
container: etree._Element,
|
|
604
|
+
*,
|
|
605
|
+
date_hint: bool = False,
|
|
606
|
+
date_1904: bool = False,
|
|
607
|
+
) -> list[str]:
|
|
608
|
+
"""从图表引用容器提取缓存数据,并按日期提示规范化值。"""
|
|
609
|
+
ref_element = _find_reference_element(container)
|
|
610
|
+
if ref_element is None:
|
|
611
|
+
return []
|
|
612
|
+
|
|
613
|
+
tag_name = etree.QName(ref_element).localname
|
|
614
|
+
if tag_name == "multiLvlStrRef":
|
|
615
|
+
return _extract_multilevel_string_cache(ref_element)
|
|
616
|
+
|
|
617
|
+
cache_element = ref_element.find("c:strCache", namespaces=_NS)
|
|
618
|
+
if cache_element is None:
|
|
619
|
+
cache_element = ref_element.find("c:numCache", namespaces=_NS)
|
|
620
|
+
if cache_element is None:
|
|
621
|
+
return []
|
|
622
|
+
|
|
623
|
+
return _extract_cache_points(
|
|
624
|
+
cache_element,
|
|
625
|
+
date_hint=date_hint,
|
|
626
|
+
date_1904=date_1904,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _extract_cache_points(
|
|
631
|
+
cache_element: etree._Element,
|
|
632
|
+
*,
|
|
633
|
+
date_hint: bool = False,
|
|
634
|
+
date_1904: bool = False,
|
|
635
|
+
) -> list[str]:
|
|
636
|
+
"""按缓存点索引还原连续序列,并拒绝异常大的稀疏索引范围。"""
|
|
637
|
+
points = {}
|
|
638
|
+
for point in cache_element.findall("c:pt", namespaces=_NS):
|
|
639
|
+
raw_index = point.get("idx")
|
|
640
|
+
if raw_index is None:
|
|
641
|
+
continue
|
|
642
|
+
try:
|
|
643
|
+
point_index = int(raw_index)
|
|
644
|
+
except ValueError:
|
|
645
|
+
continue
|
|
646
|
+
|
|
647
|
+
value_element = point.find("c:v", namespaces=_NS)
|
|
648
|
+
raw_value = value_element.text if value_element is not None else ""
|
|
649
|
+
points[point_index] = _stringify_cache_value(
|
|
650
|
+
raw_value,
|
|
651
|
+
date_hint=date_hint,
|
|
652
|
+
date_1904=date_1904,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
if not points:
|
|
656
|
+
return []
|
|
657
|
+
|
|
658
|
+
max_index = max(points.keys())
|
|
659
|
+
if max_index + 1 > _MAX_CACHE_INDEX_SPAN:
|
|
660
|
+
return []
|
|
661
|
+
|
|
662
|
+
return [points.get(index, "") for index in range(max_index + 1)]
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _extract_multilevel_string_cache(ref_element: etree._Element) -> list[str]:
|
|
666
|
+
"""合并多级分类缓存的同索引文本,生成扁平分类标签序列。"""
|
|
667
|
+
level_maps = []
|
|
668
|
+
max_index = -1
|
|
669
|
+
for level in ref_element.findall("c:multiLvlStrCache/c:lvl", namespaces=_NS):
|
|
670
|
+
values = {}
|
|
671
|
+
for point in level.findall("c:pt", namespaces=_NS):
|
|
672
|
+
raw_index = point.get("idx")
|
|
673
|
+
if raw_index is None:
|
|
674
|
+
continue
|
|
675
|
+
try:
|
|
676
|
+
point_index = int(raw_index)
|
|
677
|
+
except ValueError:
|
|
678
|
+
continue
|
|
679
|
+
value_element = point.find("c:v", namespaces=_NS)
|
|
680
|
+
values[point_index] = value_element.text if value_element is not None else ""
|
|
681
|
+
max_index = max(max_index, point_index)
|
|
682
|
+
level_maps.append(values)
|
|
683
|
+
|
|
684
|
+
if max_index < 0:
|
|
685
|
+
return []
|
|
686
|
+
|
|
687
|
+
if max_index + 1 > _MAX_CACHE_INDEX_SPAN:
|
|
688
|
+
return []
|
|
689
|
+
|
|
690
|
+
rows = []
|
|
691
|
+
for point_index in range(max_index + 1):
|
|
692
|
+
parts = [value_map[point_index] for value_map in level_maps if value_map.get(point_index)]
|
|
693
|
+
rows.append(" / ".join(parts))
|
|
694
|
+
return rows
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _extract_tx_formula(tx_element: etree._Element) -> str | None:
|
|
698
|
+
"""从图表序列名称节点提取字符串引用公式。"""
|
|
699
|
+
if tx_element is None:
|
|
700
|
+
return None
|
|
701
|
+
str_ref = tx_element.find("c:strRef", namespaces=_NS)
|
|
702
|
+
if str_ref is None:
|
|
703
|
+
return None
|
|
704
|
+
formula_element = str_ref.find("c:f", namespaces=_NS)
|
|
705
|
+
if formula_element is None or formula_element.text is None:
|
|
706
|
+
return None
|
|
707
|
+
return formula_element.text.strip()
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _extract_tx_text(tx_element: etree._Element) -> str | None:
|
|
711
|
+
"""从图表序列名称节点提取缓存文本或直接文本。"""
|
|
712
|
+
if tx_element is None:
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
str_cache = tx_element.find("c:strRef/c:strCache", namespaces=_NS)
|
|
716
|
+
if str_cache is not None:
|
|
717
|
+
values = _extract_cache_points(str_cache)
|
|
718
|
+
return values[0] if values else None
|
|
719
|
+
|
|
720
|
+
value_element = tx_element.find("c:v", namespaces=_NS)
|
|
721
|
+
if value_element is not None and value_element.text:
|
|
722
|
+
return value_element.text.strip()
|
|
723
|
+
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _extract_title_text(title_element: etree._Element) -> str:
|
|
728
|
+
"""拼接图表标题节点中的全部富文本片段。"""
|
|
729
|
+
if title_element is None:
|
|
730
|
+
return ""
|
|
731
|
+
texts = title_element.findall(".//a:t", namespaces=_NS)
|
|
732
|
+
return "".join(text.text or "" for text in texts).strip()
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _find_reference_element(container: etree._Element) -> object | None:
|
|
736
|
+
"""在容器中查找受支持的字符串、数字或多级字符串引用节点。"""
|
|
737
|
+
if container is None:
|
|
738
|
+
return None
|
|
739
|
+
for tag_name in ("strRef", "numRef", "multiLvlStrRef"):
|
|
740
|
+
ref_element = container.find(f"c:{tag_name}", namespaces=_NS)
|
|
741
|
+
if ref_element is not None:
|
|
742
|
+
return ref_element
|
|
743
|
+
return None
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _first_non_none(*values: Any) -> object | None:
|
|
747
|
+
"""返回参数序列中的第一个非空对象。"""
|
|
748
|
+
for value in values:
|
|
749
|
+
if value is not None:
|
|
750
|
+
return value
|
|
751
|
+
return None
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _collect_plot_elements(plot_area: etree._Element) -> list[tuple[str, etree._Element]]:
|
|
755
|
+
"""收集绘图区中受支持的图表节点及其局部标签名。"""
|
|
756
|
+
plot_elements = []
|
|
757
|
+
for child in plot_area:
|
|
758
|
+
if not isinstance(child.tag, str):
|
|
759
|
+
continue
|
|
760
|
+
tag_name = etree.QName(child).localname
|
|
761
|
+
if tag_name in _PLOT_TAGS:
|
|
762
|
+
plot_elements.append((tag_name, child))
|
|
763
|
+
return plot_elements
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _plot_kind_from_tag_name(tag_name: str, has_date_axis: bool) -> str:
|
|
767
|
+
"""根据 OOXML 图表标签和日期轴信息归一化绘图类别。"""
|
|
768
|
+
if tag_name == "scatterChart":
|
|
769
|
+
return "scatter"
|
|
770
|
+
if tag_name == "bubbleChart":
|
|
771
|
+
return "bubble"
|
|
772
|
+
if has_date_axis:
|
|
773
|
+
return "date"
|
|
774
|
+
return "category"
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _chart_uses_date_1904(root: etree._Element) -> bool:
|
|
778
|
+
"""判断图表是否使用以 1904 年为起点的 Excel 日期系统。"""
|
|
779
|
+
date_1904 = root.find("c:date1904", namespaces=_NS)
|
|
780
|
+
if date_1904 is None:
|
|
781
|
+
return False
|
|
782
|
+
return date_1904.get("val") == "1"
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _resolve_series_name(series: SeriesSpec, index: int, workbook: Workbook | None = None) -> str:
|
|
786
|
+
"""按工作簿引用、缓存名称和默认序号依次解析序列名称。"""
|
|
787
|
+
if workbook is not None and series.name_formula:
|
|
788
|
+
workbook_name = _read_formula_scalar(workbook, series.name_formula)
|
|
789
|
+
if workbook_name:
|
|
790
|
+
return workbook_name
|
|
791
|
+
if series.literal_name:
|
|
792
|
+
return series.literal_name
|
|
793
|
+
return f"Series{index}"
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _get_shared_axis_values(sequences: list[list[Any]]) -> list[Any] | None:
|
|
797
|
+
"""判断多个序列是否共享相同轴值,并在一致时返回首个序列。"""
|
|
798
|
+
if not sequences:
|
|
799
|
+
return None
|
|
800
|
+
|
|
801
|
+
normalized = [_normalize_sequence(sequence) for sequence in sequences]
|
|
802
|
+
first = normalized[0]
|
|
803
|
+
if any(sequence != first for sequence in normalized[1:]):
|
|
804
|
+
return None
|
|
805
|
+
return sequences[0]
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _normalize_sequence(sequence: list[Any]) -> list[str]:
|
|
809
|
+
"""将轴值序列统一转换为可比较的字符串列表。"""
|
|
810
|
+
return [_stringify_cell_value(value) for value in sequence]
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _render_scatter_like_chart_table(
|
|
814
|
+
x_sequences: list[list[Any]] | None,
|
|
815
|
+
series_names: list[str],
|
|
816
|
+
series_y_values: list[list[Any]],
|
|
817
|
+
*,
|
|
818
|
+
x_axis_title: str,
|
|
819
|
+
) -> str:
|
|
820
|
+
"""按共享或独立 X 轴布局,将散点图序列渲染为 HTML 表格。"""
|
|
821
|
+
if not x_sequences or not series_names or len(x_sequences) != len(series_names):
|
|
822
|
+
return ""
|
|
823
|
+
|
|
824
|
+
shared_x_values = _get_shared_axis_values(x_sequences)
|
|
825
|
+
if shared_x_values is not None:
|
|
826
|
+
row_count = max(
|
|
827
|
+
len(shared_x_values),
|
|
828
|
+
max((len(values) for values in series_y_values), default=0),
|
|
829
|
+
)
|
|
830
|
+
if row_count == 0:
|
|
831
|
+
return ""
|
|
832
|
+
|
|
833
|
+
headers = [x_axis_title or ""] + series_names
|
|
834
|
+
columns = [_stringify_series_values(shared_x_values)]
|
|
835
|
+
columns.extend(_stringify_series_values(values) for values in series_y_values)
|
|
836
|
+
return _render_html_table(headers, columns, row_count)
|
|
837
|
+
|
|
838
|
+
headers = []
|
|
839
|
+
columns = []
|
|
840
|
+
row_count = 0
|
|
841
|
+
for name, x_values, y_values in zip(series_names, x_sequences, series_y_values):
|
|
842
|
+
headers.extend((f"{name} X", f"{name} Y"))
|
|
843
|
+
columns.append(_stringify_series_values(x_values))
|
|
844
|
+
columns.append(_stringify_series_values(y_values))
|
|
845
|
+
row_count = max(row_count, len(x_values), len(y_values))
|
|
846
|
+
|
|
847
|
+
if row_count == 0:
|
|
848
|
+
return ""
|
|
849
|
+
|
|
850
|
+
return _render_html_table(headers, columns, row_count)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _render_bubble_chart_table(
|
|
854
|
+
x_sequences: list[list[Any]] | None,
|
|
855
|
+
series_names: list[str],
|
|
856
|
+
series_y_values: list[list[Any]],
|
|
857
|
+
series_sizes: list[list[Any]],
|
|
858
|
+
*,
|
|
859
|
+
x_axis_title: str,
|
|
860
|
+
) -> str:
|
|
861
|
+
"""按共享或独立 X 轴布局,将气泡图三维序列渲染为 HTML 表格。"""
|
|
862
|
+
if (
|
|
863
|
+
not x_sequences
|
|
864
|
+
or not series_names
|
|
865
|
+
or len(x_sequences) != len(series_names)
|
|
866
|
+
or len(series_y_values) != len(series_names)
|
|
867
|
+
or len(series_sizes) != len(series_names)
|
|
868
|
+
):
|
|
869
|
+
return ""
|
|
870
|
+
|
|
871
|
+
shared_x_values = _get_shared_axis_values(x_sequences)
|
|
872
|
+
if shared_x_values is not None:
|
|
873
|
+
row_count = max(
|
|
874
|
+
len(shared_x_values),
|
|
875
|
+
max((len(values) for values in series_y_values), default=0),
|
|
876
|
+
max((len(values) for values in series_sizes), default=0),
|
|
877
|
+
)
|
|
878
|
+
if row_count == 0:
|
|
879
|
+
return ""
|
|
880
|
+
|
|
881
|
+
headers = [x_axis_title or ""]
|
|
882
|
+
columns = [_stringify_series_values(shared_x_values)]
|
|
883
|
+
for name, y_values, bubble_sizes in zip(series_names, series_y_values, series_sizes):
|
|
884
|
+
headers.extend((name, f"{name} size"))
|
|
885
|
+
columns.append(_stringify_series_values(y_values))
|
|
886
|
+
columns.append(_stringify_series_values(bubble_sizes))
|
|
887
|
+
return _render_html_table(headers, columns, row_count)
|
|
888
|
+
|
|
889
|
+
headers = []
|
|
890
|
+
columns = []
|
|
891
|
+
row_count = 0
|
|
892
|
+
for name, x_values, y_values, bubble_sizes in zip(
|
|
893
|
+
series_names,
|
|
894
|
+
x_sequences,
|
|
895
|
+
series_y_values,
|
|
896
|
+
series_sizes,
|
|
897
|
+
):
|
|
898
|
+
headers.extend((f"{name} X", f"{name} Y", f"{name} size"))
|
|
899
|
+
columns.append(_stringify_series_values(x_values))
|
|
900
|
+
columns.append(_stringify_series_values(y_values))
|
|
901
|
+
columns.append(_stringify_series_values(bubble_sizes))
|
|
902
|
+
row_count = max(row_count, len(x_values), len(y_values), len(bubble_sizes))
|
|
903
|
+
|
|
904
|
+
if row_count == 0:
|
|
905
|
+
return ""
|
|
906
|
+
|
|
907
|
+
return _render_html_table(headers, columns, row_count)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _stringify_series_values(
|
|
911
|
+
values: list[Any],
|
|
912
|
+
*,
|
|
913
|
+
date_hint: bool = False,
|
|
914
|
+
date_1904: bool = False,
|
|
915
|
+
) -> list[str]:
|
|
916
|
+
"""将图表序列值批量转换为适合 HTML 输出的文本。"""
|
|
917
|
+
return [
|
|
918
|
+
_stringify_cell_value(
|
|
919
|
+
value,
|
|
920
|
+
date_hint=date_hint,
|
|
921
|
+
date_1904=date_1904,
|
|
922
|
+
)
|
|
923
|
+
for value in values
|
|
924
|
+
]
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _stringify_cache_value(
|
|
928
|
+
value: str | None,
|
|
929
|
+
*,
|
|
930
|
+
date_hint: bool = False,
|
|
931
|
+
date_1904: bool = False,
|
|
932
|
+
) -> str:
|
|
933
|
+
"""规范化 OOXML 缓存值,并在需要时转换 Excel 日期序列号。"""
|
|
934
|
+
if value in (None, ""):
|
|
935
|
+
return ""
|
|
936
|
+
|
|
937
|
+
if date_hint:
|
|
938
|
+
try:
|
|
939
|
+
serial = float(value)
|
|
940
|
+
except (TypeError, ValueError):
|
|
941
|
+
return value
|
|
942
|
+
return _excel_serial_to_iso(serial, date_1904=date_1904) or value
|
|
943
|
+
|
|
944
|
+
return value
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _stringify_cell_value(
|
|
948
|
+
value: Any,
|
|
949
|
+
*,
|
|
950
|
+
date_hint: bool = False,
|
|
951
|
+
date_1904: bool = False,
|
|
952
|
+
) -> str:
|
|
953
|
+
"""将工作簿单元格值转换为稳定文本,并保留日期时间语义。"""
|
|
954
|
+
if value in (None, ""):
|
|
955
|
+
return ""
|
|
956
|
+
|
|
957
|
+
if isinstance(value, datetime):
|
|
958
|
+
if date_hint and value.time() == time():
|
|
959
|
+
return value.date().isoformat()
|
|
960
|
+
return value.isoformat(sep=" ")
|
|
961
|
+
if isinstance(value, date):
|
|
962
|
+
return value.isoformat()
|
|
963
|
+
if isinstance(value, time):
|
|
964
|
+
return value.isoformat()
|
|
965
|
+
|
|
966
|
+
if date_hint and isinstance(value, (int, float)):
|
|
967
|
+
return _excel_serial_to_iso(float(value), date_1904=date_1904) or _stringify_non_date_value(value)
|
|
968
|
+
|
|
969
|
+
return _stringify_non_date_value(value)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _excel_serial_to_iso(serial: float, *, date_1904: bool = False) -> str | None:
|
|
973
|
+
"""把 Excel 日期序列号转换为 ISO 文本,无效数值返回空结果。"""
|
|
974
|
+
if not math.isfinite(serial):
|
|
975
|
+
return None
|
|
976
|
+
try:
|
|
977
|
+
excel_value = from_excel(serial, MAC_EPOCH if date_1904 else WINDOWS_EPOCH)
|
|
978
|
+
except (TypeError, ValueError, OverflowError):
|
|
979
|
+
return None
|
|
980
|
+
if isinstance(excel_value, datetime):
|
|
981
|
+
if excel_value.time() == time():
|
|
982
|
+
return excel_value.date().isoformat()
|
|
983
|
+
return excel_value.isoformat(sep=" ")
|
|
984
|
+
if isinstance(excel_value, date):
|
|
985
|
+
return excel_value.isoformat()
|
|
986
|
+
if isinstance(excel_value, time):
|
|
987
|
+
return excel_value.isoformat()
|
|
988
|
+
return str(excel_value)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _stringify_non_date_value(value: Any) -> str:
|
|
992
|
+
"""将非日期值转换为文本,并去除整数浮点值末尾的小数部分。"""
|
|
993
|
+
if isinstance(value, float) and value.is_integer():
|
|
994
|
+
return str(int(value))
|
|
995
|
+
return str(value)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _render_html_table(headers: list[str], columns: list[list[str]], row_count: int) -> str:
|
|
999
|
+
"""按表头、列数据和行数生成已转义的紧凑 HTML 表格。"""
|
|
1000
|
+
if row_count <= 0 or len(headers) != len(columns):
|
|
1001
|
+
return ""
|
|
1002
|
+
|
|
1003
|
+
html_parts = ["<table><thead><tr>"]
|
|
1004
|
+
for header in headers:
|
|
1005
|
+
html_parts.append(f"<th>{escape(header)}</th>")
|
|
1006
|
+
html_parts.append("</tr></thead><tbody>")
|
|
1007
|
+
|
|
1008
|
+
for row_idx in range(row_count):
|
|
1009
|
+
html_parts.append("<tr>")
|
|
1010
|
+
for column in columns:
|
|
1011
|
+
value = column[row_idx] if row_idx < len(column) else ""
|
|
1012
|
+
html_parts.append(f"<td>{escape(value)}</td>")
|
|
1013
|
+
html_parts.append("</tr>")
|
|
1014
|
+
|
|
1015
|
+
html_parts.append("</tbody></table>")
|
|
1016
|
+
return "".join(html_parts)
|