From 23bff706583021fb305420eb01833a52368fd35b Mon Sep 17 00:00:00 2001 From: jd <59188306+zhangjiadi225@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:25:54 +0800 Subject: [PATCH 01/38] feat(xlsx-preview): add artifact spreadsheet preview Signed-off-by: jd <59188306+zhangjiadi225@users.noreply.github.com> --- package.json | 7 +- pnpm-lock.yaml | 331 ++++++++ .../office/OfficePreviewPanel.tsx | 26 +- .../office/XlsxPreviewPanel/XlsxGrid.tsx | 453 +++++++++++ .../XlsxPreviewPanel/XlsxPreviewPanel.tsx | 261 +++++++ .../__tests__/chart.buildChartOption.test.ts | 135 ++++ .../chart.chartXmlParser.edgeCases.test.ts | 384 +++++++++ .../__tests__/chart.chartXmlParser.test.ts | 143 ++++ .../chart.echartsChartRenderer.test.ts | 131 ++++ .../__tests__/formula.evaluator.test.ts | 338 ++++++++ .../__tests__/grid.layout.test.ts | 164 ++++ .../__tests__/grid.render.test.tsx | 418 ++++++++++ .../__tests__/panel.useXlsxWorkbook.test.tsx | 194 +++++ .../__tests__/panel.xlsxPreviewPanel.test.tsx | 326 ++++++++ .../__tests__/parser.numberFormat.test.ts | 66 ++ .../__tests__/parser.parseWorkbook.test.ts | 494 ++++++++++++ .../__tests__/parser.themeResolver.test.ts | 87 +++ .../__tests__/xlsxTestPackages.ts | 252 ++++++ .../XlsxPreviewPanel/charts/ChartRenderer.ts | 10 + .../charts/EchartsChartRenderer.ts | 47 ++ .../charts/buildChartOption.ts | 69 ++ .../XlsxPreviewPanel/fast-formula-parser.d.ts | 59 ++ .../office/XlsxPreviewPanel/gridLayout.ts | 159 ++++ .../office/XlsxPreviewPanel/index.ts | 1 + .../office/XlsxPreviewPanel/mockModel.ts | 134 ++++ .../office/XlsxPreviewPanel/renderModel.ts | 144 ++++ .../XlsxPreviewPanel/useXlsxWorkbook.ts | 133 ++++ .../XlsxPreviewPanel/worker/chartXmlParser.ts | 529 +++++++++++++ .../worker/formulaEvaluator.ts | 156 ++++ .../XlsxPreviewPanel/worker/numberFormat.ts | 87 +++ .../XlsxPreviewPanel/worker/parseWorkbook.ts | 734 ++++++++++++++++++ .../XlsxPreviewPanel/worker/themeResolver.ts | 261 +++++++ .../worker/xlsxParser.worker.ts | 19 + .../__tests__/OfficePreviewPanel.test.tsx | 54 +- src/renderer/i18n/locales/en-us.json | 9 + src/renderer/i18n/locales/zh-cn.json | 9 + src/renderer/i18n/locales/zh-tw.json | 9 + src/renderer/i18n/translate/de-de.json | 9 + src/renderer/i18n/translate/el-gr.json | 9 + src/renderer/i18n/translate/es-es.json | 9 + src/renderer/i18n/translate/fr-fr.json | 9 + src/renderer/i18n/translate/ja-jp.json | 9 + src/renderer/i18n/translate/pt-pt.json | 9 + src/renderer/i18n/translate/ro-ro.json | 9 + src/renderer/i18n/translate/ru-ru.json | 9 + src/renderer/i18n/translate/vi-vn.json | 9 + 46 files changed, 6909 insertions(+), 5 deletions(-) create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/XlsxGrid.tsx create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/XlsxPreviewPanel.tsx create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.buildChartOption.test.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.chartXmlParser.edgeCases.test.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.chartXmlParser.test.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.echartsChartRenderer.test.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/formula.evaluator.test.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.layout.test.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.useXlsxWorkbook.test.tsx create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.xlsxPreviewPanel.test.tsx create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.numberFormat.test.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.parseWorkbook.test.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.themeResolver.test.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/xlsxTestPackages.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/ChartRenderer.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/EchartsChartRenderer.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/buildChartOption.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/fast-formula-parser.d.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/gridLayout.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/index.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/mockModel.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/renderModel.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/useXlsxWorkbook.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/chartXmlParser.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/formulaEvaluator.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/numberFormat.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/parseWorkbook.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/themeResolver.ts create mode 100644 src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/xlsxParser.worker.ts diff --git a/package.json b/package.json index 2c4c606e2b8..974c77d784f 100644 --- a/package.json +++ b/package.json @@ -106,12 +106,17 @@ "@vectorstores/env": "^0.1.0", "@vectorstores/readers": "^0.1.8", "better-sqlite3": "12.11.1", + "echarts": "^6.1.0", + "exceljs": "^4.4.0", + "fast-formula-parser": "^1.0.19", "font-list": "2.0.0", "graceful-fs": "4.2.11", "grammy": "^1.36.3", "gray-matter": "4.0.3", "jsdom": "26.1.0", + "jszip": "^3.10.1", "node-stream-zip": "1.15.0", + "numfmt": "^3.2.6", "officeparser": "4.2.0", "os-proxy-config": "1.1.2", "pdf-parse": "^2.4.5", @@ -333,7 +338,7 @@ "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", "fast-diff": "^1.3.0", - "fast-xml-parser": "5.4.1", + "fast-xml-parser": "^5.4.1", "fetch-socks": "1.3.2", "form-data": "4.0.5", "fractional-indexing": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 909f2147b51..09d44ec1ba6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,15 @@ importers: better-sqlite3: specifier: 12.11.1 version: 12.11.1 + echarts: + specifier: ^6.1.0 + version: 6.1.0 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 + fast-formula-parser: + specifier: ^1.0.19 + version: 1.0.19 font-list: specifier: 2.0.0 version: 2.0.0 @@ -106,9 +115,15 @@ importers: jsdom: specifier: 26.1.0 version: 26.1.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 node-stream-zip: specifier: 1.15.0 version: 1.15.0 + numfmt: + specifier: ^3.2.6 + version: 3.2.6 officeparser: specifier: 4.2.0 version: 4.2.0 @@ -2922,6 +2937,12 @@ packages: '@expo/sudo-prompt@9.3.2': resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -6833,6 +6854,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@24.10.4': resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} @@ -7569,6 +7593,18 @@ packages: aproba@1.2.0: resolution: {integrity: sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==} + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + archiver@8.0.0: resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} engines: {node: '>=18'} @@ -7685,6 +7721,9 @@ packages: babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + bahttext@1.1.0: + resolution: {integrity: sha512-3g7DbmCwYhLnnN5hJFB+Mtbst4q2gfXpAdqOMK6tz6FmgtlrhX1n1uhuzkIhEr+WcnFR6eRvebIE5msGuEtA7Q==} + bail@1.0.5: resolution: {integrity: sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==} @@ -7730,6 +7769,10 @@ packages: bcp-47@2.1.0: resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + bessel@1.0.2: + resolution: {integrity: sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==} + engines: {node: '>=0.8'} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -7738,6 +7781,10 @@ packages: resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -7745,6 +7792,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -7833,12 +7883,20 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + builder-util-runtime@9.5.0: resolution: {integrity: sha512-7qmRMH8X/IzDM+1TysKNFo5cNWBbfacdLX4EqkuE5aiRTECAlYiKHSqEc6cc3c4Lrmpgk0utbxOPkj0iYaWAWQ==} engines: {node: '>=12.0.0'} @@ -7931,6 +7989,9 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@4.1.1: resolution: {integrity: sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==} engines: {node: '>=10'} @@ -7989,6 +8050,9 @@ packages: chevrotain@11.1.2: resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + chevrotain@7.1.2: + resolution: {integrity: sha512-9bQsXVQ7UAvzMs7iUBBJ9Yv//exOy7bIR3PByOEk4M64vIE/LsiOiX7VIkMF/vEMlrSStwsaE884Bp9CpjtC5g==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -8176,6 +8240,10 @@ packages: compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + compress-commons@7.0.1: resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} engines: {node: '>=18'} @@ -8258,6 +8326,10 @@ packages: engines: {node: '>=0.8'} hasBin: true + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + crc32-stream@7.0.1: resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==} engines: {node: '>=18'} @@ -9349,6 +9421,10 @@ packages: typebox: optional: true + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} @@ -9384,6 +9460,10 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -9400,6 +9480,9 @@ packages: fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-formula-parser@1.0.19: + resolution: {integrity: sha512-8Roq7V1XjuYj3cWZ9tILyqBHGjS7NkejRaHxSWePu0qjIcvimZwe6WfeA9che03dgWrykZqrw691qg3lwiIkMg==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -9629,6 +9712,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -10407,6 +10495,9 @@ packages: resolution: {integrity: sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==} hasBin: true + jstat@1.9.6: + resolution: {integrity: sha512-rPBkJbK2TnA8pzs93QcDDPlKcrtZWuuCo2dVR0TFLOJSxhqfWOVCSp8aV3/oSbn+4uY4yw1URtLpHQedtmXfug==} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -10578,6 +10669,9 @@ packages: linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + localforage@1.10.0: resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} @@ -10596,16 +10690,43 @@ packages: lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash.identity@3.0.0: resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -10615,6 +10736,12 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -11361,6 +11488,9 @@ packages: resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} engines: {node: '>=0.10.0'} + numfmt@3.2.6: + resolution: {integrity: sha512-MXc2KP3j+2usdHTY5/ENUc2S+3BRF/cJqnR6RHeq6LBqKoIZOAQ62DQw974nnaZOencbfkmkTPyTmMnlkCpjzg==} + nwsapi@2.2.23: resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} @@ -12195,6 +12325,9 @@ packages: resolution: {integrity: sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==} engines: {node: '>=8'} + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdir-glob@3.0.0: resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} engines: {node: '>=18'} @@ -12237,6 +12370,9 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + registry-js@1.16.1: resolution: {integrity: sha512-pQ2kD36lh+YNtpaXm6HCCb0QZtV/zQEeKnkfEIj5FDSpF/oFts7pwizEUkWSvP8IbGb4A4a5iBhhS9eUearMmQ==} @@ -13186,6 +13322,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -13503,6 +13642,9 @@ packages: unzip-crx-3@0.2.0: resolution: {integrity: sha512-0+JiUq/z7faJ6oifVB5nSwt589v1KCduqIJupNVDoWSXZtWDmjDGO3RAEOvwJ07w90aoXoP4enKsR7ecMrJtWQ==} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} @@ -13565,6 +13707,11 @@ packages: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -13983,6 +14130,10 @@ packages: youtubei.js@15.1.1: resolution: {integrity: sha512-fuEDj9Ky6cAQg93BrRVCbr+GTYNZQAIFZrx/a3oDRuGc3Mf5bS0dQfoYwwgjtSV7sgAKQEEdGtzRdBzOc8g72Q==} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + zip-stream@7.0.5: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} @@ -15982,6 +16133,25 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -19741,6 +19911,8 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@14.18.63': {} + '@types/node@24.10.4': dependencies: undici-types: 7.16.0 @@ -20906,6 +21078,42 @@ snapshots: aproba@1.2.0: {} + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + archiver@8.0.0: dependencies: async: 3.2.6 @@ -21038,6 +21246,8 @@ snapshots: transitivePeerDependencies: - supports-color + bahttext@1.1.0: {} + bail@1.0.5: {} bail@2.0.2: {} @@ -21069,6 +21279,8 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + bessel@1.0.2: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -21078,10 +21290,17 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + big-integer@1.6.52: {} + bignumber.js@9.3.1: {} binary-extensions@2.3.0: {} + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -21181,6 +21400,8 @@ snapshots: buffer-from@1.1.2: {} + buffer-indexof-polyfill@1.0.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -21191,6 +21412,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffers@0.1.1: {} + builder-util-runtime@9.5.0: dependencies: debug: 4.4.3 @@ -21305,6 +21528,10 @@ snapshots: chai@6.2.2: {} + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + chalk@4.1.1: dependencies: ansi-styles: 4.3.0 @@ -21374,6 +21601,10 @@ snapshots: '@chevrotain/utils': 11.1.2 lodash-es: 4.17.23 + chevrotain@7.1.2: + dependencies: + regexp-to-ast: 0.5.0 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -21560,6 +21791,13 @@ snapshots: compare-versions@6.1.1: {} + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + compress-commons@7.0.1: dependencies: crc-32: 1.2.2 @@ -21642,6 +21880,11 @@ snapshots: crc-32@1.2.2: {} + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + crc32-stream@7.0.1: dependencies: crc-32: 1.2.2 @@ -22842,6 +23085,18 @@ snapshots: exact-mirror@1.0.2: {} + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.19 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.5 + unzipper: 0.10.14 + uuid: 8.3.2 + execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -22909,6 +23164,11 @@ snapshots: extendable-error@0.1.7: {} + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -22919,6 +23179,13 @@ snapshots: fast-fifo@1.3.2: {} + fast-formula-parser@1.0.19: + dependencies: + bahttext: 1.1.0 + bessel: 1.0.2 + chevrotain: 7.1.2 + jstat: 1.9.6 + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -23151,6 +23418,13 @@ snapshots: fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.6.3 + function-bind@1.1.2: {} gauge@2.7.4: @@ -24090,6 +24364,8 @@ snapshots: jsonrepair@3.13.1: {} + jstat@1.9.6: {} + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -24241,6 +24517,8 @@ snapshots: linkifyjs@4.3.3: {} + listenercount@1.0.1: {} + localforage@1.10.0: dependencies: lie: 3.1.1 @@ -24260,18 +24538,40 @@ snapshots: lodash-es@4.17.23: {} + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + lodash.escaperegexp@4.1.2: {} + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + lodash.identity@3.0.0: {} + lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + lodash.isfunction@3.0.9: {} + + lodash.isnil@4.0.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isundefined@3.0.1: {} + lodash.merge@4.6.2: {} lodash.pickby@4.6.0: {} lodash.startcase@4.4.0: {} + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + lodash@4.18.1: {} log-symbols@7.0.1: @@ -25311,6 +25611,8 @@ snapshots: number-is-nan@1.0.1: {} + numfmt@3.2.6: {} + nwsapi@2.2.23: {} object-assign@4.1.1: {} @@ -26311,6 +26613,10 @@ snapshots: dependencies: readable-stream: 4.7.0 + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.7 + readdir-glob@3.0.0: dependencies: minimatch: 10.2.5 @@ -26352,6 +26658,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 + regexp-to-ast@0.5.0: {} + registry-js@1.16.1: dependencies: node-addon-api: 3.2.1 @@ -27543,6 +27851,8 @@ snapshots: dependencies: punycode: 2.3.1 + traverse@0.3.9: {} + tree-kill@1.2.2: {} trigram-utils@2.0.1: @@ -27933,6 +28243,19 @@ snapshots: mkdirp: 0.5.6 yaku: 0.16.7 + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + unzipper@0.12.5: dependencies: bluebird: 3.7.2 @@ -27991,6 +28314,8 @@ snapshots: uuid@14.0.0: {} + uuid@8.3.2: {} + uuid@9.0.1: {} uzip@0.20201231.0: {} @@ -28438,6 +28763,12 @@ snapshots: jintr: 3.3.1 undici: 6.23.0 + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + zip-stream@7.0.5: dependencies: compress-commons: 7.0.1 diff --git a/src/renderer/components/ArtifactPreview/office/OfficePreviewPanel.tsx b/src/renderer/components/ArtifactPreview/office/OfficePreviewPanel.tsx index 2ad85dad7e7..e0a8138ceba 100644 --- a/src/renderer/components/ArtifactPreview/office/OfficePreviewPanel.tsx +++ b/src/renderer/components/ArtifactPreview/office/OfficePreviewPanel.tsx @@ -7,8 +7,6 @@ import { useTranslation } from 'react-i18next' const logger = loggerService.withContext('OfficePreviewPanel') -const SUPPORTED_OFFICE_PREVIEW_EXTENSIONS = new Set(['docx', 'pptx']) - interface OfficeDocumentPreviewProps { filePath: string fileName: string @@ -31,6 +29,7 @@ export interface OfficePreviewPanelProps { let wordPreviewPanelPromise: Promise | null = null let pptxPreviewPanelPromise: Promise | null = null +let xlsxPreviewPanelPromise: Promise | null = null function loadWordPreviewPanel() { wordPreviewPanelPromise ??= import('./WordPreviewPanel') @@ -52,6 +51,26 @@ function loadPptxPreviewPanel() { return pptxPreviewPanelPromise } +function loadXlsxPreviewPanel() { + xlsxPreviewPanelPromise ??= import('./XlsxPreviewPanel') + .then((module) => module.default) + .catch((err: unknown) => { + xlsxPreviewPanelPromise = null + throw err + }) + return xlsxPreviewPanelPromise +} + +// Extension → lazy panel loader. Doubles as the source of truth for which office +// documents get an inline preview; anything not listed falls back to "open externally". +const OFFICE_PREVIEW_LOADERS: Record Promise> = { + docx: loadWordPreviewPanel, + pptx: loadPptxPreviewPanel, + xlsx: loadXlsxPreviewPanel +} + +const SUPPORTED_OFFICE_PREVIEW_EXTENSIONS = new Set(Object.keys(OFFICE_PREVIEW_LOADERS)) + function extOf(name: string | undefined): string { if (!name) return '' const dot = name.lastIndexOf('.') @@ -115,7 +134,8 @@ function SupportedOfficePreview({ let cancelled = false setLoadError(null) - const loader = extension === 'docx' ? loadWordPreviewPanel : loadPptxPreviewPanel + const loader = OFFICE_PREVIEW_LOADERS[extension] + if (!loader) return loader() .then((Component) => { if (!cancelled) setLoadedPreview({ extension, Component }) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/XlsxGrid.tsx b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/XlsxGrid.tsx new file mode 100644 index 00000000000..46b25eb378c --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/XlsxGrid.tsx @@ -0,0 +1,453 @@ +import { cn } from '@cherrystudio/ui/lib/utils' +import { useVirtualizer } from '@tanstack/react-virtual' +import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { + type AxisLayout, + buildAxisLayout, + cellAddress, + colName, + mergesInView, + scaledFontSizePx, + type ViewportRect +} from './gridLayout' +import type { BorderEdge, CellRenderModel, CellStyle, ChartModel, SheetRenderModel } from './renderModel' + +export interface SelectedCellInfo { + /** 'B4' 风格地址 */ + address: string + cell: CellRenderModel | null +} + +export interface XlsxGridProps { + sheet: SheetRenderModel + styles: CellStyle[] + /** imageId → object URL;生命周期由面板管理(创建/revoke) */ + imageUrls: Record + /** 1 = 100%。缩放实现:布局尺寸乘系数(含字号),不用 CSS transform */ + zoom: number + onSelectCell?: (info: SelectedCellInfo | null) => void + /** 图表渲染注入点;返回清理函数。面板传入 ChartRenderer 实现 */ + renderChart?: (chart: ChartModel, container: HTMLElement) => () => void +} + +/** 行/列表头基准尺寸(px,zoom=1) */ +const ROW_HEADER_WIDTH_PX = 44 +const COL_HEADER_HEIGHT_PX = 22 +/** 虚拟滚动的可视区外预渲染项数 */ +const OVERSCAN = 6 +/** 默认(未设置边框时)网格线,跟随 DESIGN.md 的边框语义 token */ +const DEFAULT_GRID_LINE = '0.5px solid var(--color-border)' + +/** 单元格内容默认对齐:数字右对齐,布尔/错误居中,其余左对齐(CellStyle.hAlign 未设置时的规则) */ +const defaultHAlign = (cell: CellRenderModel): 'left' | 'center' | 'right' => { + if (typeof cell.raw === 'number') return 'right' + if (typeof cell.raw === 'boolean') return 'center' + return 'left' +} + +const BORDER_STYLE_CSS: Record = { + thin: 'solid', + medium: 'solid', + thick: 'solid', + dashed: 'dashed', + dotted: 'dotted', + hair: 'dotted', + double: 'double' +} +const BORDER_WIDTH_PX: Record = { + thin: 1, + hair: 1, + dashed: 1, + dotted: 1, + medium: 1.5, + thick: 2, + double: 3 +} + +const borderEdgeToCss = (edge: BorderEdge | undefined): string | undefined => + edge ? `${BORDER_WIDTH_PX[edge.style]}px ${BORDER_STYLE_CSS[edge.style]} ${edge.color}` : undefined + +const H_ALIGN_TO_JUSTIFY: Record, React.CSSProperties['justifyContent']> = { + left: 'flex-start', + center: 'center', + right: 'flex-end', + justify: 'stretch' +} +const V_ALIGN_TO_ITEMS: Record, React.CSSProperties['alignItems']> = { + top: 'flex-start', + middle: 'center', + bottom: 'flex-end' +} + +/** CellStyle → inline style(不含定位/尺寸,由调用方叠加)。v1 简化:文本溢出一律 overflow hidden(wrap 除外),不做 Excel 的相邻空单元格溢出流。 */ +const cellStyleToCss = (style: CellStyle | undefined, zoom: number): React.CSSProperties => { + const css: React.CSSProperties = { fontSize: scaledFontSizePx(style?.fontSizePx, zoom) } + if (!style) return css + if (style.fontFamily) css.fontFamily = style.fontFamily + if (style.bold) css.fontWeight = 'bold' + if (style.italic) css.fontStyle = 'italic' + if (style.underline && style.strike) css.textDecoration = 'underline line-through' + else if (style.underline) css.textDecoration = 'underline' + else if (style.strike) css.textDecoration = 'line-through' + if (style.color) css.color = style.color + if (style.bg) css.backgroundColor = style.bg + if (style.hAlign) css.justifyContent = H_ALIGN_TO_JUSTIFY[style.hAlign] + if (style.vAlign) css.alignItems = V_ALIGN_TO_ITEMS[style.vAlign] + if (style.wrap) { + css.whiteSpace = 'normal' + css.wordBreak = 'break-word' + } + if (style.indent) css.paddingLeft = style.indent * 8 * zoom + const borderRight = borderEdgeToCss(style.borderRight) + const borderBottom = borderEdgeToCss(style.borderBottom) + const borderTop = borderEdgeToCss(style.borderTop) + const borderLeft = borderEdgeToCss(style.borderLeft) + if (borderRight) css.borderRight = borderRight + if (borderBottom) css.borderBottom = borderBottom + if (borderTop) css.borderTop = borderTop + if (borderLeft) css.borderLeft = borderLeft + return css +} + +interface CellViewProps { + cell: CellRenderModel | undefined + style: CellStyle | undefined + zoom: number + /** 首行/首列补画 top/left 网格线,避免相邻格重叠加粗(见 05 文档) */ + isFirstRow: boolean + isFirstCol: boolean + selected: boolean + positionStyle: React.CSSProperties +} + +/** 普通/合并单元格共用的渲染体。合并覆盖但非 master 的单元格由调用方传入 cell=undefined 只保留背景。 */ +const CellView = memo(function CellView({ + cell, + style, + zoom, + isFirstRow, + isFirstCol, + selected, + positionStyle +}: CellViewProps) { + const css = cellStyleToCss(style, zoom) + const hAlign = style?.hAlign ?? (cell ? defaultHAlign(cell) : 'left') + + const finalCss: React.CSSProperties = { + ...positionStyle, + ...css, + display: 'flex', + justifyContent: css.justifyContent ?? H_ALIGN_TO_JUSTIFY[hAlign], + overflow: 'hidden', + whiteSpace: css.whiteSpace ?? 'nowrap', + boxSizing: 'border-box', + borderRight: css.borderRight ?? DEFAULT_GRID_LINE, + borderBottom: css.borderBottom ?? DEFAULT_GRID_LINE, + borderTop: isFirstRow ? (css.borderTop ?? DEFAULT_GRID_LINE) : css.borderTop, + borderLeft: isFirstCol ? (css.borderLeft ?? DEFAULT_GRID_LINE) : css.borderLeft + } + + return ( +
+ {cell && ( + + {cell.text} + + )} +
+ ) +}) + +interface UnsupportedChartPlaceholderProps { + chart: ChartModel +} + +/** unsupported 图表占位框:虚线边框 + 居中文案 + 原始类型名 */ +const UnsupportedChartPlaceholder = ({ chart }: UnsupportedChartPlaceholderProps) => { + const { t } = useTranslation() + return ( +
+ {t('xlsx_preview.chart_unsupported')} + {chart.rawTypeName && {chart.rawTypeName}} +
+ ) +} + +interface ChartHostProps { + chart: ChartModel + renderChart: (chart: ChartModel, container: HTMLElement) => () => void +} + +/** 图表挂载宿主:ref 回调管理 renderChart 返回的 dispose,节点卸载/替换时调用。 */ +const ChartHost = ({ chart, renderChart }: ChartHostProps) => { + const disposeRef = useRef<(() => void) | null>(null) + const setRef = useCallback( + (node: HTMLDivElement | null) => { + disposeRef.current?.() + disposeRef.current = node ? renderChart(chart, node) : null + }, + [chart, renderChart] + ) + + return
+} + +const XlsxGrid = ({ sheet, styles, imageUrls, zoom, onSelectCell, renderChart }: XlsxGridProps) => { + const scrollElRef = useRef(null) + const [selected, setSelected] = useState<{ row: number; col: number } | null>(null) + const [viewport, setViewport] = useState({ top: 0, left: 0, bottom: 0, right: 0 }) + + const rowLayout: AxisLayout = useMemo( + () => buildAxisLayout(sheet.rowCount, sheet.defaultRowHeightPx, sheet.rowHeightsPx, zoom), + [sheet.rowCount, sheet.defaultRowHeightPx, sheet.rowHeightsPx, zoom] + ) + const colLayout: AxisLayout = useMemo( + () => buildAxisLayout(sheet.colCount, sheet.defaultColWidthPx, sheet.colWidthsPx, zoom), + [sheet.colCount, sheet.defaultColWidthPx, sheet.colWidthsPx, zoom] + ) + + const rowHeaderWidth = ROW_HEADER_WIDTH_PX * zoom + const colHeaderHeight = COL_HEADER_HEIGHT_PX * zoom + + const rowVirtualizer = useVirtualizer({ + count: sheet.rowCount, + getScrollElement: () => scrollElRef.current, + estimateSize: (index) => rowLayout.sizes[index], + overscan: OVERSCAN + }) + const colVirtualizer = useVirtualizer({ + horizontal: true, + count: sheet.colCount, + getScrollElement: () => scrollElRef.current, + estimateSize: (index) => colLayout.sizes[index], + overscan: OVERSCAN + }) + + const readViewport = useCallback( + (el: HTMLDivElement): ViewportRect => ({ + top: el.scrollTop, + left: el.scrollLeft, + bottom: el.scrollTop + el.clientHeight, + right: el.scrollLeft + el.clientWidth + }), + [] + ) + + const handleScroll = useCallback( + (e: React.UIEvent) => setViewport(readViewport(e.currentTarget)), + [readViewport] + ) + + const scrollElCallback = useCallback( + (node: HTMLDivElement | null) => { + scrollElRef.current = node + if (node) setViewport(readViewport(node)) + }, + [readViewport] + ) + + const mergesVisible = useMemo( + () => mergesInView(sheet.merges, viewport, rowLayout, colLayout), + [sheet.merges, viewport, rowLayout, colLayout] + ) + + const getCell = useCallback((row: number, col: number) => sheet.cells[`${row}:${col}`], [sheet.cells]) + const getStyle = useCallback( + (cell: CellRenderModel | undefined) => (cell?.styleId !== undefined ? styles[cell.styleId] : undefined), + [styles] + ) + + /** 命中(row,col)所在的合并区,若有 */ + const findMerge = useCallback( + (row: number, col: number) => + sheet.merges.find((m) => row >= m.top && row <= m.bottom && col >= m.left && col <= m.right), + [sheet.merges] + ) + + const selectCell = useCallback( + (row: number, col: number) => { + const merge = findMerge(row, col) + const masterRow = merge?.top ?? row + const masterCol = merge?.left ?? col + setSelected({ row: masterRow, col: masterCol }) + onSelectCell?.({ address: cellAddress(masterRow, masterCol), cell: getCell(masterRow, masterCol) ?? null }) + }, + [findMerge, getCell, onSelectCell] + ) + + const clearSelection = useCallback(() => { + setSelected(null) + onSelectCell?.(null) + }, [onSelectCell]) + + const totalWidth = colLayout.totalSize + rowHeaderWidth + const totalHeight = rowLayout.totalSize + colHeaderHeight + + const virtualRows = rowVirtualizer.getVirtualItems() + const virtualCols = colVirtualizer.getVirtualItems() + + return ( +
{ + if (e.key === 'Escape') clearSelection() + }} + tabIndex={0}> +
+ {/* 列表头(sticky top):A B C… */} +
+ {virtualCols.map((vc) => ( +
+ {colName(vc.index + 1)} +
+ ))} +
+ + {/* 行表头(sticky left):1 2 3… */} +
+ {virtualRows.map((vr) => ( +
+ {vr.index + 1} +
+ ))} +
+ + {/* 单元格层:双向虚拟滚动。任何被合并覆盖的单元格(含 master)在此层置空内容—— + master 的内容+样式由下方独立的合并层渲染,避免重复;这里只保留背景占位。 */} +
+ {virtualRows.map((vr) => + virtualCols.map((vc) => { + const row = vr.index + 1 + const col = vc.index + 1 + const isCovered = Boolean(findMerge(row, col)) + const cell = isCovered ? undefined : getCell(row, col) + const style = isCovered ? undefined : getStyle(cell) + const isSelected = !isCovered && selected?.row === row && selected?.col === col + return ( +
selectCell(row, col)} + role="gridcell" + aria-selected={isSelected}> + +
+ ) + }) + )} +
+ + {/* 合并层:独立于单元格虚拟化,master 滚出视口但合并区与视口相交时依然渲染;命中测试优先于单元格层(DOM 顺序在后) */} +
+ {mergesVisible.map(({ merge, masterRow, masterCol, rect }) => { + const cell = getCell(masterRow, masterCol) + const style = getStyle(cell) + const isSelected = selected?.row === masterRow && selected?.col === masterCol + return ( +
selectCell(masterRow, masterCol)} + role="gridcell" + aria-selected={isSelected} + data-testid="xlsx-grid-merge-cell"> + +
+ ) + })} +
+ + {/* 浮动层:图片 + 图表,按 PxRect * zoom 绝对定位 */} +
+ {sheet.floatingImages.map((img) => { + const src = imageUrls[img.imageId] + if (!src) return null + return ( + + ) + })} + {sheet.charts.map((chart, i) => { + const positionStyle: React.CSSProperties = { + top: chart.rect.y * zoom, + left: chart.rect.x * zoom, + width: chart.rect.width * zoom, + height: chart.rect.height * zoom + } + return ( +
+ {chart.type === 'unsupported' || !renderChart ? ( + + ) : ( + + )} +
+ ) + })} +
+
+
+ ) +} + +export default memo(XlsxGrid) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/XlsxPreviewPanel.tsx b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/XlsxPreviewPanel.tsx new file mode 100644 index 00000000000..6ec18aa7289 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/XlsxPreviewPanel.tsx @@ -0,0 +1,261 @@ +import { Button, Tooltip } from '@cherrystudio/ui' +import { cn } from '@cherrystudio/ui/lib/utils' +import { EmptyState, LoadingState } from '@renderer/components/chat' +import { AlertCircle, FileSpreadsheet, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react' +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import type { ChartRenderer } from './charts/ChartRenderer' +import type { ChartModel, SheetRenderModel, WorkbookRenderModel } from './renderModel' +import { useXlsxWorkbook } from './useXlsxWorkbook' +import type { SelectedCellInfo } from './XlsxGrid' +import XlsxGrid from './XlsxGrid' + +interface XlsxPreviewPanelProps { + filePath: string + fileName: string + refreshKey: number + sourceSize?: number + actions?: ReactNode +} + +/** 缩放档位,默认下标 2(=1) */ +const ZOOM_LEVELS = [0.5, 0.75, 1, 1.25, 1.5, 2] +const DEFAULT_ZOOM = 1 + +/** 与 ArtifactPane 的 2MB 文本预览上限文案保持同样"静态标签"风格,而非动态格式化 */ +const XLSX_PREVIEW_MAX_SIZE_LABEL = '20 MB' + +const formatZoomLabel = (zoom: number) => `${Math.round(zoom * 100)}%` + +/** model.sheets 中可见的 sheet;全部 hidden 时退化显示第一个,保证至少有一个 tab 可选 */ +const visibleSheets = (model: WorkbookRenderModel): SheetRenderModel[] => { + const visible = model.sheets.filter((sheet) => !sheet.hidden) + if (visible.length > 0) return visible + return model.sheets.slice(0, 1) +} + +let chartRendererPromise: Promise | null = null + +const loadChartRenderer = () => { + chartRendererPromise ??= import('./charts/EchartsChartRenderer') + .then((module) => module.echartsChartRenderer) + .catch((err: unknown) => { + chartRendererPromise = null + throw err + }) + return chartRendererPromise +} + +const sheetHasChart = (sheet: SheetRenderModel | undefined): boolean => Boolean(sheet && sheet.charts.length > 0) + +/** + * xlsx 只读预览面板:工具栏(缩放)+ XlsxGrid + sheet 标签栏 + 状态栏(公式)。 + * 状态机/主题背景/懒加载接线对齐 PdfPreviewPanel。 + */ +const XlsxPreviewPanel = ({ filePath, fileName, refreshKey, sourceSize, actions }: XlsxPreviewPanelProps) => { + const { t } = useTranslation() + const state = useXlsxWorkbook(filePath, refreshKey, sourceSize) + const [zoom, setZoom] = useState(DEFAULT_ZOOM) + const [activeSheetName, setActiveSheetName] = useState(null) + const [selectedCell, setSelectedCell] = useState(null) + const [chartRenderer, setChartRenderer] = useState(null) + const imageUrlsRef = useRef>({}) + const [imageUrls, setImageUrls] = useState>({}) + + const model = state.status === 'ready' ? state.model : null + const sheets = useMemo(() => (model ? visibleSheets(model) : []), [model]) + const activeSheet = sheets.find((sheet) => sheet.name === activeSheetName) ?? sheets[0] ?? null + + // sheet 切换/模型替换时重置选中单元格,并把当前 sheet 落到有效值上 + useEffect(() => { + setSelectedCell(null) + if (sheets.length === 0) { + setActiveSheetName(null) + return + } + if (!sheets.some((sheet) => sheet.name === activeSheetName)) { + setActiveSheetName(sheets[0].name) + } + }, [sheets, activeSheetName]) + + // 图片 object URL:ready 时按 model.images 建表,model 替换/卸载时 revoke 旧表 + useEffect(() => { + if (!model) return + + const nextUrls: Record = {} + for (const [imageId, image] of Object.entries(model.images)) { + nextUrls[Number(imageId)] = URL.createObjectURL(new Blob([image.data], { type: image.mime })) + } + imageUrlsRef.current = nextUrls + setImageUrls(nextUrls) + + return () => { + for (const url of Object.values(nextUrls)) { + URL.revokeObjectURL(url) + } + } + }, [model]) + + // 图表懒加载:首个含图表的 model 出现才触发 + useEffect(() => { + if (chartRenderer || !sheets.some((sheet) => sheetHasChart(sheet))) return + let cancelled = false + void loadChartRenderer().then((renderer) => { + if (!cancelled) setChartRenderer(renderer) + }) + return () => { + cancelled = true + } + }, [sheets, chartRenderer]) + + const renderChart = useMemo(() => { + if (!chartRenderer) return undefined + return (chart: ChartModel, container: HTMLElement) => chartRenderer.render(chart, container) + }, [chartRenderer]) + + const zoomIndex = ZOOM_LEVELS.indexOf(zoom) + const zoomOut = useCallback(() => { + setZoom((current) => { + const index = ZOOM_LEVELS.indexOf(current) + return index > 0 ? ZOOM_LEVELS[index - 1] : current + }) + }, []) + const zoomIn = useCallback(() => { + setZoom((current) => { + const index = ZOOM_LEVELS.indexOf(current) + return index >= 0 && index < ZOOM_LEVELS.length - 1 ? ZOOM_LEVELS[index + 1] : current + }) + }, []) + const resetZoom = useCallback(() => setZoom(DEFAULT_ZOOM), []) + + if (state.status === 'loading' || state.status === 'idle') { + return ( +
+ +
+ ) + } + + if (state.status === 'error') { + return + } + + if (state.status === 'oversize') { + return ( + + ) + } + + if (!model || !activeSheet) return null + + // 选中格:地址 + 内容(公式格显示 `= 公式原文`,值在网格内);无选中显示 sheet 名 + const selectedCellContent = selectedCell?.cell?.formula ? `= ${selectedCell.cell.formula}` : selectedCell?.cell?.text + const statusBarText = selectedCell + ? selectedCellContent + ? `${selectedCell.address} ${selectedCellContent}` + : selectedCell.address + : activeSheet.name + + return ( +
+
+ + + + + {formatZoomLabel(zoom)} + + + + + + + +
+ +
+ +
+ +
+ + {statusBarText} + + {selectedCell?.cell?.formulaState === 'unevaluated' && ( + {t('xlsx_preview.formula_not_evaluated')} + )} +
+ +
+ {sheets.map((sheet) => ( + + ))} +
+
+ ) +} + +export default XlsxPreviewPanel diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.buildChartOption.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.buildChartOption.test.ts new file mode 100644 index 00000000000..d2d2c422f11 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.buildChartOption.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' + +import { buildChartOption } from '../charts/buildChartOption' +import type { ChartModel } from '../renderModel' + +/** + * ChartModel → echarts option 纯函数映射测试。见 04-wp-charts.md Part 2 映射规则。 + * 不依赖 DOM/echarts 实例,可在任意环境下运行(jsdom 缺 canvas 时这是验证映射逻辑的主路径)。 + */ + +const baseRect = { x: 0, y: 0, width: 100, height: 100 } + +describe('buildChartOption — bar/line/area (category axis)', () => { + it('maps a column bar chart to a category x-axis + bar series', () => { + const chart: ChartModel = { + rect: baseRect, + type: 'bar', + barDirection: 'col', + title: 'Sales', + series: [{ name: 'Q', categories: ['Q1', 'Q2'], values: [10, 20] }] + } + const option = buildChartOption(chart) as any + + expect(option.title.text).toBe('Sales') + expect(option.xAxis.type).toBe('category') + expect(option.xAxis.data).toEqual(['Q1', 'Q2']) + expect(option.yAxis.type).toBe('value') + expect(option.series).toHaveLength(1) + expect(option.series[0].type).toBe('bar') + expect(option.series[0].name).toBe('Q') + expect(option.series[0].data).toEqual([10, 20]) + }) + + it('swaps x/y axes for a horizontal bar chart (barDirection === "bar")', () => { + const chart: ChartModel = { + rect: baseRect, + type: 'bar', + barDirection: 'bar', + series: [{ categories: ['A', 'B'], values: [1, 2] }] + } + const option = buildChartOption(chart) as any + + expect(option.xAxis.type).toBe('value') + expect(option.yAxis.type).toBe('category') + expect(option.yAxis.data).toEqual(['A', 'B']) + }) + + it('sets series.stack when chart.stacked is true, and leaves it unset otherwise', () => { + const stacked: ChartModel = { + rect: baseRect, + type: 'bar', + stacked: true, + series: [ + { name: 'A', categories: ['x'], values: [1] }, + { name: 'B', categories: ['x'], values: [2] } + ] + } + const stackedOption = buildChartOption(stacked) as any + expect(stackedOption.series[0].stack).toBe('total') + expect(stackedOption.series[1].stack).toBe('total') + + const unstacked: ChartModel = { ...stacked, stacked: undefined } + const unstackedOption = buildChartOption(unstacked) as any + expect(unstackedOption.series[0].stack).toBeUndefined() + }) + + it('maps a line chart to type "line" without areaStyle', () => { + const chart: ChartModel = { + rect: baseRect, + type: 'line', + series: [{ categories: ['a'], values: [1] }] + } + const option = buildChartOption(chart) as any + expect(option.series[0].type).toBe('line') + expect(option.series[0].areaStyle).toBeUndefined() + }) + + it('maps an area chart to echarts line type with areaStyle set', () => { + const chart: ChartModel = { + rect: baseRect, + type: 'area', + series: [{ categories: ['a'], values: [1] }] + } + const option = buildChartOption(chart) as any + expect(option.series[0].type).toBe('line') + expect(option.series[0].areaStyle).toEqual({}) + }) + + it('maps null values to the echarts "-" gap marker', () => { + const chart: ChartModel = { + rect: baseRect, + type: 'line', + series: [{ categories: ['a', 'b'], values: [1, null] }] + } + const option = buildChartOption(chart) as any + expect(option.series[0].data).toEqual([1, '-']) + }) + + it('omits the title when chart.title is undefined', () => { + const chart: ChartModel = { rect: baseRect, type: 'line', series: [{ categories: [], values: [] }] } + const option = buildChartOption(chart) as any + expect(option.title).toBeUndefined() + }) +}) + +describe('buildChartOption — pie', () => { + it('maps the first series categories/values to name/value pairs', () => { + const chart: ChartModel = { + rect: baseRect, + type: 'pie', + title: 'Share', + series: [ + { name: 'Revenue', categories: ['A', 'B', 'C'], values: [10, 20, null] }, + { name: 'Ignored second series', categories: ['X'], values: [999] } + ] + } + const option = buildChartOption(chart) as any + + expect(option.title.text).toBe('Share') + expect(option.series).toHaveLength(1) + expect(option.series[0].type).toBe('pie') + expect(option.series[0].name).toBe('Revenue') + expect(option.series[0].data).toEqual([ + { name: 'A', value: 10 }, + { name: 'B', value: 20 }, + { name: 'C', value: '-' } + ]) + }) + + it('produces an empty data array when there are no series', () => { + const chart: ChartModel = { rect: baseRect, type: 'pie', series: [] } + const option = buildChartOption(chart) as any + expect(option.series[0].data).toEqual([]) + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.chartXmlParser.edgeCases.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.chartXmlParser.edgeCases.test.ts new file mode 100644 index 00000000000..d73cc9e7a11 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.chartXmlParser.edgeCases.test.ts @@ -0,0 +1,384 @@ +import JSZip from 'jszip' +import { describe, expect, it } from 'vitest' + +import { DEFAULT_COL_WIDTH_PX, DEFAULT_ROW_HEIGHT_PX } from '../gridLayout' +import type { SheetDataAccessor, SheetLayoutAccessor } from '../worker/chartXmlParser' +import { parseCharts } from '../worker/chartXmlParser' + +/** + * Edge-case coverage using a minimal hand-assembled xlsx package (xlsx = zip of XML parts). + * Only the OPC parts that parseCharts actually reads are included: + * xl/workbook.xml, xl/_rels/workbook.xml.rels, xl/worksheets/_rels/sheet1.xml.rels, + * xl/drawings/drawing1.xml (+ .rels), xl/charts/chartN.xml + * These are written with the standard Excel-style `c:`/`a:`/`r:` namespace prefixes; + * the sibling test helper builds the unprefixed drawing variant used by openpyxl. + */ + +const DEFAULT_LAYOUT: SheetLayoutAccessor = { + colX: (col) => (col - 1) * DEFAULT_COL_WIDTH_PX, + rowY: (row) => (row - 1) * DEFAULT_ROW_HEIGHT_PX +} + +const emptyDataAccessor: SheetDataAccessor = { + readRange: () => null +} + +const WORKBOOK_XML = ` + + + + +` + +const WORKBOOK_RELS_XML = ` + + +` + +const SHEET_RELS_XML = ` + + +` + +/** Builds a base zip with workbook/rels wired to a single Sheet1, ready for a drawing + charts to be added. */ +const buildBaseZip = (): JSZip => { + const zip = new JSZip() + zip.file('xl/workbook.xml', WORKBOOK_XML) + zip.file('xl/_rels/workbook.xml.rels', WORKBOOK_RELS_XML) + zip.file('xl/worksheets/_rels/sheet1.xml.rels', SHEET_RELS_XML) + return zip +} + +const twoCellAnchorXml = (chartRId: string) => ` + + 1010 + 50100 + + + + + + + + + + + ` + +const absoluteAnchorXml = (chartRId: string) => ` + + + + + + + + + + + + + + ` + +const wrapDrawing = (anchors: string) => ` + +${anchors} +` + +const drawingRelsXml = ( + rels: { id: string; target: string }[] +) => ` + +${rels.map((r) => ` `).join('\n')} +` + +const CHART_NS = `xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"` + +describe('parseCharts — anchor kinds (Excel-style c:/a: prefixes)', () => { + it('computes rect from twoCellAnchor via from/to point difference', async () => { + const chartXml = ` + + + + + + + + Sheet1!$A$2:$A$3AB + Sheet1!$B$2:$B$312 + + + + + ` + + const zip = buildBaseZip() + zip.file('xl/drawings/drawing1.xml', wrapDrawing(twoCellAnchorXml('rId1'))) + zip.file('xl/drawings/_rels/drawing1.xml.rels', drawingRelsXml([{ id: 'rId1', target: '../charts/chart1.xml' }])) + zip.file('xl/charts/chart1.xml', chartXml) + + const { charts, warnings } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(warnings).toEqual([]) + expect(charts).toHaveLength(1) + // from: col=1,row=1 (0-based) → x=64px,y=20px ; to: col=5,row=10 (0-based) → x=320px,y=200px + expect(charts[0].rect).toEqual({ x: DEFAULT_COL_WIDTH_PX, y: DEFAULT_ROW_HEIGHT_PX, width: 256, height: 180 }) + }) + + it('computes rect from absoluteAnchor via pos + ext EMU', async () => { + const chartXml = ` + + + + + + + + + ` + + const zip = buildBaseZip() + zip.file('xl/drawings/drawing1.xml', wrapDrawing(absoluteAnchorXml('rId1'))) + zip.file('xl/drawings/_rels/drawing1.xml.rels', drawingRelsXml([{ id: 'rId1', target: '../charts/chart1.xml' }])) + zip.file('xl/charts/chart1.xml', chartXml) + + const { charts } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts).toHaveLength(1) + // pos: x=95250 EMU=10px, y=190500 EMU=20px; ext: cx=2857500 EMU=300px, cy=1428750 EMU=150px + expect(charts[0].rect).toEqual({ x: 10, y: 20, width: 300, height: 150 }) + }) +}) + +describe('parseCharts — sparse cache idx padding', () => { + it('pads missing idx entries with null up to ptCount', async () => { + const chartXml = ` + + + + + + + Sheet1!$A$2:$A$5Q1Q4 + Sheet1!$B$2:$B$52030 + + + + + ` + + const zip = buildBaseZip() + zip.file('xl/drawings/drawing1.xml', wrapDrawing(twoCellAnchorXml('rId1'))) + zip.file('xl/drawings/_rels/drawing1.xml.rels', drawingRelsXml([{ id: 'rId1', target: '../charts/chart1.xml' }])) + zip.file('xl/charts/chart1.xml', chartXml) + + const { charts, warnings } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(warnings).toEqual([]) + expect(charts[0].series[0].categories).toEqual(['Q1', '', '', 'Q4']) + expect(charts[0].series[0].values).toEqual([null, 20, 30, null]) + }) +}) + +describe('parseCharts — single series normalization', () => { + it('treats a lone c:ser node (not an array) the same as an array of one', async () => { + const chartXml = ` + + + + + + + Sheet1!$A$2:$A$3AB + Sheet1!$B$2:$B$312 + + + + + ` + + const zip = buildBaseZip() + zip.file('xl/drawings/drawing1.xml', wrapDrawing(twoCellAnchorXml('rId1'))) + zip.file('xl/drawings/_rels/drawing1.xml.rels', drawingRelsXml([{ id: 'rId1', target: '../charts/chart1.xml' }])) + zip.file('xl/charts/chart1.xml', chartXml) + + const { charts } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts[0].type).toBe('pie') + expect(charts[0].series).toHaveLength(1) + expect(charts[0].series[0].values).toEqual([1, 2]) + }) +}) + +describe('parseCharts — combo chart degrades to first supported type', () => { + it('picks the first supported plotArea chart child and warns about the rest', async () => { + const chartXml = ` + + + + + + Sheet1!$B$2:$B$31 + + + Sheet1!$C$2:$C$3 + + + + ` + + const zip = buildBaseZip() + zip.file('xl/drawings/drawing1.xml', wrapDrawing(twoCellAnchorXml('rId1'))) + zip.file('xl/drawings/_rels/drawing1.xml.rels', drawingRelsXml([{ id: 'rId1', target: '../charts/chart1.xml' }])) + zip.file('xl/charts/chart1.xml', chartXml) + + const { charts, warnings } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts).toHaveLength(1) + expect(charts[0].type).toBe('bar') + expect(warnings.some((w) => w.includes('combo chart'))).toBe(true) + }) +}) + +describe('parseCharts — doughnut/pie3D degrade to pie with a warning', () => { + it('renders doughnutChart as pie and records a warning', async () => { + const chartXml = ` + + + + + Sheet1!$B$2:$B$31 + + + + ` + + const zip = buildBaseZip() + zip.file('xl/drawings/drawing1.xml', wrapDrawing(twoCellAnchorXml('rId1'))) + zip.file('xl/drawings/_rels/drawing1.xml.rels', drawingRelsXml([{ id: 'rId1', target: '../charts/chart1.xml' }])) + zip.file('xl/charts/chart1.xml', chartXml) + + const { charts, warnings } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts[0].type).toBe('pie') + expect(warnings.some((w) => w.includes('doughnutChart'))).toBe(true) + }) +}) + +describe('parseCharts — resilience to malformed chart parts', () => { + it('drops only the chart whose part is malformed, keeping the others', async () => { + const goodChartXml = ` + + + + + + Sheet1!$B$2:$B$31 + + + + ` + + const zip = buildBaseZip() + const anchors = twoCellAnchorXml('rId1') + twoCellAnchorXml('rId2') + zip.file('xl/drawings/drawing1.xml', wrapDrawing(anchors)) + zip.file( + 'xl/drawings/_rels/drawing1.xml.rels', + drawingRelsXml([ + { id: 'rId1', target: '../charts/chart1.xml' }, + { id: 'rId2', target: '../charts/chart2.xml' } + ]) + ) + zip.file('xl/charts/chart1.xml', goodChartXml) + // chart2 is missing entirely from the zip — simulates a broken/absent part. + + const { charts, warnings } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts).toHaveLength(1) + expect(charts[0].type).toBe('bar') + expect(warnings.some((w) => w.includes('chart part missing'))).toBe(true) + }) + + it('drops a chart with unparseable XML content, keeping the other charts alive', async () => { + const goodChartXml = ` + + + + + Sheet1!$B$2:$B$35 + + + + ` + + const zip = buildBaseZip() + const anchors = twoCellAnchorXml('rId1') + twoCellAnchorXml('rId2') + zip.file('xl/drawings/drawing1.xml', wrapDrawing(anchors)) + zip.file( + 'xl/drawings/_rels/drawing1.xml.rels', + drawingRelsXml([ + { id: 'rId1', target: '../charts/chart1.xml' }, + { id: 'rId2', target: '../charts/chart2.xml' } + ]) + ) + zip.file('xl/charts/chart1.xml', goodChartXml) + // No c:chartSpace/c:chart/c:plotArea structure at all — not a recognizable chart part. + zip.file('xl/charts/chart2.xml', '<<>>') + + const { charts, warnings } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts).toHaveLength(1) + expect(charts[0].type).toBe('line') + expect(warnings.some((w) => w.includes('failed to parse chart'))).toBe(true) + }) +}) + +describe('parseCharts — title from rich text run concatenation', () => { + it('concatenates multiple a:t runs into the title text', async () => { + const chartXml = ` + + + Sales 2026 + + + + Sheet1!$B$2:$B$31 + + + + ` + + const zip = buildBaseZip() + zip.file('xl/drawings/drawing1.xml', wrapDrawing(twoCellAnchorXml('rId1'))) + zip.file('xl/drawings/_rels/drawing1.xml.rels', drawingRelsXml([{ id: 'rId1', target: '../charts/chart1.xml' }])) + zip.file('xl/charts/chart1.xml', chartXml) + + const { charts } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts[0].title).toBe('Sales 2026') + }) + + it('leaves title undefined when there is no c:title node', async () => { + const chartXml = ` + + + + + + Sheet1!$B$2:$B$31 + + + + ` + + const zip = buildBaseZip() + zip.file('xl/drawings/drawing1.xml', wrapDrawing(twoCellAnchorXml('rId1'))) + zip.file('xl/drawings/_rels/drawing1.xml.rels', drawingRelsXml([{ id: 'rId1', target: '../charts/chart1.xml' }])) + zip.file('xl/charts/chart1.xml', chartXml) + + const { charts } = await parseCharts(zip, 'Sheet1', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts[0].title).toBeUndefined() + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.chartXmlParser.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.chartXmlParser.test.ts new file mode 100644 index 00000000000..252b948cce3 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.chartXmlParser.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_COL_WIDTH_PX, DEFAULT_ROW_HEIGHT_PX } from '../gridLayout' +import type { SheetDataAccessor, SheetLayoutAccessor } from '../worker/chartXmlParser' +import { parseCharts } from '../worker/chartXmlParser' +import { buildChartWorkbookZip } from './xlsxTestPackages' + +/** Default column width / row height for every column/row — mirrors an unstyled sheet. */ +const DEFAULT_LAYOUT: SheetLayoutAccessor = { + colX: (col) => (col - 1) * DEFAULT_COL_WIDTH_PX, + rowY: (row) => (row - 1) * DEFAULT_ROW_HEIGHT_PX +} + +const emptyDataAccessor: SheetDataAccessor = { + readRange: () => null +} + +describe('parseCharts — generated chart workbook with cache', () => { + it('parses all four chart types with categories/values/title from cache', async () => { + const zip = await buildChartWorkbookZip('basic') + const { charts, warnings } = await parseCharts(zip, 'Data', DEFAULT_LAYOUT, emptyDataAccessor) + + // bar (stacked, col) + bar (horizontal) + line + pie + area + expect(charts).toHaveLength(5) + + const bar = charts[0] + expect(bar.type).toBe('bar') + expect(bar.barDirection).toBe('col') + expect(bar.stacked).toBe(true) + expect(bar.title).toBe('Bar Chart') + expect(bar.series).toHaveLength(2) + expect(bar.series[0]).toEqual({ + name: 'SeriesA', + categories: ['Q1', 'Q2', 'Q3', 'Q4'], + values: [10, 20, 30, 40] + }) + expect(bar.series[1]).toEqual({ + name: 'SeriesB', + categories: ['Q1', 'Q2', 'Q3', 'Q4'], + values: [5, 15, 25, 35] + }) + + const hbar = charts[1] + expect(hbar.type).toBe('bar') + expect(hbar.barDirection).toBe('bar') + expect(hbar.stacked).toBeUndefined() + expect(hbar.title).toBe('Horizontal Bar Chart') + + const line = charts[2] + expect(line.type).toBe('line') + expect(line.title).toBe('Line Chart') + expect(line.series[0].values).toEqual([10, 20, 30, 40]) + + const pie = charts[3] + expect(pie.type).toBe('pie') + expect(pie.title).toBe('Pie Chart') + expect(pie.series[0].categories).toEqual(['Q1', 'Q2', 'Q3', 'Q4']) + + const area = charts[4] + expect(area.type).toBe('area') + expect(area.title).toBe('Area Chart') + + expect(warnings).toEqual([]) + }) + + it('computes PxRect from oneCellAnchor using layout.colX/rowY + ext EMU', async () => { + const zip = await buildChartWorkbookZip('basic') + const { charts } = await parseCharts(zip, 'Data', DEFAULT_LAYOUT, emptyDataAccessor) + + // First chart anchored at col=4 (0-based) row=1 (0-based) → col E, row 2 (1-based) + const bar = charts[0] + expect(bar.rect.x).toBeCloseTo(DEFAULT_LAYOUT.colX(5), 5) // 1-based col 5 = 'E' + expect(bar.rect.y).toBeCloseTo(DEFAULT_LAYOUT.rowY(2), 5) + expect(bar.rect.width).toBeGreaterThan(0) + expect(bar.rect.height).toBeGreaterThan(0) + }) +}) + +describe('parseCharts — generated reference-only chart workbook', () => { + it('falls back to SheetDataAccessor.readRange when cache is absent', async () => { + const zip = await buildChartWorkbookZip('no-cache') + const seenRefs: string[] = [] + const accessor: SheetDataAccessor = { + readRange: (ref) => { + seenRefs.push(ref) + if (ref.includes('$A$')) return [['Q1'], ['Q2'], ['Q3'], ['Q4']] + if (ref.includes('$B$')) return [[10], [20], [30], [40]] + if (ref === "'Data'!B1") return [['SeriesA']] + return null + } + } + + const { charts, warnings } = await parseCharts(zip, 'Data', DEFAULT_LAYOUT, accessor) + + expect(charts).toHaveLength(4) + for (const chart of charts) { + expect(chart.series[0].categories).toEqual(['Q1', 'Q2', 'Q3', 'Q4']) + expect(chart.series[0].values).toEqual([10, 20, 30, 40]) + expect(chart.series[0].name).toBe('SeriesA') + } + expect(warnings).toEqual([]) + expect(seenRefs.length).toBeGreaterThan(0) + expect(seenRefs.some((ref) => ref.includes('$A$2:$A$5'))).toBe(true) + expect(seenRefs.some((ref) => ref.includes('$B$1:$B$5') || ref.includes('$B$2:$B$5'))).toBe(true) + }) + + it('produces an empty series with a warning when both cache and reference resolve to nothing', async () => { + const zip = await buildChartWorkbookZip('no-cache') + const { charts, warnings } = await parseCharts(zip, 'Data', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts).toHaveLength(4) + for (const chart of charts) { + expect(chart.series[0].values).toEqual([]) + // categories fall back to 1..n sequence when both cache and reference are empty, + // but with 0 values there is nothing to number either. + expect(chart.series[0].categories).toEqual([]) + } + expect(warnings.length).toBeGreaterThan(0) + expect(warnings.some((w) => w.includes('no category and value data'))).toBe(true) + }) +}) + +describe('parseCharts — generated workbook with unsupported chart type', () => { + it('reports unsupported chart types with rawTypeName', async () => { + const zip = await buildChartWorkbookZip('unsupported') + const { charts, warnings } = await parseCharts(zip, 'Data', DEFAULT_LAYOUT, emptyDataAccessor) + + expect(charts).toHaveLength(1) + expect(charts[0].type).toBe('unsupported') + expect(charts[0].rawTypeName).toBe('scatterChart') + expect(charts[0].series).toEqual([]) + expect(warnings).toEqual([]) + }) +}) + +describe('parseCharts — sheet without drawing/charts', () => { + it('returns an empty result when the sheet has no drawing relationship', async () => { + const zip = await buildChartWorkbookZip('basic') + const { charts, warnings } = await parseCharts(zip, 'NonExistentSheet', DEFAULT_LAYOUT, emptyDataAccessor) + expect(charts).toEqual([]) + expect(warnings).toEqual([]) + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.echartsChartRenderer.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.echartsChartRenderer.test.ts new file mode 100644 index 00000000000..96f8747fc41 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/chart.echartsChartRenderer.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ChartModel } from '../renderModel' + +/** + * EchartsChartRenderer 生命周期测试。 + * + * jsdom 下没有 `canvas` npm 包时,echarts 的 CanvasRenderer 无法真正创建 2D 上下文 + * (HTMLCanvasElement.getContext('2d') 在 jsdom 里抛 "Not implemented"),这是环境限制, + * 不是本包的 bug。按 04-wp-charts.md 的预案,这里用 vi.mock 替身验证渲染适配器自身的接线逻辑 + * (init/setOption 调用、ResizeObserver 首次有尺寸时的处理、dispose 清理), + * ChartModel → option 的实际映射由 chart.buildChartOption.test.ts 覆盖。 + */ + +const setOption = vi.fn() +const resize = vi.fn() +const dispose = vi.fn() +const init = vi.fn((_dom: HTMLElement, _theme?: unknown, _opts?: unknown) => ({ setOption, resize, dispose })) + +vi.mock('echarts/core', () => ({ + init, + use: vi.fn() +})) +vi.mock('echarts/charts', () => ({ BarChart: {}, LineChart: {}, PieChart: {} })) +vi.mock('echarts/components', () => ({ + GridComponent: {}, + TooltipComponent: {}, + LegendComponent: {}, + TitleComponent: {} +})) +vi.mock('echarts/renderers', () => ({ CanvasRenderer: {} })) + +let resizeObserverInstances: { + callback: ResizeObserverCallback + observe: ReturnType + disconnect: ReturnType +}[] = [] + +class FakeResizeObserver { + callback: ResizeObserverCallback + observe = vi.fn() + disconnect = vi.fn() + constructor(callback: ResizeObserverCallback) { + this.callback = callback + resizeObserverInstances.push(this as any) + } + unobserve = vi.fn() +} + +const chart: ChartModel = { + rect: { x: 0, y: 0, width: 100, height: 100 }, + type: 'bar', + barDirection: 'col', + series: [{ categories: ['a'], values: [1] }] +} + +describe('echartsChartRenderer.render', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + resizeObserverInstances = [] + setOption.mockClear() + resize.mockClear() + dispose.mockClear() + init.mockClear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('initializes an echarts instance on the container and sets the mapped option', async () => { + const { echartsChartRenderer } = await import('../charts/EchartsChartRenderer') + const container = document.createElement('div') + + const dispose1 = echartsChartRenderer.render(chart, container) + + expect(init).toHaveBeenCalledTimes(1) + expect(init.mock.calls[0][0]).toBe(container) + expect(setOption).toHaveBeenCalledTimes(1) + expect(setOption.mock.calls[0][0]).toMatchObject({ series: [expect.objectContaining({ type: 'bar' })] }) + + dispose1() + }) + + it('observes the container for resize and re-applies the option + resizes once it first gets a nonzero size', async () => { + const { echartsChartRenderer } = await import('../charts/EchartsChartRenderer') + const container = document.createElement('div') + Object.defineProperty(container, 'clientWidth', { value: 0, configurable: true }) + Object.defineProperty(container, 'clientHeight', { value: 0, configurable: true }) + + const disposeFn = echartsChartRenderer.render(chart, container) + expect(resizeObserverInstances).toHaveLength(1) + const observer = resizeObserverInstances[0] + expect(observer.observe).toHaveBeenCalledWith(container) + + setOption.mockClear() + + // Still zero-size: resize callback should be a no-op. + observer.callback([] as any, observer as any) + expect(setOption).not.toHaveBeenCalled() + expect(resize).not.toHaveBeenCalled() + + // Container gains a real size: should re-apply option once and call resize. + Object.defineProperty(container, 'clientWidth', { value: 400, configurable: true }) + Object.defineProperty(container, 'clientHeight', { value: 300, configurable: true }) + observer.callback([] as any, observer as any) + expect(setOption).toHaveBeenCalledTimes(1) + expect(resize).toHaveBeenCalledTimes(1) + + // Subsequent resize events should just resize, not re-apply the option again. + setOption.mockClear() + observer.callback([] as any, observer as any) + expect(setOption).not.toHaveBeenCalled() + expect(resize).toHaveBeenCalledTimes(2) + + disposeFn() + }) + + it('dispose() disconnects the resize observer and disposes the echarts instance', async () => { + const { echartsChartRenderer } = await import('../charts/EchartsChartRenderer') + const container = document.createElement('div') + + const disposeFn = echartsChartRenderer.render(chart, container) + const observer = resizeObserverInstances[0] + + disposeFn() + + expect(observer.disconnect).toHaveBeenCalledTimes(1) + expect(dispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/formula.evaluator.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/formula.evaluator.test.ts new file mode 100644 index 00000000000..ada19214785 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/formula.evaluator.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from 'vitest' + +import { + createFormulaEvaluator, + type EvalContext, + type FormulaCellRef, + type FormulaEvaluator +} from '../worker/formulaEvaluator' + +type StubCellValue = string | number | boolean | null +interface StubCell { + /** 公式原文,不含前导 '='。设置了 formula 的单元格会递归调用 evaluator.evaluate */ + formula?: string + /** 非公式单元格的原始值 */ + value?: StubCellValue +} +type StubSheet = Record + +/** + * 手写 EvalContext stub:模拟 A 包按契约描述的行为——公式单元格的 getCellValue + * 递归调用同一个 evaluator 的 evaluate(),从而天然形成链式/环形递归。 + * 不依赖 A 的实现进度。 + */ +function makeStubContext(sheets: Record): { + ctx: EvalContext + bind: (evaluator: FormulaEvaluator) => void + callCount: (ref: FormulaCellRef) => number +} { + const held: { evaluator?: FormulaEvaluator } = {} + const callCounts = new Map() + + const ctx: EvalContext = { + getCellValue(ref: FormulaCellRef): StubCellValue { + const fullKey = `${ref.sheet}!${ref.row}:${ref.col}` + callCounts.set(fullKey, (callCounts.get(fullKey) ?? 0) + 1) + + const cell = sheets[ref.sheet]?.[`${ref.row}:${ref.col}`] + if (!cell) return null + if (cell.formula !== undefined) { + const outcome = held.evaluator!.evaluate(cell.formula, ref) + return outcome.state === 'evaluated' ? (outcome.value ?? null) : null + } + return cell.value ?? null + } + } + + return { + ctx, + bind: (evaluator) => { + held.evaluator = evaluator + }, + callCount: (ref) => callCounts.get(`${ref.sheet}!${ref.row}:${ref.col}`) ?? 0 + } +} + +/** 便捷构造:创建 evaluator 并完成 stub 的循环绑定 */ +function setup(sheets: Record, budgetMs = 5000) { + const { ctx, bind, callCount } = makeStubContext(sheets) + const evaluator = createFormulaEvaluator(ctx, budgetMs) + bind(evaluator) + return { evaluator, callCount } +} + +const POS: FormulaCellRef = { sheet: 'Sheet1', row: 1, col: 1 } + +describe('createFormulaEvaluator — basic operators and functions', () => { + it('evaluates four arithmetic operators', () => { + const { evaluator } = setup({}) + expect(evaluator.evaluate('1+2', POS)).toEqual({ state: 'evaluated', value: 3 }) + expect(evaluator.evaluate('5-2', { ...POS, row: 2 })).toEqual({ state: 'evaluated', value: 3 }) + expect(evaluator.evaluate('4*3', { ...POS, row: 3 })).toEqual({ state: 'evaluated', value: 12 }) + expect(evaluator.evaluate('10/4', { ...POS, row: 4 })).toEqual({ state: 'evaluated', value: 2.5 }) + }) + + it('evaluates SUM/AVERAGE/IF/COUNT/ROUND/CONCATENATE over cell references', () => { + const sheets = { + Sheet1: { + '1:1': { value: 1 }, + '2:1': { value: 2 }, + '3:1': { value: 3 } + } + } + const { evaluator } = setup(sheets) + expect(evaluator.evaluate('SUM(A1:A3)', { sheet: 'Sheet1', row: 10, col: 1 })).toEqual({ + state: 'evaluated', + value: 6 + }) + expect(evaluator.evaluate('AVERAGE(A1:A3)', { sheet: 'Sheet1', row: 10, col: 2 })).toEqual({ + state: 'evaluated', + value: 2 + }) + expect(evaluator.evaluate('IF(A1>0,"pos","neg")', { sheet: 'Sheet1', row: 10, col: 3 })).toEqual({ + state: 'evaluated', + value: 'pos' + }) + expect(evaluator.evaluate('COUNT(A1:A3)', { sheet: 'Sheet1', row: 10, col: 4 })).toEqual({ + state: 'evaluated', + value: 3 + }) + expect(evaluator.evaluate('ROUND(1.2345,2)', { sheet: 'Sheet1', row: 10, col: 5 })).toEqual({ + state: 'evaluated', + value: 1.23 + }) + expect(evaluator.evaluate('CONCATENATE("a","b")', { sheet: 'Sheet1', row: 10, col: 6 })).toEqual({ + state: 'evaluated', + value: 'ab' + }) + }) + + it('evaluates VLOOKUP against a stub table', () => { + const sheets = { + Sheet1: { + '1:1': { value: 1 }, + '1:2': { value: 'one' }, + '2:1': { value: 2 }, + '2:2': { value: 'two' }, + '3:1': { value: 3 }, + '3:2': { value: 'three' } + } + } + const { evaluator } = setup(sheets) + expect(evaluator.evaluate('VLOOKUP(2,A1:B3,2,FALSE)', { sheet: 'Sheet1', row: 10, col: 1 })).toEqual({ + state: 'evaluated', + value: 'two' + }) + }) + + it('resolves cross-sheet references', () => { + const sheets = { + Sheet1: { '1:1': { value: 10 } }, + Sheet2: {} + } + const { evaluator } = setup(sheets) + const outcome = evaluator.evaluate('Sheet1!A1*2', { sheet: 'Sheet2', row: 1, col: 1 }) + expect(outcome).toEqual({ state: 'evaluated', value: 20 }) + }) +}) + +describe('createFormulaEvaluator — range aggregation with holes', () => { + it('treats empty cells within a range as 0 for SUM', () => { + const sheets = { + Sheet1: { + '1:1': { value: 1 }, + '2:1': { value: 2 }, + // 3:1 空 + '4:1': { value: 4 }, + '5:1': { value: 5 } + } + } + const { evaluator } = setup(sheets) + expect(evaluator.evaluate('SUM(A1:A5)', { sheet: 'Sheet1', row: 10, col: 1 })).toEqual({ + state: 'evaluated', + value: 12 + }) + }) +}) + +describe('createFormulaEvaluator — chained dependency and memoization', () => { + it('recursively evaluates a dependency chain and memoizes intermediate results', () => { + const sheets = { + Sheet1: { + '1:1': { value: 1 }, // A1 = 1 + '1:2': { formula: 'A1+1' }, // B1 = A1 + 1 + '1:3': { formula: 'B1*2' } // C1 = B1 * 2 + } + } + const { evaluator, callCount } = setup(sheets) + + const outcome = evaluator.evaluate('B1*2', { sheet: 'Sheet1', row: 1, col: 3 }) + expect(outcome).toEqual({ state: 'evaluated', value: 4 }) + + // B1 的公式应当只被真正求值一次(memo 命中),对应 A1 只被读取一次 + expect(callCount({ sheet: 'Sheet1', row: 1, col: 1 })).toBe(1) + + // 再次直接对 B1 求值应命中 memo,不再重新读取 A1 + const b1Outcome = evaluator.evaluate('A1+1', { sheet: 'Sheet1', row: 1, col: 2 }) + expect(b1Outcome).toEqual({ state: 'evaluated', value: 2 }) + expect(callCount({ sheet: 'Sheet1', row: 1, col: 1 })).toBe(1) + }) +}) + +describe('createFormulaEvaluator — circular references', () => { + it('marks both cells in a two-cell cycle as unevaluated without hanging', () => { + const sheets = { + Sheet1: { + '1:1': { formula: 'B1' }, // A1 = B1 + '1:2': { formula: 'A1' } // B1 = A1 + } + } + const { evaluator } = setup(sheets) + + const a1 = evaluator.evaluate('B1', { sheet: 'Sheet1', row: 1, col: 1 }) + expect(a1).toEqual({ state: 'unevaluated' }) + + const b1 = evaluator.evaluate('A1', { sheet: 'Sheet1', row: 1, col: 2 }) + expect(b1).toEqual({ state: 'unevaluated' }) + }) + + it('does not poison an unrelated ancestor that merely reads a cyclic cell', () => { + // D1 = A1 + E1, A1 = B1, B1 = A1 (A1/B1 互为环), E1 = 5 常量 + const sheets = { + Sheet1: { + '1:1': { formula: 'B1' }, // A1 + '1:2': { formula: 'A1' }, // B1 + '1:5': { value: 5 }, // E1 + '1:4': { formula: 'A1+E1' } // D1 + } + } + const { evaluator } = setup(sheets) + + const d1 = evaluator.evaluate('A1+E1', { sheet: 'Sheet1', row: 1, col: 4 }) + // A1 参与环 → 对 D1 而言取到 null(按 0 参与运算),D1 本身不在环上,应正常给出数值结果 + expect(d1).toEqual({ state: 'evaluated', value: 5 }) + }) +}) + +describe('createFormulaEvaluator — result classification (contract §2 table)', () => { + it('classifies a normal return as evaluated', () => { + const { evaluator } = setup({}) + expect(evaluator.evaluate('1+1', POS)).toEqual({ state: 'evaluated', value: 2 }) + }) + + it.each([ + ['1/0', '#DIV/0!'], + ['1+"a"', '#VALUE!'], + ['NA()', '#N/A'], + ['SQRT(-1)', '#NUM!'], + ['#NULL!', '#NULL!'], + ['A1:B1 A2:B2', '#NULL!'] + ])('treats legitimately returned error code from %s as evaluated(%s)', (formula, code) => { + const { evaluator } = setup({}) + const outcome = evaluator.evaluate(formula, POS) + expect(outcome.state).toBe('evaluated') + expect(outcome.value).toBe(code) + }) + + it('classifies #REF! (e.g. VLOOKUP out-of-range column) as evaluated', () => { + const sheets = { + Sheet1: { + '1:1': { value: 1 }, + '1:2': { value: 'one' } + } + } + const { evaluator } = setup(sheets) + const outcome = evaluator.evaluate('VLOOKUP(1,A1:B1,5)', { sheet: 'Sheet1', row: 10, col: 1 }) + expect(outcome).toEqual({ state: 'evaluated', value: '#REF!' }) + }) + + it('classifies #NAME? (undefined name/range) as unevaluated', () => { + const { evaluator } = setup({}) + const outcome = evaluator.evaluate('SOME_UNDEFINED_NAME', POS) + expect(outcome).toEqual({ state: 'unevaluated' }) + }) + + it('classifies an unknown function call as unevaluated', () => { + const { evaluator } = setup({}) + expect(evaluator.evaluate('FOOBAR(1)', POS)).toEqual({ state: 'unevaluated' }) + }) + + it('classifies a parse/syntax error as unevaluated', () => { + const { evaluator } = setup({}) + expect(evaluator.evaluate('1+', POS)).toEqual({ state: 'unevaluated' }) + }) +}) + +describe('createFormulaEvaluator — unknown functions', () => { + it('returns unevaluated for an unrecognized function name', () => { + const { evaluator } = setup({}) + expect(evaluator.evaluate('FOOBAR(1)', POS)).toEqual({ state: 'unevaluated' }) + }) +}) + +describe('createFormulaEvaluator — budget', () => { + it('with budgetMs=0, immediately returns unevaluated for everything', () => { + const sheets = { Sheet1: { '1:1': { value: 1 } } } + const { evaluator } = setup(sheets, 0) + const start = Date.now() + expect(evaluator.evaluate('1+1', POS)).toEqual({ state: 'unevaluated' }) + expect(evaluator.evaluate('SUM(A1:A1)', { ...POS, row: 2 })).toEqual({ state: 'unevaluated' }) + expect(Date.now() - start).toBeLessThan(50) + }) +}) + +describe('createFormulaEvaluator — malformed / adversarial input', () => { + it('does not throw on unbalanced parentheses', () => { + const { evaluator } = setup({}) + expect(() => evaluator.evaluate('((((', POS)).not.toThrow() + expect(evaluator.evaluate('((((', POS)).toEqual({ state: 'unevaluated' }) + }) + + it('does not throw on an extremely long garbage string', () => { + const { evaluator } = setup({}) + const garbage = `${'A1+'.repeat(5000)}1` + expect(() => evaluator.evaluate(garbage, POS)).not.toThrow() + }) + + it('does not throw when the formula string is empty', () => { + const { evaluator } = setup({}) + expect(() => evaluator.evaluate('', POS)).not.toThrow() + expect(evaluator.evaluate('', POS)).toEqual({ state: 'unevaluated' }) + }) + + it('does not let an exception thrown by getCellValue escape', () => { + const ctx: EvalContext = { + getCellValue() { + throw new Error('boom') + } + } + const evaluator = createFormulaEvaluator(ctx, 5000) + expect(() => evaluator.evaluate('A1+1', POS)).not.toThrow() + expect(evaluator.evaluate('A1+1', POS)).toEqual({ state: 'unevaluated' }) + }) +}) + +describe('createFormulaEvaluator — recursion depth guard', () => { + it('does not stack-overflow or hang on an extremely deep non-cyclic reference chain', () => { + // 深度 200 远超建议上限 64:验证深度守卫生效,整体求值在有限时间内正常返回 + // (不断言链顶端的具体数值——深链中段一旦触发深度上限被判 unevaluated 并按 0 参与运算, + // 链顶端会据此算出一个"降级但良定义"的数字,这是预期的优雅降级而非 bug)。 + const DEPTH = 200 + const sheets: Record = { Sheet1: { '1:1': { value: 0 } } } + for (let row = 2; row <= DEPTH; row++) { + sheets.Sheet1[`${row}:1`] = { formula: `A${row - 1}+1` } + } + const { evaluator } = setup(sheets) + const start = Date.now() + let outcome: ReturnType | undefined + expect(() => { + outcome = evaluator.evaluate(`A${DEPTH - 1}+1`, { sheet: 'Sheet1', row: DEPTH, col: 1 }) + }).not.toThrow() + expect(Date.now() - start).toBeLessThan(1000) + expect(outcome).toBeDefined() + expect(['evaluated', 'unevaluated']).toContain(outcome!.state) + if (outcome!.state === 'evaluated') { + expect(typeof outcome!.value === 'number' || outcome!.value === null).toBe(true) + } + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.layout.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.layout.test.ts new file mode 100644 index 00000000000..62fb8cbbc6b --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.layout.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' + +import { + axisOffset, + buildAxisLayout, + DEFAULT_FONT_SIZE_PX, + findCoveringMerge, + mergeRectPx, + mergesInView, + scaledFontSizePx +} from '../gridLayout' + +describe('buildAxisLayout', () => { + it('builds a prefix-sum table using the default size when no override exists', () => { + const layout = buildAxisLayout(3, 20, {}, 1) + expect(layout.sizes).toEqual([20, 20, 20]) + expect(layout.offsets).toEqual([0, 20, 40]) + expect(layout.totalSize).toBe(60) + }) + + it('applies sparse overrides keyed by 1-based index', () => { + const layout = buildAxisLayout(3, 20, { 2: 50 }, 1) + expect(layout.sizes).toEqual([20, 50, 20]) + expect(layout.offsets).toEqual([0, 20, 70]) + expect(layout.totalSize).toBe(90) + }) + + it('treats a zero-size override (hidden row/col) as a zero-width slot, not a skip', () => { + const layout = buildAxisLayout(3, 20, { 2: 0 }, 1) + expect(layout.sizes).toEqual([20, 0, 20]) + expect(layout.offsets).toEqual([0, 20, 20]) + expect(layout.totalSize).toBe(40) + }) + + it('scales every size and offset by the zoom factor', () => { + const layout = buildAxisLayout(2, 20, { 1: 30 }, 2) + expect(layout.sizes).toEqual([60, 40]) + expect(layout.offsets).toEqual([0, 60]) + expect(layout.totalSize).toBe(100) + }) + + it('returns an empty layout for a zero count', () => { + const layout = buildAxisLayout(0, 20, {}, 1) + expect(layout.sizes).toEqual([]) + expect(layout.offsets).toEqual([]) + expect(layout.totalSize).toBe(0) + }) +}) + +describe('axisOffset', () => { + const layout = buildAxisLayout(3, 20, {}, 1) + + it('looks up the offset of a valid 0-based index', () => { + expect(axisOffset(layout, 0)).toBe(0) + expect(axisOffset(layout, 1)).toBe(20) + expect(axisOffset(layout, 2)).toBe(40) + }) + + it('clamps a negative index to 0', () => { + expect(axisOffset(layout, -1)).toBe(0) + }) + + it('clamps an out-of-range index to the total size (one-past-the-end lookup)', () => { + expect(axisOffset(layout, 3)).toBe(60) + expect(axisOffset(layout, 100)).toBe(60) + }) +}) + +describe('mergeRectPx', () => { + it('computes the pixel rect spanning the merged rows/cols', () => { + const rowLayout = buildAxisLayout(5, 20, {}, 1) + const colLayout = buildAxisLayout(5, 64, {}, 1) + // merge covers rows 1-2, cols 1-4 (1-based, inclusive) — matches mockModel's title merge + const rect = mergeRectPx({ top: 1, left: 1, bottom: 1, right: 4 }, rowLayout, colLayout) + expect(rect).toEqual({ x: 0, y: 0, width: 64 * 4, height: 20 }) + }) + + it('computes a rect for a merge that does not start at the origin', () => { + const rowLayout = buildAxisLayout(5, 20, {}, 1) + const colLayout = buildAxisLayout(5, 64, {}, 1) + const rect = mergeRectPx({ top: 2, left: 2, bottom: 3, right: 3 }, rowLayout, colLayout) + expect(rect).toEqual({ x: 64, y: 20, width: 64 * 2, height: 20 * 2 }) + }) +}) + +describe('mergesInView', () => { + const rowLayout = buildAxisLayout(100, 20, {}, 1) + const colLayout = buildAxisLayout(20, 64, {}, 1) + + it('includes a merge that intersects the viewport', () => { + const merges = [{ top: 1, left: 1, bottom: 1, right: 4 }] + const result = mergesInView(merges, { top: 0, left: 0, bottom: 100, right: 300 }, rowLayout, colLayout) + expect(result).toHaveLength(1) + expect(result[0].masterRow).toBe(1) + expect(result[0].masterCol).toBe(1) + expect(result[0].rect).toEqual({ x: 0, y: 0, width: 64 * 4, height: 20 }) + }) + + it('excludes a merge entirely outside the viewport', () => { + const merges = [{ top: 1, left: 1, bottom: 1, right: 4 }] + const result = mergesInView(merges, { top: 500, left: 500, bottom: 600, right: 600 }, rowLayout, colLayout) + expect(result).toHaveLength(0) + }) + + it('keeps a merge visible when its master cell has scrolled out of view but the merged area still intersects', () => { + // Merge spans rows 10-40 (tall), viewport only sees rows 30-35 — master row 10 is off-screen + // but the merge rect (rows 10-40) still overlaps the [30,35] viewport window. + const merges = [{ top: 10, left: 1, bottom: 40, right: 2 }] + const viewportTop = axisOffset(rowLayout, 29) // start of row 30 (0-based index 29) + const viewportBottom = axisOffset(rowLayout, 35) + const result = mergesInView( + merges, + { top: viewportTop, left: 0, bottom: viewportBottom, right: 200 }, + rowLayout, + colLayout + ) + expect(result).toHaveLength(1) + expect(result[0].masterRow).toBe(10) + expect(result[0].masterCol).toBe(1) + }) + + it('treats a viewport edge that only touches (does not overlap) the merge as not intersecting', () => { + const merges = [{ top: 1, left: 1, bottom: 2, right: 2 }] + const mergeBottom = axisOffset(rowLayout, 2) // pixel just after the merge ends + const result = mergesInView( + merges, + { top: mergeBottom, left: 0, bottom: mergeBottom + 100, right: 200 }, + rowLayout, + colLayout + ) + expect(result).toHaveLength(0) + }) +}) + +describe('findCoveringMerge', () => { + const merges = [{ top: 2, left: 2, bottom: 4, right: 4 }] + + it('returns the merge covering an interior cell', () => { + expect(findCoveringMerge(merges, 3, 3)).toBe(merges[0]) + }) + + it('returns the merge for the master cell itself', () => { + expect(findCoveringMerge(merges, 2, 2)).toBe(merges[0]) + }) + + it('returns undefined for a cell outside every merge', () => { + expect(findCoveringMerge(merges, 1, 1)).toBeUndefined() + expect(findCoveringMerge(merges, 5, 5)).toBeUndefined() + }) +}) + +describe('scaledFontSizePx', () => { + it('falls back to the Excel default (11pt) when fontSizePx is undefined', () => { + expect(scaledFontSizePx(undefined, 1)).toBeCloseTo(DEFAULT_FONT_SIZE_PX) + }) + + it('scales an explicit font size by the zoom factor', () => { + expect(scaledFontSizePx(16, 2)).toBe(32) + }) + + it('scales the default font size by the zoom factor too', () => { + expect(scaledFontSizePx(undefined, 2)).toBeCloseTo(DEFAULT_FONT_SIZE_PX * 2) + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx new file mode 100644 index 00000000000..505358270cc --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx @@ -0,0 +1,418 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createMockWorkbookModel } from '../mockModel' +import type { ChartModel, SheetRenderModel } from '../renderModel' +import type { SelectedCellInfo, XlsxGridProps } from '../XlsxGrid' + +// jsdom has no real layout, so — matching the existing convention in +// src/renderer/components/VirtualList/__tests__/DynamicVirtualList.test.tsx — we mock +// @tanstack/react-virtual's useVirtualizer and assert on "what was passed in" / "what +// virtual items were rendered" rather than pixel-perfect layout. +interface MockVirtualItem { + key: string + index: number + start: number + size: number +} + +const mocks = vi.hoisted(() => ({ + rowRange: [] as MockVirtualItem[], + colRange: [] as MockVirtualItem[], + lastRowOptions: null as any, + lastColOptions: null as any, + useVirtualizer: vi.fn() +})) + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: mocks.useVirtualizer +})) + +const toVirtualItems = ( + prefix: string, + indexes: number[], + size: (i: number) => number, + start: (i: number) => number +): MockVirtualItem[] => + indexes.map((index) => ({ key: `${prefix}-${index}`, index, start: start(index), size: size(index) })) + +const setRangeFromCounts = ( + rowIndexes: number[], + colIndexes: number[], + rowSize: (i: number) => number, + colSize: (i: number) => number, + rowStart: (i: number) => number, + colStart: (i: number) => number +) => { + mocks.rowRange = toVirtualItems('row', rowIndexes, rowSize, rowStart) + mocks.colRange = toVirtualItems('col', colIndexes, colSize, colStart) +} + +const virtualizerImpl = (options: any) => { + const isHorizontal = Boolean(options.horizontal) + if (isHorizontal) { + mocks.lastColOptions = options + } else { + mocks.lastRowOptions = options + } + return { + getVirtualItems: () => (isHorizontal ? mocks.colRange : mocks.rowRange), + getTotalSize: () => { + const range = isHorizontal ? mocks.colRange : mocks.rowRange + const last = range[range.length - 1] + return last ? last.start + last.size : 0 + } + } +} + +let XlsxGrid: (props: XlsxGridProps) => ReactNode +beforeEach(async () => { + vi.clearAllMocks() + mocks.useVirtualizer.mockImplementation(virtualizerImpl) + const mod = await import('../XlsxGrid') + XlsxGrid = mod.default +}) + +const model = createMockWorkbookModel() +const salesSheet = model.sheets[0] + +/** Visible range covering the header area (rows 1-6, cols 1-4) — enough to see title/headers/data without the far scroll target. */ +const showHeaderRange = () => { + setRangeFromCounts( + [0, 1, 2, 3, 4, 5], + [0, 1, 2, 3], + () => 20, + () => 64, + (i) => i * 20, + (i) => i * 64 + ) +} + +/** + * jsdom always reports clientWidth/clientHeight as 0, so the merge layer (which derives its + * viewport from the real scroll container's scrollTop/scrollLeft/clientWidth/clientHeight, + * independent of the mocked row/col virtualizers) would never see a non-empty viewport unless we + * stub the container's client size and fire a scroll event, matching what a real browser would + * report for a rendered, sized panel. + */ +const setScrollViewport = ( + container: HTMLElement, + { scrollTop = 0, scrollLeft = 0, clientWidth = 2000, clientHeight = 2000 } = {} +) => { + const scrollEl = container.querySelector('[data-testid="xlsx-grid-scroll"]') as HTMLElement + Object.defineProperty(scrollEl, 'clientWidth', { configurable: true, value: clientWidth }) + Object.defineProperty(scrollEl, 'clientHeight', { configurable: true, value: clientHeight }) + scrollEl.scrollTop = scrollTop + scrollEl.scrollLeft = scrollLeft + fireEvent.scroll(scrollEl) +} + +describe('XlsxGrid — mock model rendering', () => { + beforeEach(() => { + showHeaderRange() + }) + + it('renders the expected text within the visible range', () => { + render() + expect(screen.getByText('季度')).toBeInTheDocument() + expect(screen.getByText('销量')).toBeInTheDocument() + expect(screen.getByText('Q1')).toBeInTheDocument() + }) + + it('does not render a far cell that is outside the current virtual range', () => { + render() + expect(screen.queryByText('滚动到我')).not.toBeInTheDocument() + }) + + it('renders a far cell once the virtual range is scrolled to include it', () => { + const { rerender } = render() + expect(screen.queryByText('滚动到我')).not.toBeInTheDocument() + + // Simulate the virtualizer reporting row 60 / col 10 (0-based 59 / 9) as visible. + setRangeFromCounts( + [59], + [9], + () => 20, + () => 64, + () => 59 * 20, + () => 9 * 64 + ) + rerender() + + expect(screen.getByText('滚动到我')).toBeInTheDocument() + }) + + it('passes precise estimateSize functions backed by the sheet layout (no measureElement reliance)', () => { + render() + // Row 1 has an explicit override (36px in mockModel); row 2 uses the default (20px). + expect(mocks.lastRowOptions.estimateSize(0)).toBe(36) + expect(mocks.lastRowOptions.estimateSize(1)).toBe(20) + // Row 7 (index 6) is hidden (height 0 override). + expect(mocks.lastRowOptions.estimateSize(6)).toBe(0) + // Col A (index 0) has an explicit override (110px); col E (index 4) is hidden (0). + expect(mocks.lastColOptions.estimateSize(0)).toBe(110) + expect(mocks.lastColOptions.estimateSize(4)).toBe(0) + }) +}) + +describe('XlsxGrid — style mapping', () => { + beforeEach(() => { + showHeaderRange() + }) + + it('applies bold, background, and center alignment from the header style (styleId 1)', () => { + render() + const header = screen.getByText('季度') + const cellDiv = header.closest('div') + expect(cellDiv).toHaveStyle({ fontWeight: 'bold', backgroundColor: '#d9e1f2', justifyContent: 'center' }) + }) + + it('applies wrap (whiteSpace normal) from the wrap style (styleId 3)', () => { + // mockModel cell '3:4' (row 3, col 4) is 0-based [2, 3]. + setRangeFromCounts( + [2], + [3], + () => 20, + () => 64, + () => 0, + () => 0 + ) + render() + const wrapped = screen.getByText('春节档期拉动,环比增长明显,渠道补货集中在一月下旬。') + expect(wrapped.closest('div')).toHaveStyle({ whiteSpace: 'normal' }) + }) + + it('right-aligns a numeric cell with no explicit hAlign by default-aligning on cell type', () => { + // Notes sheet cell 3:2 is a bare boolean with no styleId — should default-center. + const notesSheet = model.sheets[1] + setRangeFromCounts( + [0, 1, 2], + [0, 1], + () => 20, + () => 64, + (i) => i * 20, + (i) => i * 64 + ) + render() + const boolCell = screen.getByText('TRUE') + expect(boolCell.closest('div')).toHaveStyle({ justifyContent: 'center' }) + }) +}) + +/** Col widths for A..D in the mock model (A is overridden to 110px, B/C/D default to 64px). */ +const salesColWidth = (i: number) => (i === 0 ? 110 : 64) +const salesColStart = (i: number) => (i === 0 ? 0 : 110 + (i - 1) * 64) + +/** Visible range covering just the title merge's row (row 1) and its 4 covered columns. */ +const showTitleMergeRange = () => { + setRangeFromCounts( + [0], + [0, 1, 2, 3], + () => 36, + salesColWidth, + () => 0, + salesColStart + ) +} + +describe('XlsxGrid — merged cells', () => { + it('renders the master text of a merge exactly once, sized to the sum of covered rows/cols', () => { + // Merge is rows 1-1, cols 1-4. Cover cols 1-4 in the virtual range so the underlying + // per-cell layer would otherwise also try to render col A..D. + showTitleMergeRange() + const { container } = render() + setScrollViewport(container) + + const titleMatches = screen.getAllByText('2026 年度销售汇总') + expect(titleMatches).toHaveLength(1) + + const mergeCell = screen.getByTestId('xlsx-grid-merge-cell') + // width = col A (110) + col B/C/D (64 each) = 302; height = row 1 (36) + expect(mergeCell.firstElementChild).toHaveStyle({ width: '302px', height: '36px' }) + }) + + it('keeps the merge layer visible when the master row has scrolled out of the virtual row range', () => { + // Only row index 5 (row 6, "合计") is in the virtual range — row 0 (the merge's master row) is not. + setRangeFromCounts( + [5], + [0, 1, 2, 3], + () => 20, + salesColWidth, + () => 100, + salesColStart + ) + const { container } = render() + // Scroll viewport still spans y=[0, 2000], so it overlaps the merge's rect (y=[0,36]) even + // though row 0 itself is absent from the (mocked) row virtualizer's reported range. + setScrollViewport(container) + + // The per-cell layer has no row-0 item, but the merge layer computes visibility from the + // scroll viewport independently of the row virtualizer's current range. + expect(screen.getByText('2026 年度销售汇总')).toBeInTheDocument() + expect(screen.getByTestId('xlsx-grid-merge-cell')).toBeInTheDocument() + }) +}) + +describe('XlsxGrid — zoom', () => { + it('doubles cell position/size and font size when zoom goes from 1 to 2', () => { + setRangeFromCounts( + [1], + [0], + () => 40, + () => 128, + () => 40, + () => 0 + ) + const { rerender } = render() + + setRangeFromCounts( + [1], + [0], + () => 40, + () => 128, + () => 40, + () => 0 + ) + rerender() + + const cell = screen.getByText('季度') + const cellDiv = cell.closest('div') as HTMLElement + expect(cellDiv).toHaveStyle({ top: '40px', left: '0px', width: '128px', height: '40px' }) + }) +}) + +describe('XlsxGrid — cell selection', () => { + beforeEach(() => { + showHeaderRange() + }) + + it('reports the address and cell when a plain cell is clicked', () => { + const onSelectCell = vi.fn() + render() + + fireEvent.click(screen.getByText('季度')) + + expect(onSelectCell).toHaveBeenCalledWith<[SelectedCellInfo]>({ + address: 'A2', + cell: expect.objectContaining({ text: '季度' }) + }) + }) + + it('reports the master address/cell when a merged region is clicked', () => { + showTitleMergeRange() + const onSelectCell = vi.fn() + const { container } = render( + + ) + setScrollViewport(container) + + fireEvent.click(screen.getByTestId('xlsx-grid-merge-cell')) + + expect(onSelectCell).toHaveBeenCalledWith<[SelectedCellInfo]>({ + address: 'A1', + cell: expect.objectContaining({ text: '2026 年度销售汇总' }) + }) + }) + + it('clears the selection (calls back with null) on Escape', () => { + const onSelectCell = vi.fn() + render() + + fireEvent.click(screen.getByText('季度')) + onSelectCell.mockClear() + + fireEvent.keyDown(screen.getByTestId('xlsx-grid-scroll'), { key: 'Escape' }) + expect(onSelectCell).toHaveBeenCalledWith(null) + }) +}) + +describe('XlsxGrid — floating layer', () => { + it('renders a floating image at its scaled position with the resolved object URL', () => { + setRangeFromCounts( + [0], + [0], + () => 20, + () => 64, + () => 0, + () => 0 + ) + render() + + const img = screen.getByTestId('xlsx-grid-floating-image') as HTMLImageElement + expect(img.src).toContain('blob:mock-url') + // floatingImages[0].rect = { x: 340, y: 44, width: 160, height: 90 }, zoom=2 + expect(img).toHaveStyle({ top: '88px', left: '680px', width: '320px', height: '180px' }) + }) + + it('does not render an image when its object URL has not been resolved yet', () => { + setRangeFromCounts( + [0], + [0], + () => 20, + () => 64, + () => 0, + () => 0 + ) + render() + expect(screen.queryByTestId('xlsx-grid-floating-image')).not.toBeInTheDocument() + }) + + it('invokes renderChart for a supported chart and calls the returned dispose function on unmount', () => { + setRangeFromCounts( + [0], + [0], + () => 20, + () => 64, + () => 0, + () => 0 + ) + const dispose = vi.fn() + const renderChart = vi.fn((_chart: ChartModel, _container: HTMLElement) => dispose) + + const { unmount } = render( + + ) + + expect(renderChart).toHaveBeenCalledTimes(1) + const [chartArg, container] = renderChart.mock.calls[0] + expect(chartArg.type).toBe('bar') + expect(container).toBeInstanceOf(HTMLElement) + expect(dispose).not.toHaveBeenCalled() + + unmount() + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('renders the unsupported chart placeholder with the raw type name instead of calling renderChart', () => { + setRangeFromCounts( + [0], + [0], + () => 20, + () => 64, + () => 0, + () => 0 + ) + const renderChart = vi.fn(() => vi.fn()) + render() + + expect(screen.getByText('xlsx_preview.chart_unsupported')).toBeInTheDocument() + expect(screen.getByText('scatterChart')).toBeInTheDocument() + // renderChart should only be invoked for the supported ('bar') chart, not the unsupported one. + expect(renderChart).toHaveBeenCalledTimes(1) + }) + + it('falls back to the unsupported placeholder for a supported chart type when no renderChart is provided', () => { + setRangeFromCounts( + [0], + [0], + () => 20, + () => 64, + () => 0, + () => 0 + ) + const barOnlySheet: SheetRenderModel = { ...salesSheet, charts: [salesSheet.charts[0]] } + render() + expect(screen.getByText('xlsx_preview.chart_unsupported')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.useXlsxWorkbook.test.tsx b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.useXlsxWorkbook.test.tsx new file mode 100644 index 00000000000..9b33ca95820 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.useXlsxWorkbook.test.tsx @@ -0,0 +1,194 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createMockWorkbookModel } from '../mockModel' +import type { XlsxParseRequest, XlsxParseResponse } from '../renderModel' +import { useXlsxWorkbook, XLSX_PREVIEW_MAX_SIZE_BYTES } from '../useXlsxWorkbook' + +class FakeXlsxWorker { + onmessage: ((event: { data: XlsxParseResponse }) => void) | null = null + onerror: ((event: { message: string; error?: unknown }) => void) | null = null + terminate = vi.fn() + postMessage = vi.fn((request: XlsxParseRequest) => { + mocks.requests.push(request) + }) + + constructor() { + mocks.workers.push(this) + } + + respond(response: XlsxParseResponse) { + this.onmessage?.({ data: response }) + } +} + +const mocks = vi.hoisted(() => ({ + fsRead: vi.fn(), + workers: [] as unknown[], + requests: [] as Array<{ id: number; fileName: string; data: ArrayBuffer }>, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + } +})) + +vi.mock('@logger', () => ({ + loggerService: { withContext: () => mocks.logger } +})) + +vi.mock('../worker/xlsxParser.worker?worker', () => ({ + default: FakeXlsxWorker +})) + +const lastWorker = () => { + const worker = mocks.workers.at(-1) + if (!worker) throw new Error('Expected a worker instance') + return worker as unknown as FakeXlsxWorker +} + +describe('useXlsxWorkbook', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.workers.length = 0 + mocks.requests.length = 0 + mocks.fsRead.mockResolvedValue(new Uint8Array([1, 2, 3, 4])) + Object.defineProperty(window, 'api', { + configurable: true, + value: { fs: { read: mocks.fsRead } } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns oversize without creating a worker when the file exceeds the 20MB cap', async () => { + const oversizeBytes = XLSX_PREVIEW_MAX_SIZE_BYTES + 1 + mocks.fsRead.mockResolvedValue(new ArrayBuffer(oversizeBytes)) + + const { result } = renderHook(() => useXlsxWorkbook('/tmp/big.xlsx', 0)) + + await waitFor(() => expect(result.current.status).toBe('oversize')) + expect(result.current).toEqual({ status: 'oversize', sizeBytes: oversizeBytes }) + expect(mocks.workers).toHaveLength(0) + }) + + it('returns oversize from sourceSize without reading the file', async () => { + const oversizeBytes = XLSX_PREVIEW_MAX_SIZE_BYTES + 1 + const { result } = renderHook(() => useXlsxWorkbook('/tmp/big.xlsx', 0, oversizeBytes)) + + await waitFor(() => expect(result.current).toEqual({ status: 'oversize', sizeBytes: oversizeBytes })) + expect(mocks.fsRead).not.toHaveBeenCalled() + expect(mocks.workers).toHaveLength(0) + }) + + it('parses via the worker and reaches ready', async () => { + const model = createMockWorkbookModel() + const { result } = renderHook(() => useXlsxWorkbook('/tmp/dir/book.xlsx', 0)) + + expect(result.current.status).toBe('loading') + await waitFor(() => expect(mocks.requests).toHaveLength(1)) + expect(mocks.requests[0].fileName).toBe('book.xlsx') + + act(() => lastWorker().respond({ id: mocks.requests[0].id, ok: true, model })) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current).toEqual({ status: 'ready', model }) + }) + + it('discards responses whose id does not match the latest request', async () => { + const model = createMockWorkbookModel() + const { result } = renderHook(() => useXlsxWorkbook('/tmp/book.xlsx', 0)) + + await waitFor(() => expect(mocks.requests).toHaveLength(1)) + const currentId = mocks.requests[0].id + + act(() => lastWorker().respond({ id: currentId - 1, ok: true, model })) + expect(result.current.status).toBe('loading') + + act(() => lastWorker().respond({ id: currentId, ok: true, model })) + await waitFor(() => expect(result.current.status).toBe('ready')) + }) + + it('re-reads and re-parses when refreshKey changes, reusing the lazily created worker', async () => { + const model = createMockWorkbookModel() + const { result, rerender } = renderHook(({ refreshKey }) => useXlsxWorkbook('/tmp/book.xlsx', refreshKey), { + initialProps: { refreshKey: 0 } + }) + + await waitFor(() => expect(mocks.requests).toHaveLength(1)) + act(() => lastWorker().respond({ id: mocks.requests[0].id, ok: true, model })) + await waitFor(() => expect(result.current.status).toBe('ready')) + + rerender({ refreshKey: 1 }) + + expect(result.current.status).toBe('loading') + await waitFor(() => expect(mocks.requests).toHaveLength(2)) + expect(mocks.fsRead).toHaveBeenCalledTimes(2) + expect(mocks.requests[1].id).toBeGreaterThan(mocks.requests[0].id) + expect(mocks.workers).toHaveLength(1) + + // The pre-refresh response id is now stale — it must not flip the state. + act(() => lastWorker().respond({ id: mocks.requests[0].id, ok: true, model })) + expect(result.current.status).toBe('loading') + + act(() => lastWorker().respond({ id: mocks.requests[1].id, ok: true, model })) + await waitFor(() => expect(result.current.status).toBe('ready')) + }) + + it('maps a failed parse response to the error state', async () => { + const { result } = renderHook(() => useXlsxWorkbook('/tmp/corrupt.xlsx', 0)) + + await waitFor(() => expect(mocks.requests).toHaveLength(1)) + act(() => lastWorker().respond({ id: mocks.requests[0].id, ok: false, message: 'not an xlsx' })) + + await waitFor(() => expect(result.current).toEqual({ status: 'error', message: 'not an xlsx' })) + }) + + it('maps a file read failure to the error state without creating a worker', async () => { + mocks.fsRead.mockRejectedValue(new Error('ENOENT: gone')) + + const { result } = renderHook(() => useXlsxWorkbook('/tmp/gone.xlsx', 0)) + + await waitFor(() => expect(result.current).toEqual({ status: 'error', message: 'ENOENT: gone' })) + expect(mocks.workers).toHaveLength(0) + }) + + it('terminates the worker on unmount', async () => { + const { unmount } = renderHook(() => useXlsxWorkbook('/tmp/book.xlsx', 0)) + + await waitFor(() => expect(mocks.workers).toHaveLength(1)) + const worker = lastWorker() + + unmount() + + expect(worker.terminate).toHaveBeenCalledTimes(1) + }) + + it('logs model warnings deduplicated across parses', async () => { + const model = createMockWorkbookModel() + model.warnings = ['w-a', 'w-a', 'w-b'] + + const { result, rerender } = renderHook(({ refreshKey }) => useXlsxWorkbook('/tmp/book.xlsx', refreshKey), { + initialProps: { refreshKey: 0 } + }) + + await waitFor(() => expect(mocks.requests).toHaveLength(1)) + act(() => lastWorker().respond({ id: mocks.requests[0].id, ok: true, model })) + await waitFor(() => expect(result.current.status).toBe('ready')) + + expect(mocks.logger.warn).toHaveBeenCalledTimes(2) + expect(mocks.logger.warn).toHaveBeenCalledWith('w-a') + expect(mocks.logger.warn).toHaveBeenCalledWith('w-b') + + // Same warnings from a re-parse are not logged again. + rerender({ refreshKey: 1 }) + await waitFor(() => expect(mocks.requests).toHaveLength(2)) + act(() => lastWorker().respond({ id: mocks.requests[1].id, ok: true, model })) + await waitFor(() => expect(result.current.status).toBe('ready')) + + expect(mocks.logger.warn).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.xlsxPreviewPanel.test.tsx b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.xlsxPreviewPanel.test.tsx new file mode 100644 index 00000000000..c382f524bb7 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.xlsxPreviewPanel.test.tsx @@ -0,0 +1,326 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type React from 'react' +import type { PropsWithChildren } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createMockWorkbookModel } from '../mockModel' +import type { ChartModel } from '../renderModel' +import type { XlsxWorkbookState } from '../useXlsxWorkbook' +import type { XlsxGridProps } from '../XlsxGrid' +import XlsxPreviewPanel from '../XlsxPreviewPanel' + +const mocks = vi.hoisted(() => ({ + workbookState: { status: 'idle' } as unknown, + useXlsxWorkbookCalls: [] as Array<{ filePath: string; refreshKey: number; sourceSize?: number }>, + gridProps: [] as unknown[], + chartRendererRender: vi.fn(() => () => {}), + chartRendererModuleLoadCount: 0, + createObjectURL: vi.fn(), + revokeObjectURL: vi.fn() +})) + +vi.mock('../useXlsxWorkbook', () => ({ + useXlsxWorkbook: (filePath: string, refreshKey: number, sourceSize?: number) => { + mocks.useXlsxWorkbookCalls.push({ filePath, refreshKey, sourceSize }) + return mocks.workbookState + } +})) + +vi.mock('../XlsxGrid', () => ({ + default: (props: { + sheet: { name: string; cells: Record } + zoom: number + onSelectCell?: (info: unknown) => void + renderChart?: (chart: unknown, container: HTMLElement) => () => void + }) => { + mocks.gridProps.push(props) + const selectCell = (key: string) => + props.onSelectCell?.({ + address: key, + cell: props.sheet.cells[key === 'B6' ? '6:2' : key === 'B9' ? '9:2' : '3:1'] ?? null + }) + return ( +
+ + + +
+ ) + } +})) + +vi.mock('../charts/EchartsChartRenderer', () => { + mocks.chartRendererModuleLoadCount += 1 + return { + echartsChartRenderer: { render: mocks.chartRendererRender } + } +}) + +vi.mock('@cherrystudio/ui', () => ({ + Button: ({ children, ...props }: PropsWithChildren>) => ( + + ), + Tooltip: ({ children }: PropsWithChildren<{ content?: string; delay?: number }>) => <>{children} +})) + +vi.mock('@cherrystudio/ui/lib/utils', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' ') +})) + +vi.mock('@renderer/components/chat', () => ({ + EmptyState: ({ title, description, actions }: { title: string; description?: string; actions?: React.ReactNode }) => ( +
+ {title} + {description} + {actions} +
+ ), + LoadingState: ({ label }: { label?: string }) =>
{label}
+})) + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key + }) +})) + +const setWorkbookState = (state: XlsxWorkbookState) => { + mocks.workbookState = state +} + +const lastGridProps = () => { + const props = mocks.gridProps.at(-1) + if (!props) throw new Error('Expected XlsxGrid to have rendered') + return props as XlsxGridProps +} + +/** mock 模型去掉 charts,避免与图表懒加载无关的用例触发 act 警告 */ +const modelWithoutCharts = () => { + const model = createMockWorkbookModel() + for (const sheet of model.sheets) sheet.charts = [] + return model +} + +const renderPanel = () => + render() + +describe('XlsxPreviewPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useXlsxWorkbookCalls.length = 0 + mocks.gridProps.length = 0 + mocks.chartRendererRender.mockImplementation(() => () => {}) + mocks.createObjectURL.mockReturnValue('blob:xlsx-image') + setWorkbookState({ status: 'loading' }) + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: mocks.createObjectURL }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: mocks.revokeObjectURL }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders the loading state while parsing', () => { + setWorkbookState({ status: 'loading' }) + + renderPanel() + + expect(screen.getByTestId('loading-state')).toBeInTheDocument() + expect(screen.queryByTestId('xlsx-grid')).not.toBeInTheDocument() + }) + + it('renders the error state with the parse failure message', () => { + setWorkbookState({ status: 'error', message: 'not an xlsx' }) + + renderPanel() + + expect(screen.getByTestId('empty-state')).toHaveTextContent('common.error') + expect(screen.getByTestId('empty-state')).toHaveTextContent('not an xlsx') + }) + + it('renders the oversize state with the size-limit message', () => { + setWorkbookState({ status: 'oversize', sizeBytes: 25 * 1024 * 1024 }) + + renderPanel() + + expect(screen.getByTestId('empty-state')).toHaveTextContent('xlsx_preview.too_large.title') + expect(screen.getByTestId('empty-state')).toHaveTextContent('xlsx_preview.too_large.description') + }) + + it('surfaces the actions slot in the oversize and error states', () => { + const actions = + setWorkbookState({ status: 'oversize', sizeBytes: 25 * 1024 * 1024 }) + + const { rerender } = render( + + ) + expect(screen.getByRole('button', { name: 'Open externally' })).toBeInTheDocument() + + setWorkbookState({ status: 'error', message: 'boom' }) + rerender( + + ) + expect(screen.getByRole('button', { name: 'Open externally' })).toBeInTheDocument() + }) + + it('forwards sourceSize to the workbook hook', () => { + setWorkbookState({ status: 'loading' }) + + render( + + ) + + expect(mocks.useXlsxWorkbookCalls.at(-1)).toEqual({ + filePath: '/tmp/workspace/book.xlsx', + refreshKey: 0, + sourceSize: 4096 + }) + }) + + it('renders the grid, visible sheet tabs, and the sheet name in the status bar when ready', () => { + setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) + + renderPanel() + + expect(screen.getByTestId('xlsx-preview-panel')).toBeInTheDocument() + expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-sheet-name', 'Sales') + expect(screen.getByRole('tab', { name: 'Sales' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'false') + // Hidden sheets get no tab. + expect(screen.queryByRole('tab', { name: 'HiddenSheet' })).not.toBeInTheDocument() + expect(screen.getByTestId('xlsx-preview-status-bar')).toHaveTextContent('Sales') + }) + + it('falls back to the first sheet when every sheet is hidden', () => { + const model = modelWithoutCharts() + for (const sheet of model.sheets) sheet.hidden = true + setWorkbookState({ status: 'ready', model }) + + renderPanel() + + expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-sheet-name', 'Sales') + expect(screen.getByRole('tab', { name: 'Sales' })).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: 'Notes' })).not.toBeInTheDocument() + }) + + it('switches sheets via the tabs and clears the selection', () => { + setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) + + renderPanel() + + fireEvent.click(screen.getByTestId('grid-select-b6')) + expect(screen.getByTestId('xlsx-preview-status-bar')).toHaveTextContent('B6') + + fireEvent.click(screen.getByRole('tab', { name: 'Notes' })) + + expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-sheet-name', 'Notes') + expect(screen.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByTestId('xlsx-preview-status-bar')).toHaveTextContent('Notes') + }) + + it('shows the formula source for a selected formula cell', () => { + setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) + + renderPanel() + + fireEvent.click(screen.getByTestId('grid-select-b6')) + + expect(screen.getByTestId('xlsx-preview-status-bar')).toHaveTextContent('B6 = SUM(B3:B5)') + expect(screen.queryByText('xlsx_preview.formula_not_evaluated')).not.toBeInTheDocument() + }) + + it('adds the unevaluated hint for a selected unevaluated formula cell', () => { + setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) + + renderPanel() + + fireEvent.click(screen.getByTestId('grid-select-b9')) + + expect(screen.getByTestId('xlsx-preview-status-bar')).toHaveTextContent('B9 = FOOBAR(B3:B5)') + expect(screen.getByText('xlsx_preview.formula_not_evaluated')).toBeInTheDocument() + }) + + it('shows address plus cell text for a selected plain cell', () => { + setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) + + renderPanel() + + fireEvent.click(screen.getByTestId('grid-select-a3')) + + expect(screen.getByTestId('xlsx-preview-status-bar')).toHaveTextContent('A3 Q1') + }) + + it('steps through the zoom levels and resets', () => { + setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) + + renderPanel() + + expect(screen.getByTestId('xlsx-preview-zoom-value')).toHaveTextContent('100%') + + fireEvent.click(screen.getByRole('button', { name: 'preview.zoom_in' })) + expect(screen.getByTestId('xlsx-preview-zoom-value')).toHaveTextContent('125%') + expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-zoom', '1.25') + + fireEvent.click(screen.getByRole('button', { name: 'preview.reset' })) + expect(screen.getByTestId('xlsx-preview-zoom-value')).toHaveTextContent('100%') + expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-zoom', '1') + + fireEvent.click(screen.getByRole('button', { name: 'preview.zoom_out' })) + expect(screen.getByTestId('xlsx-preview-zoom-value')).toHaveTextContent('75%') + }) + + it('creates image object URLs when ready and revokes them on unmount', () => { + setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) + + const { unmount } = renderPanel() + + expect(mocks.createObjectURL).toHaveBeenCalledTimes(1) + expect(lastGridProps().imageUrls).toEqual({ 1: 'blob:xlsx-image' }) + expect(mocks.revokeObjectURL).not.toHaveBeenCalled() + + unmount() + + expect(mocks.revokeObjectURL).toHaveBeenCalledWith('blob:xlsx-image') + }) + + it('lazily loads the echarts renderer once a sheet has charts and wires renderChart into the grid', async () => { + setWorkbookState({ status: 'ready', model: createMockWorkbookModel() }) + + renderPanel() + + await waitFor(() => expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-has-render-chart', 'true')) + + const container = document.createElement('div') + const chart = { type: 'bar' } as ChartModel + lastGridProps().renderChart?.(chart, container) + expect(mocks.chartRendererRender).toHaveBeenCalledWith(chart, container) + }) + + it('does not wire renderChart for models without charts', () => { + setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) + + renderPanel() + + expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-has-render-chart', 'false') + }) + + it('forwards filePath and refreshKey to useXlsxWorkbook', () => { + setWorkbookState({ status: 'loading' }) + + renderPanel() + + expect(mocks.useXlsxWorkbookCalls.at(-1)).toEqual({ filePath: '/tmp/workspace/book.xlsx', refreshKey: 0 }) + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.numberFormat.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.numberFormat.test.ts new file mode 100644 index 00000000000..bc70ebd4446 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.numberFormat.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import { formatCellValue } from '../worker/numberFormat' + +describe('formatCellValue', () => { + it('null/undefined -> empty string', () => { + expect(formatCellValue(null, undefined, false)).toBe('') + expect(formatCellValue(undefined, undefined, false)).toBe('') + }) + + it('General format: integer has no decimal point', () => { + expect(formatCellValue(42, undefined, false)).toBe('42') + }) + + it('General format: string passes through unchanged', () => { + expect(formatCellValue('hello', undefined, false)).toBe('hello') + }) + + it('General format: boolean -> TRUE/FALSE', () => { + expect(formatCellValue(true, undefined, false)).toBe('TRUE') + expect(formatCellValue(false, undefined, false)).toBe('FALSE') + }) + + it('General format: very large number falls back to scientific notation without throwing', () => { + const text = formatCellValue(12345678901234, undefined, false) + expect(text.length).toBeGreaterThan(0) + }) + + it('error-code strings pass through unaffected by numFmt', () => { + expect(formatCellValue('#DIV/0!', '#,##0.00', false)).toBe('#DIV/0!') + }) + + it('percent format', () => { + expect(formatCellValue(0.4567, '0.00%', false)).toBe('45.67%') + }) + + it('thousands separator + fixed decimals', () => { + expect(formatCellValue(1234.5, '#,##0.00', false)).toBe('1,234.50') + }) + + it('date format (1900 system)', () => { + const date = new Date(Date.UTC(2026, 0, 15)) + expect(formatCellValue(date, 'yyyy-mm-dd', false)).toBe('2026-01-15') + }) + + it('date1904 flag does not affect formatting a JS Date (ExcelJS already resolved it to an absolute instant)', () => { + const date = new Date(Date.UTC(2026, 0, 15)) + expect(formatCellValue(date, 'yyyy-mm-dd', true)).toBe('2026-01-15') + }) + + it('date-time format preserves time-of-day fraction', () => { + const date = new Date(Date.UTC(2026, 0, 15, 13, 30, 0)) + expect(formatCellValue(date, 'yyyy-mm-dd hh:mm:ss', false)).toBe('2026-01-15 13:30:00') + }) + + it('invalid format string falls back to String(raw) instead of throwing', () => { + expect(formatCellValue(123, 'bogus[[[', false)).toBe('123') + }) + + it('known Excel serial reference: 2024-01-01 is serial 45292 (1900 system)', () => { + // Sanity-check against a widely-known Excel serial to validate the epoch handling, + // independent of the date1904 branch. + const date = new Date(Date.UTC(2024, 0, 1)) + expect(formatCellValue(date, '0', false)).toBe('45292') + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.parseWorkbook.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.parseWorkbook.test.ts new file mode 100644 index 00000000000..6b78f003c4b --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.parseWorkbook.test.ts @@ -0,0 +1,494 @@ +import ExcelJS from 'exceljs' +import { beforeAll, describe, expect, it } from 'vitest' + +import type { CellStyle, WorkbookRenderModel } from '../renderModel' +import { parseWorkbook } from '../worker/parseWorkbook' +import { buildChartWorkbookArrayBuffer } from './xlsxTestPackages' + +async function toArrayBuffer(workbook: ExcelJS.Workbook): Promise { + const buf = await workbook.xlsx.writeBuffer() + // writeBuffer() 在 Node 下返回 Buffer(Uint8Array 子类);拷贝为独立 ArrayBuffer + const view = buf as unknown as Uint8Array + const arrayBuffer = new ArrayBuffer(view.byteLength) + new Uint8Array(arrayBuffer).set(view) + return arrayBuffer +} + +const excelColor = (color: { theme?: number; tint?: number; indexed?: number }): ExcelJS.Color => + color as unknown as ExcelJS.Color + +describe('parseWorkbook — sheets, values, hidden', () => { + let model: WorkbookRenderModel + + beforeAll(async () => { + const wb = new ExcelJS.Workbook() + + const s1 = wb.addWorksheet('Data') + s1.getCell('A1').value = 'hello' + s1.getCell('A2').value = 42 + s1.getCell('A3').value = true + s1.getCell('A4').value = new Date(Date.UTC(2026, 0, 15)) + s1.getCell('A5').value = { richText: [{ text: 'Hello ' }, { text: 'World', font: { bold: true } }] } + s1.getCell('A6').value = { text: 'Cherry Studio', hyperlink: 'https://github.com/CherryHQ/cherry-studio' } + s1.getCell('A7').value = { error: '#DIV/0!' } + // sparse: leave gaps + + const s2 = wb.addWorksheet('Hidden', { state: 'hidden' }) + s2.getCell('A1').value = 'secret' + + const buffer = await toArrayBuffer(wb) + model = await parseWorkbook(buffer, 'sample.xlsx') + }) + + it('parses multiple sheets and preserves order/name', () => { + expect(model.sheets.map((s) => s.name)).toEqual(['Data', 'Hidden']) + }) + + it('marks hidden sheet as hidden but still parses its cells', () => { + const hidden = model.sheets.find((s) => s.name === 'Hidden')! + expect(hidden.hidden).toBe(true) + expect(hidden.cells['1:1'].text).toBe('secret') + }) + + it('does not create entries for empty cells (sparse)', () => { + const data = model.sheets[0] + expect(data.cells['100:100']).toBeUndefined() + }) + + it('string value: raw + text', () => { + const cell = model.sheets[0].cells['1:1'] + expect(cell.raw).toBe('hello') + expect(cell.text).toBe('hello') + }) + + it('number value: raw + text', () => { + const cell = model.sheets[0].cells['2:1'] + expect(cell.raw).toBe(42) + expect(cell.text).toBe('42') + }) + + it('boolean value: text is TRUE/FALSE', () => { + const cell = model.sheets[0].cells['3:1'] + expect(cell.raw).toBe(true) + expect(cell.text).toBe('TRUE') + }) + + it('Date value: raw is ISO string, text formatted (General fallback)', () => { + const cell = model.sheets[0].cells['4:1'] + expect(typeof cell.raw).toBe('string') + expect(cell.raw).toContain('2026-01-15') + expect(cell.text.length).toBeGreaterThan(0) + }) + + it('richText: concatenates plain text, drops run-level styles, warns once', () => { + const cell = model.sheets[0].cells['5:1'] + expect(cell.text).toBe('Hello World') + expect(model.warnings).toContain('richtext-run-styles-dropped') + }) + + it('hyperlink: text shown, hyperlink field populated', () => { + const cell = model.sheets[0].cells['6:1'] + expect(cell.text).toBe('Cherry Studio') + expect(cell.hyperlink).toBe('https://github.com/CherryHQ/cherry-studio') + }) + + it('error value: text/raw is the error code', () => { + const cell = model.sheets[0].cells['7:1'] + expect(cell.text).toBe('#DIV/0!') + expect(cell.raw).toBe('#DIV/0!') + }) +}) + +describe('parseWorkbook — styles: extraction + dedup', () => { + let model: WorkbookRenderModel + + beforeAll(async () => { + const wb = new ExcelJS.Workbook() + const ws = wb.addWorksheet('S1') + + ws.getCell('A1').value = 'styled-1' + ws.getCell('A1').font = { bold: true, italic: true, underline: true, strike: true, name: 'Calibri', size: 12 } + ws.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } } + ws.getCell('A1').border = { + top: { style: 'thin', color: { argb: 'FF000000' } }, + bottom: { style: 'thick', color: { argb: 'FF111111' } } + } + ws.getCell('A1').alignment = { horizontal: 'center', vertical: 'middle', wrapText: true } + + // Identical style on a different cell -> should dedup to same styleId + ws.getCell('B1').value = 'styled-2' + ws.getCell('B1').font = { bold: true, italic: true, underline: true, strike: true, name: 'Calibri', size: 12 } + ws.getCell('B1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFFF00' } } + ws.getCell('B1').border = { + top: { style: 'thin', color: { argb: 'FF000000' } }, + bottom: { style: 'thick', color: { argb: 'FF111111' } } + } + ws.getCell('B1').alignment = { horizontal: 'center', vertical: 'middle', wrapText: true } + + // Fully default cell -> no styleId + ws.getCell('C1').value = 'plain' + + const buffer = await toArrayBuffer(wb) + model = await parseWorkbook(buffer, 'styles.xlsx') + }) + + it('extracts font/fill/border/alignment onto CellStyle', () => { + const styleId = model.sheets[0].cells['1:1'].styleId! + const style = model.styles[styleId] + expect(style.bold).toBe(true) + expect(style.italic).toBe(true) + expect(style.underline).toBe(true) + expect(style.strike).toBe(true) + expect(style.fontFamily).toBe('Calibri') + expect(style.fontSizePx).toBeCloseTo(12 * (4 / 3)) + expect(style.bg).toBe('#ffff00') + expect(style.borderTop).toEqual({ style: 'thin', color: '#000000' }) + expect(style.borderBottom).toEqual({ style: 'thick', color: '#111111' }) + expect(style.hAlign).toBe('center') + expect(style.vAlign).toBe('middle') + expect(style.wrap).toBe(true) + }) + + it('dedupes identical styles to the same styleId', () => { + const idA = model.sheets[0].cells['1:1'].styleId + const idB = model.sheets[0].cells['1:2'].styleId + expect(idA).toBeDefined() + expect(idA).toBe(idB) + }) + + it('fully default cell has no styleId', () => { + expect(model.sheets[0].cells['1:3'].styleId).toBeUndefined() + }) +}) + +describe('parseWorkbook — merges, row/col sizing, hidden', () => { + let model: WorkbookRenderModel + + beforeAll(async () => { + const wb = new ExcelJS.Workbook() + const ws = wb.addWorksheet('S1') + ws.mergeCells('A1:C1') + ws.getCell('A1').value = 'merged header' + + ws.getRow(2).height = 40 + ws.getCell('A2').value = 'tall row' + + // hidden row/col must carry a real value — ExcelJS silently drops hidden + // metadata for a row/col with zero cell content on write (see report). + ws.getRow(3).hidden = true + ws.getCell('A3').value = 'hidden row content' + + ws.getColumn(1).width = 30 + ws.getCell('A4').value = 'wide col' + + ws.getColumn(5).hidden = true + ws.getCell('E4').value = 'hidden col content' + + const buffer = await toArrayBuffer(wb) + model = await parseWorkbook(buffer, 'sizing.xlsx') + }) + + it('captures merge ranges (1-based, closed interval)', () => { + expect(model.sheets[0].merges).toEqual([{ top: 1, left: 1, bottom: 1, right: 3 }]) + }) + + it('converts non-default row height pt -> px', () => { + expect(model.sheets[0].rowHeightsPx[2]).toBeCloseTo((40 * 96) / 72) + }) + + it('hidden row height is 0', () => { + expect(model.sheets[0].rowHeightsPx[3]).toBe(0) + }) + + it('converts non-default column width chars -> px', () => { + expect(model.sheets[0].colWidthsPx[1]).toBe(Math.round(30 * 7) + 5) + }) + + it('hidden column width is 0', () => { + expect(model.sheets[0].colWidthsPx[5]).toBe(0) + }) +}) + +describe('parseWorkbook — formulas: three states', () => { + let model: WorkbookRenderModel + + beforeAll(async () => { + const wb = new ExcelJS.Workbook() + const ws = wb.addWorksheet('S1') + + ws.getCell('A1').value = 10 + ws.getCell('A2').value = 20 + // cached: has result baked in by the writer + ws.getCell('A3').value = { formula: 'A1+A2', result: 30 } + // no cache: parser must invoke the formula evaluator (skeleton returns unevaluated + // unless WP-B has since implemented it; assert on the contractually-guaranteed shape) + ws.getCell('A4').value = { formula: 'A1*A2' } + // an evaluator-unfriendly / unknown function should remain unevaluated + ws.getCell('A5').value = { formula: 'NOTAREALFUNCTION(A1)' } + + const buffer = await toArrayBuffer(wb) + model = await parseWorkbook(buffer, 'formulas.xlsx') + }) + + it('cached formula: formulaState=cached, text = formatted cached result', () => { + const cell = model.sheets[0].cells['3:1'] + expect(cell.formula).toBe('A1+A2') + expect(cell.formulaState).toBe('cached') + expect(cell.text).toBe('30') + }) + + it('formula without cache: either evaluated correctly or unevaluated with formula text shown', () => { + const cell = model.sheets[0].cells['4:1'] + expect(cell.formula).toBe('A1*A2') + if (cell.formulaState === 'evaluated') { + expect(cell.text).toBe('200') + } else { + expect(cell.formulaState).toBe('unevaluated') + expect(cell.text).toBe('=A1*A2') + } + }) + + it('unresolvable function formula ends up unevaluated with formula text shown, greyed by UI', () => { + const cell = model.sheets[0].cells['5:1'] + expect(cell.formula).toBe('NOTAREALFUNCTION(A1)') + expect(cell.formulaState).toBe('unevaluated') + expect(cell.text).toBe('=NOTAREALFUNCTION(A1)') + }) +}) + +describe('parseWorkbook — formulas: forward references evaluate recursively', () => { + let model: WorkbookRenderModel + + beforeAll(async () => { + const wb = new ExcelJS.Workbook() + const ws1 = wb.addWorksheet('S1') + const ws2 = wb.addWorksheet('S2') + + // A1 引用文件顺序靠后的 B2(同 sheet 前向引用) + ws1.getCell('A1').value = { formula: 'B2*2' } + ws1.getCell('B2').value = { formula: 'SUM(3,4)' } + // A2 引用靠后 sheet 的公式单元格(跨 sheet 前向引用) + ws1.getCell('A2').value = { formula: 'S2!A1+1' } + ws2.getCell('A1').value = { formula: '5*2' } + // 互相引用的环:不得挂死,双双 unevaluated + ws1.getCell('A5').value = { formula: 'A6' } + ws1.getCell('A6').value = { formula: 'A5' } + + const buffer = await toArrayBuffer(wb) + model = await parseWorkbook(buffer, 'forward-refs.xlsx') + }) + + it('same-sheet forward reference gets the dependency value, not the null placeholder', () => { + expect(model.sheets[0].cells['2:2'].text).toBe('7') + expect(model.sheets[0].cells['1:1'].formulaState).toBe('evaluated') + expect(model.sheets[0].cells['1:1'].text).toBe('14') + }) + + it('cross-sheet forward reference evaluates the later sheet first', () => { + expect(model.sheets[1].cells['1:1'].text).toBe('10') + expect(model.sheets[0].cells['2:1'].formulaState).toBe('evaluated') + expect(model.sheets[0].cells['2:1'].text).toBe('11') + }) + + it('mutual reference cycle degrades both cells to unevaluated without hanging', () => { + expect(model.sheets[0].cells['5:1'].formulaState).toBe('unevaluated') + expect(model.sheets[0].cells['6:1'].formulaState).toBe('unevaluated') + expect(model.warnings).toContain('formula-unevaluated') + }) +}) + +describe('parseWorkbook — number formats', () => { + let model: WorkbookRenderModel + + beforeAll(async () => { + const wb = new ExcelJS.Workbook() + const ws = wb.addWorksheet('S1') + + ws.getCell('A1').value = 0.4567 + ws.getCell('A1').numFmt = '0.00%' + + ws.getCell('A2').value = 1234.5 + ws.getCell('A2').numFmt = '#,##0.00' + + ws.getCell('A3').value = new Date(Date.UTC(2026, 0, 15)) + ws.getCell('A3').numFmt = 'yyyy-mm-dd' + + ws.getCell('A4').value = 12345678901234 + // General (default, no explicit numFmt) + + const wb1904 = new ExcelJS.Workbook() + wb1904.properties.date1904 = true + const ws1904 = wb1904.addWorksheet('S1') + ws1904.getCell('A1').value = new Date(Date.UTC(2026, 0, 15)) + ws1904.getCell('A1').numFmt = 'yyyy-mm-dd' + + const buffer = await toArrayBuffer(wb) + model = await parseWorkbook(buffer, 'numfmt.xlsx') + + const buffer1904 = await toArrayBuffer(wb1904) + model1904 = await parseWorkbook(buffer1904, 'numfmt1904.xlsx') + }) + + let model1904: WorkbookRenderModel + + it('percent format', () => { + expect(model.sheets[0].cells['1:1'].text).toBe('45.67%') + }) + + it('thousands + 2 decimals format', () => { + expect(model.sheets[0].cells['2:1'].text).toBe('1,234.50') + }) + + it('date format yyyy-mm-dd (1900 system)', () => { + expect(model.sheets[0].cells['3:1'].text).toBe('2026-01-15') + }) + + it('General format on a large number does not throw and produces readable text', () => { + const text = model.sheets[0].cells['4:1'].text + expect(text.length).toBeGreaterThan(0) + }) + + it('date1904 workbook: ExcelJS already resolves the stored serial to the correct absolute Date, so text is unaffected', () => { + expect(model1904.sheets[0].cells['1:1'].text).toBe('2026-01-15') + }) +}) + +describe('parseWorkbook — theme colors', () => { + let model: WorkbookRenderModel + + beforeAll(async () => { + const wb = new ExcelJS.Workbook() + const ws = wb.addWorksheet('Theme') + + ws.getCell('A1').value = 'theme text' + ws.getCell('A1').font = { color: excelColor({ theme: 1 }) } + + ws.getCell('A2').value = 'theme fill' + ws.getCell('A2').fill = { type: 'pattern', pattern: 'solid', fgColor: excelColor({ theme: 0 }) } + + ws.getCell('A3').value = 'theme darker' + ws.getCell('A3').font = { color: excelColor({ theme: 4, tint: -0.25 }) } + + ws.getCell('A4').value = 'theme lighter' + ws.getCell('A4').font = { color: excelColor({ theme: 4, tint: 0.4 }) } + + ws.getCell('A5').value = 'indexed red' + ws.getCell('A5').font = { color: excelColor({ indexed: 2 }) } + + ws.getCell('A6').value = 'indexed yellow' + ws.getCell('A6').font = { color: excelColor({ indexed: 5 }) } + + ws.getCell('A7').value = 'indexed blue' + ws.getCell('A7').font = { color: excelColor({ indexed: 12 }) } + + model = await parseWorkbook(await toArrayBuffer(wb), 'theme-colors-runtime.xlsx') + }) + + const styleOf = (model_: WorkbookRenderModel, addr: string): CellStyle | undefined => { + const cell = model_.sheets[0].cells[addr] + return cell?.styleId !== undefined ? model_.styles[cell.styleId] : undefined + } + + it('theme=1 (dk1) resolves to black text', () => { + expect(styleOf(model, '1:1')?.color).toBe('#000000') + }) + + it('theme=0 (lt1) resolves to white fill', () => { + expect(styleOf(model, '2:1')?.bg).toBe('#ffffff') + }) + + it('theme=4 (accent1=4F81BD) with tint -0.25 darkens per HSL algorithm', () => { + expect(styleOf(model, '3:1')?.color).toBe('#376092') + }) + + it('theme=4 (accent1=4F81BD) with tint +0.40 lightens per HSL algorithm', () => { + expect(styleOf(model, '4:1')?.color).toBe('#95b3d7') + }) + + it('indexed color 2 -> red', () => { + expect(styleOf(model, '5:1')?.color).toBe('#ff0000') + }) + + it('indexed color 5 -> yellow', () => { + expect(styleOf(model, '6:1')?.color).toBe('#ffff00') + }) + + it('indexed color 12 -> blue', () => { + expect(styleOf(model, '7:1')?.color).toBe('#0000ff') + }) +}) + +describe('parseWorkbook — floating images', () => { + let model: WorkbookRenderModel + + const PNG_BASE64 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' + + beforeAll(async () => { + const wb = new ExcelJS.Workbook() + const ws = wb.addWorksheet('S1') + const imgId = wb.addImage({ base64: PNG_BASE64, extension: 'png' }) + // oneCellAnchor: tl + ext(px——ExcelJS 写入时乘 9525 转 EMU,读取时再除回来)。 + // addImage 类型声明要求 tl/br 为 Anchor 实例,但运行时接受纯 { col, row } 字面量 + // (见 lib/doc/image.js set model);测试内按运行时行为传入。 + ws.addImage(imgId, { + tl: { col: 1, row: 1 }, + ext: { width: 20, height: 10 } + } as unknown as ExcelJS.ImagePosition) + // twoCellAnchor: tl + br + ws.addImage(imgId, { tl: { col: 3, row: 3 }, br: { col: 5, row: 5 } } as unknown as ExcelJS.ImageRange) + + const buffer = await toArrayBuffer(wb) + model = await parseWorkbook(buffer, 'images.xlsx') + }) + + it('registers image binary data under model.images', () => { + expect(Object.keys(model.images).length).toBeGreaterThan(0) + const first = Object.values(model.images)[0] + expect(first.mime).toBe('image/png') + expect(first.data.byteLength).toBeGreaterThan(0) + }) + + it('oneCellAnchor (tl+ext): PxRect uses the px extent ExcelJS already converted', () => { + const sheet = model.sheets[0] + const oneCell = sheet.floatingImages.find((f) => f.rect.width === 20) + expect(oneCell).toBeDefined() + expect(oneCell!.rect.height).toBeCloseTo(10) + }) + + it('twoCellAnchor (tl+br): PxRect derived from column/row cumulative offsets', () => { + const sheet = model.sheets[0] + const twoCell = sheet.floatingImages.find((f) => f.rect.width !== 20) + expect(twoCell).toBeDefined() + expect(twoCell!.rect.width).toBeGreaterThan(0) + expect(twoCell!.rect.height).toBeGreaterThan(0) + }) +}) + +describe('parseWorkbook — corrupted input', () => { + it('rejects with a readable error message', async () => { + const garbage = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) + await expect(parseWorkbook(garbage.buffer, 'corrupt.xlsx')).rejects.toThrow(/./) + }) +}) + +describe('parseWorkbook — openpyxl default-namespace chart drawings must not crash ExcelJS', () => { + // openpyxl-style drawings use without an xdr: prefix; without + // normalization ExcelJS crashes during reconcile. + it.each([ + ['basic', 'charts-basic-runtime.xlsx'], + ['no-cache', 'charts-no-cache-runtime.xlsx'] + ] as const)('parses generated %s chart package through the full pipeline', async (kind, name) => { + const buffer = await buildChartWorkbookArrayBuffer(kind) + const model = await parseWorkbook(buffer, name) + + expect(model.sheets.length).toBeGreaterThan(0) + const charts = model.sheets.flatMap((sheet) => sheet.charts) + expect(charts.length).toBeGreaterThan(0) + for (const chart of charts) { + if (chart.type === 'unsupported') continue + expect(chart.series.length).toBeGreaterThan(0) + expect(chart.series[0].values.length).toBeGreaterThan(0) + } + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.themeResolver.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.themeResolver.test.ts new file mode 100644 index 00000000000..b88f6e990d9 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.themeResolver.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { parseTheme, resolveColor } from '../worker/themeResolver' + +const SAMPLE_THEME_XML = ` + + + + + + + + + + + + + + + + + +` + +describe('parseTheme', () => { + it('null input falls back to Office default theme colors', () => { + const theme = parseTheme(null) + // 契约索引顺序:[lt1, dk1, lt2, dk2, accent1..6, hlink, folHlink] + expect(theme.colors[0]).toBe('#ffffff') // lt1 + expect(theme.colors[1]).toBe('#000000') // dk1 + expect(theme.colors).toHaveLength(12) + }) + + it('parses real theme XML and applies the dk1/lt1 index swap from contract §6', () => { + const theme = parseTheme(SAMPLE_THEME_XML) + expect(theme.colors[0]).toBe('#ffffff') // index 0 = lt1 (XML: window/FFFFFF) + expect(theme.colors[1]).toBe('#000000') // index 1 = dk1 (XML: windowText/000000) + expect(theme.colors[4]).toBe('#4f81bd') // accent1 + }) + + it('malformed XML falls back to default theme rather than throwing', () => { + const theme = parseTheme(' { + const theme = parseTheme(SAMPLE_THEME_XML) + + it('argb takes priority: opaque argb -> 6-digit css hex', () => { + expect(resolveColor({ argb: 'FFFF0000' }, theme)).toBe('#ff0000') + }) + + it('argb alpha byte is ignored (Excel cell formats have no transparency)', () => { + // openpyxl 把 6 位色值规范化成 '00RRGGBB',按字面渲染会得到全透明文字 + expect(resolveColor({ argb: '00FFFFFF' }, theme)).toBe('#ffffff') + expect(resolveColor({ argb: '80FF0000' }, theme)).toBe('#ff0000') + }) + + it('theme index without tint returns the scheme color as-is', () => { + expect(resolveColor({ theme: 1 }, theme)).toBe('#000000') + }) + + it('theme + tint applies HSL tint algorithm (dark tint)', () => { + // accent1 = 4F81BD, tint -0.25 -> 376092 (cross-checked against a Python colorsys reference) + expect(resolveColor({ theme: 4, tint: -0.25 }, theme)).toBe('#376092') + }) + + it('theme + tint applies HSL tint algorithm (light tint)', () => { + // accent1 = 4F81BD, tint +0.40 -> 95B3D7 + expect(resolveColor({ theme: 4, tint: 0.4 }, theme)).toBe('#95b3d7') + }) + + it('indexed color resolves via the built-in 64-color palette', () => { + expect(resolveColor({ indexed: 2 }, theme)).toBe('#ff0000') // red + expect(resolveColor({ indexed: 5 }, theme)).toBe('#ffff00') // yellow + expect(resolveColor({ indexed: 12 }, theme)).toBe('#0000ff') // blue + }) + + it('undefined color ref returns undefined', () => { + expect(resolveColor(undefined, theme)).toBeUndefined() + }) + + it('priority: argb > theme+tint > indexed', () => { + expect(resolveColor({ argb: 'FF00FF00', theme: 1, indexed: 2 }, theme)).toBe('#00ff00') + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/xlsxTestPackages.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/xlsxTestPackages.ts new file mode 100644 index 00000000000..1927ce2dbe2 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/xlsxTestPackages.ts @@ -0,0 +1,252 @@ +import ExcelJS from 'exceljs' +import JSZip from 'jszip' + +type ChartWorkbookKind = 'basic' | 'no-cache' | 'unsupported' +type ChartTypeName = 'barChart' | 'lineChart' | 'pieChart' | 'areaChart' | 'scatterChart' + +interface SeriesSpec { + name: string + nameRef: string + categories: string[] + categoryRef: string + values: number[] + valueRef: string +} + +interface ChartSpec { + typeName: ChartTypeName + title: string + series: SeriesSpec[] + barDirection?: 'col' | 'bar' + stacked?: boolean +} + +const CATEGORIES = ['Q1', 'Q2', 'Q3', 'Q4'] +const SERIES_A = [10, 20, 30, 40] +const SERIES_B = [5, 15, 25, 35] + +const RELS_NS = 'http://schemas.openxmlformats.org/package/2006/relationships' +const DRAWING_NS = 'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing' +const CHART_NS = 'http://schemas.openxmlformats.org/drawingml/2006/chart' +const DRAWING_REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' +const CONTENT_TYPE_DRAWING = 'application/vnd.openxmlformats-officedocument.drawing+xml' +const CONTENT_TYPE_CHART = 'application/vnd.openxmlformats-officedocument.drawingml.chart+xml' + +const escapeXml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + +const toArrayBuffer = async (workbook: ExcelJS.Workbook): Promise => { + const buf = await workbook.xlsx.writeBuffer() + const view = buf as unknown as Uint8Array + const arrayBuffer = new ArrayBuffer(view.byteLength) + new Uint8Array(arrayBuffer).set(view) + return arrayBuffer +} + +const createDataWorkbook = async (): Promise => { + const workbook = new ExcelJS.Workbook() + const ws = workbook.addWorksheet('Data') + ws.addRow(['Category', 'SeriesA', 'SeriesB']) + CATEGORIES.forEach((category, i) => { + ws.addRow([category, SERIES_A[i], SERIES_B[i]]) + }) + + return JSZip.loadAsync(await toArrayBuffer(workbook)) +} + +const baseSeriesA = (): SeriesSpec => ({ + name: 'SeriesA', + nameRef: "'Data'!B1", + categories: CATEGORIES, + categoryRef: "'Data'!$A$2:$A$5", + values: SERIES_A, + valueRef: "'Data'!$B$2:$B$5" +}) + +const baseSeriesB = (): SeriesSpec => ({ + name: 'SeriesB', + nameRef: "'Data'!C1", + categories: CATEGORIES, + categoryRef: "'Data'!$A$2:$A$5", + values: SERIES_B, + valueRef: "'Data'!$C$2:$C$5" +}) + +const chartSpecsFor = (kind: ChartWorkbookKind): ChartSpec[] => { + if (kind === 'unsupported') { + return [ + { + typeName: 'scatterChart', + title: 'Scatter Chart', + series: [baseSeriesA()] + } + ] + } + + if (kind === 'no-cache') { + return [ + { typeName: 'barChart', title: 'Bar Chart', barDirection: 'col', series: [baseSeriesA()] }, + { typeName: 'lineChart', title: 'Line Chart', series: [baseSeriesA()] }, + { typeName: 'pieChart', title: 'Pie Chart', series: [baseSeriesA()] }, + { typeName: 'areaChart', title: 'Area Chart', series: [baseSeriesA()] } + ] + } + + return [ + { + typeName: 'barChart', + title: 'Bar Chart', + barDirection: 'col', + stacked: true, + series: [baseSeriesA(), baseSeriesB()] + }, + { typeName: 'barChart', title: 'Horizontal Bar Chart', barDirection: 'bar', series: [baseSeriesA()] }, + { typeName: 'lineChart', title: 'Line Chart', series: [baseSeriesA()] }, + { typeName: 'pieChart', title: 'Pie Chart', series: [baseSeriesA()] }, + { typeName: 'areaChart', title: 'Area Chart', series: [baseSeriesA()] } + ] +} + +const cacheXml = (tagName: 'strCache' | 'numCache', values: T[]): string => ` + <${tagName}> + + ${values.map((value, i) => `${escapeXml(String(value))}`).join('')} + ` + +const seriesXml = (series: SeriesSpec, includeCache: boolean): string => ` + + + + + ${escapeXml(series.nameRef)} + ${includeCache ? cacheXml('strCache', [series.name]) : ''} + + + + + ${escapeXml(series.categoryRef)} + ${includeCache ? cacheXml('strCache', series.categories) : ''} + + + + + ${escapeXml(series.valueRef)} + ${includeCache ? cacheXml('numCache', series.values) : ''} + + + ` + +const titleXml = (title: string): string => ` + + <tx> + <rich> + <a:p xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"> + <a:r><a:t>${escapeXml(title)}</a:t></a:r> + </a:p> + </rich> + </tx> + ` + +const chartXml = ( + spec: ChartSpec, + includeCache: boolean +): string => ` + + + ${titleXml(spec.title)} + + <${spec.typeName}> + ${spec.typeName === 'barChart' ? `` : ''} + ${spec.stacked ? '' : ''} + ${spec.series.map((series) => seriesXml(series, includeCache)).join('')} + + + +` + +const oneCellAnchorXml = (chartRId: string, row: number): string => ` + + 40${row}0 + + + + + + + + + + + + ` + +const drawingXml = (chartCount: number): string => ` + +${Array.from({ length: chartCount }, (_, i) => oneCellAnchorXml(`rId${i + 1}`, 1 + i * 16)).join('')} +` + +const drawingRelsXml = (chartCount: number): string => ` + +${Array.from( + { length: chartCount }, + (_, i) => ` ` +).join('\n')} +` + +const sheetRelsXml = ` + + +` + +const addContentTypeOverride = (xml: string, partName: string, contentType: string): string => { + if (xml.includes(`PartName="${partName}"`)) return xml + return xml.replace('', ``) +} + +const addSheetDrawingReference = async (zip: JSZip): Promise => { + const sheetPath = 'xl/worksheets/sheet1.xml' + const sheetXml = await zip.file(sheetPath)?.async('string') + if (!sheetXml || sheetXml.includes('', `${drawingRef}`) + zip.file(sheetPath, updated) +} + +export const buildChartWorkbookZip = async (kind: ChartWorkbookKind): Promise => { + const zip = await createDataWorkbook() + const specs = chartSpecsFor(kind) + const includeCache = kind === 'basic' + + await addSheetDrawingReference(zip) + zip.file('xl/worksheets/_rels/sheet1.xml.rels', sheetRelsXml) + zip.file('xl/drawings/drawing1.xml', drawingXml(specs.length)) + zip.file('xl/drawings/_rels/drawing1.xml.rels', drawingRelsXml(specs.length)) + specs.forEach((spec, i) => { + zip.file(`xl/charts/chart${i + 1}.xml`, chartXml(spec, includeCache)) + }) + + const contentTypesFile = zip.file('[Content_Types].xml') + const contentTypes = contentTypesFile ? await contentTypesFile.async('string') : null + if (contentTypes) { + let updated = addContentTypeOverride(contentTypes, '/xl/drawings/drawing1.xml', CONTENT_TYPE_DRAWING) + specs.forEach((_, i) => { + updated = addContentTypeOverride(updated, `/xl/charts/chart${i + 1}.xml`, CONTENT_TYPE_CHART) + }) + zip.file('[Content_Types].xml', updated) + } + + return zip +} + +export const buildChartWorkbookArrayBuffer = async (kind: ChartWorkbookKind): Promise => { + const zip = await buildChartWorkbookZip(kind) + return zip.generateAsync({ type: 'arraybuffer' }) +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/ChartRenderer.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/ChartRenderer.ts new file mode 100644 index 00000000000..186e6ca2d40 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/ChartRenderer.ts @@ -0,0 +1,10 @@ +import type { ChartModel } from '../renderModel' + +/** + * 图表渲染适配器(冻结)。 + * 可替换性边界:具体图表库(echarts)的任何类型/概念不得泄漏出实现文件。 + */ +export interface ChartRenderer { + /** 挂载并渲染;返回 dispose。实现内部自行处理容器尺寸变化 */ + render(chart: ChartModel, container: HTMLElement): () => void +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/EchartsChartRenderer.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/EchartsChartRenderer.ts new file mode 100644 index 00000000000..6586e569caa --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/EchartsChartRenderer.ts @@ -0,0 +1,47 @@ +import { BarChart, LineChart, PieChart } from 'echarts/charts' +import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components' +import * as echarts from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' + +import type { ChartModel } from '../renderModel' +import { buildChartOption } from './buildChartOption' +import type { ChartRenderer } from './ChartRenderer' + +echarts.use([ + BarChart, + LineChart, + PieChart, + GridComponent, + TooltipComponent, + LegendComponent, + TitleComponent, + CanvasRenderer +]) + +/** + * echarts 实现(按需引入 echarts/core + 单图表模块,禁止全量 import)。 + * 本文件由面板懒加载(import()),不得被静态引用进主 bundle。 + */ +export const echartsChartRenderer: ChartRenderer = { + render(chart: ChartModel, container: HTMLElement): () => void { + const instance = echarts.init(container, undefined, { renderer: 'canvas' }) + instance.setOption(buildChartOption(chart)) + + let hasSized = container.clientWidth > 0 && container.clientHeight > 0 + + const resizeObserver = new ResizeObserver(() => { + if (container.clientWidth === 0 || container.clientHeight === 0) return + if (!hasSized) { + hasSized = true + instance.setOption(buildChartOption(chart)) + } + instance.resize() + }) + resizeObserver.observe(container) + + return () => { + resizeObserver.disconnect() + instance.dispose() + } + } +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/buildChartOption.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/buildChartOption.ts new file mode 100644 index 00000000000..a56e91dc382 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/charts/buildChartOption.ts @@ -0,0 +1,69 @@ +import type { EChartsCoreOption } from 'echarts/core' + +import type { ChartModel, ChartSeries } from '../renderModel' + +/** + * ChartModel → echarts option 的纯映射函数(可测,不依赖 DOM/echarts 实例)。 + * 见 .context/xlsx-preview/04-wp-charts.md Part 2 映射规则。 + */ + +/** echarts 用 '-' 表示空值 */ +const toEchartsValue = (value: number | null): number | '-' => (value === null ? '-' : value) + +const buildCategoryAxisData = (series: ChartSeries[]): (string | number)[] => series[0]?.categories ?? [] + +const buildBarLineAreaOption = (chart: ChartModel): EChartsCoreOption => { + const isHorizontal = chart.type === 'bar' && chart.barDirection === 'bar' + const categoryAxis = { + type: 'category' as const, + data: buildCategoryAxisData(chart.series) + } + const valueAxis = { type: 'value' as const } + + const series = chart.series.map((s) => { + const base = { + name: s.name, + data: s.values.map(toEchartsValue), + stack: chart.stacked ? 'total' : undefined + } + if (chart.type === 'bar') return { ...base, type: 'bar' as const } + if (chart.type === 'area') return { ...base, type: 'line' as const, areaStyle: {} } + return { ...base, type: 'line' as const } + }) + + return { + title: chart.title ? { text: chart.title } : undefined, + tooltip: { trigger: 'axis' }, + legend: chart.series.length > 1 || chart.series[0]?.name ? {} : undefined, + grid: { containLabel: true }, + xAxis: isHorizontal ? valueAxis : categoryAxis, + yAxis: isHorizontal ? categoryAxis : valueAxis, + series + } +} + +const buildPieOption = (chart: ChartModel): EChartsCoreOption => { + const first = chart.series[0] + const data = (first?.categories ?? []).map((name, i) => ({ + name: String(name), + value: toEchartsValue(first?.values[i] ?? null) + })) + + return { + title: chart.title ? { text: chart.title } : undefined, + tooltip: { trigger: 'item' }, + legend: {}, + series: [ + { + type: 'pie', + name: first?.name, + data + } + ] + } +} + +export const buildChartOption = (chart: ChartModel): EChartsCoreOption => { + if (chart.type === 'pie') return buildPieOption(chart) + return buildBarLineAreaOption(chart) +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/fast-formula-parser.d.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/fast-formula-parser.d.ts new file mode 100644 index 00000000000..6d6673bec2f --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/fast-formula-parser.d.ts @@ -0,0 +1,59 @@ +/** + * fast-formula-parser 未随包提供类型声明的最小 shim,按 WP-B 实际使用面收紧。 + * + * 与包的真实运行时形状对齐(源码读自 node_modules/fast-formula-parser@1.0.19): + * - `index.js` 是 `module.exports = FormulaParser`(class 本身作为默认导出), + * `FormulaError`/`MAX_ROW`/`MAX_COLUMN` 等是挂在该 class 上的静态属性 + * (`Object.assign(FormulaParser, { FormulaError, ... })`),而不是独立的具名导出。 + * 因此本 shim 把 `FormulaError` 声明为 `FormulaParser` 的 static 字段, + * 使用方应写 `FormulaParser.FormulaError`,而不是 + * `import { FormulaError } from 'fast-formula-parser'`(该具名导入在纯 CJS 互操作下不可靠)。 + * - `onCell` 回调收到的 ref 形状是 `{ address, sheet, row, col }`(1-based row/col, + * sheet 已由库内部默认填充,不会是 null/undefined)。 + * - `onRange` 回调收到的 ref 形状是 `{ sheet, from: {row,col}, to: {row,col} }`, + * 期望返回值是行主序矩阵:`result[row-from.row][col-from.col]`。 + * - `parse()` 对"合法 Excel 错误结果"(#DIV/0! 等)是**返回**一个 FormulaError 实例 + * (不抛出);对未知函数名、语法错误、以及 onCell/onRange 回调内部抛出的异常, + * 则包装为 FormulaError('#ERROR!', ...) 并**抛出**。详见 formulaEvaluator.ts 头部注释。 + */ +declare module 'fast-formula-parser' { + export interface ParserPosition { + sheet: string + row: number + col: number + } + + export interface ParserCellRef extends ParserPosition { + address: string + } + + export interface ParserRangePoint { + row: number + col: number + } + + export interface ParserRangeRef { + sheet: string + from: ParserRangePoint + to: ParserRangePoint + } + + export class FormulaErrorClass extends Error { + constructor(error: string, message?: string, details?: unknown) + /** 错误码,如 '#DIV/0!' '#NAME?' */ + readonly error: string + } + + export interface FormulaParserConfig { + onCell?: (ref: ParserCellRef) => unknown + onRange?: (ref: ParserRangeRef) => unknown[][] + } + + export default class FormulaParser { + /** 静态属性,见文件头注释——不要用具名 import 取 FormulaError */ + static FormulaError: typeof FormulaErrorClass + constructor(config?: FormulaParserConfig) + /** allowReturnArray=false(默认)时,多格区间/联合引用结果一律归一为 #VALUE! */ + parse(formula: string, position: ParserPosition, allowReturnArray?: boolean): unknown + } +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/gridLayout.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/gridLayout.ts new file mode 100644 index 00000000000..6c0e96eb6fd --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/gridLayout.ts @@ -0,0 +1,159 @@ +/** + * 布局常量与换算纯函数(契约 §6,冻结)。 + * 布局函数(前缀和、可视区合并计算)由 WP-D 在本文件追加:.context/xlsx-preview/05-wp-grid.md + */ + +/** Excel 默认列宽 8.43 字符(Calibri 11,MDW=7)≈ 64px */ +export const DEFAULT_COL_WIDTH_PX = 64 +/** Excel 默认行高 15pt = 20px */ +export const DEFAULT_ROW_HEIGHT_PX = 20 +/** 解析行/列硬上限,超出截断并记 warning */ +export const MAX_ROWS = 200_000 +export const MAX_COLS = 500 + +/** Excel 字符宽 → px(Calibri 11,MDW=7) */ +export const charWidthToPx = (width: number): number => Math.round(width * 7) + 5 + +/** pt → px */ +export const ptToPx = (pt: number): number => (pt * 96) / 72 + +/** EMU → px(914400 EMU/inch ÷ 96 dpi) */ +export const emuToPx = (emu: number): number => emu / 9525 + +/** 列号(1-based)→ 'A' / 'Z' / 'AA' */ +export const colName = (col: number): string => { + let name = '' + let n = col + while (n > 0) { + const rem = (n - 1) % 26 + name = String.fromCharCode(65 + rem) + name + n = Math.floor((n - 1) / 26) + } + return name +} + +/** (row, col) 1-based → 'B4' 风格地址 */ +export const cellAddress = (row: number, col: number): string => `${colName(col)}${row}` + +// --------------------------------------------------------------------------- +// WP-D 布局纯函数(追加区):前缀和、可视区合并计算。见 05-wp-grid.md。 +// --------------------------------------------------------------------------- + +/** 一个轴(行或列)的布局:每项 zoom 后尺寸(px)+ 起始偏移(px)的前缀和表 */ +export interface AxisLayout { + /** sizes[i] = 第 i 项(0-based index,对应 1-based 序号 i+1)zoom 后尺寸,px */ + sizes: number[] + /** offsets[i] = 第 i 项的起始偏移(px);offsets.length === sizes.length */ + offsets: number[] + /** 总尺寸,px */ + totalSize: number +} + +/** + * 构建一个轴的布局(行高或列宽),已按 zoom 缩放。 + * @param count 项数(rowCount / colCount) + * @param defaultSizePx 默认尺寸(zoom=1) + * @param overrides 稀疏覆盖表,1-based key(隐藏 → 0) + * @param zoom 缩放系数 + */ +export const buildAxisLayout = ( + count: number, + defaultSizePx: number, + overrides: Record, + zoom: number +): AxisLayout => { + const sizes = new Array(count) + const offsets = new Array(count) + let offset = 0 + for (let i = 0; i < count; i++) { + const oneBased = i + 1 + const rawSize = overrides[oneBased] ?? defaultSizePx + const size = rawSize * zoom + sizes[i] = size + offsets[i] = offset + offset += size + } + return { sizes, offsets, totalSize: offset } +} + +/** 查表:第 i 项(0-based)的起始偏移,px(越界返回 totalSize) */ +export const axisOffset = (layout: AxisLayout, index: number): number => { + if (index < 0) return 0 + if (index >= layout.offsets.length) return layout.totalSize + return layout.offsets[index] +} + +export interface ViewportRect { + top: number + left: number + bottom: number + right: number +} + +export interface MergeRangeLike { + top: number + left: number + bottom: number + right: number +} + +export interface MergeInView { + merge: M + /** master 单元格地址(合并区左上角) */ + masterRow: number + masterCol: number + /** 像素矩形(相对网格原点,已含 zoom) */ + rect: PxRectLike +} + +export interface PxRectLike { + x: number + y: number + width: number + height: number +} + +/** 合并区(1-based 闭区间)→ 像素矩形,基于行/列 AxisLayout */ +export const mergeRectPx = (merge: MergeRangeLike, rowLayout: AxisLayout, colLayout: AxisLayout): PxRectLike => { + const x = axisOffset(colLayout, merge.left - 1) + const y = axisOffset(rowLayout, merge.top - 1) + const right = axisOffset(colLayout, merge.right) + const bottom = axisOffset(rowLayout, merge.bottom) + return { x, y, width: right - x, height: bottom - y } +} + +/** + * 与可视矩形相交的合并区(含 master 坐标与像素矩形)。 + * 即使 master 单元格本身滚出视口,只要合并区与视口相交仍会返回—— + * 这是合并层必须独立渲染而非依赖单元格虚拟化的原因。 + */ +export const mergesInView = ( + merges: M[], + viewport: ViewportRect, + rowLayout: AxisLayout, + colLayout: AxisLayout +): MergeInView[] => { + const result: MergeInView[] = [] + for (const merge of merges) { + const rect = mergeRectPx(merge, rowLayout, colLayout) + const intersects = + rect.x < viewport.right && + rect.x + rect.width > viewport.left && + rect.y < viewport.bottom && + rect.y + rect.height > viewport.top + if (!intersects) continue + result.push({ merge, masterRow: merge.top, masterCol: merge.left, rect }) + } + return result +} + +/** 单元格是否被某个合并区覆盖但不是 master(用于普通单元格层置空内容) */ +export const findCoveringMerge = (merges: M[], row: number, col: number): M | undefined => + merges.find((m) => row >= m.top && row <= m.bottom && col >= m.left && col <= m.right) + +/** Excel 默认字号 11pt → px(zoom=1),与 CellStyle.fontSizePx 未设置时的默认一致 */ +export const DEFAULT_FONT_SIZE_PX = ptToPx(11) + +/** 字号(px,zoom=1)按 zoom 缩放 */ +export const scaledFontSizePx = (fontSizePx: number | undefined, zoom: number): number => + (fontSizePx ?? DEFAULT_FONT_SIZE_PX) * zoom diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/index.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/index.ts new file mode 100644 index 00000000000..3ecf47f75cf --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/index.ts @@ -0,0 +1 @@ +export { default } from './XlsxPreviewPanel' diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/mockModel.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/mockModel.ts new file mode 100644 index 00000000000..b7f2f2f2742 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/mockModel.ts @@ -0,0 +1,134 @@ +import { DEFAULT_COL_WIDTH_PX, DEFAULT_ROW_HEIGHT_PX } from './gridLayout' +import type { WorkbookRenderModel } from './renderModel' + +/** + * 供 WP-D 开发/测试用的手写样例模型(冻结)。 + * 覆盖:多 sheet(含 hidden)、样式、合并区、隐藏行列、非默认尺寸、 + * 三种公式态、超链接、浮动图片、bar 图表、unsupported 图表占位。 + * 测试可在此基础上浅拷贝改造,不要直接改本文件。 + */ +export const createMockWorkbookModel = (): WorkbookRenderModel => ({ + fileName: 'mock-sales.xlsx', + styles: [ + // 0: 标题(合并区 master) + { + bold: true, + fontSizePx: 21, + color: '#ffffff', + bg: '#4472c4', + hAlign: 'center', + vAlign: 'middle' + }, + // 1: 表头 + { + bold: true, + bg: '#d9e1f2', + hAlign: 'center', + borderBottom: { style: 'thin', color: '#8ea9db' } + }, + // 2: 数字列(右对齐 + 货币格式) + { hAlign: 'right', numFmt: '#,##0.00' }, + // 3: 换行长文本 + { wrap: true, vAlign: 'top' }, + // 4: 日期 + { hAlign: 'center', numFmt: 'yyyy-mm-dd' } + ], + images: { + 1: { mime: 'image/png', data: new ArrayBuffer(8) } + }, + warnings: [], + sheets: [ + { + name: 'Sales', + hidden: false, + rowCount: 60, + colCount: 10, + defaultRowHeightPx: DEFAULT_ROW_HEIGHT_PX, + defaultColWidthPx: DEFAULT_COL_WIDTH_PX, + rowHeightsPx: { 1: 36, 7: 0 }, // 行 7 隐藏 + colWidthsPx: { 1: 110, 5: 0 }, // 列 E 隐藏 + merges: [{ top: 1, left: 1, bottom: 1, right: 4 }], + cells: { + '1:1': { text: '2026 年度销售汇总', raw: '2026 年度销售汇总', styleId: 0 }, + '2:1': { text: '季度', raw: '季度', styleId: 1 }, + '2:2': { text: '销量', raw: '销量', styleId: 1 }, + '2:3': { text: '日期', raw: '日期', styleId: 1 }, + '2:4': { text: '备注', raw: '备注', styleId: 1 }, + '3:1': { text: 'Q1', raw: 'Q1' }, + '3:2': { text: '1,250.00', raw: 1250, styleId: 2 }, + '3:3': { text: '2026-01-15', raw: 45672, styleId: 4 }, + '3:4': { + text: '春节档期拉动,环比增长明显,渠道补货集中在一月下旬。', + raw: '春节档期拉动,环比增长明显,渠道补货集中在一月下旬。', + styleId: 3 + }, + '4:1': { text: 'Q2', raw: 'Q2' }, + '4:2': { text: '980.50', raw: 980.5, styleId: 2 }, + '5:1': { text: 'Q3', raw: 'Q3' }, + '5:2': { text: '1,530.25', raw: 1530.25, styleId: 2 }, + '6:1': { text: '合计', raw: '合计', styleId: 1 }, + // 三种公式态 + '6:2': { text: '3,760.75', raw: 3760.75, formula: 'SUM(B3:B5)', formulaState: 'cached', styleId: 2 }, + '8:1': { text: '均值', raw: '均值' }, + '8:2': { text: '1,253.58', raw: 1253.583, formula: 'AVERAGE(B3:B5)', formulaState: 'evaluated', styleId: 2 }, + '9:1': { text: '预测', raw: '预测' }, + '9:2': { text: '=FOOBAR(B3:B5)', formula: 'FOOBAR(B3:B5)', formulaState: 'unevaluated' }, + '10:1': { + text: 'Cherry Studio', + raw: 'Cherry Studio', + hyperlink: 'https://github.com/CherryHQ/cherry-studio' + }, + // 远端单元格:验证虚拟滚动 + '60:10': { text: '滚动到我', raw: '滚动到我' } + }, + floatingImages: [{ rect: { x: 340, y: 44, width: 160, height: 90 }, imageId: 1 }], + charts: [ + { + rect: { x: 24, y: 240, width: 360, height: 220 }, + type: 'bar', + title: '季度销量', + barDirection: 'col', + series: [{ name: '销量', categories: ['Q1', 'Q2', 'Q3'], values: [1250, 980.5, 1530.25] }] + }, + { + rect: { x: 420, y: 240, width: 300, height: 220 }, + type: 'unsupported', + rawTypeName: 'scatterChart', + series: [] + } + ] + }, + { + name: 'Notes', + hidden: false, + rowCount: 3, + colCount: 2, + defaultRowHeightPx: DEFAULT_ROW_HEIGHT_PX, + defaultColWidthPx: DEFAULT_COL_WIDTH_PX, + rowHeightsPx: {}, + colWidthsPx: {}, + merges: [], + cells: { + '1:1': { text: '普通表', raw: '普通表' }, + '3:2': { text: 'TRUE', raw: true } + }, + floatingImages: [], + charts: [], + frozen: { xSplit: 0, ySplit: 1 } + }, + { + name: 'HiddenSheet', + hidden: true, + rowCount: 1, + colCount: 1, + defaultRowHeightPx: DEFAULT_ROW_HEIGHT_PX, + defaultColWidthPx: DEFAULT_COL_WIDTH_PX, + rowHeightsPx: {}, + colWidthsPx: {}, + merges: [], + cells: { '1:1': { text: '不应显示', raw: '不应显示' } }, + floatingImages: [], + charts: [] + } + ] +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/renderModel.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/renderModel.ts new file mode 100644 index 00000000000..0dbfe1e7dd2 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/renderModel.ts @@ -0,0 +1,144 @@ +/** + * xlsx 预览的中间渲染模型 — Worker 产出、UI 消费的唯一契约层。 + * 全部类型必须可结构化克隆(纯数据,无类实例、无函数)。 + * 契约文档:.context/xlsx-preview/01-CONTRACT.md — 修改需经编排者裁决。 + */ + +export interface WorkbookRenderModel { + fileName: string + /** 工作簿级去重样式表;CellRenderModel.styleId 指向此处 */ + styles: CellStyle[] + sheets: SheetRenderModel[] + /** 图片二进制,imageId → 数据;postMessage 时作为 Transferable 传输 */ + images: Record + /** 解析过程中的非致命问题(不支持的特性、求值失败统计等),面板负责 log */ + warnings: string[] +} + +export interface SheetRenderModel { + name: string + hidden: boolean + /** 使用范围(含样式/合并/浮动对象波及的最大行列),1-based */ + rowCount: number + colCount: number + /** 默认尺寸,px(zoom=1) */ + defaultRowHeightPx: number + defaultColWidthPx: number + /** 稀疏覆盖:仅存非默认值。隐藏行/列 → 0 */ + rowHeightsPx: Record + colWidthsPx: Record + /** 稀疏单元格表,key = `${row}:${col}`(1-based) */ + cells: Record + merges: MergeRange[] + floatingImages: FloatingObjectModel[] + charts: ChartModel[] + /** 冻结窗格信息:v1 UI 不用,解析保留作扩展位 */ + frozen?: { xSplit: number; ySplit: number } +} + +/** 1-based,闭区间 */ +export interface MergeRange { + top: number + left: number + bottom: number + right: number +} + +export type FormulaState = + | 'cached' // 文件自带缓存结果 + | 'evaluated' // fast-formula-parser 算出(含 #DIV/0! 等合法错误结果) + | 'unevaluated' // 无法求值 → UI 显示公式原文并置灰 + +export interface CellRenderModel { + /** 最终显示文本(数字格式已应用)。unevaluated 公式 → 公式原文(含前导 '=') */ + text: string + /** 底层原始值,状态栏/后续扩展用 */ + raw?: string | number | boolean | null + /** 公式原文,不含前导 '='(与 ExcelJS 一致);非公式单元格为 undefined */ + formula?: string + formulaState?: FormulaState + /** 指向 WorkbookRenderModel.styles 的下标;undefined = 全默认样式 */ + styleId?: number + hyperlink?: string +} + +export interface BorderEdge { + style: 'thin' | 'medium' | 'thick' | 'dashed' | 'dotted' | 'double' | 'hair' + /** 已解析的 CSS 颜色 */ + color: string +} + +export interface CellStyle { + fontFamily?: string + /** pt * 4/3,zoom=1 */ + fontSizePx?: number + bold?: boolean + italic?: boolean + underline?: boolean + strike?: boolean + /** 文字色,CSS 颜色 */ + color?: string + /** 填充色,CSS 颜色(pattern 填充取 fgColor 近似为纯色) */ + bg?: string + borderTop?: BorderEdge + borderRight?: BorderEdge + borderBottom?: BorderEdge + borderLeft?: BorderEdge + /** 未设置时 UI 按类型默认:数字右对齐、文本左对齐、布尔/错误居中 */ + hAlign?: 'left' | 'center' | 'right' | 'justify' + vAlign?: 'top' | 'middle' | 'bottom' + wrap?: boolean + indent?: number + /** 原始格式串,调试/状态栏用 */ + numFmt?: string +} + +/** 浮动对象锚定矩形:zoom=1 的 px 坐标,原点为网格 A1 左上角(不含行列表头) */ +export interface PxRect { + x: number + y: number + width: number + height: number +} + +export interface FloatingObjectModel { + rect: PxRect + /** 指向 WorkbookRenderModel.images */ + imageId: number +} + +export type ChartType = 'bar' | 'line' | 'pie' | 'area' | 'unsupported' + +export interface ChartSeries { + name?: string + categories: (string | number)[] + values: (number | null)[] +} + +export interface ChartModel { + rect: PxRect + type: ChartType + /** type === 'unsupported' 时的原始类型名(如 'scatterChart'),占位提示用 */ + rawTypeName?: string + title?: string + series: ChartSeries[] + /** bar 专用:'col' 纵向柱 | 'bar' 横向条 */ + barDirection?: 'col' | 'bar' + stacked?: boolean +} + +// --------------------------------------------------------------------------- +// Worker 协议 +// --------------------------------------------------------------------------- + +export interface XlsxParseRequest { + /** 递增序号,响应回带;面板丢弃过期响应 */ + id: number + fileName: string + /** postMessage transfer */ + data: ArrayBuffer +} + +export type XlsxParseResponse = + | { id: number; ok: true; model: WorkbookRenderModel } + | { id: number; ok: false; message: string } diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/useXlsxWorkbook.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/useXlsxWorkbook.ts new file mode 100644 index 00000000000..1a0bbc5485a --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/useXlsxWorkbook.ts @@ -0,0 +1,133 @@ +import { loggerService } from '@logger' +import { useEffect, useRef, useState } from 'react' + +import type { WorkbookRenderModel, XlsxParseRequest, XlsxParseResponse } from './renderModel' + +const logger = loggerService.withContext('XlsxPreviewPanel') + +/** 超过此体积不解析,降级为外部应用打开 */ +export const XLSX_PREVIEW_MAX_SIZE_BYTES = 20 * 1024 * 1024 + +export type XlsxWorkbookState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ready'; model: WorkbookRenderModel } + | { status: 'error'; message: string } + | { status: 'oversize'; sizeBytes: number } + +type XlsxWorker = Pick & { + onmessage: ((event: MessageEvent) => void) | null + onerror: ((event: ErrorEvent) => void) | null +} + +/** window.api.fs.read 经 IPC 结构化克隆后落地为 Uint8Array-like 对象;归一化为真正的 ArrayBuffer(postMessage transfer 需要)。 */ +const toArrayBuffer = (data: unknown): ArrayBuffer => { + if (data instanceof ArrayBuffer) return data + if (ArrayBuffer.isView(data)) { + const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + return view.slice().buffer + } + return data as ArrayBuffer +} + +/** + * 读文件字节(window.api.fs.read)→ Worker 解析 → 状态机。 + * 体积上限、Worker 惰性创建、请求 id 递增丢弃过期响应、refreshKey 重解析均在此实现。 + */ +export function useXlsxWorkbook(filePath: string, refreshKey: number, sourceSize?: number): XlsxWorkbookState { + const [state, setState] = useState({ status: 'idle' }) + const workerRef = useRef(null) + const requestIdRef = useRef(0) + const loggedWarningsRef = useRef>(new Set()) + + useEffect(() => { + let cancelled = false + const requestId = ++requestIdRef.current + + // Skip the read when the on-disk size already blows the limit — don't pull a huge + // workbook into memory just to reject it after the fact. The post-read byte check + // below still guards callers that don't pass sourceSize. + if (typeof sourceSize === 'number' && sourceSize > XLSX_PREVIEW_MAX_SIZE_BYTES) { + setState({ status: 'oversize', sizeBytes: sourceSize }) + return + } + + setState({ status: 'loading' }) + + void (async () => { + let bytes: ArrayBuffer + try { + const raw = await window.api.fs.read(filePath) + if (cancelled || requestId !== requestIdRef.current) return + bytes = toArrayBuffer(raw) + } catch (error) { + if (cancelled || requestId !== requestIdRef.current) return + const normalized = error instanceof Error ? error : new Error(String(error)) + logger.error(`Failed to read file: ${filePath}`, normalized) + setState({ status: 'error', message: normalized.message }) + return + } + + if (bytes.byteLength > XLSX_PREVIEW_MAX_SIZE_BYTES) { + setState({ status: 'oversize', sizeBytes: bytes.byteLength }) + return + } + + try { + workerRef.current ??= (await createXlsxWorker()) as unknown as XlsxWorker + } catch (error) { + if (cancelled || requestId !== requestIdRef.current) return + const normalized = error instanceof Error ? error : new Error(String(error)) + logger.error('Failed to create xlsx parser worker', normalized) + setState({ status: 'error', message: normalized.message }) + return + } + + const worker = workerRef.current + worker.onmessage = (event: MessageEvent) => { + if (cancelled || event.data.id !== requestIdRef.current) return + if (event.data.ok) { + for (const warning of event.data.model.warnings) { + if (loggedWarningsRef.current.has(warning)) continue + loggedWarningsRef.current.add(warning) + logger.warn(warning) + } + setState({ status: 'ready', model: event.data.model }) + } else { + setState({ status: 'error', message: event.data.message }) + } + } + worker.onerror = (event: ErrorEvent) => { + if (cancelled || requestId !== requestIdRef.current) return + logger.error( + 'xlsx parser worker crashed', + event.error instanceof Error ? event.error : new Error(event.message) + ) + setState({ status: 'error', message: event.message }) + } + + const fileName = filePath.split('/').pop() ?? filePath + const request: XlsxParseRequest = { id: requestId, fileName, data: bytes } + worker.postMessage(request, [bytes]) + })() + + return () => { + cancelled = true + } + }, [filePath, refreshKey, sourceSize]) + + useEffect( + () => () => { + workerRef.current?.terminate() + workerRef.current = null + }, + [] + ) + + return state +} + +async function createXlsxWorker(): Promise { + const WorkerModule = await import('./worker/xlsxParser.worker?worker') + return new WorkerModule.default() +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/chartXmlParser.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/chartXmlParser.ts new file mode 100644 index 00000000000..1e2ffcdc655 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/chartXmlParser.ts @@ -0,0 +1,529 @@ +import { XMLParser } from 'fast-xml-parser' +import type JSZip from 'jszip' + +import { emuToPx } from '../gridLayout' +import type { ChartModel, ChartSeries, ChartType, PxRect } from '../renderModel' + +/** + * drawing + chart XML → ChartModel[]:OPC 部件链、cache 优先/引用回填、锚点换算 + * 见 .context/xlsx-preview/04-wp-charts.md。 + */ + +export interface SheetDataAccessor { + /** A1 风格区间引用(可含 sheet 名)→ 原始值二维数组;引用无效返回 null */ + readRange(ref: string): (string | number | null)[][] | null +} + +export interface SheetLayoutAccessor { + /** 列左边缘累计偏移(px, zoom=1),col 为 1-based */ + colX(col: number): number + /** 行上边缘累计偏移(px, zoom=1),row 为 1-based */ + rowY(row: number): number +} + +export interface ParsedCharts { + charts: ChartModel[] + warnings: string[] +} + +// --------------------------------------------------------------------------- +// XML 解析工具 +// --------------------------------------------------------------------------- + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + parseAttributeValue: false, + removeNSPrefix: false, + // 富文本(标题)运行片段之间的空格有意义(如 'Sales ' + '2026'),不能被裁剪 + trimValues: false +}) + +/** 任意 fast-xml-parser 产出的节点值:元素(对象/数组)或原始值 */ +type XmlNode = Record + +/** 去掉命名空间前缀,如 'c:chart' → 'chart'、'a:t' → 't' */ +const localName = (tag: string): string => { + const idx = tag.indexOf(':') + return idx === -1 ? tag : tag.slice(idx + 1) +} + +/** 在对象节点上按 local name 查找子节点(兼容 'c:xxx' 与 'xxx' 两种命名空间前缀写法) */ +const findChild = (node: unknown, name: string): unknown => { + if (!node || typeof node !== 'object') return undefined + const obj = node as XmlNode + for (const key of Object.keys(obj)) { + if (key.startsWith('@_')) continue + if (localName(key) === name) return obj[key] + } + return undefined +} + +/** 按 local name 查找全部匹配子节点,统一归一化为数组(fast-xml-parser 单个子节点不产出数组) */ +const findChildren = (node: unknown, name: string): unknown[] => { + const found = findChild(node, name) + if (found === undefined) return [] + return Array.isArray(found) ? found : [found] +} + +/** 遍历对象节点的全部直接子节点,返回 [localName, value][],跳过属性 */ +const entries = (node: unknown): [string, unknown][] => { + if (!node || typeof node !== 'object') return [] + const obj = node as XmlNode + return Object.keys(obj) + .filter((key) => !key.startsWith('@_')) + .map((key) => [localName(key), obj[key]]) +} + +/** 按 local name 查找属性(兼容 'r:id' 与 'id' 两种命名空间前缀写法) */ +const getAttr = (node: unknown, attr: string): string | undefined => { + if (!node || typeof node !== 'object') return undefined + const obj = node as XmlNode + for (const key of Object.keys(obj)) { + if (!key.startsWith('@_')) continue + if (localName(key.slice(2)) === attr) { + const value = obj[key] + return value === undefined ? undefined : String(value) + } + } + return undefined +} + +/** `` 形式节点取 val 属性 */ +const valAttr = (node: unknown): string | undefined => getAttr(node, 'val') + +const toNumber = (value: unknown): number | undefined => { + if (value === undefined || value === null || value === '') return undefined + const n = Number(value) + return Number.isNaN(n) ? undefined : n +} + +// --------------------------------------------------------------------------- +// OPC 部件解析:relationships +// --------------------------------------------------------------------------- + +interface Relationship { + id: string + type: string + target: string +} + +const parseRelationships = (xml: string): Relationship[] => { + const doc = xmlParser.parse(xml) + const root = findChild(doc, 'Relationships') + return findChildren(root, 'Relationship').map((rel) => ({ + id: getAttr(rel, 'Id') ?? '', + type: getAttr(rel, 'Type') ?? '', + target: getAttr(rel, 'Target') ?? '' + })) +} + +/** 解析 rels target 相对路径(相对于 baseDir)或绝对路径(以 '/' 开头,相对包根) */ +const resolveTarget = (baseDir: string, target: string): string => { + if (target.startsWith('/')) return target.slice(1) + const baseParts = baseDir.split('/').filter(Boolean) + const targetParts = target.split('/') + for (const part of targetParts) { + if (part === '.' || part === '') continue + if (part === '..') baseParts.pop() + else baseParts.push(part) + } + return baseParts.join('/') +} + +const readZipText = async (zip: JSZip, path: string): Promise => { + const file = zip.file(path) + if (!file) return null + return file.async('string') +} + +/** sheet 名 → worksheet 部件路径,经 workbook.xml + workbook.xml.rels 解出 */ +const resolveSheetPartPath = async (zip: JSZip, sheetName: string): Promise => { + const workbookXml = await readZipText(zip, 'xl/workbook.xml') + if (!workbookXml) return null + const doc = xmlParser.parse(workbookXml) + const workbook = findChild(doc, 'workbook') + const sheets = findChildren(findChild(workbook, 'sheets'), 'sheet') + const sheetEntry = sheets.find((s) => getAttr(s, 'name') === sheetName) + if (!sheetEntry) return null + const rId = getAttr(sheetEntry, 'id') // r:id → localName 'id' + if (!rId) return null + + const relsXml = await readZipText(zip, 'xl/_rels/workbook.xml.rels') + if (!relsXml) return null + const rels = parseRelationships(relsXml) + const rel = rels.find((r) => r.id === rId) + if (!rel) return null + return resolveTarget('xl', rel.target) +} + +/** worksheet 部件路径 → drawing 部件路径,经 sheetN.xml.rels 解出 */ +const resolveDrawingPartPath = async (zip: JSZip, sheetPartPath: string): Promise => { + const dir = sheetPartPath.split('/').slice(0, -1).join('/') + const fileName = sheetPartPath.split('/').pop() ?? '' + const relsPath = `${dir}/_rels/${fileName}.rels` + const relsXml = await readZipText(zip, relsPath) + if (!relsXml) return null + const rels = parseRelationships(relsXml) + const rel = rels.find((r) => r.type.endsWith('/drawing')) + if (!rel) return null + return resolveTarget(dir, rel.target) +} + +// --------------------------------------------------------------------------- +// drawing XML → 锚点 + chart part 引用 +// --------------------------------------------------------------------------- + +interface AnchoredChartRef { + rect: PxRect + chartRId: string +} + +const readAnchorPoint = (node: unknown): { col: number; colOff: number; row: number; rowOff: number } => ({ + col: toNumber(findChild(node, 'col')) ?? 0, + colOff: toNumber(findChild(node, 'colOff')) ?? 0, + row: toNumber(findChild(node, 'row')) ?? 0, + rowOff: toNumber(findChild(node, 'rowOff')) ?? 0 +}) + +const anchorPointToPx = ( + point: { col: number; colOff: number; row: number; rowOff: number }, + layout: SheetLayoutAccessor +): { x: number; y: number } => ({ + // col/row 属性是 0-based;layout.colX/rowY 接口是 1-based 累计偏移 + x: layout.colX(point.col + 1) + emuToPx(point.colOff), + y: layout.rowY(point.row + 1) + emuToPx(point.rowOff) +}) + +/** 从单个锚点节点(twoCellAnchor/oneCellAnchor/absoluteAnchor)中取出 graphicFrame → chart 的 rId,并算出 PxRect */ +const extractAnchoredChart = ( + anchorNode: unknown, + kind: 'twoCellAnchor' | 'oneCellAnchor' | 'absoluteAnchor', + layout: SheetLayoutAccessor +): AnchoredChartRef | null => { + const graphicFrame = findChild(anchorNode, 'graphicFrame') + if (!graphicFrame) return null // pic(图片)或其他,忽略 + + const graphic = findChild(graphicFrame, 'graphic') + const graphicData = findChild(graphic, 'graphicData') + const chartNode = findChild(graphicData, 'chart') + if (!chartNode) return null + + const chartRId = getAttr(chartNode, 'id') + if (!chartRId) return null + + let rect: PxRect + if (kind === 'twoCellAnchor') { + const from = anchorPointToPx(readAnchorPoint(findChild(anchorNode, 'from')), layout) + const to = anchorPointToPx(readAnchorPoint(findChild(anchorNode, 'to')), layout) + rect = { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } + } else if (kind === 'oneCellAnchor') { + const from = anchorPointToPx(readAnchorPoint(findChild(anchorNode, 'from')), layout) + const ext = findChild(anchorNode, 'ext') + rect = { + x: from.x, + y: from.y, + width: emuToPx(toNumber(getAttr(ext, 'cx')) ?? 0), + height: emuToPx(toNumber(getAttr(ext, 'cy')) ?? 0) + } + } else { + const pos = findChild(anchorNode, 'pos') + const ext = findChild(anchorNode, 'ext') + rect = { + x: emuToPx(toNumber(getAttr(pos, 'x')) ?? 0), + y: emuToPx(toNumber(getAttr(pos, 'y')) ?? 0), + width: emuToPx(toNumber(getAttr(ext, 'cx')) ?? 0), + height: emuToPx(toNumber(getAttr(ext, 'cy')) ?? 0) + } + } + + return { rect, chartRId } +} + +const ANCHOR_KINDS = ['twoCellAnchor', 'oneCellAnchor', 'absoluteAnchor'] as const + +const extractAnchoredCharts = (drawingXml: string, layout: SheetLayoutAccessor): AnchoredChartRef[] => { + const doc = xmlParser.parse(drawingXml) + const root = findChild(doc, 'wsDr') + const results: AnchoredChartRef[] = [] + for (const kind of ANCHOR_KINDS) { + for (const anchorNode of findChildren(root, kind)) { + const extracted = extractAnchoredChart(anchorNode, kind, layout) + if (extracted) results.push(extracted) + } + } + return results +} + +// --------------------------------------------------------------------------- +// chart XML → ChartModel +// --------------------------------------------------------------------------- + +const PLOT_AREA_TYPE_MAP: Record = { + barChart: 'bar', + lineChart: 'line', + pieChart: 'pie', + pie3DChart: 'pie', + doughnutChart: 'pie', + areaChart: 'area' +} + +/** 富文本节点(title 的 tx/rich,或 strRef 缺失时的字面量)拼接纯文本,取全部 a:t */ +const collectText = (node: unknown, acc: string[] = []): string[] => { + if (Array.isArray(node)) { + for (const item of node) collectText(item, acc) + return acc + } + if (!node || typeof node !== 'object') return acc + for (const [name, value] of entries(node)) { + if (name === 't' && typeof value !== 'object') { + acc.push(String(value)) + } else { + collectText(value, acc) + } + } + return acc +} + +const parseTitle = (chartNode: unknown): string | undefined => { + const title = findChild(chartNode, 'title') + if (!title) return undefined + const text = collectText(title).join('') + return text.length > 0 ? text : undefined +} + +/** numCache/strCache → 按 idx 补齐到 ptCount 长度的数组(缺失 idx → null) */ +const readCache = (refNode: unknown, cacheTag: string): (string | number | null)[] | null => { + const cache = findChild(refNode, cacheTag) + if (!cache) return null + const ptCount = toNumber(valAttr(findChild(cache, 'ptCount'))) ?? 0 + const pts = findChildren(cache, 'pt') + if (pts.length === 0 && ptCount === 0) return null + + const result: (string | number | null)[] = new Array(ptCount).fill(null) + for (const pt of pts) { + const idx = toNumber(getAttr(pt, 'idx')) + if (idx === undefined) continue + const vNode = findChild(pt, 'v') + const raw = typeof vNode === 'object' ? undefined : vNode + if (raw === undefined) continue + const value = cacheTag === 'numCache' ? (toNumber(raw) ?? null) : String(raw) + if (idx < result.length) { + result[idx] = value + } else { + // ptCount 与实际 idx 不一致时兜底扩展 + while (result.length <= idx) result.push(null) + result[idx] = value + } + } + return result +} + +/** 取 numRef/strRef 的 f(引用公式串),不含 sheet 前缀处理(交给 SheetDataAccessor) */ +const readRefFormula = (refNode: unknown): string | undefined => { + const f = findChild(refNode, 'f') + return typeof f === 'string' || typeof f === 'number' ? String(f) : undefined +} + +/** c:cat 或 c:val 节点(内部是 numRef 或 strRef)→ 数据数组 + 引用串 */ +const readDataSource = (sourceNode: unknown): { cache: (string | number | null)[] | null; ref: string | undefined } => { + if (!sourceNode) return { cache: null, ref: undefined } + const numRef = findChild(sourceNode, 'numRef') + const strRef = findChild(sourceNode, 'strRef') + const refNode = numRef ?? strRef + const cacheTag = numRef ? 'numCache' : 'strCache' + const cache = readCache(refNode, cacheTag) + const ref = readRefFormula(refNode) + return { cache, ref } +} + +/** name 来源:c:tx → strRef(优先 cache,其次引用回填)或字面 c:v */ +const readSeriesName = (txNode: unknown, data: SheetDataAccessor, warnings: string[]): string | undefined => { + if (!txNode) return undefined + const strRef = findChild(txNode, 'strRef') + if (strRef) { + const cache = readCache(strRef, 'strCache') + if (cache && cache.length > 0 && cache[0] !== null) return String(cache[0]) + const ref = readRefFormula(strRef) + if (ref) { + const rows = safeReadRange(data, ref, warnings) + const first = rows?.[0]?.[0] + if (first !== null && first !== undefined) return String(first) + } + return undefined + } + const literal = findChild(txNode, 'v') + return typeof literal === 'object' ? undefined : literal !== undefined ? String(literal) : undefined +} + +const safeReadRange = ( + data: SheetDataAccessor, + ref: string, + warnings: string[] +): (string | number | null)[][] | null => { + try { + return data.readRange(ref) + } catch (err) { + warnings.push(`chart data reference failed: ${ref} (${(err as Error).message})`) + return null + } +} + +/** 二维区域读取结果展平为一维(区间通常是单行或单列) */ +const flattenRange = (rows: (string | number | null)[][]): (string | number | null)[] => { + if (rows.length === 1) return rows[0] + return rows.map((row) => row[0]) +} + +const readCategoriesOrValues = ( + sourceNode: unknown, + data: SheetDataAccessor, + warnings: string[] +): (string | number | null)[] => { + const { cache, ref } = readDataSource(sourceNode) + if (cache && cache.length > 0) return cache + if (ref) { + const rows = safeReadRange(data, ref, warnings) + if (rows && rows.length > 0) return flattenRange(rows) + warnings.push(`chart data reference returned no data: ${ref}`) + } + return [] +} + +const parseSeries = (serNode: unknown, data: SheetDataAccessor, warnings: string[]): ChartSeries => { + const name = readSeriesName(findChild(serNode, 'tx'), data, warnings) + + const catValues = readCategoriesOrValues(findChild(serNode, 'cat'), data, warnings) + const rawValues = readCategoriesOrValues(findChild(serNode, 'val'), data, warnings) + + const values: (number | null)[] = rawValues.map((v) => (typeof v === 'number' ? v : v === null ? null : Number(v))) + const normalizedValues = values.map((v) => (v !== null && Number.isNaN(v) ? null : v)) + + const categories: (string | number)[] = + catValues.length > 0 ? catValues.map((c) => (c === null ? '' : c)) : normalizedValues.map((_, i) => i + 1) + + if (catValues.length === 0 && rawValues.length === 0) { + warnings.push('chart series has no category and value data (cache and reference both empty)') + } + + return { name, categories, values: normalizedValues } +} + +/** plotArea 下第一个受支持的图表类型节点;记录组合图降级 warning */ +const pickChartTypeNode = (plotArea: unknown, warnings: string[]): { tag: string; node: unknown } | null => { + const candidates = entries(plotArea).filter(([name]) => name.endsWith('Chart')) + if (candidates.length === 0) return null + + const supported = candidates.find(([name]) => name in PLOT_AREA_TYPE_MAP) + if (candidates.length > 1) { + warnings.push( + `combo chart detected (${candidates.map(([n]) => n).join(', ')}); rendering only "${ + (supported ?? candidates[0])[0] + }"` + ) + } + + const [tag, node] = supported ?? candidates[0] + return { tag, node: Array.isArray(node) ? node[0] : node } +} + +const parseChartXml = (chartXml: string, data: SheetDataAccessor, warnings: string[]): Omit => { + const doc = xmlParser.parse(chartXml) + const chartSpace = findChild(doc, 'chartSpace') + const chartNode = findChild(chartSpace, 'chart') + const plotArea = findChild(chartNode, 'plotArea') + + const picked = pickChartTypeNode(plotArea, warnings) + const title = parseTitle(chartNode) + + if (!picked) { + // 找不到任何 c:xxxChart 子节点(如整份 chart XML 损坏/结构不符预期): + // 视为该图表解析失败,交给调用方 catch 丢弃 + 记录 warning,而不是伪造一个占位图表。 + throw new Error('no chart type node found under plotArea') + } + + const { tag, node } = picked + + if (!(tag in PLOT_AREA_TYPE_MAP)) { + return { type: 'unsupported', rawTypeName: tag, title, series: [] } + } + + const type = PLOT_AREA_TYPE_MAP[tag] + + if (tag === 'pie3DChart' || tag === 'doughnutChart') { + warnings.push(`${tag} rendered as a regular pie chart`) + } + + const serNodes = findChildren(node, 'ser') + const series = serNodes.map((ser) => parseSeries(ser, data, warnings)) + + const result: Omit = { type, title, series } + + if (type === 'bar') { + const barDir = valAttr(findChild(node, 'barDir')) + result.barDirection = barDir === 'bar' ? 'bar' : 'col' + } + + if (type === 'bar' || type === 'line' || type === 'area') { + const grouping = valAttr(findChild(node, 'grouping')) + if (grouping === 'stacked' || grouping === 'percentStacked') result.stacked = true + } + + return result +} + +// --------------------------------------------------------------------------- +// 入口 +// --------------------------------------------------------------------------- + +export async function parseCharts( + zip: JSZip, + sheetName: string, + layout: SheetLayoutAccessor, + data: SheetDataAccessor +): Promise { + const warnings: string[] = [] + const charts: ChartModel[] = [] + + try { + const sheetPartPath = await resolveSheetPartPath(zip, sheetName) + if (!sheetPartPath) return { charts, warnings } + + const drawingPartPath = await resolveDrawingPartPath(zip, sheetPartPath) + if (!drawingPartPath) return { charts, warnings } + + const drawingXml = await readZipText(zip, drawingPartPath) + if (!drawingXml) return { charts, warnings } + + const drawingDir = drawingPartPath.split('/').slice(0, -1).join('/') + const drawingFileName = drawingPartPath.split('/').pop() ?? '' + const drawingRelsXml = await readZipText(zip, `${drawingDir}/_rels/${drawingFileName}.rels`) + const drawingRels = drawingRelsXml ? parseRelationships(drawingRelsXml) : [] + + const anchoredCharts = extractAnchoredCharts(drawingXml, layout) + + for (const anchored of anchoredCharts) { + try { + const rel = drawingRels.find((r) => r.id === anchored.chartRId) + if (!rel) { + warnings.push(`chart relationship not found: ${anchored.chartRId}`) + continue + } + const chartPartPath = resolveTarget(drawingDir, rel.target) + const chartXml = await readZipText(zip, chartPartPath) + if (!chartXml) { + warnings.push(`chart part missing: ${chartPartPath}`) + continue + } + const parsed = parseChartXml(chartXml, data, warnings) + charts.push({ ...parsed, rect: anchored.rect }) + } catch (err) { + warnings.push(`failed to parse chart: ${(err as Error).message}`) + } + } + } catch (err) { + warnings.push(`failed to parse charts for sheet "${sheetName}": ${(err as Error).message}`) + } + + return { charts, warnings } +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/formulaEvaluator.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/formulaEvaluator.ts new file mode 100644 index 00000000000..4ca92b9b893 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/formulaEvaluator.ts @@ -0,0 +1,156 @@ +/** + * 公式 best-effort 求值(三级策略的第 2/3 级;调用方已确认无缓存值才会调 evaluate)。 + * 错误分类语义、环检测、memo、预算见 .context/xlsx-preview/03-wp-formula.md。 + * + * fast-formula-parser 实际行为(与该文档描述有出入之处,已用 node 直接探测确认, + * 详见完成报告): + * - `parser.parse()` 对"合法 Excel 错误结果"——#DIV/0! #VALUE! #N/A #NUM! #NULL! #REF!, + * 以及作为裸标识符出现的未知具名区域(#NAME?)——是**返回**一个 FormulaError 实例,不抛出。 + * - 未知函数调用(如 `FOOBAR(1)`)、语法/词法错误、以及 onCell/onRange 回调内部抛出的 + * 任何异常,都会被包装成 FormulaError('#ERROR!', ...) 并**抛出**,而不是返回 #NAME?。 + * 因此"未知函数 → unevaluated"在本实现中是通过"任何 parse() 抛出 → unevaluated"的 + * 兜底规则覆盖的,并不依赖抛出对象的错误码。 + * - 是否抛出与"合法错误码"这两类之间的边界,和 03 文档 §2 表格描述的语义一致 + * (只是"#NAME?"在库里可能来自返回值也可能来自被包装的抛出,两种情况本实现都归为 + * unevaluated,与文档结果相符)。 + */ +import type { ParserCellRef, ParserRangeRef } from 'fast-formula-parser' +import FormulaParser from 'fast-formula-parser' + +export interface FormulaCellRef { + sheet: string + row: number + col: number +} + +export interface EvalContext { + /** 返回单元格原始值(公式单元格返回其求值结果;触发递归求值) */ + getCellValue(ref: FormulaCellRef): string | number | boolean | null +} + +export interface EvalOutcome { + state: 'evaluated' | 'unevaluated' + /** evaluated 时必有(#DIV/0! 等合法错误结果以 string 表达) */ + value?: string | number | boolean | null +} + +export interface FormulaEvaluator { + evaluate(formula: string, pos: FormulaCellRef): EvalOutcome +} + +/** 递归深度上限:防深链公式爆栈 */ +const MAX_DEPTH = 64 + +/** parse() 返回的合法 Excel 错误码中,视为"求值器不支持/无法解析"而非合法结果的一类 */ +const UNEVALUATED_ERROR_CODES = new Set(['#NAME?']) + +const UNEVALUATED_OUTCOME: EvalOutcome = { state: 'unevaluated' } + +function makeKey(ref: FormulaCellRef): string { + return `${ref.sheet}!${ref.row}:${ref.col}` +} + +/** parse() 的原始返回值 → 单元格标量值(EvalOutcome.value 的取值范围) */ +function normalizeValue(result: unknown): string | number | boolean | null { + if (result === null || result === undefined) return null + if (typeof result === 'number' || typeof result === 'string' || typeof result === 'boolean') { + return result + } + // 理论上 parser 已经把 array/range/union 等归一为标量或 FormulaError, + // 走到这里说明遇到了未预期的返回形态,保守按无法求值处理(调用方兜底判断)。 + return null +} + +export function createFormulaEvaluator(ctx: EvalContext, budgetMs: number): FormulaEvaluator { + const deadline = Date.now() + budgetMs + + /** 已完成求值的单元格结果缓存,key = "sheet!row:col" */ + const memo = new Map() + /** 当前调用栈上正在求值的单元格(环检测) */ + const visiting = new Set() + /** 本轮检测到属于某个环的 key,待其所在帧结束时强制改判为 unevaluated */ + const cyclicKeys = new Set() + + function markCycle(key: string): void { + const stack = [...visiting] + const idx = stack.indexOf(key) + const cycleMembers = idx === -1 ? stack : stack.slice(idx) + for (const member of cycleMembers) { + cyclicKeys.add(member) + } + } + + function classifyParseResult(result: unknown): EvalOutcome { + if (result instanceof FormulaParser.FormulaError) { + if (UNEVALUATED_ERROR_CODES.has(result.error)) { + return UNEVALUATED_OUTCOME + } + return { state: 'evaluated', value: result.error } + } + return { state: 'evaluated', value: normalizeValue(result) } + } + + function evaluate(formula: string, pos: FormulaCellRef): EvalOutcome { + const key = makeKey(pos) + + const memoized = memo.get(key) + if (memoized) return memoized + + if (Date.now() >= deadline) { + // 预算耗尽:后续所有未求值单元格一律快速失败,memo 保证 O(1) + memo.set(key, UNEVALUATED_OUTCOME) + return UNEVALUATED_OUTCOME + } + + if (visiting.has(key)) { + // 重入 = 环:标记当前活跃调用栈中构成环的所有帧,自身不 memo(由拥有该帧的调用方收尾) + markCycle(key) + return UNEVALUATED_OUTCOME + } + + if (visiting.size >= MAX_DEPTH) { + const outcome = UNEVALUATED_OUTCOME + memo.set(key, outcome) + return outcome + } + + visiting.add(key) + let outcome: EvalOutcome + try { + const parser = new FormulaParser({ + onCell: (ref: ParserCellRef) => { + return ctx.getCellValue({ sheet: ref.sheet, row: ref.row, col: ref.col }) + }, + onRange: (ref: ParserRangeRef) => { + const rows: unknown[][] = [] + for (let row = ref.from.row; row <= ref.to.row; row++) { + const cols: unknown[] = [] + for (let col = ref.from.col; col <= ref.to.col; col++) { + cols.push(ctx.getCellValue({ sheet: ref.sheet, row, col })) + } + rows.push(cols) + } + return rows + } + }) + const result = parser.parse(formula, { sheet: pos.sheet, row: pos.row, col: pos.col }) + outcome = classifyParseResult(result) + } catch { + // 解析异常、未知函数(库内部包装为 #ERROR! 抛出)、回调抛出的任何异常:一律 unevaluated, + // 绝不让一个怪公式的异常逃逸出本模块。 + outcome = UNEVALUATED_OUTCOME + } finally { + visiting.delete(key) + } + + if (cyclicKeys.has(key)) { + outcome = UNEVALUATED_OUTCOME + cyclicKeys.delete(key) + } + + memo.set(key, outcome) + return outcome + } + + return { evaluate } +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/numberFormat.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/numberFormat.ts new file mode 100644 index 00000000000..0c6e73e2dee --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/numberFormat.ts @@ -0,0 +1,87 @@ +import * as numfmt from 'numfmt' + +/** + * numfmt 包装:数字/日期/布尔 → 按 Excel 数字格式渲染的显示文本。 + * Date 输入先转 Excel serial 再交 numfmt;格式失败回退 String(raw)。 + * + * 注 1:numfmt.format 的类型声明要求 pattern 为 string(非 optional),但其文档与运行时 + * 行为都是"缺省按 General 处理";故本文件在调用处显式传 numFmt ?? 'General' 以满足类型检查, + * 行为与直接传 undefined 完全一致。 + * 注 2:numfmt 的 dateToSerial/dateFromSerial 只实现 1900 日期体系(无公开 date1904 选项), + * 但这里的输入始终是 ExcelJS 已解析好的 JS Date(绝对时间点,ExcelJS 在读取阶段已经用 + * workbook.properties.date1904 把 1904 体系文件里的原始 serial 换算成了正确的 Date)。 + * 因此本函数把 Date 转回 serial 时只需算 1900 体系下这个真实日期对应的 serial(供 numfmt + * 用它固有的 1900 约定正确渲染),不需要再对 date1904 做二次偏移——`date1904` 参数目前没有 + * 分支需要用到,保留在签名中是为了匹配契约 §8 的冻结签名(未来若接入表示"原始 serial 数字" + * 而非 Date 的输入路径,例如公式引擎直接产出 date serial,则需要在那条路径上应用 + * 1904 体系 serial = 1900 体系 serial - 1462 的偏移)。 + */ + +/** JS Date(按 UTC 分量读取,与 ExcelJS 读出的 Date 语义一致)→ 1900 体系 Excel serial */ +function dateToExcelSerial(date: Date): number { + return ( + numfmt.dateToSerial([ + date.getUTCFullYear(), + date.getUTCMonth() + 1, + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds() + ]) ?? 0 + ) +} + +export function formatCellValue(raw: unknown, numFmt: string | undefined, date1904: boolean): string { + const pattern = numFmt ?? 'General' + void date1904 + + if (raw === null || raw === undefined) { + return '' + } + + if (raw instanceof Date) { + const serial = dateToExcelSerial(raw) + try { + return numfmt.format(pattern, serial, { throws: true }) + } catch { + return String(raw) + } + } + + // ISO 字符串形态的日期(见 parseWorkbook:raw 存 ISO 字符串)在 formula 结果回填等场景下 + // 可能以字符串形式传入;仅当能解析成合法日期且带有日期类格式时才转换,否则按普通字符串处理。 + if (typeof raw === 'string' && numFmt && numfmt.isDateFormat(numFmt)) { + const parsed = new Date(raw) + if (!Number.isNaN(parsed.getTime())) { + const serial = dateToExcelSerial(parsed) + try { + return numfmt.format(pattern, serial, { throws: true }) + } catch { + return String(raw) + } + } + } + + if (typeof raw === 'boolean') { + try { + return numfmt.format(pattern, raw, { throws: true }) + } catch { + return raw ? 'TRUE' : 'FALSE' + } + } + + if (typeof raw === 'number') { + try { + return numfmt.format(pattern, raw, { throws: true }) + } catch { + return String(raw) + } + } + + // 字符串(含 General):原样返回;若指定了格式串仍尝试走 numfmt(如 '@' 文本格式),失败则原样。 + try { + return numfmt.format(pattern, raw, { throws: true }) + } catch { + return String(raw) + } +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/parseWorkbook.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/parseWorkbook.ts new file mode 100644 index 00000000000..e0aca73acaf --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/parseWorkbook.ts @@ -0,0 +1,734 @@ +import ExcelJS from 'exceljs' +import JSZip from 'jszip' + +import { charWidthToPx, DEFAULT_COL_WIDTH_PX, DEFAULT_ROW_HEIGHT_PX, MAX_COLS, MAX_ROWS, ptToPx } from '../gridLayout' +import type { + BorderEdge, + CellRenderModel, + CellStyle, + FloatingObjectModel, + FormulaState, + MergeRange, + SheetRenderModel, + WorkbookRenderModel +} from '../renderModel' +import { parseCharts, type SheetDataAccessor, type SheetLayoutAccessor } from './chartXmlParser' +import { createFormulaEvaluator, type EvalContext, type FormulaCellRef } from './formulaEvaluator' +import { formatCellValue } from './numberFormat' +import { type ExcelColorRef, parseTheme, resolveColor, type ResolvedTheme } from './themeResolver' + +const FORMULA_BUDGET_MS = 5000 + +/** 内部:公式流水线第一遍收集到的待求值单元格 */ +interface PendingFormulaCell { + sheetName: string + row: number + col: number + formula: string + numFmt: string | undefined +} + +/** ExcelJS 富文本单元格值 */ +interface RichTextCellValue { + richText: { text: string }[] +} + +function isRichTextValue(value: unknown): value is RichTextCellValue { + return typeof value === 'object' && value !== null && Array.isArray((value as RichTextCellValue).richText) +} + +interface HyperlinkCellValue { + text: unknown + hyperlink: string +} + +function isHyperlinkValue(value: unknown): value is HyperlinkCellValue { + return typeof value === 'object' && value !== null && 'hyperlink' in value && 'text' in value +} + +interface FormulaCellValue { + formula?: string + sharedFormula?: string + result?: number | string | boolean | Date | { error: string } +} + +function isFormulaValue(value: unknown): value is FormulaCellValue { + return typeof value === 'object' && value !== null && ('formula' in value || 'sharedFormula' in value) +} + +interface ErrorCellValue { + error: string +} + +function isErrorValue(value: unknown): value is ErrorCellValue { + return typeof value === 'object' && value !== null && 'error' in value && !('formula' in value) +} + +/** + * ExcelJS 的 `WorksheetModel` 类型声明未包含 `rows`/`cols`(见 index.d.ts),但运行时 + * `worksheet.model.rows`/`.cols` 确有输出(稀疏,仅含非默认行/列定义)。这里补充本地类型 + * 覆盖实际使用面,避免用 `any`。 + */ +interface WorksheetModelRow { + number?: number + height?: number + hidden?: boolean +} +interface WorksheetModelCol { + min?: number + max?: number + width?: number + hidden?: boolean +} +interface WorksheetModelWithLayout { + rows?: WorksheetModelRow[] + cols?: WorksheetModelCol[] +} + +/** + * ExcelJS `ImageRange` 类型声明遗漏了 oneCellAnchor 场景下的 `ext`,运行时确有输出。 + * 注意单位是 **px**:ExcelJS 的 ExtXform.parseOpen 读取时已做 EMU/9525 换算,勿再除。 + */ +interface ImageRangeWithExt { + ext?: { width: number; height: number } +} + +/** 'A1:D1' → MergeRange(1-based,闭区间) */ +function parseMergeRef(ref: string): MergeRange | null { + const match = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/.exec(ref) + if (!match) return null + const left = colNameToIndex(match[1]) + const top = Number(match[2]) + const right = colNameToIndex(match[3]) + const bottom = Number(match[4]) + return { top, left, bottom, right } +} + +/** 提取错误结果字符串形态(formula.result 可能是 CellErrorValue 对象) */ +function errorResultToString(result: unknown): string | undefined { + if (typeof result === 'object' && result !== null && 'error' in result) { + return (result as { error: string }).error + } + return undefined +} + +function colNameToIndex(name: string): number { + let n = 0 + for (let i = 0; i < name.length; i++) { + n = n * 26 + (name.charCodeAt(i) - 64) + } + return n +} + +/** A1 风格区间引用(可含 sheet 名,如 `Sheet1!$A$1:$B$3` 或 `A1`)解析结果 */ +interface ParsedA1Range { + sheet?: string + top: number + left: number + bottom: number + right: number +} + +/** 解析 chartXmlParser 需要的 A1 区间引用;不合法返回 null */ +function parseA1Range(ref: string): ParsedA1Range | null { + let sheet: string | undefined + let rest = ref + const bangIndex = ref.lastIndexOf('!') + if (bangIndex !== -1) { + sheet = ref.slice(0, bangIndex).replace(/^'|'$/g, '') + rest = ref.slice(bangIndex + 1) + } + const cellPattern = /\$?([A-Z]+)\$?(\d+)/g + const cells: { col: number; row: number }[] = [] + let match: RegExpExecArray | null + while ((match = cellPattern.exec(rest)) !== null) { + cells.push({ col: colNameToIndex(match[1]), row: Number(match[2]) }) + } + if (cells.length === 0) return null + const cols = cells.map((c) => c.col) + const rows = cells.map((c) => c.row) + return { + sheet, + top: Math.min(...rows), + left: Math.min(...cols), + bottom: Math.max(...rows), + right: Math.max(...cols) + } +} + +/** 将 ExcelJS Style.font/fill/border/alignment 映射为 CellStyle,颜色经 theme 解析 */ +function buildCellStyle(cell: ExcelJS.Cell, theme: ResolvedTheme, warnings: Set): CellStyle | undefined { + const style: CellStyle = {} + let hasAny = false + + const font = cell.font + if (font) { + if (font.name) { + style.fontFamily = font.name + hasAny = true + } + if (font.size) { + style.fontSizePx = font.size * (4 / 3) + hasAny = true + } + if (font.bold) { + style.bold = true + hasAny = true + } + if (font.italic) { + style.italic = true + hasAny = true + } + if (font.underline) { + style.underline = true + hasAny = true + } + if (font.strike) { + style.strike = true + hasAny = true + } + if (font.color) { + const resolved = resolveColor(font.color as ExcelColorRef, theme) + if (resolved) { + style.color = resolved + hasAny = true + } + } + } + + const fill = cell.fill + if (fill && fill.type === 'pattern') { + if (fill.pattern === 'solid' && fill.fgColor) { + const resolved = resolveColor(fill.fgColor as ExcelColorRef, theme) + if (resolved) { + style.bg = resolved + hasAny = true + } + } else if (fill.pattern !== 'none' && fill.fgColor) { + // 非 solid pattern:近似取 fgColor 为纯色 + const resolved = resolveColor(fill.fgColor as ExcelColorRef, theme) + if (resolved) { + style.bg = resolved + hasAny = true + } + warnings.add('cell-fill-pattern-approximated') + } + } + + const border = cell.border + if (border) { + const sides = ['top', 'right', 'bottom', 'left'] as const + const styleKeys = ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'] as const + const supportedBorderStyles = new Set([ + 'thin', + 'medium', + 'thick', + 'dashed', + 'dotted', + 'double', + 'hair' + ]) + sides.forEach((side, i) => { + const edge = border[side] + if (edge?.style && supportedBorderStyles.has(edge.style as BorderEdge['style'])) { + const color = resolveColor(edge.color as ExcelColorRef, theme) ?? '#000000' + style[styleKeys[i]] = { style: edge.style as BorderEdge['style'], color } + hasAny = true + } else if (edge?.style) { + warnings.add('border-style-unsupported-approximated-as-thin') + const color = resolveColor(edge.color as ExcelColorRef, theme) ?? '#000000' + style[styleKeys[i]] = { style: 'thin', color } + hasAny = true + } + }) + } + + const alignment = cell.alignment + if (alignment) { + if (alignment.horizontal) { + const hMap: Record = { + left: 'left', + center: 'center', + centerContinuous: 'center', + right: 'right', + justify: 'justify' + } + const mapped = hMap[alignment.horizontal] + if (mapped) { + style.hAlign = mapped + hasAny = true + } + } + if (alignment.vertical) { + const vMap: Record = { top: 'top', middle: 'middle', bottom: 'bottom' } + const mapped = vMap[alignment.vertical] + if (mapped) { + style.vAlign = mapped + hasAny = true + } + } + if (alignment.wrapText) { + style.wrap = true + hasAny = true + } + if (alignment.indent) { + style.indent = alignment.indent + hasAny = true + } + } + + const numFmt = cell.numFmt + if (numFmt && numFmt !== 'General') { + style.numFmt = numFmt + hasAny = true + } + + return hasAny ? style : undefined +} + +/** 工作簿级样式去重表 */ +class StyleTable { + private map = new Map() + private list: CellStyle[] = [] + + intern(style: CellStyle | undefined): number | undefined { + if (!style) return undefined + const key = JSON.stringify(style) + const existing = this.map.get(key) + if (existing !== undefined) return existing + const id = this.list.length + this.map.set(key, id) + this.list.push(style) + return id + } + + get styles(): CellStyle[] { + return this.list + } +} + +const XDR_NS = 'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing' + +/** + * openpyxl 等生成器把 drawing XML 写成默认命名空间(,元素无 xdr: 前缀)。 + * ExcelJS 的 DrawingXform 按字面量 'xdr:wsDr' 匹配,解析结果为 undefined,并在 reconcile + * 阶段以 "Cannot read properties of undefined (reading 'anchors')" 崩溃——任何含图表的 + * openpyxl 文件都无法打开。喂给 ExcelJS 前把无前缀 drawing 部件重写为 xdr: 前缀 + * (drawing 的默认命名空间即 spreadsheetDrawing,前缀化语义不变;a:/c:/r: 等已带前缀的 + * 元素不受影响)。图表/主题走我们自己的命名空间无关解析器,不依赖此转换。 + */ +async function normalizeDrawingsForExcelJs(zip: JSZip, original: ArrayBuffer): Promise { + const drawingFiles = zip.file(/^xl\/drawings\/[a-zA-Z0-9]+\.xml$/) + let modified = false + for (const file of drawingFiles) { + const xml = await file.async('string') + if (/]/.test(xml)) continue + const prefixed = xml + .replace(/<(\/?)(?![a-zA-Z0-9._-]+:)(?![?!])([a-zA-Z][a-zA-Z0-9._-]*)/g, '<$1xdr:$2') + .replace(/ { + let zip: JSZip + let dataForExcelJs = data + try { + zip = await JSZip.loadAsync(data) + dataForExcelJs = await normalizeDrawingsForExcelJs(zip, data) + } catch (err) { + throw new Error(`Failed to parse xlsx file: ${err instanceof Error ? err.message : String(err)}`) + } + + const workbook = new ExcelJS.Workbook() + try { + await workbook.xlsx.load(dataForExcelJs) + } catch (err) { + throw new Error(`Failed to parse xlsx file: ${err instanceof Error ? err.message : String(err)}`) + } + const themeXmlFile = zip.file('xl/theme/theme1.xml') + const themeXml = themeXmlFile ? await themeXmlFile.async('string') : null + const theme = parseTheme(themeXml) + + const date1904 = workbook.properties?.date1904 ?? false + const warnings = new Set() + const styleTable = new StyleTable() + const images: Record = {} + const imageIdCache = new Map() // ExcelJS imageId -> WorkbookRenderModel imageId + let nextImageId = 0 + + const sheets: SheetRenderModel[] = [] + // 跨 sheet 单元格原始值查表,供公式求值使用 + const rawValueTable = new Map() + const pendingFormulas: PendingFormulaCell[] = [] + // sheet 名 -> 待填充的 cells 表(公式求值结果二次回填要用) + const sheetCellsByName = new Map>() + + for (const worksheet of workbook.worksheets) { + const cells: Record = {} + sheetCellsByName.set(worksheet.name, cells) + + let maxRow = 0 + let maxCol = 0 + + worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + row.eachCell({ includeEmpty: true }, (cell, colNumber) => { + const value = cell.value + const style = buildCellStyle(cell, theme, warnings) + + if ((value === null || value === undefined) && !style) { + return + } + + if (rowNumber > MAX_ROWS || colNumber > MAX_COLS) { + warnings.add('sheet-truncated') + return + } + + maxRow = Math.max(maxRow, rowNumber) + maxCol = Math.max(maxCol, colNumber) + + const styleId = styleTable.intern(style) + const key = `${rowNumber}:${colNumber}` + const cellModel: CellRenderModel = { text: '', styleId } + + if (value === null || value === undefined) { + cellModel.text = '' + cells[key] = cellModel + rawValueTable.set(`${worksheet.name}!${rowNumber}:${colNumber}`, null) + return + } + + if (isFormulaValue(value)) { + const errorStr = errorResultToString(value.result) + const hasResult = value.result !== undefined + // sharedFormula 从属单元格只有 result/sharedFormula(master 地址),没有 formula 原文 + const formulaText = value.formula + + if (hasResult) { + const rawResult: string | number | boolean | null = + errorStr !== undefined + ? errorStr + : value.result instanceof Date + ? value.result.toISOString() + : (value.result as string | number | boolean) + cellModel.raw = rawResult + cellModel.formula = value.formula ?? undefined + cellModel.formulaState = 'cached' as FormulaState + cellModel.text = formatCellValue( + value.result instanceof Date ? value.result : rawResult, + cell.numFmt, + date1904 + ) + rawValueTable.set(`${worksheet.name}!${rowNumber}:${colNumber}`, rawResult) + } else if (formulaText) { + // 有公式原文但无缓存值:进入第二遍求值 + cellModel.formula = formulaText + cellModel.text = `=${formulaText}` + cellModel.formulaState = 'unevaluated' as FormulaState + pendingFormulas.push({ + sheetName: worksheet.name, + row: rowNumber, + col: colNumber, + formula: formulaText, + numFmt: cell.numFmt + }) + rawValueTable.set(`${worksheet.name}!${rowNumber}:${colNumber}`, null) + } else { + // shared-formula 从属单元格且无缓存值:v1 不做引用平移,标 unevaluated + cellModel.formulaState = 'unevaluated' as FormulaState + cellModel.text = '' + warnings.add('shared-formula-without-cache-unevaluated') + rawValueTable.set(`${worksheet.name}!${rowNumber}:${colNumber}`, null) + } + cells[key] = cellModel + return + } + + if (isErrorValue(value)) { + cellModel.raw = value.error + cellModel.text = value.error + cells[key] = cellModel + rawValueTable.set(`${worksheet.name}!${rowNumber}:${colNumber}`, value.error) + return + } + + if (isHyperlinkValue(value)) { + cellModel.hyperlink = value.hyperlink + const text = typeof value.text === 'string' ? value.text : String(value.text) + cellModel.raw = text + cellModel.text = text + cells[key] = cellModel + rawValueTable.set(`${worksheet.name}!${rowNumber}:${colNumber}`, text) + return + } + + if (isRichTextValue(value)) { + const text = value.richText.map((seg) => seg.text).join('') + cellModel.raw = text + cellModel.text = text + warnings.add('richtext-run-styles-dropped') + cells[key] = cellModel + rawValueTable.set(`${worksheet.name}!${rowNumber}:${colNumber}`, text) + return + } + + if (value instanceof Date) { + cellModel.raw = value.toISOString() + cellModel.text = formatCellValue(value, cell.numFmt, date1904) + cells[key] = cellModel + // 供公式引用:用 serial 数值近似不可行(需 date1904 上下文),此处退化为 ISO 字符串 + rawValueTable.set(`${worksheet.name}!${rowNumber}:${colNumber}`, value.toISOString()) + return + } + + // string / number / boolean + cellModel.raw = value as string | number | boolean + cellModel.text = formatCellValue(value, cell.numFmt, date1904) + cells[key] = cellModel + rawValueTable.set(`${worksheet.name}!${rowNumber}:${colNumber}`, value as string | number | boolean) + }) + }) + + // 合并区 + const merges: MergeRange[] = [] + for (const ref of worksheet.model.merges ?? []) { + const range = parseMergeRef(ref) + if (range) { + merges.push(range) + maxRow = Math.max(maxRow, range.bottom) + maxCol = Math.max(maxCol, range.right) + } + } + + // 行高列宽(稀疏) + const rowHeightsPx: Record = {} + const colWidthsPx: Record = {} + const worksheetModel = worksheet.model as ExcelJS.WorksheetModel & WorksheetModelWithLayout + + for (const rowModel of worksheetModel.rows ?? []) { + if (rowModel.number === undefined) continue + if (rowModel.hidden) { + rowHeightsPx[rowModel.number] = 0 + } else if (rowModel.height !== undefined) { + rowHeightsPx[rowModel.number] = ptToPx(rowModel.height) + } + } + + for (const colModel of worksheetModel.cols ?? []) { + const min = colModel.min ?? 0 + const max = colModel.max ?? min + for (let c = min; c <= max; c++) { + if (colModel.hidden) { + colWidthsPx[c] = 0 + } else if (colModel.width !== undefined) { + colWidthsPx[c] = charWidthToPx(colModel.width) + } + } + } + + // 浮动图片 + const floatingImages: FloatingObjectModel[] = [] + const colX = (col: number): number => { + let x = 0 + for (let c = 1; c < col; c++) { + x += colWidthsPx[c] ?? DEFAULT_COL_WIDTH_PX + } + return x + } + const rowY = (row: number): number => { + let y = 0 + for (let r = 1; r < row; r++) { + y += rowHeightsPx[r] ?? DEFAULT_ROW_HEIGHT_PX + } + return y + } + + for (const img of worksheet.getImages()) { + const excelImageId = Number(img.imageId) + let renderImageId = imageIdCache.get(excelImageId) + if (renderImageId === undefined) { + const stored = workbook.getImage(excelImageId) + if (stored?.buffer) { + const nodeBuffer = stored.buffer as unknown as Uint8Array + const copy = new ArrayBuffer(nodeBuffer.byteLength) + new Uint8Array(copy).set(nodeBuffer) + renderImageId = nextImageId++ + images[renderImageId] = { mime: `image/${stored.extension}`, data: copy } + imageIdCache.set(excelImageId, renderImageId) + } + } + if (renderImageId === undefined) continue + + const tl = img.range.tl + const x = colX(tl.nativeCol + 1) + tl.nativeColOff / 9525 + const y = rowY(tl.nativeRow + 1) + tl.nativeRowOff / 9525 + + let width: number + let height: number + const ext = (img.range as ExcelJS.ImageRange & ImageRangeWithExt).ext + if (img.range.br) { + const br = img.range.br + const brX = colX(br.nativeCol + 1) + br.nativeColOff / 9525 + const brY = rowY(br.nativeRow + 1) + br.nativeRowOff / 9525 + width = brX - x + height = brY - y + } else if (ext) { + // ExcelJS 已把 ext 的 EMU 换算成 px(ExtXform.parseOpen),直接使用 + width = ext.width + height = ext.height + } else { + warnings.add('image-anchor-missing-extent') + continue + } + + floatingImages.push({ rect: { x, y, width, height }, imageId: renderImageId }) + maxRow = Math.max(maxRow, Math.ceil(tl.nativeRow + 1)) + maxCol = Math.max(maxCol, Math.ceil(tl.nativeCol + 1)) + } + + // frozen panes + const view = worksheet.views?.[0] + const frozen = view && view.state === 'frozen' ? { xSplit: view.xSplit ?? 0, ySplit: view.ySplit ?? 0 } : undefined + + const rowCount = Math.min(Math.max(maxRow, 1), MAX_ROWS) + const colCount = Math.min(Math.max(maxCol, 1), MAX_COLS) + if (maxRow > MAX_ROWS || maxCol > MAX_COLS) { + warnings.add('sheet-truncated') + } + + sheets.push({ + name: worksheet.name, + hidden: worksheet.state === 'hidden' || worksheet.state === 'veryHidden', + rowCount, + colCount, + defaultRowHeightPx: DEFAULT_ROW_HEIGHT_PX, + defaultColWidthPx: DEFAULT_COL_WIDTH_PX, + rowHeightsPx, + colWidthsPx, + cells, + merges, + floatingImages, + charts: [], + ...(frozen ? { frozen } : {}) + }) + } + + // 公式求值第二遍 + if (pendingFormulas.length > 0) { + // 前向引用(公式引用文件顺序靠后的公式单元格)必须递归求值,而不是读第一遍 + // 留在 rawValueTable 里的 null 占位——求值器自带 memo 与环检测,可安全重入。 + const pendingByKey = new Map() + for (const pending of pendingFormulas) { + pendingByKey.set(`${pending.sheetName}!${pending.row}:${pending.col}`, pending) + } + + // eslint-disable-next-line prefer-const -- 与 evalContext 闭包互相引用,先声明后赋值 + let evaluator: ReturnType + const evalContext: EvalContext = { + getCellValue(ref: FormulaCellRef) { + const refKey = `${ref.sheet}!${ref.row}:${ref.col}` + const pending = pendingByKey.get(refKey) + if (pending) { + // memo 保证已算过的 O(1) 返回;环/失败由求值器判定为 unevaluated → null + const outcome = evaluator.evaluate(pending.formula, { sheet: ref.sheet, row: ref.row, col: ref.col }) + if (outcome.state === 'evaluated') { + const value = outcome.value ?? null + rawValueTable.set(refKey, value) + return value + } + return null + } + return rawValueTable.get(refKey) ?? null + } + } + evaluator = createFormulaEvaluator(evalContext, FORMULA_BUDGET_MS) + + for (const pending of pendingFormulas) { + const outcome = evaluator.evaluate(pending.formula, { + sheet: pending.sheetName, + row: pending.row, + col: pending.col + }) + const cellsForSheet = sheetCellsByName.get(pending.sheetName) + if (!cellsForSheet) continue + const key = `${pending.row}:${pending.col}` + const cellModel = cellsForSheet[key] + if (!cellModel) continue + + if (outcome.state === 'evaluated') { + const value = outcome.value ?? null + cellModel.raw = value + cellModel.formulaState = 'evaluated' + cellModel.text = formatCellValue(value, pending.numFmt, date1904) + rawValueTable.set(`${pending.sheetName}!${pending.row}:${pending.col}`, value) + } else { + warnings.add('formula-unevaluated') + } + } + } + + // 图表(需要在公式求值之后,以便引用回填用求值后的单元格值) + for (const worksheet of workbook.worksheets) { + const sheetModel = sheets.find((s) => s.name === worksheet.name) + if (!sheetModel) continue + + const layout: SheetLayoutAccessor = { + colX(col: number) { + let x = 0 + for (let c = 1; c < col; c++) { + x += sheetModel.colWidthsPx[c] ?? DEFAULT_COL_WIDTH_PX + } + return x + }, + rowY(row: number) { + let y = 0 + for (let r = 1; r < row; r++) { + y += sheetModel.rowHeightsPx[r] ?? DEFAULT_ROW_HEIGHT_PX + } + return y + } + } + + const dataAccessor: SheetDataAccessor = { + readRange(ref: string): (string | number | null)[][] | null { + const parsed = parseA1Range(ref) + if (!parsed) return null + const refSheet = parsed.sheet ?? worksheet.name + const result: (string | number | null)[][] = [] + for (let r = parsed.top; r <= parsed.bottom; r++) { + const rowValues: (string | number | null)[] = [] + for (let c = parsed.left; c <= parsed.right; c++) { + const raw = rawValueTable.get(`${refSheet}!${r}:${c}`) + rowValues.push(typeof raw === 'boolean' ? (raw ? 1 : 0) : (raw ?? null)) + } + result.push(rowValues) + } + return result + } + } + + try { + const { charts, warnings: chartWarnings } = await parseCharts(zip, worksheet.name, layout, dataAccessor) + sheetModel.charts = charts + chartWarnings.forEach((w) => warnings.add(w)) + } catch (err) { + warnings.add(`chart-parse-failed:${err instanceof Error ? err.message : String(err)}`) + } + } + + return { + fileName, + styles: styleTable.styles, + sheets, + images, + warnings: Array.from(warnings) + } +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/themeResolver.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/themeResolver.ts new file mode 100644 index 00000000000..8f1c160385a --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/themeResolver.ts @@ -0,0 +1,261 @@ +import { XMLParser } from 'fast-xml-parser' + +/** + * theme1.xml 主题色解析 + tint/indexed 颜色求解。 + * 索引映射与 tint 算法见契约 §6(注意 dk1/lt1 互换)。 + */ + +export interface ResolvedTheme { + /** 索引即契约 §6 映射后的顺序:[lt1, dk1, lt2, dk2, accent1..6, hlink, folHlink] */ + colors: string[] +} + +/** ExcelJS 颜色引用形态(argb / theme+tint / indexed) */ +export interface ExcelColorRef { + argb?: string + theme?: number + tint?: number + indexed?: number +} + +/** Office 默认主题色表(clrScheme 顺序:dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink) */ +const DEFAULT_OFFICE_THEME_CLR_SCHEME = { + dk1: '000000', + lt1: 'FFFFFF', + dk2: '44546A', + lt2: 'E7E6E6', + accent1: '4472C4', + accent2: 'ED7D31', + accent3: 'A5A5A5', + accent4: 'FFC000', + accent5: '5B9BD5', + accent6: '70AD47', + hlink: '0563C1', + folHlink: '954F72' +} + +/** ECMA-376 §18.8.27 内置 64 色板(indexed color),0-based 下标 = indexed 值 */ +const INDEXED_PALETTE: string[] = [ + '000000', + 'FFFFFF', + 'FF0000', + '00FF00', + '0000FF', + 'FFFF00', + 'FF00FF', + '00FFFF', + '000000', + 'FFFFFF', + 'FF0000', + '00FF00', + '0000FF', + 'FFFF00', + 'FF00FF', + '00FFFF', + '800000', + '008000', + '000080', + '808000', + '800080', + '008080', + 'C0C0C0', + '808080', + '9999FF', + '993366', + 'FFFFCC', + 'CCFFFF', + '660066', + 'FF8080', + '0066CC', + 'CCCCFF', + '000080', + 'FF00FF', + 'FFFF00', + '00FFFF', + '800080', + '800000', + '008080', + '0000FF', + '00CCFF', + 'CCFFFF', + 'CCFFCC', + 'FFFF99', + '99CCFF', + 'FF99CC', + 'CC99FF', + 'FFCC99', + '3366FF', + '33CCCC', + '99CC00', + 'FFCC00', + 'FF9900', + 'FF6600', + '666699', + '969696', + '003366', + '339966', + '003300', + '333300', + '993300', + '993366', + '333399', + '333333' +] + +/** RGB (0-255) → HSL (h: 0-360, s/l: 0-1) */ +function rgbToHsl(r: number, g: number, b: number): [number, number, number] { + const rn = r / 255 + const gn = g / 255 + const bn = b / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const l = (max + min) / 2 + if (max === min) { + return [0, 0, l] + } + const d = max - min + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + let h: number + switch (max) { + case rn: + h = (gn - bn) / d + (gn < bn ? 6 : 0) + break + case gn: + h = (bn - rn) / d + 2 + break + default: + h = (rn - gn) / d + 4 + } + h *= 60 + return [h, s, l] +} + +/** HSL → RGB (0-255) */ +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + if (s === 0) { + const v = Math.round(l * 255) + return [v, v, v] + } + const hue2rgb = (p: number, q: number, t: number): number => { + let tt = t + if (tt < 0) tt += 1 + if (tt > 1) tt -= 1 + if (tt < 1 / 6) return p + (q - p) * 6 * tt + if (tt < 1 / 2) return q + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6 + return p + } + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + const hn = h / 360 + const r = hue2rgb(p, q, hn + 1 / 3) + const g = hue2rgb(p, q, hn) + const b = hue2rgb(p, q, hn - 1 / 3) + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)] +} + +const clampByte = (n: number): number => Math.min(255, Math.max(0, n)) + +/** 对 6 位 RGB hex(无 alpha)应用 tint,契约 §6 算法 */ +function applyTint(rgbHex: string, tint: number): string { + const r = parseInt(rgbHex.slice(0, 2), 16) + const g = parseInt(rgbHex.slice(2, 4), 16) + const b = parseInt(rgbHex.slice(4, 6), 16) + const [h, s, l] = rgbToHsl(r, g, b) + const newL = tint < 0 ? l * (1 + tint) : l * (1 - tint) + tint + const [nr, ng, nb] = hslToRgb(h, s, Math.min(1, Math.max(0, newL))) + return [nr, ng, nb].map((v) => clampByte(v).toString(16).padStart(2, '0')).join('') +} + +/** 6 位 RGB hex → CSS 颜色 */ +const rgbHexToCss = (rgbHex: string): string => `#${rgbHex.toLowerCase()}` + +const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' }) + +interface ClrNode { + 'a:srgbClr'?: { '@_val'?: string } + 'a:sysClr'?: { '@_val'?: string; '@_lastClr'?: string } +} + +function readClr(node: ClrNode | undefined): string | undefined { + if (!node) return undefined + if (node['a:srgbClr']?.['@_val']) return node['a:srgbClr']['@_val'] + if (node['a:sysClr']?.['@_lastClr']) return node['a:sysClr']['@_lastClr'] + return undefined +} + +/** themeXml 为 null → 返回内置 Office 默认主题色表 */ +export function parseTheme(themeXml: string | null): ResolvedTheme { + let scheme = DEFAULT_OFFICE_THEME_CLR_SCHEME + if (themeXml) { + try { + const parsed = xmlParser.parse(themeXml) + const clrScheme = parsed?.['a:theme']?.['a:themeElements']?.['a:clrScheme'] + if (clrScheme) { + scheme = { + dk1: readClr(clrScheme['a:dk1']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.dk1, + lt1: readClr(clrScheme['a:lt1']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.lt1, + dk2: readClr(clrScheme['a:dk2']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.dk2, + lt2: readClr(clrScheme['a:lt2']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.lt2, + accent1: readClr(clrScheme['a:accent1']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.accent1, + accent2: readClr(clrScheme['a:accent2']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.accent2, + accent3: readClr(clrScheme['a:accent3']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.accent3, + accent4: readClr(clrScheme['a:accent4']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.accent4, + accent5: readClr(clrScheme['a:accent5']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.accent5, + accent6: readClr(clrScheme['a:accent6']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.accent6, + hlink: readClr(clrScheme['a:hlink']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.hlink, + folHlink: readClr(clrScheme['a:folHlink']) ?? DEFAULT_OFFICE_THEME_CLR_SCHEME.folHlink + } + } + } catch { + scheme = DEFAULT_OFFICE_THEME_CLR_SCHEME + } + } + + // 契约索引顺序:[lt1, dk1, lt2, dk2, accent1..6, hlink, folHlink](与 XML 内 dk1/lt1 顺序互换) + const colors = [ + scheme.lt1, + scheme.dk1, + scheme.lt2, + scheme.dk2, + scheme.accent1, + scheme.accent2, + scheme.accent3, + scheme.accent4, + scheme.accent5, + scheme.accent6, + scheme.hlink, + scheme.folHlink + ].map(rgbHexToCss) + + return { colors } +} + +/** + * ARGB → CSS 颜色。alpha 字节**必须忽略**:Excel 的单元格格式不支持透明度, + * 该字节只是历史填充位——openpyxl 等生成器把 6 位色值规范化成 '00RRGGBB'(alpha=00), + * 若按字面渲染会得到全透明的文字/填充。 + */ +function argbToCss(argb: string): string { + return rgbHexToCss(argb.length === 8 ? argb.slice(2) : argb) +} + +/** 优先级:argb > theme+tint > indexed > undefined */ +export function resolveColor(color: ExcelColorRef | undefined, theme: ResolvedTheme): string | undefined { + if (!color) return undefined + if (color.argb) { + return argbToCss(color.argb) + } + if (color.theme !== undefined) { + const base = theme.colors[color.theme] + if (base === undefined) return undefined + if (!color.tint) return base + return rgbHexToCss(applyTint(base.slice(1), color.tint)) + } + if (color.indexed !== undefined) { + const rgbHex = INDEXED_PALETTE[color.indexed] + if (rgbHex === undefined) return undefined + return rgbHexToCss(rgbHex) + } + return undefined +} diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/xlsxParser.worker.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/xlsxParser.worker.ts new file mode 100644 index 00000000000..44f0234aac9 --- /dev/null +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/worker/xlsxParser.worker.ts @@ -0,0 +1,19 @@ +import type { XlsxParseRequest, XlsxParseResponse } from '../renderModel' +import { parseWorkbook } from './parseWorkbook' + +/** 薄胶水:逻辑全在 parseWorkbook(可测纯函数),此文件不写业务逻辑。 */ +const scope = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null + postMessage(message: XlsxParseResponse, transfer?: Transferable[]): void +} + +scope.onmessage = async (event) => { + const { id, fileName, data } = event.data + try { + const model = await parseWorkbook(data, fileName) + const transfers = Object.values(model.images).map((image) => image.data) + scope.postMessage({ id, ok: true, model }, transfers) + } catch (error) { + scope.postMessage({ id, ok: false, message: error instanceof Error ? error.message : String(error) }) + } +} diff --git a/src/renderer/components/ArtifactPreview/office/__tests__/OfficePreviewPanel.test.tsx b/src/renderer/components/ArtifactPreview/office/__tests__/OfficePreviewPanel.test.tsx index fb193953a94..0b62cce8c0d 100644 --- a/src/renderer/components/ArtifactPreview/office/__tests__/OfficePreviewPanel.test.tsx +++ b/src/renderer/components/ArtifactPreview/office/__tests__/OfficePreviewPanel.test.tsx @@ -20,8 +20,16 @@ const mocks = vi.hoisted(() => ({ sourceSize?: number actions?: React.ReactNode }>, + xlsxPreviewPanelProps: [] as Array<{ + fileName: string + filePath: string + refreshKey: number + sourceSize?: number + actions?: React.ReactNode + }>, wordPreviewPanelModuleLoadCount: 0, - pptxPreviewPanelModuleLoadCount: 0 + pptxPreviewPanelModuleLoadCount: 0, + xlsxPreviewPanelModuleLoadCount: 0 })) vi.mock('../WordPreviewPanel', () => { @@ -58,6 +66,23 @@ vi.mock('../PptxPreviewPanel', () => { } }) +vi.mock('../XlsxPreviewPanel', () => { + mocks.xlsxPreviewPanelModuleLoadCount += 1 + return { + default: (props: { + fileName: string + filePath: string + refreshKey: number + sourceSize?: number + actions?: React.ReactNode + }) => { + mocks.xlsxPreviewPanelProps.push(props) + return
+ }, + __esModule: true + } +}) + vi.mock('@cherrystudio/ui', () => ({ Button: ({ children, ...props }: PropsWithChildren>) => ( - - - {formatZoomLabel(zoom)} - - - - - - - -
-
-
- - {statusBarText} - - {selectedCell?.cell?.formulaState === 'unevaluated' && ( - {t('xlsx_preview.formula_not_evaluated')} - )} -
- -
- {sheets.map((sheet) => ( - - ))} + {/* 底部栏(对齐 Excel):sheet 标签在左,选中格信息居右,缩放控件在最右 */} +
+
+ {sheets.map((sheet) => ( + + ))} +
+ +
+ {statusBarText && ( + + {statusBarText} + + )} + {selectedCell?.cell?.formulaState === 'unevaluated' && ( + {t('xlsx_preview.formula_not_evaluated')} + )} +
+ +
+ + + + + {formatZoomLabel(zoom)} + + + + +
) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx index 505358270cc..07ae5c8e076 100644 --- a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx @@ -154,6 +154,26 @@ describe('XlsxGrid — mock model rendering', () => { expect(mocks.lastColOptions.estimateSize(0)).toBe(110) expect(mocks.lastColOptions.estimateSize(4)).toBe(0) }) + + it('extends the grid with blank rows/cols beyond the used range at default sizes', () => { + render() + // Used range is 60×10; the virtualizers must be given a larger padded range. + expect(mocks.lastRowOptions.count).toBeGreaterThan(salesSheet.rowCount) + expect(mocks.lastColOptions.count).toBeGreaterThan(salesSheet.colCount) + // Padded rows/cols beyond the used range fall back to the sheet defaults. + expect(mocks.lastRowOptions.estimateSize(salesSheet.rowCount)).toBe(20) + expect(mocks.lastColOptions.estimateSize(salesSheet.colCount)).toBe(64) + }) + + it('grows the padded range to fill a viewport larger than the used range', () => { + const notesSheet = model.sheets[1] // 3×2 used range + const { container } = render() + setScrollViewport(container, { clientWidth: 2000, clientHeight: 2000 }) + + // 2000px viewport ÷ 20px default rows = 100 rows, ÷ 64px default cols = 32 cols (plus padding). + expect(mocks.lastRowOptions.count).toBeGreaterThanOrEqual(100) + expect(mocks.lastColOptions.count).toBeGreaterThanOrEqual(32) + }) }) describe('XlsxGrid — style mapping', () => { diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.xlsxPreviewPanel.test.tsx b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.xlsxPreviewPanel.test.tsx index c382f524bb7..8ef02d422c0 100644 --- a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.xlsxPreviewPanel.test.tsx +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/panel.xlsxPreviewPanel.test.tsx @@ -189,7 +189,7 @@ describe('XlsxPreviewPanel', () => { }) }) - it('renders the grid, visible sheet tabs, and the sheet name in the status bar when ready', () => { + it('renders the grid and visible sheet tabs when ready, with no status text until a cell is selected', () => { setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) renderPanel() @@ -200,7 +200,8 @@ describe('XlsxPreviewPanel', () => { expect(screen.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'false') // Hidden sheets get no tab. expect(screen.queryByRole('tab', { name: 'HiddenSheet' })).not.toBeInTheDocument() - expect(screen.getByTestId('xlsx-preview-status-bar')).toHaveTextContent('Sales') + // No selection → no status text (the sheet tabs already show the active sheet). + expect(screen.queryByTestId('xlsx-preview-status-bar')).not.toBeInTheDocument() }) it('falls back to the first sheet when every sheet is hidden', () => { @@ -227,7 +228,8 @@ describe('XlsxPreviewPanel', () => { expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-sheet-name', 'Notes') expect(screen.getByRole('tab', { name: 'Notes' })).toHaveAttribute('aria-selected', 'true') - expect(screen.getByTestId('xlsx-preview-status-bar')).toHaveTextContent('Notes') + // Selection was cleared by the sheet switch, so the status text disappears. + expect(screen.queryByTestId('xlsx-preview-status-bar')).not.toBeInTheDocument() }) it('shows the formula source for a selected formula cell', () => { @@ -262,7 +264,7 @@ describe('XlsxPreviewPanel', () => { expect(screen.getByTestId('xlsx-preview-status-bar')).toHaveTextContent('A3 Q1') }) - it('steps through the zoom levels and resets', () => { + it('steps through the zoom levels without a reset control', () => { setWorkbookState({ status: 'ready', model: modelWithoutCharts() }) renderPanel() @@ -273,12 +275,13 @@ describe('XlsxPreviewPanel', () => { expect(screen.getByTestId('xlsx-preview-zoom-value')).toHaveTextContent('125%') expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-zoom', '1.25') - fireEvent.click(screen.getByRole('button', { name: 'preview.reset' })) - expect(screen.getByTestId('xlsx-preview-zoom-value')).toHaveTextContent('100%') - expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-zoom', '1') - + fireEvent.click(screen.getByRole('button', { name: 'preview.zoom_out' })) fireEvent.click(screen.getByRole('button', { name: 'preview.zoom_out' })) expect(screen.getByTestId('xlsx-preview-zoom-value')).toHaveTextContent('75%') + expect(screen.getByTestId('xlsx-grid')).toHaveAttribute('data-zoom', '0.75') + + // Zoom reset lives nowhere anymore — the top toolbar refresh covers "back to default". + expect(screen.queryByRole('button', { name: 'preview.reset' })).not.toBeInTheDocument() }) it('creates image object URLs when ready and revokes them on unmount', () => { From 6e66e57114290875f26fe44273bcad9b6fc7f3f6 Mon Sep 17 00:00:00 2001 From: jd <59188306+zhangjiadi225@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:12:35 +0800 Subject: [PATCH 03/38] fix(xlsx-preview): honor sheet sizing defaults and stop clipping cell text mid-line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row heights were dropping in two ways: - sheetFormatPr defaultRowHeight/defaultColWidth were ignored — every non-overridden row/col rendered at the hardcoded 15pt/8.43ch global default. Now read per sheet (worksheet.properties) and threaded into the render model, image anchors, and chart layout. - Row definitions were read via worksheet.model.rows, whose serializer drops cell-less rows (e.g.
+ + {/* 选中格弹层:覆盖展示完整内容,DOM 序在最后、z-10 低于 sticky 表头(z-20) */} + {selected && selectedRect && ( +
+ +
+ )} ) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.layout.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.layout.test.ts index 62fb8cbbc6b..0d5e7a5daf2 100644 --- a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.layout.test.ts +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.layout.test.ts @@ -4,10 +4,13 @@ import { axisOffset, buildAxisLayout, DEFAULT_FONT_SIZE_PX, + DEFAULT_ROW_HEIGHT_PX, findCoveringMerge, mergeRectPx, mergesInView, - scaledFontSizePx + scaledFontSizePx, + WRAP_LINE_HEIGHT, + wrapClampLines } from '../gridLayout' describe('buildAxisLayout', () => { @@ -162,3 +165,20 @@ describe('scaledFontSizePx', () => { expect(scaledFontSizePx(undefined, 2)).toBeCloseTo(DEFAULT_FONT_SIZE_PX * 2) }) }) + +describe('wrapClampLines', () => { + it('fits exactly one line in a default-height row at the default font size', () => { + expect(wrapClampLines(DEFAULT_ROW_HEIGHT_PX, DEFAULT_FONT_SIZE_PX)).toBe(1) + }) + + it('only counts whole lines that fully fit the cell height', () => { + // 40px / (14.67px * 1.3 ≈ 19.07px per line) ≈ 2.1 → 2 whole lines + expect(wrapClampLines(40, DEFAULT_FONT_SIZE_PX)).toBe(2) + expect(wrapClampLines(3 * DEFAULT_FONT_SIZE_PX * WRAP_LINE_HEIGHT, DEFAULT_FONT_SIZE_PX)).toBe(3) + }) + + it('never returns less than one line, even for rows shorter than a line', () => { + expect(wrapClampLines(5, DEFAULT_FONT_SIZE_PX)).toBe(1) + expect(wrapClampLines(0, DEFAULT_FONT_SIZE_PX)).toBe(1) + }) +}) diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx index 07ae5c8e076..1afe0a23445 100644 --- a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/grid.render.test.tsx @@ -347,6 +347,53 @@ describe('XlsxGrid — cell selection', () => { }) }) +describe('XlsxGrid — selected cell overlay', () => { + beforeEach(() => { + showHeaderRange() + }) + + it('shows an absolutely positioned overlay with the full text when a cell is clicked', () => { + render() + + expect(screen.queryByTestId('xlsx-grid-selected-overlay')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('季度')) + + const overlay = screen.getByTestId('xlsx-grid-selected-overlay') + expect(overlay).toHaveTextContent('季度') + // A2 rect: col A at x=0 (110px wide), row 2 at y=36 (20px tall). The overlay is at least the + // cell rect and absolutely positioned — it overlays neighbours instead of pushing the layout. + expect(overlay).toHaveStyle({ + position: 'absolute', + top: '36px', + left: '0px', + minWidth: '110px', + minHeight: '20px' + }) + }) + + it('covers the whole merge rect when the selection is a merge master', () => { + showTitleMergeRange() + const { container } = render() + setScrollViewport(container) + + fireEvent.click(screen.getByTestId('xlsx-grid-merge-cell')) + + const overlay = screen.getByTestId('xlsx-grid-selected-overlay') + expect(overlay).toHaveTextContent('2026 年度销售汇总') + expect(overlay).toHaveStyle({ minWidth: '302px', minHeight: '36px' }) + }) + + it('removes the overlay when the selection is cleared with Escape', () => { + render() + + fireEvent.click(screen.getByText('季度')) + expect(screen.getByTestId('xlsx-grid-selected-overlay')).toBeInTheDocument() + + fireEvent.keyDown(screen.getByTestId('xlsx-grid-scroll'), { key: 'Escape' }) + expect(screen.queryByTestId('xlsx-grid-selected-overlay')).not.toBeInTheDocument() + }) +}) + describe('XlsxGrid — floating layer', () => { it('renders a floating image at its scaled position with the resolved object URL', () => { setRangeFromCounts( diff --git a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.parseWorkbook.test.ts b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.parseWorkbook.test.ts index 6b78f003c4b..a6f1245981a 100644 --- a/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.parseWorkbook.test.ts +++ b/src/renderer/components/ArtifactPreview/office/XlsxPreviewPanel/__tests__/parser.parseWorkbook.test.ts @@ -1,4 +1,5 @@ import ExcelJS from 'exceljs' +import JSZip from 'jszip' import { beforeAll, describe, expect, it } from 'vitest' import type { CellStyle, WorkbookRenderModel } from '../renderModel' @@ -209,6 +210,44 @@ describe('parseWorkbook — merges, row/col sizing, hidden', () => { }) }) +describe('parseWorkbook — per-sheet defaults + cell-less row definitions', () => { + let model: WorkbookRenderModel + + beforeAll(async () => { + const wb = new ExcelJS.Workbook() + const ws = wb.addWorksheet('S1', { properties: { defaultRowHeight: 30, defaultColWidth: 16 } }) + ws.getCell('A1').value = 'x' + const buffer = await toArrayBuffer(wb) + + // ExcelJS 写文件时丢弃「无单元格且无高度」的行定义(Row.model 返回 null),这类行 + // 只会出自其他生成器——用 JSZip 注入原始 XML 还原真实场景。 + const zip = await JSZip.loadAsync(buffer) + const sheetPath = 'xl/worksheets/sheet1.xml' + const sheetXml = await zip.file(sheetPath)!.async('string') + zip.file( + sheetPath, + sheetXml.replace('', '