@rowsncolumns/toolkit 24.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +100 -0
- package/Readme.md +27 -0
- package/dist/cjs/autofilter-condition.d.ts +43 -0
- package/dist/cjs/autofilter-condition.d.ts.map +1 -0
- package/dist/cjs/autofilter-condition.js +155 -0
- package/dist/cjs/csv-export.d.ts +49 -0
- package/dist/cjs/csv-export.d.ts.map +1 -0
- package/dist/cjs/csv-export.js +142 -0
- package/dist/cjs/csv-import-worker.d.ts +16 -0
- package/dist/cjs/csv-import-worker.d.ts.map +1 -0
- package/dist/cjs/csv-import-worker.js +60 -0
- package/dist/cjs/csv-import.d.ts +38 -0
- package/dist/cjs/csv-import.d.ts.map +1 -0
- package/dist/cjs/csv-import.js +166 -0
- package/dist/cjs/csv-parser/helpers.d.ts +14 -0
- package/dist/cjs/csv-parser/helpers.d.ts.map +1 -0
- package/dist/cjs/csv-parser/helpers.js +145 -0
- package/dist/cjs/csv-parser/types.d.ts +66 -0
- package/dist/cjs/csv-parser/types.d.ts.map +1 -0
- package/dist/cjs/csv-parser/types.js +14 -0
- package/dist/cjs/csv-parser/worker.d.ts +2 -0
- package/dist/cjs/csv-parser/worker.d.ts.map +1 -0
- package/dist/cjs/csv-parser/worker.js +200 -0
- package/dist/cjs/excel-export.d.ts +11 -0
- package/dist/cjs/excel-export.d.ts.map +1 -0
- package/dist/cjs/excel-export.js +22 -0
- package/dist/cjs/excel-import-session.d.ts +51 -0
- package/dist/cjs/excel-import-session.d.ts.map +1 -0
- package/dist/cjs/excel-import-session.js +307 -0
- package/dist/cjs/excel-import-worker.d.ts +7 -0
- package/dist/cjs/excel-import-worker.d.ts.map +1 -0
- package/dist/cjs/excel-import-worker.js +119 -0
- package/dist/cjs/excel-parser/builtin-numfmts.d.ts +24 -0
- package/dist/cjs/excel-parser/builtin-numfmts.d.ts.map +1 -0
- package/dist/cjs/excel-parser/builtin-numfmts.js +63 -0
- package/dist/cjs/excel-parser/cfb-decryptor.d.ts +53 -0
- package/dist/cjs/excel-parser/cfb-decryptor.d.ts.map +1 -0
- package/dist/cjs/excel-parser/cfb-decryptor.js +553 -0
- package/dist/cjs/excel-parser/cfb-encryptor.d.ts +23 -0
- package/dist/cjs/excel-parser/cfb-encryptor.d.ts.map +1 -0
- package/dist/cjs/excel-parser/cfb-encryptor.js +200 -0
- package/dist/cjs/excel-parser/dv-cell-stamp.d.ts +29 -0
- package/dist/cjs/excel-parser/dv-cell-stamp.d.ts.map +1 -0
- package/dist/cjs/excel-parser/dv-cell-stamp.js +239 -0
- package/dist/cjs/excel-parser/helpers.d.ts +28 -0
- package/dist/cjs/excel-parser/helpers.d.ts.map +1 -0
- package/dist/cjs/excel-parser/helpers.js +188 -0
- package/dist/cjs/excel-parser/interface.d.ts +179 -0
- package/dist/cjs/excel-parser/interface.d.ts.map +1 -0
- package/dist/cjs/excel-parser/interface.js +1 -0
- package/dist/cjs/excel-parser/plugins/base/index.d.ts +31 -0
- package/dist/cjs/excel-parser/plugins/base/index.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/base/index.js +101 -0
- package/dist/cjs/excel-parser/plugins/ods/conditional-formatting.d.ts +16 -0
- package/dist/cjs/excel-parser/plugins/ods/conditional-formatting.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/ods/conditional-formatting.js +488 -0
- package/dist/cjs/excel-parser/plugins/ods/data-validations.d.ts +15 -0
- package/dist/cjs/excel-parser/plugins/ods/data-validations.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/ods/data-validations.js +353 -0
- package/dist/cjs/excel-parser/plugins/ods/formula.d.ts +8 -0
- package/dist/cjs/excel-parser/plugins/ods/formula.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/ods/formula.js +82 -0
- package/dist/cjs/excel-parser/plugins/ods/index.d.ts +122 -0
- package/dist/cjs/excel-parser/plugins/ods/index.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/ods/index.js +3642 -0
- package/dist/cjs/excel-parser/plugins/ods/sparklines.d.ts +25 -0
- package/dist/cjs/excel-parser/plugins/ods/sparklines.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/ods/sparklines.js +221 -0
- package/dist/cjs/excel-parser/plugins/ods/styles.d.ts +60 -0
- package/dist/cjs/excel-parser/plugins/ods/styles.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/ods/styles.js +808 -0
- package/dist/cjs/excel-parser/plugins/ods/types.d.ts +7 -0
- package/dist/cjs/excel-parser/plugins/ods/types.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/ods/types.js +1 -0
- package/dist/cjs/excel-parser/plugins/xls/index.d.ts +5 -0
- package/dist/cjs/excel-parser/plugins/xls/index.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xls/index.js +19 -0
- package/dist/cjs/excel-parser/plugins/xlsb/biff-record-reader.d.ts +13 -0
- package/dist/cjs/excel-parser/plugins/xlsb/biff-record-reader.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsb/biff-record-reader.js +49 -0
- package/dist/cjs/excel-parser/plugins/xlsb/index.d.ts +117 -0
- package/dist/cjs/excel-parser/plugins/xlsb/index.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsb/index.js +2972 -0
- package/dist/cjs/excel-parser/plugins/xlsb/rgce-parser.d.ts +18 -0
- package/dist/cjs/excel-parser/plugins/xlsb/rgce-parser.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsb/rgce-parser.js +527 -0
- package/dist/cjs/excel-parser/plugins/xlsm/index.d.ts +5 -0
- package/dist/cjs/excel-parser/plugins/xlsm/index.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsm/index.js +10 -0
- package/dist/cjs/excel-parser/plugins/xlsx/charts.d.ts +60 -0
- package/dist/cjs/excel-parser/plugins/xlsx/charts.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/charts.js +2178 -0
- package/dist/cjs/excel-parser/plugins/xlsx/conditional-formatting.d.ts +18 -0
- package/dist/cjs/excel-parser/plugins/xlsx/conditional-formatting.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/conditional-formatting.js +620 -0
- package/dist/cjs/excel-parser/plugins/xlsx/data-validations.d.ts +4 -0
- package/dist/cjs/excel-parser/plugins/xlsx/data-validations.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/data-validations.js +515 -0
- package/dist/cjs/excel-parser/plugins/xlsx/drawing.d.ts +39 -0
- package/dist/cjs/excel-parser/plugins/xlsx/drawing.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/drawing.js +941 -0
- package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts +320 -0
- package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/index.js +6056 -0
- package/dist/cjs/excel-parser/plugins/xlsx/numfmt.d.ts +2 -0
- package/dist/cjs/excel-parser/plugins/xlsx/numfmt.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/numfmt.js +3 -0
- package/dist/cjs/excel-parser/plugins/xlsx/pivots.d.ts +40 -0
- package/dist/cjs/excel-parser/plugins/xlsx/pivots.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/pivots.js +342 -0
- package/dist/cjs/excel-parser/plugins/xlsx/relationships.d.ts +8 -0
- package/dist/cjs/excel-parser/plugins/xlsx/relationships.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/relationships.js +26 -0
- package/dist/cjs/excel-parser/plugins/xlsx/sheet-views.d.ts +26 -0
- package/dist/cjs/excel-parser/plugins/xlsx/sheet-views.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/sheet-views.js +59 -0
- package/dist/cjs/excel-parser/plugins/xlsx/styles.d.ts +44 -0
- package/dist/cjs/excel-parser/plugins/xlsx/styles.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/styles.js +1377 -0
- package/dist/cjs/excel-parser/plugins/xlsx/theme.d.ts +50 -0
- package/dist/cjs/excel-parser/plugins/xlsx/theme.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/theme.js +150 -0
- package/dist/cjs/excel-parser/plugins/xlsx/threaded-comments.d.ts +47 -0
- package/dist/cjs/excel-parser/plugins/xlsx/threaded-comments.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/threaded-comments.js +141 -0
- package/dist/cjs/excel-parser/plugins/xlsx/value-type-detection.d.ts +44 -0
- package/dist/cjs/excel-parser/plugins/xlsx/value-type-detection.d.ts.map +1 -0
- package/dist/cjs/excel-parser/plugins/xlsx/value-type-detection.js +407 -0
- package/dist/cjs/excel-parser/registry.d.ts +5 -0
- package/dist/cjs/excel-parser/registry.d.ts.map +1 -0
- package/dist/cjs/excel-parser/registry.js +21 -0
- package/dist/cjs/excel-parser/style-registry.d.ts +51 -0
- package/dist/cjs/excel-parser/style-registry.d.ts.map +1 -0
- package/dist/cjs/excel-parser/style-registry.js +175 -0
- package/dist/cjs/excel-parser/theme-default.d.ts +3 -0
- package/dist/cjs/excel-parser/theme-default.d.ts.map +1 -0
- package/dist/cjs/excel-parser/theme-default.js +19 -0
- package/dist/cjs/excel-parser/types.d.ts +365 -0
- package/dist/cjs/excel-parser/types.d.ts.map +1 -0
- package/dist/cjs/excel-parser/types.js +59 -0
- package/dist/cjs/excel-parser/utils.d.ts +6 -0
- package/dist/cjs/excel-parser/utils.d.ts.map +1 -0
- package/dist/cjs/excel-parser/utils.js +30 -0
- package/dist/cjs/excel-parser/workbook.d.ts +51 -0
- package/dist/cjs/excel-parser/workbook.d.ts.map +1 -0
- package/dist/cjs/excel-parser/workbook.js +317 -0
- package/dist/cjs/excel-parser/worker.d.ts +2 -0
- package/dist/cjs/excel-parser/worker.d.ts.map +1 -0
- package/dist/cjs/excel-parser/worker.js +1149 -0
- package/dist/cjs/excel-parser/zip-parser-streaming.d.ts +13 -0
- package/dist/cjs/excel-parser/zip-parser-streaming.d.ts.map +1 -0
- package/dist/cjs/excel-parser/zip-parser-streaming.js +200 -0
- package/dist/cjs/excel-parser/zip-parser.d.ts +11 -0
- package/dist/cjs/excel-parser/zip-parser.d.ts.map +1 -0
- package/dist/cjs/excel-parser/zip-parser.js +49 -0
- package/dist/cjs/exporter/invalid-data-validation.d.ts +19 -0
- package/dist/cjs/exporter/invalid-data-validation.d.ts.map +1 -0
- package/dist/cjs/exporter/invalid-data-validation.js +39 -0
- package/dist/cjs/exporter/ods/ods-export.d.ts +26 -0
- package/dist/cjs/exporter/ods/ods-export.d.ts.map +1 -0
- package/dist/cjs/exporter/ods/ods-export.js +3714 -0
- package/dist/cjs/exporter/ods/sparkline-builder.d.ts +33 -0
- package/dist/cjs/exporter/ods/sparkline-builder.d.ts.map +1 -0
- package/dist/cjs/exporter/ods/sparkline-builder.js +221 -0
- package/dist/cjs/exporter/xlsx/chart-builder.d.ts +3 -0
- package/dist/cjs/exporter/xlsx/chart-builder.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/chart-builder.js +1901 -0
- package/dist/cjs/exporter/xlsx/chart-templates.d.ts +1781 -0
- package/dist/cjs/exporter/xlsx/chart-templates.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/chart-templates.js +1798 -0
- package/dist/cjs/exporter/xlsx/future-functions.d.ts +10 -0
- package/dist/cjs/exporter/xlsx/future-functions.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/future-functions.js +264 -0
- package/dist/cjs/exporter/xlsx/pivot-builder.d.ts +58 -0
- package/dist/cjs/exporter/xlsx/pivot-builder.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/pivot-builder.js +277 -0
- package/dist/cjs/exporter/xlsx/power-query-builder.d.ts +26 -0
- package/dist/cjs/exporter/xlsx/power-query-builder.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/power-query-builder.js +306 -0
- package/dist/cjs/exporter/xlsx/slicer-builder.d.ts +11 -0
- package/dist/cjs/exporter/xlsx/slicer-builder.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/slicer-builder.js +87 -0
- package/dist/cjs/exporter/xlsx/sparkline-builder.d.ts +61 -0
- package/dist/cjs/exporter/xlsx/sparkline-builder.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/sparkline-builder.js +489 -0
- package/dist/cjs/exporter/xlsx/style-builder.d.ts +93 -0
- package/dist/cjs/exporter/xlsx/style-builder.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/style-builder.js +813 -0
- package/dist/cjs/exporter/xlsx/threaded-comments-builder.d.ts +58 -0
- package/dist/cjs/exporter/xlsx/threaded-comments-builder.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/threaded-comments-builder.js +189 -0
- package/dist/cjs/exporter/xlsx/xlsx-export.d.ts +121 -0
- package/dist/cjs/exporter/xlsx/xlsx-export.d.ts.map +1 -0
- package/dist/cjs/exporter/xlsx/xlsx-export.js +4596 -0
- package/dist/cjs/helpers/theme-xml-generator.d.ts +8 -0
- package/dist/cjs/helpers/theme-xml-generator.d.ts.map +1 -0
- package/dist/cjs/helpers/theme-xml-generator.js +33 -0
- package/dist/cjs/helpers.d.ts +34 -0
- package/dist/cjs/helpers.d.ts.map +1 -0
- package/dist/cjs/helpers.js +189 -0
- package/dist/cjs/index.d.ts +16 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +15 -0
- package/dist/cjs/indexed-colors.d.ts +2 -0
- package/dist/cjs/indexed-colors.d.ts.map +1 -0
- package/dist/cjs/indexed-colors.js +75 -0
- package/dist/cjs/ods-export.d.ts +24 -0
- package/dist/cjs/ods-export.d.ts.map +1 -0
- package/dist/cjs/ods-export.js +23 -0
- package/dist/cjs/parse.d.ts +24 -0
- package/dist/cjs/parse.d.ts.map +1 -0
- package/dist/cjs/parse.js +68 -0
- package/dist/cjs/power-query.d.ts +41 -0
- package/dist/cjs/power-query.d.ts.map +1 -0
- package/dist/cjs/power-query.js +22 -0
- package/dist/cjs/server.d.ts +9 -0
- package/dist/cjs/server.d.ts.map +1 -0
- package/dist/cjs/server.js +8 -0
- package/dist/cjs/theme1.d.ts +3 -0
- package/dist/cjs/theme1.d.ts.map +1 -0
- package/dist/cjs/theme1.js +2 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -0
- package/dist/cjs/types.d.ts +14 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +14 -0
- package/dist/cjs/vitest.config.d.ts +3 -0
- package/dist/cjs/vitest.config.d.ts.map +1 -0
- package/dist/cjs/vitest.config.js +16 -0
- package/dist/esm/autofilter-condition.d.ts +43 -0
- package/dist/esm/autofilter-condition.d.ts.map +1 -0
- package/dist/esm/autofilter-condition.js +145 -0
- package/dist/esm/csv-export.d.ts +49 -0
- package/dist/esm/csv-export.d.ts.map +1 -0
- package/dist/esm/csv-export.js +126 -0
- package/dist/esm/csv-import-worker.d.ts +16 -0
- package/dist/esm/csv-import-worker.d.ts.map +1 -0
- package/dist/esm/csv-import-worker.js +60 -0
- package/dist/esm/csv-import.d.ts +38 -0
- package/dist/esm/csv-import.d.ts.map +1 -0
- package/dist/esm/csv-import.js +132 -0
- package/dist/esm/csv-parser/helpers.d.ts +14 -0
- package/dist/esm/csv-parser/helpers.d.ts.map +1 -0
- package/dist/esm/csv-parser/helpers.js +0 -0
- package/dist/esm/csv-parser/types.d.ts +66 -0
- package/dist/esm/csv-parser/types.d.ts.map +1 -0
- package/dist/esm/csv-parser/types.js +14 -0
- package/dist/esm/csv-parser/worker.d.ts +2 -0
- package/dist/esm/csv-parser/worker.d.ts.map +1 -0
- package/dist/esm/csv-parser/worker.js +154 -0
- package/dist/esm/excel-export.d.ts +11 -0
- package/dist/esm/excel-export.d.ts.map +1 -0
- package/dist/esm/excel-export.js +13 -0
- package/dist/esm/excel-import-session.d.ts +51 -0
- package/dist/esm/excel-import-session.d.ts.map +1 -0
- package/dist/esm/excel-import-session.js +255 -0
- package/dist/esm/excel-import-worker.d.ts +7 -0
- package/dist/esm/excel-import-worker.d.ts.map +1 -0
- package/dist/esm/excel-import-worker.js +116 -0
- package/dist/esm/excel-parser/builtin-numfmts.d.ts +24 -0
- package/dist/esm/excel-parser/builtin-numfmts.d.ts.map +1 -0
- package/dist/esm/excel-parser/builtin-numfmts.js +59 -0
- package/dist/esm/excel-parser/cfb-decryptor.d.ts +53 -0
- package/dist/esm/excel-parser/cfb-decryptor.d.ts.map +1 -0
- package/dist/esm/excel-parser/cfb-decryptor.js +493 -0
- package/dist/esm/excel-parser/cfb-encryptor.d.ts +23 -0
- package/dist/esm/excel-parser/cfb-encryptor.d.ts.map +1 -0
- package/dist/esm/excel-parser/cfb-encryptor.js +181 -0
- package/dist/esm/excel-parser/dv-cell-stamp.d.ts +29 -0
- package/dist/esm/excel-parser/dv-cell-stamp.d.ts.map +1 -0
- package/dist/esm/excel-parser/dv-cell-stamp.js +155 -0
- package/dist/esm/excel-parser/helpers.d.ts +28 -0
- package/dist/esm/excel-parser/helpers.d.ts.map +1 -0
- package/dist/esm/excel-parser/helpers.js +160 -0
- package/dist/esm/excel-parser/interface.d.ts +179 -0
- package/dist/esm/excel-parser/interface.d.ts.map +1 -0
- package/dist/esm/excel-parser/interface.js +1 -0
- package/dist/esm/excel-parser/plugins/base/index.d.ts +31 -0
- package/dist/esm/excel-parser/plugins/base/index.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/base/index.js +64 -0
- package/dist/esm/excel-parser/plugins/ods/conditional-formatting.d.ts +16 -0
- package/dist/esm/excel-parser/plugins/ods/conditional-formatting.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/ods/conditional-formatting.js +405 -0
- package/dist/esm/excel-parser/plugins/ods/data-validations.d.ts +15 -0
- package/dist/esm/excel-parser/plugins/ods/data-validations.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/ods/data-validations.js +331 -0
- package/dist/esm/excel-parser/plugins/ods/formula.d.ts +8 -0
- package/dist/esm/excel-parser/plugins/ods/formula.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/ods/formula.js +82 -0
- package/dist/esm/excel-parser/plugins/ods/index.d.ts +122 -0
- package/dist/esm/excel-parser/plugins/ods/index.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/ods/index.js +2896 -0
- package/dist/esm/excel-parser/plugins/ods/sparklines.d.ts +25 -0
- package/dist/esm/excel-parser/plugins/ods/sparklines.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/ods/sparklines.js +188 -0
- package/dist/esm/excel-parser/plugins/ods/styles.d.ts +60 -0
- package/dist/esm/excel-parser/plugins/ods/styles.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/ods/styles.js +732 -0
- package/dist/esm/excel-parser/plugins/ods/types.d.ts +7 -0
- package/dist/esm/excel-parser/plugins/ods/types.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/ods/types.js +1 -0
- package/dist/esm/excel-parser/plugins/xls/index.d.ts +5 -0
- package/dist/esm/excel-parser/plugins/xls/index.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xls/index.js +7 -0
- package/dist/esm/excel-parser/plugins/xlsb/biff-record-reader.d.ts +13 -0
- package/dist/esm/excel-parser/plugins/xlsb/biff-record-reader.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsb/biff-record-reader.js +48 -0
- package/dist/esm/excel-parser/plugins/xlsb/index.d.ts +117 -0
- package/dist/esm/excel-parser/plugins/xlsb/index.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsb/index.js +2339 -0
- package/dist/esm/excel-parser/plugins/xlsb/rgce-parser.d.ts +18 -0
- package/dist/esm/excel-parser/plugins/xlsb/rgce-parser.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsb/rgce-parser.js +522 -0
- package/dist/esm/excel-parser/plugins/xlsm/index.d.ts +5 -0
- package/dist/esm/excel-parser/plugins/xlsm/index.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsm/index.js +6 -0
- package/dist/esm/excel-parser/plugins/xlsx/charts.d.ts +60 -0
- package/dist/esm/excel-parser/plugins/xlsx/charts.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/charts.js +2036 -0
- package/dist/esm/excel-parser/plugins/xlsx/conditional-formatting.d.ts +18 -0
- package/dist/esm/excel-parser/plugins/xlsx/conditional-formatting.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/conditional-formatting.js +621 -0
- package/dist/esm/excel-parser/plugins/xlsx/data-validations.d.ts +4 -0
- package/dist/esm/excel-parser/plugins/xlsx/data-validations.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/data-validations.js +493 -0
- package/dist/esm/excel-parser/plugins/xlsx/drawing.d.ts +39 -0
- package/dist/esm/excel-parser/plugins/xlsx/drawing.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/drawing.js +905 -0
- package/dist/esm/excel-parser/plugins/xlsx/index.d.ts +320 -0
- package/dist/esm/excel-parser/plugins/xlsx/index.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/index.js +4654 -0
- package/dist/esm/excel-parser/plugins/xlsx/numfmt.d.ts +2 -0
- package/dist/esm/excel-parser/plugins/xlsx/numfmt.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/numfmt.js +3 -0
- package/dist/esm/excel-parser/plugins/xlsx/pivots.d.ts +40 -0
- package/dist/esm/excel-parser/plugins/xlsx/pivots.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/pivots.js +254 -0
- package/dist/esm/excel-parser/plugins/xlsx/relationships.d.ts +8 -0
- package/dist/esm/excel-parser/plugins/xlsx/relationships.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/relationships.js +13 -0
- package/dist/esm/excel-parser/plugins/xlsx/sheet-views.d.ts +26 -0
- package/dist/esm/excel-parser/plugins/xlsx/sheet-views.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/sheet-views.js +64 -0
- package/dist/esm/excel-parser/plugins/xlsx/styles.d.ts +44 -0
- package/dist/esm/excel-parser/plugins/xlsx/styles.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/styles.js +1365 -0
- package/dist/esm/excel-parser/plugins/xlsx/theme.d.ts +50 -0
- package/dist/esm/excel-parser/plugins/xlsx/theme.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/theme.js +127 -0
- package/dist/esm/excel-parser/plugins/xlsx/threaded-comments.d.ts +47 -0
- package/dist/esm/excel-parser/plugins/xlsx/threaded-comments.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/threaded-comments.js +103 -0
- package/dist/esm/excel-parser/plugins/xlsx/value-type-detection.d.ts +44 -0
- package/dist/esm/excel-parser/plugins/xlsx/value-type-detection.d.ts.map +1 -0
- package/dist/esm/excel-parser/plugins/xlsx/value-type-detection.js +399 -0
- package/dist/esm/excel-parser/registry.d.ts +5 -0
- package/dist/esm/excel-parser/registry.d.ts.map +1 -0
- package/dist/esm/excel-parser/registry.js +20 -0
- package/dist/esm/excel-parser/style-registry.d.ts +51 -0
- package/dist/esm/excel-parser/style-registry.d.ts.map +1 -0
- package/dist/esm/excel-parser/style-registry.js +163 -0
- package/dist/esm/excel-parser/theme-default.d.ts +3 -0
- package/dist/esm/excel-parser/theme-default.d.ts.map +1 -0
- package/dist/esm/excel-parser/theme-default.js +19 -0
- package/dist/esm/excel-parser/types.d.ts +365 -0
- package/dist/esm/excel-parser/types.d.ts.map +1 -0
- package/dist/esm/excel-parser/types.js +59 -0
- package/dist/esm/excel-parser/utils.d.ts +6 -0
- package/dist/esm/excel-parser/utils.d.ts.map +1 -0
- package/dist/esm/excel-parser/utils.js +25 -0
- package/dist/esm/excel-parser/workbook.d.ts +51 -0
- package/dist/esm/excel-parser/workbook.d.ts.map +1 -0
- package/dist/esm/excel-parser/workbook.js +192 -0
- package/dist/esm/excel-parser/worker.d.ts +2 -0
- package/dist/esm/excel-parser/worker.d.ts.map +1 -0
- package/dist/esm/excel-parser/worker.js +862 -0
- package/dist/esm/excel-parser/zip-parser-streaming.d.ts +13 -0
- package/dist/esm/excel-parser/zip-parser-streaming.d.ts.map +1 -0
- package/dist/esm/excel-parser/zip-parser-streaming.js +119 -0
- package/dist/esm/excel-parser/zip-parser.d.ts +11 -0
- package/dist/esm/excel-parser/zip-parser.d.ts.map +1 -0
- package/dist/esm/excel-parser/zip-parser.js +43 -0
- package/dist/esm/exporter/invalid-data-validation.d.ts +19 -0
- package/dist/esm/exporter/invalid-data-validation.d.ts.map +1 -0
- package/dist/esm/exporter/invalid-data-validation.js +37 -0
- package/dist/esm/exporter/ods/ods-export.d.ts +26 -0
- package/dist/esm/exporter/ods/ods-export.d.ts.map +1 -0
- package/dist/esm/exporter/ods/ods-export.js +3238 -0
- package/dist/esm/exporter/ods/sparkline-builder.d.ts +33 -0
- package/dist/esm/exporter/ods/sparkline-builder.d.ts.map +1 -0
- package/dist/esm/exporter/ods/sparkline-builder.js +165 -0
- package/dist/esm/exporter/xlsx/chart-builder.d.ts +3 -0
- package/dist/esm/exporter/xlsx/chart-builder.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/chart-builder.js +1805 -0
- package/dist/esm/exporter/xlsx/chart-templates.d.ts +1781 -0
- package/dist/esm/exporter/xlsx/chart-templates.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/chart-templates.js +1798 -0
- package/dist/esm/exporter/xlsx/future-functions.d.ts +10 -0
- package/dist/esm/exporter/xlsx/future-functions.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/future-functions.js +264 -0
- package/dist/esm/exporter/xlsx/pivot-builder.d.ts +58 -0
- package/dist/esm/exporter/xlsx/pivot-builder.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/pivot-builder.js +230 -0
- package/dist/esm/exporter/xlsx/power-query-builder.d.ts +26 -0
- package/dist/esm/exporter/xlsx/power-query-builder.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/power-query-builder.js +257 -0
- package/dist/esm/exporter/xlsx/slicer-builder.d.ts +11 -0
- package/dist/esm/exporter/xlsx/slicer-builder.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/slicer-builder.js +84 -0
- package/dist/esm/exporter/xlsx/sparkline-builder.d.ts +61 -0
- package/dist/esm/exporter/xlsx/sparkline-builder.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/sparkline-builder.js +437 -0
- package/dist/esm/exporter/xlsx/style-builder.d.ts +93 -0
- package/dist/esm/exporter/xlsx/style-builder.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/style-builder.js +795 -0
- package/dist/esm/exporter/xlsx/threaded-comments-builder.d.ts +58 -0
- package/dist/esm/exporter/xlsx/threaded-comments-builder.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/threaded-comments-builder.js +175 -0
- package/dist/esm/exporter/xlsx/xlsx-export.d.ts +121 -0
- package/dist/esm/exporter/xlsx/xlsx-export.d.ts.map +1 -0
- package/dist/esm/exporter/xlsx/xlsx-export.js +4062 -0
- package/dist/esm/helpers/theme-xml-generator.d.ts +8 -0
- package/dist/esm/helpers/theme-xml-generator.d.ts.map +1 -0
- package/dist/esm/helpers/theme-xml-generator.js +33 -0
- package/dist/esm/helpers.d.ts +34 -0
- package/dist/esm/helpers.d.ts.map +1 -0
- package/dist/esm/helpers.js +182 -0
- package/dist/esm/index.d.ts +16 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/indexed-colors.d.ts +2 -0
- package/dist/esm/indexed-colors.d.ts.map +1 -0
- package/dist/esm/indexed-colors.js +75 -0
- package/dist/esm/ods-export.d.ts +24 -0
- package/dist/esm/ods-export.d.ts.map +1 -0
- package/dist/esm/ods-export.js +12 -0
- package/dist/esm/parse.d.ts +24 -0
- package/dist/esm/parse.d.ts.map +1 -0
- package/dist/esm/parse.js +55 -0
- package/dist/esm/power-query.d.ts +41 -0
- package/dist/esm/power-query.d.ts.map +1 -0
- package/dist/esm/power-query.js +19 -0
- package/dist/esm/server.d.ts +9 -0
- package/dist/esm/server.d.ts.map +1 -0
- package/dist/esm/server.js +8 -0
- package/dist/esm/theme1.d.ts +3 -0
- package/dist/esm/theme1.d.ts.map +1 -0
- package/dist/esm/theme1.js +2 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -0
- package/dist/esm/types.d.ts +14 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +14 -0
- package/dist/esm/vitest.config.d.ts +3 -0
- package/dist/esm/vitest.config.d.ts.map +1 -0
- package/dist/esm/vitest.config.js +16 -0
- package/package.json +62 -0
- package/xlsx.d.ts +52 -0
|
@@ -0,0 +1,4062 @@
|
|
|
1
|
+
import { cellRangeToAddress, cellToAddress, escapeCharacters, getCellEffectiveFormat, getCellEffectiveValue, getCellFormattedValue, getCellUserEnteredFormat, getCellUserEnteredValue, getExtendedValueBool, getExtendedValueError, getExtendedValueFormula, getExtendedValueNumber, getExtendedValueString, isNil, sortSheetsByIndex, } from "@rowsncolumns/utils";
|
|
2
|
+
import { zipSync } from "fflate";
|
|
3
|
+
import { omitInvalidDataValidationError } from "../invalid-data-validation";
|
|
4
|
+
import { buildAutoFilterConditionXml } from "../../autofilter-condition";
|
|
5
|
+
import { applyPowerQueryParts } from "./power-query-builder";
|
|
6
|
+
import { qualifyFutureFunctions } from "./future-functions";
|
|
7
|
+
import { PERSON_CONTENT_TYPE, PERSON_REL_TYPE, PersonRegistry, THREADED_COMMENT_REL_TYPE, THREADED_COMMENTS_CONTENT_TYPE, ThreadedCommentsCollector, } from "./threaded-comments-builder";
|
|
8
|
+
import { CFBEncryptor } from "../../excel-parser/cfb-encryptor";
|
|
9
|
+
import { columnPixelToChars, imageUrlToBase64 } from "../../helpers";
|
|
10
|
+
import { generateThemeXml } from "../../helpers/theme-xml-generator";
|
|
11
|
+
import theme1Xml from "../../theme1";
|
|
12
|
+
import { tokenize as defaultTokenizer } from "@rowsncolumns/calculator";
|
|
13
|
+
import { CellXfsRegistry, deriveEffectiveNumberFormatFromFormula, } from "@rowsncolumns/spreadsheet-state";
|
|
14
|
+
import { buildChartXml } from "./chart-builder";
|
|
15
|
+
import { buildPivotCacheDefinitionXml, buildPivotTableRelsXml, buildPivotTableXml, } from "./pivot-builder";
|
|
16
|
+
import { buildSlicerCacheXml, buildSlicerXml } from "./slicer-builder";
|
|
17
|
+
import { buildSparklineGroupsXml } from "./sparkline-builder";
|
|
18
|
+
import { StyleBuilder } from "./style-builder";
|
|
19
|
+
const BLANK_LABEL = "(Blanks)";
|
|
20
|
+
const isStyleReference = (value) => !!value && typeof value === "object" && "sid" in value;
|
|
21
|
+
const DEFAULT_COLUMN_WIDTH_PX = 100;
|
|
22
|
+
const DEFAULT_ROW_HEIGHT_PX = 21;
|
|
23
|
+
const pixelToPoints = (px) => Math.max(1, Number(((px * 72) / 96).toFixed(2)));
|
|
24
|
+
export const createExcelFile = async ({ conditionalFormats: _conditionalFormats, dataValidations: _dataValidations, sheets, sheetData, sharedStrings: sourceSharedStrings, tables: _tables = [], cellXfs, theme, defaultFont, embeds = [], charts = [], slicers = [], pivotTables = [], externalLinks = [], namedRanges = [], namedCellStyles, customTableStyles, iterativeCalculationOptions, ignoreFormulaPatterns, citations, comments, password, locale, use1904 = false, properties, powerQuery, }) => {
|
|
25
|
+
const sortedSheets = sortSheetsByIndex(sheets, true);
|
|
26
|
+
const sharedStrings = new SharedStrings();
|
|
27
|
+
// Workbook-scoped author registry shared across every sheet's threaded
|
|
28
|
+
// comments, so an author maps to a single `person` entry / personId.
|
|
29
|
+
const personRegistry = new PersonRegistry();
|
|
30
|
+
const styleBuilder = new StyleBuilder();
|
|
31
|
+
if (defaultFont) {
|
|
32
|
+
styleBuilder.setDefaultFont(defaultFont);
|
|
33
|
+
}
|
|
34
|
+
if (namedCellStyles && namedCellStyles.length > 0) {
|
|
35
|
+
styleBuilder.setNamedCellStyles(namedCellStyles);
|
|
36
|
+
}
|
|
37
|
+
if (customTableStyles && customTableStyles.length > 0) {
|
|
38
|
+
styleBuilder.setCustomTableStyles(customTableStyles);
|
|
39
|
+
}
|
|
40
|
+
// Drop tables with invalid coords or overlapping ranges — Excel rejects them
|
|
41
|
+
// and would trigger a recovery prompt. Filter once so the id remap and the
|
|
42
|
+
// prepared output stay in lockstep.
|
|
43
|
+
const exportableTables = filterExportableTables(_tables ?? []);
|
|
44
|
+
// Create table ID mapping before prepareTables changes them
|
|
45
|
+
const tableIdMap = new Map();
|
|
46
|
+
exportableTables.forEach((table, index) => {
|
|
47
|
+
tableIdMap.set(table.id, index + 1);
|
|
48
|
+
tableIdMap.set(String(table.id), index + 1);
|
|
49
|
+
});
|
|
50
|
+
const preparedTables = prepareTables(exportableTables);
|
|
51
|
+
const embedsBySheet = groupEmbedsBySheet(embeds ?? []);
|
|
52
|
+
const textBoxesBySheet = groupTextBoxesBySheet(embeds ?? []);
|
|
53
|
+
const shapesBySheet = groupShapesBySheet(embeds ?? []);
|
|
54
|
+
const chartsBySheet = groupChartsBySheet(charts ?? []);
|
|
55
|
+
// Remap slicer tableIds to match prepared table IDs
|
|
56
|
+
const remappedSlicers = slicers.map((slicer) => {
|
|
57
|
+
if (slicer.spec.type !== "table")
|
|
58
|
+
return slicer;
|
|
59
|
+
const remappedTableIds = slicer.spec.tableIds
|
|
60
|
+
.map((tid) => tableIdMap.get(tid))
|
|
61
|
+
.filter((id) => id !== undefined)
|
|
62
|
+
.map(String); // Keep as strings for consistency
|
|
63
|
+
return {
|
|
64
|
+
...slicer,
|
|
65
|
+
spec: {
|
|
66
|
+
...slicer.spec,
|
|
67
|
+
tableIds: remappedTableIds,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
const slicersBySheet = groupSlicersBySheet(remappedSlicers ?? []);
|
|
72
|
+
const workbookSheetNames = buildWorkbookSheetNames(sortedSheets);
|
|
73
|
+
const sheetNameById = new Map();
|
|
74
|
+
sortedSheets.forEach((sheet, index) => {
|
|
75
|
+
sheetNameById.set(sheet.sheetId ?? index + 1, workbookSheetNames[index]);
|
|
76
|
+
});
|
|
77
|
+
const dataValidationsById = _dataValidations?.reduce((acc, rule) => {
|
|
78
|
+
acc[rule.id] = rule;
|
|
79
|
+
return acc;
|
|
80
|
+
}, {}) ?? undefined;
|
|
81
|
+
// Initialize slicer tracking variables
|
|
82
|
+
let globalSlicerIndex = 1;
|
|
83
|
+
const slicerCacheEntries = [];
|
|
84
|
+
const slicerEntries = [];
|
|
85
|
+
// Phase D2 — pivot table tracking. PivotCacheDefinition lives
|
|
86
|
+
// workbook-wide (referenced from workbook.xml's <pivotCaches>);
|
|
87
|
+
// pivotTable lives per-sheet, attached to the target sheet's
|
|
88
|
+
// relationships. We track:
|
|
89
|
+
// - cache entries: pivotCacheDefinition<N>.xml + its .rels
|
|
90
|
+
// - table entries: pivotTable<N>.xml + its .rels (links to cache)
|
|
91
|
+
// - per-cache rId on the workbook (for <pivotCaches>)
|
|
92
|
+
let globalPivotIndex = 1;
|
|
93
|
+
const pivotCacheEntries = [];
|
|
94
|
+
const pivotCacheRelsEntries = [];
|
|
95
|
+
const pivotTableEntries = [];
|
|
96
|
+
const pivotTableRelsEntries = [];
|
|
97
|
+
// Group pivots by the sheet they render on (targetPosition.sheetId).
|
|
98
|
+
const pivotsByTargetSheet = new Map();
|
|
99
|
+
for (const p of pivotTables) {
|
|
100
|
+
const sid = p.targetPosition?.sheetId;
|
|
101
|
+
if (sid == null)
|
|
102
|
+
continue;
|
|
103
|
+
const list = pivotsByTargetSheet.get(sid);
|
|
104
|
+
if (list)
|
|
105
|
+
list.push(p);
|
|
106
|
+
else
|
|
107
|
+
pivotsByTargetSheet.set(sid, [p]);
|
|
108
|
+
}
|
|
109
|
+
// Records pivot caches that need a workbook-level entry (added
|
|
110
|
+
// after the sheet loop closes). Each entry: { cacheIndex, rIdOnWorkbook }
|
|
111
|
+
const workbookPivotCacheRefs = [];
|
|
112
|
+
const worksheetEntries = sortedSheets.map((sheet, index) => {
|
|
113
|
+
const rows = getRowsForSheet(sheetData, sheet.sheetId);
|
|
114
|
+
const sheetRelationships = new RelationshipsBuilder();
|
|
115
|
+
const sheetTables = preparedTables.filter((table) => table.sheetId === sheet.sheetId);
|
|
116
|
+
const embedImages = embedsBySheet.get(sheet.sheetId) ?? [];
|
|
117
|
+
const textBoxes = textBoxesBySheet.get(sheet.sheetId) ?? [];
|
|
118
|
+
const sheetShapes = shapesBySheet.get(sheet.sheetId) ?? [];
|
|
119
|
+
const sheetCharts = chartsBySheet.get(sheet.sheetId) ?? [];
|
|
120
|
+
const sheetSlicers = slicersBySheet.get(sheet.sheetId) ?? [];
|
|
121
|
+
const worksheetSlicerRelIds = [];
|
|
122
|
+
const drawingSlicers = [];
|
|
123
|
+
const sheetConditionalFormats = (_conditionalFormats ?? []).filter((rule) => rule.ranges?.some((range) => range.sheetId === sheet.sheetId));
|
|
124
|
+
const drawingContext = new DrawingContext(sheetRelationships, `../drawings/drawing${index + 1}.xml`, embedImages.length > 0 ||
|
|
125
|
+
textBoxes.length > 0 ||
|
|
126
|
+
sheetShapes.length > 0 ||
|
|
127
|
+
sheetCharts.length > 0 ||
|
|
128
|
+
sheetSlicers.length > 0);
|
|
129
|
+
const sheetCitations = citations?.filter((citation) => citation.range.sheetId === sheet.sheetId);
|
|
130
|
+
// Generate slicer XML for this sheet
|
|
131
|
+
for (const placement of sheetSlicers) {
|
|
132
|
+
const { slicer } = placement;
|
|
133
|
+
const cacheIndex = globalSlicerIndex;
|
|
134
|
+
const slicerIndex = globalSlicerIndex;
|
|
135
|
+
const cacheName = buildSlicerCacheName(slicer.spec.title || `Slicer${slicerIndex}`);
|
|
136
|
+
// Create slicer cache XML
|
|
137
|
+
const slicerCacheXml = buildSlicerCacheXml(slicer, sheetTables, cacheName);
|
|
138
|
+
if (slicerCacheXml) {
|
|
139
|
+
slicerCacheEntries.push({
|
|
140
|
+
path: `xl/slicerCaches/slicerCache${cacheIndex}.xml`,
|
|
141
|
+
xml: slicerCacheXml,
|
|
142
|
+
});
|
|
143
|
+
// Create slicer XML
|
|
144
|
+
const slicerXml = buildSlicerXml(slicer, cacheName);
|
|
145
|
+
slicerEntries.push({
|
|
146
|
+
path: `xl/slicers/slicer${slicerIndex}.xml`,
|
|
147
|
+
xml: slicerXml,
|
|
148
|
+
});
|
|
149
|
+
// Add relationship from worksheet to slicer
|
|
150
|
+
const worksheetSlicerRelId = sheetRelationships.add(SLICER_REL_TYPE, `../slicers/slicer${slicerIndex}.xml`);
|
|
151
|
+
worksheetSlicerRelIds.push(worksheetSlicerRelId);
|
|
152
|
+
drawingSlicers.push({
|
|
153
|
+
slicerIndex,
|
|
154
|
+
title: slicer.spec.title || `Slicer ${slicerIndex}`,
|
|
155
|
+
rowIndex: placement.rowIndex,
|
|
156
|
+
columnIndex: placement.columnIndex,
|
|
157
|
+
offsetX: placement.offsetX,
|
|
158
|
+
offsetY: placement.offsetY,
|
|
159
|
+
width: placement.width,
|
|
160
|
+
height: placement.height,
|
|
161
|
+
});
|
|
162
|
+
globalSlicerIndex += 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Phase D2 — emit pivot table XMLs + relationships for this
|
|
166
|
+
// sheet. PivotTable lives on the rendering sheet; its cache
|
|
167
|
+
// definition is workbook-scoped (added to <pivotCaches> after
|
|
168
|
+
// this loop).
|
|
169
|
+
const sheetPivots = pivotsByTargetSheet.get(sheet.sheetId) ?? [];
|
|
170
|
+
for (const pivot of sheetPivots) {
|
|
171
|
+
const cacheIndex = globalPivotIndex;
|
|
172
|
+
const tableIndex = globalPivotIndex;
|
|
173
|
+
const sourceSheet = sortedSheets.find((s) => s.sheetId === pivot.source?.sheetId);
|
|
174
|
+
const sourceSheetName = sourceSheet?.title ?? sheet.title ?? `Sheet${sheet.sheetId}`;
|
|
175
|
+
// Field universe: union of every field referenced by the
|
|
176
|
+
// pivot. PivotCacheDefinition requires one <cacheField>
|
|
177
|
+
// entry per field, and pivotTable.fld indices reference them.
|
|
178
|
+
const fieldNameList = [];
|
|
179
|
+
const fieldIndex = new Map();
|
|
180
|
+
const registerField = (name) => {
|
|
181
|
+
if (!name || fieldIndex.has(name))
|
|
182
|
+
return;
|
|
183
|
+
fieldIndex.set(name, fieldNameList.length);
|
|
184
|
+
fieldNameList.push(name);
|
|
185
|
+
};
|
|
186
|
+
for (const r of pivot.rows ?? [])
|
|
187
|
+
registerField(r.field);
|
|
188
|
+
for (const c of pivot.columns ?? [])
|
|
189
|
+
registerField(c.field);
|
|
190
|
+
for (const v of pivot.values ?? [])
|
|
191
|
+
registerField(v.field);
|
|
192
|
+
const cacheXml = buildPivotCacheDefinitionXml(pivot, sourceSheetName, fieldNameList);
|
|
193
|
+
const tableXml = buildPivotTableXml(pivot, cacheIndex, fieldIndex);
|
|
194
|
+
if (!cacheXml || !tableXml)
|
|
195
|
+
continue;
|
|
196
|
+
pivotCacheEntries.push({
|
|
197
|
+
path: `xl/pivotCache/pivotCacheDefinition${cacheIndex}.xml`,
|
|
198
|
+
xml: cacheXml,
|
|
199
|
+
});
|
|
200
|
+
pivotTableEntries.push({
|
|
201
|
+
path: `xl/pivotTables/pivotTable${tableIndex}.xml`,
|
|
202
|
+
xml: tableXml,
|
|
203
|
+
});
|
|
204
|
+
// pivotTable<N>.xml.rels → links to its cache definition.
|
|
205
|
+
pivotTableRelsEntries.push({
|
|
206
|
+
path: `xl/pivotTables/_rels/pivotTable${tableIndex}.xml.rels`,
|
|
207
|
+
xml: buildPivotTableRelsXml("rId1", `../pivotCache/pivotCacheDefinition${cacheIndex}.xml`),
|
|
208
|
+
});
|
|
209
|
+
// sheet → pivotTable relationship.
|
|
210
|
+
sheetRelationships.add(PIVOT_TABLE_REL_TYPE, `../pivotTables/pivotTable${tableIndex}.xml`);
|
|
211
|
+
// Record the cache for the workbook-level <pivotCaches> list.
|
|
212
|
+
workbookPivotCacheRefs.push({ cacheIndex });
|
|
213
|
+
globalPivotIndex += 1;
|
|
214
|
+
}
|
|
215
|
+
const result = buildWorksheetXml({
|
|
216
|
+
sheet,
|
|
217
|
+
rows,
|
|
218
|
+
sharedStrings,
|
|
219
|
+
sourceSharedStrings,
|
|
220
|
+
cellXfs,
|
|
221
|
+
styleBuilder,
|
|
222
|
+
tables: sheetTables,
|
|
223
|
+
relationships: sheetRelationships,
|
|
224
|
+
drawingContext,
|
|
225
|
+
conditionalFormats: sheetConditionalFormats,
|
|
226
|
+
dataValidationsById,
|
|
227
|
+
dataValidations: _dataValidations,
|
|
228
|
+
ignoreFormulaPatterns,
|
|
229
|
+
citations: sheetCitations,
|
|
230
|
+
comments,
|
|
231
|
+
personRegistry,
|
|
232
|
+
slicerRelIds: worksheetSlicerRelIds,
|
|
233
|
+
sheetData,
|
|
234
|
+
sheets: sortedSheets,
|
|
235
|
+
locale,
|
|
236
|
+
});
|
|
237
|
+
return {
|
|
238
|
+
index,
|
|
239
|
+
path: `xl/worksheets/sheet${index + 1}.xml`,
|
|
240
|
+
relationshipsPath: `xl/worksheets/_rels/sheet${index + 1}.xml.rels`,
|
|
241
|
+
content: result.xml,
|
|
242
|
+
relationshipsXml: result.relationshipsXml,
|
|
243
|
+
drawingPath: `xl/drawings/drawing${index + 1}.xml`,
|
|
244
|
+
drawingRelsPath: `xl/drawings/_rels/drawing${index + 1}.xml.rels`,
|
|
245
|
+
cellImages: result.images,
|
|
246
|
+
embedImages,
|
|
247
|
+
textBoxes,
|
|
248
|
+
shapes: sheetShapes,
|
|
249
|
+
charts: sheetCharts,
|
|
250
|
+
slicers: drawingSlicers,
|
|
251
|
+
sheet,
|
|
252
|
+
commentsXml: result.commentsXml,
|
|
253
|
+
vmlDrawingXml: result.vmlDrawingXml,
|
|
254
|
+
threadedCommentsXml: result.threadedCommentsXml,
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
const { xml: workbookRelationshipsXml, sheetRelationshipIds, slicerCacheRelationshipIds, pivotCacheRelationshipIds, externalLinkRelationshipIds, } = buildWorkbookRelationships(worksheetEntries.length, slicerCacheEntries.length, externalLinks.length, pivotCacheEntries.length, !personRegistry.isEmpty());
|
|
258
|
+
const workbookXml = buildWorkbookXml(sortedSheets, sheetRelationshipIds, workbookSheetNames, namedRanges, iterativeCalculationOptions, slicerCacheRelationshipIds, use1904, externalLinkRelationshipIds, pivotCacheRelationshipIds);
|
|
259
|
+
const sharedStringsXml = buildSharedStringsXml(sharedStrings);
|
|
260
|
+
const rootRelationshipsXml = buildRootRelationshipsXml();
|
|
261
|
+
const appXml = buildAppXml(workbookSheetNames, properties);
|
|
262
|
+
const coreXml = buildCoreXml(properties);
|
|
263
|
+
const stylesXml = styleBuilder.toXml();
|
|
264
|
+
const themeXml = theme ? generateThemeXml(theme) : theme1Xml;
|
|
265
|
+
const files = {
|
|
266
|
+
"_rels/.rels": encodeXml(rootRelationshipsXml),
|
|
267
|
+
"docProps/app.xml": encodeXml(appXml),
|
|
268
|
+
"docProps/core.xml": encodeXml(coreXml),
|
|
269
|
+
"xl/workbook.xml": encodeXml(workbookXml),
|
|
270
|
+
"xl/_rels/workbook.xml.rels": encodeXml(workbookRelationshipsXml),
|
|
271
|
+
"xl/sharedStrings.xml": encodeXml(sharedStringsXml),
|
|
272
|
+
"xl/styles.xml": encodeXml(stylesXml),
|
|
273
|
+
"xl/theme/theme1.xml": encodeXml(themeXml),
|
|
274
|
+
};
|
|
275
|
+
for (const entry of worksheetEntries) {
|
|
276
|
+
files[entry.path] = encodeXml(entry.content);
|
|
277
|
+
if (entry.relationshipsXml) {
|
|
278
|
+
files[entry.relationshipsPath] = encodeXml(entry.relationshipsXml);
|
|
279
|
+
}
|
|
280
|
+
if (entry.commentsXml) {
|
|
281
|
+
files[`xl/comments${entry.sheet.sheetId}.xml`] = encodeXml(entry.commentsXml);
|
|
282
|
+
}
|
|
283
|
+
if (entry.vmlDrawingXml) {
|
|
284
|
+
files[`xl/drawings/vmlDrawing${entry.sheet.sheetId}.vml`] = encodeXml(entry.vmlDrawingXml);
|
|
285
|
+
}
|
|
286
|
+
if (entry.threadedCommentsXml) {
|
|
287
|
+
files[`xl/threadedComments/threadedComment${entry.sheet.sheetId}.xml`] =
|
|
288
|
+
encodeXml(entry.threadedCommentsXml);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Workbook-level author list shared by every sheet's threaded comments.
|
|
292
|
+
const personsXml = personRegistry.toXml();
|
|
293
|
+
if (personsXml) {
|
|
294
|
+
files["xl/persons/person.xml"] = encodeXml(personsXml);
|
|
295
|
+
}
|
|
296
|
+
for (const table of preparedTables) {
|
|
297
|
+
const rows = getRowsForSheet(sheetData, table.sheetId);
|
|
298
|
+
const tableXml = buildTableXml(table, rows, sourceSharedStrings, styleBuilder);
|
|
299
|
+
if (tableXml) {
|
|
300
|
+
files[table.path] = encodeXml(tableXml);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
let globalImageIndex = 1;
|
|
304
|
+
let globalChartIndex = 1;
|
|
305
|
+
const drawingSheets = [];
|
|
306
|
+
const imageExtensions = new Set();
|
|
307
|
+
const chartEntries = [];
|
|
308
|
+
for (const entry of worksheetEntries) {
|
|
309
|
+
const placements = [...entry.cellImages, ...entry.embedImages];
|
|
310
|
+
const textBoxesForSheet = entry.textBoxes ?? [];
|
|
311
|
+
const shapesForSheet = entry.shapes ?? [];
|
|
312
|
+
const chartsForSheet = entry.charts ?? [];
|
|
313
|
+
const slicersForSheet = entry.slicers ?? [];
|
|
314
|
+
if (!placements.length &&
|
|
315
|
+
!textBoxesForSheet.length &&
|
|
316
|
+
!shapesForSheet.length &&
|
|
317
|
+
!chartsForSheet.length &&
|
|
318
|
+
!slicersForSheet.length)
|
|
319
|
+
continue;
|
|
320
|
+
const drawingParts = await buildDrawingParts({
|
|
321
|
+
sheet: entry.sheet,
|
|
322
|
+
placements,
|
|
323
|
+
textBoxes: textBoxesForSheet,
|
|
324
|
+
shapes: shapesForSheet,
|
|
325
|
+
startImageIndex: globalImageIndex,
|
|
326
|
+
startChartIndex: globalChartIndex,
|
|
327
|
+
charts: chartsForSheet,
|
|
328
|
+
slicers: slicersForSheet,
|
|
329
|
+
sheetNameById,
|
|
330
|
+
});
|
|
331
|
+
if (!drawingParts)
|
|
332
|
+
continue;
|
|
333
|
+
files[entry.drawingPath] = encodeXml(drawingParts.xml);
|
|
334
|
+
if (drawingParts.relsXml) {
|
|
335
|
+
files[entry.drawingRelsPath] = encodeXml(drawingParts.relsXml);
|
|
336
|
+
}
|
|
337
|
+
for (const media of drawingParts.media) {
|
|
338
|
+
files[media.path] = media.data;
|
|
339
|
+
imageExtensions.add(media.extension);
|
|
340
|
+
}
|
|
341
|
+
for (const chartEntry of drawingParts.charts) {
|
|
342
|
+
chartEntries.push(chartEntry);
|
|
343
|
+
}
|
|
344
|
+
globalImageIndex = drawingParts.nextImageIndex;
|
|
345
|
+
globalChartIndex = drawingParts.nextChartIndex;
|
|
346
|
+
drawingSheets.push(entry.index + 1);
|
|
347
|
+
}
|
|
348
|
+
for (const chartEntry of chartEntries) {
|
|
349
|
+
files[chartEntry.path] = encodeXml(chartEntry.xml);
|
|
350
|
+
}
|
|
351
|
+
for (const slicerCacheEntry of slicerCacheEntries) {
|
|
352
|
+
files[slicerCacheEntry.path] = encodeXml(slicerCacheEntry.xml);
|
|
353
|
+
}
|
|
354
|
+
for (const slicerEntry of slicerEntries) {
|
|
355
|
+
files[slicerEntry.path] = encodeXml(slicerEntry.xml);
|
|
356
|
+
}
|
|
357
|
+
// Phase D2 — emit pivot files.
|
|
358
|
+
for (const cacheEntry of pivotCacheEntries) {
|
|
359
|
+
files[cacheEntry.path] = encodeXml(cacheEntry.xml);
|
|
360
|
+
}
|
|
361
|
+
for (const cacheRels of pivotCacheRelsEntries) {
|
|
362
|
+
files[cacheRels.path] = encodeXml(cacheRels.xml);
|
|
363
|
+
}
|
|
364
|
+
for (const tableEntry of pivotTableEntries) {
|
|
365
|
+
files[tableEntry.path] = encodeXml(tableEntry.xml);
|
|
366
|
+
}
|
|
367
|
+
for (const tableRels of pivotTableRelsEntries) {
|
|
368
|
+
files[tableRels.path] = encodeXml(tableRels.xml);
|
|
369
|
+
}
|
|
370
|
+
// Add external link files (for formulas referencing other workbooks)
|
|
371
|
+
for (let i = 0; i < externalLinks.length; i++) {
|
|
372
|
+
const link = externalLinks[i];
|
|
373
|
+
const linkPath = `xl/externalLinks/externalLink${i + 1}.xml`;
|
|
374
|
+
files[linkPath] = encodeXml(link.xmlContent);
|
|
375
|
+
// Add the relationship file if present
|
|
376
|
+
if (link.relsContent) {
|
|
377
|
+
const relsPath = `xl/externalLinks/_rels/externalLink${i + 1}.xml.rels`;
|
|
378
|
+
files[relsPath] = encodeXml(link.relsContent);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const commentsSheets = worksheetEntries
|
|
382
|
+
.filter((entry) => entry.commentsXml)
|
|
383
|
+
.map((entry) => entry.sheet.sheetId ?? entry.index + 1);
|
|
384
|
+
const threadedCommentsSheets = worksheetEntries
|
|
385
|
+
.filter((entry) => entry.threadedCommentsXml)
|
|
386
|
+
.map((entry) => entry.sheet.sheetId ?? entry.index + 1);
|
|
387
|
+
const contentTypesXml = buildContentTypesXml({
|
|
388
|
+
sheetCount: worksheetEntries.length,
|
|
389
|
+
tableCount: preparedTables.length,
|
|
390
|
+
drawingSheets,
|
|
391
|
+
mediaExtensions: Array.from(imageExtensions),
|
|
392
|
+
chartCount: chartEntries.length,
|
|
393
|
+
slicerCount: slicerEntries.length,
|
|
394
|
+
slicerCacheCount: slicerCacheEntries.length,
|
|
395
|
+
pivotTableCount: pivotTableEntries.length,
|
|
396
|
+
pivotCacheCount: pivotCacheEntries.length,
|
|
397
|
+
commentsSheets,
|
|
398
|
+
threadedCommentsSheets,
|
|
399
|
+
hasPersons: !personRegistry.isEmpty(),
|
|
400
|
+
externalLinkCount: externalLinks.length,
|
|
401
|
+
});
|
|
402
|
+
files["[Content_Types].xml"] = encodeXml(contentTypesXml);
|
|
403
|
+
// Preserved Power Query machinery (DataMashup customXml, connections.xml, queryTable parts) is
|
|
404
|
+
// re-emitted verbatim as a post-pass over the assembled files, re-attaching each queryTable to
|
|
405
|
+
// its regenerated table part when the table still matches. No-op without an envelope.
|
|
406
|
+
applyPowerQueryParts(files, powerQuery);
|
|
407
|
+
const zipBytes = zipSync(files);
|
|
408
|
+
if (password?.trim()) {
|
|
409
|
+
return CFBEncryptor.encrypt(zipBytes, password.trim());
|
|
410
|
+
}
|
|
411
|
+
return zipBytes;
|
|
412
|
+
};
|
|
413
|
+
const XML_DECLARATION = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`;
|
|
414
|
+
const WORKSHEET_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
|
|
415
|
+
const REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
416
|
+
const PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships";
|
|
417
|
+
const DEFAULT_PAGE_MARGINS = '<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>';
|
|
418
|
+
const TABLE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/table";
|
|
419
|
+
const DRAWING_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing";
|
|
420
|
+
const IMAGE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
|
|
421
|
+
const CHART_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart";
|
|
422
|
+
const SLICER_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/slicer";
|
|
423
|
+
const SLICER_CACHE_REL_TYPE = "http://schemas.microsoft.com/office/2007/relationships/slicerCache";
|
|
424
|
+
// Phase D2 — XLSX pivot relationship types (ECMA-376).
|
|
425
|
+
const PIVOT_TABLE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable";
|
|
426
|
+
const PIVOT_CACHE_DEFINITION_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition";
|
|
427
|
+
const HYPERLINK_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink";
|
|
428
|
+
const COMMENTS_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
|
|
429
|
+
const EMUS_PER_PIXEL = 9525;
|
|
430
|
+
const CREATOR = "Rows n Columns";
|
|
431
|
+
const textEncoder = new TextEncoder();
|
|
432
|
+
class RelationshipsBuilder {
|
|
433
|
+
nextId = 1;
|
|
434
|
+
entries = [];
|
|
435
|
+
add(type, target, mode) {
|
|
436
|
+
const id = `rId${this.nextId++}`;
|
|
437
|
+
this.entries.push({ id, type, target, mode });
|
|
438
|
+
return id;
|
|
439
|
+
}
|
|
440
|
+
isEmpty() {
|
|
441
|
+
return this.entries.length === 0;
|
|
442
|
+
}
|
|
443
|
+
toXml() {
|
|
444
|
+
if (!this.entries.length) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
const relationships = this.entries
|
|
448
|
+
.map((entry) => `<Relationship Id="${entry.id}" Type="${entry.type}" Target="${escapeXmlAttr(entry.target)}"${entry.mode ? ` TargetMode="${entry.mode}"` : ""}/>`)
|
|
449
|
+
.join("");
|
|
450
|
+
return `${XML_DECLARATION}<Relationships xmlns="${PACKAGE_REL_NS}">${relationships}</Relationships>`;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
class DrawingContext {
|
|
454
|
+
relationships;
|
|
455
|
+
target;
|
|
456
|
+
constructor(relationships, target, hasExisting) {
|
|
457
|
+
this.relationships = relationships;
|
|
458
|
+
this.target = target;
|
|
459
|
+
if (hasExisting) {
|
|
460
|
+
this.relId = this.relationships.add(DRAWING_REL_TYPE, this.target);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
relId;
|
|
464
|
+
ensureRel() {
|
|
465
|
+
if (!this.relId) {
|
|
466
|
+
this.relId = this.relationships.add(DRAWING_REL_TYPE, this.target);
|
|
467
|
+
}
|
|
468
|
+
return this.relId;
|
|
469
|
+
}
|
|
470
|
+
hasDrawing() {
|
|
471
|
+
return !!this.relId;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const SQREF_A1_RANGE = /^\$?([A-Z]+)\$?(\d+)(?::\$?([A-Z]+)\$?(\d+))?$/;
|
|
475
|
+
const sqrefColNumber = (letters) => letters.split("").reduce((acc, ch) => acc * 26 + (ch.charCodeAt(0) - 64), 0);
|
|
476
|
+
/**
|
|
477
|
+
* Normalize a dataValidation sqref list: collapse degenerate ranges
|
|
478
|
+
* (`C7:C7` → `C7`), drop exact duplicates, and drop members fully contained
|
|
479
|
+
* in another member. The collector receives both range-form refs and
|
|
480
|
+
* per-cell addresses for the same rule, which otherwise emits noisy
|
|
481
|
+
* `sqref="C7:C7 C8:C8 C7 C8"` lists.
|
|
482
|
+
*/
|
|
483
|
+
export const normalizeSqrefMembers = (refs) => {
|
|
484
|
+
const parsed = refs.map((raw) => {
|
|
485
|
+
const m = SQREF_A1_RANGE.exec(raw);
|
|
486
|
+
if (!m)
|
|
487
|
+
return { ref: raw };
|
|
488
|
+
const c1 = sqrefColNumber(m[1]);
|
|
489
|
+
const r1 = Number(m[2]);
|
|
490
|
+
const c2 = m[3] ? sqrefColNumber(m[3]) : c1;
|
|
491
|
+
const r2 = m[4] ? Number(m[4]) : r1;
|
|
492
|
+
const rect = {
|
|
493
|
+
c1: Math.min(c1, c2),
|
|
494
|
+
r1: Math.min(r1, r2),
|
|
495
|
+
c2: Math.max(c1, c2),
|
|
496
|
+
r2: Math.max(r1, r2),
|
|
497
|
+
};
|
|
498
|
+
const ref = rect.c1 === rect.c2 && rect.r1 === rect.r2
|
|
499
|
+
? `${m[1]}${m[2]}`
|
|
500
|
+
: raw;
|
|
501
|
+
return { ref, rect };
|
|
502
|
+
});
|
|
503
|
+
const contains = (a, b) => a.c1 <= b.c1 && a.r1 <= b.r1 && a.c2 >= b.c2 && a.r2 >= b.r2;
|
|
504
|
+
const out = [];
|
|
505
|
+
const seen = new Set();
|
|
506
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
507
|
+
const { ref, rect } = parsed[i];
|
|
508
|
+
if (seen.has(ref))
|
|
509
|
+
continue;
|
|
510
|
+
if (rect) {
|
|
511
|
+
const covered = parsed.some((other, j) => j !== i &&
|
|
512
|
+
other.rect &&
|
|
513
|
+
contains(other.rect, rect) &&
|
|
514
|
+
// ties (identical rects) keep the earliest occurrence only
|
|
515
|
+
!(contains(rect, other.rect) && j > i));
|
|
516
|
+
if (covered)
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
seen.add(ref);
|
|
520
|
+
out.push(ref);
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
};
|
|
524
|
+
class DataValidationCollector {
|
|
525
|
+
entries = new Map();
|
|
526
|
+
add(address, rule) {
|
|
527
|
+
const config = convertDataValidationRule(rule);
|
|
528
|
+
if (!config)
|
|
529
|
+
return;
|
|
530
|
+
const key = getDataValidationKey(config);
|
|
531
|
+
const existing = this.entries.get(key);
|
|
532
|
+
if (existing) {
|
|
533
|
+
existing.refs.push(address);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
this.entries.set(key, {
|
|
537
|
+
config,
|
|
538
|
+
refs: [address],
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
toXml() {
|
|
542
|
+
if (!this.entries.size) {
|
|
543
|
+
return "";
|
|
544
|
+
}
|
|
545
|
+
const items = Array.from(this.entries.values()).map((entry) => buildDataValidationXml(entry.config, entry.refs));
|
|
546
|
+
return `<dataValidations count="${this.entries.size}">${items.join("")}</dataValidations>`;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
class CommentsCollector {
|
|
550
|
+
entries = [];
|
|
551
|
+
add(address, text, author = "System") {
|
|
552
|
+
this.entries.push({ address, text, author });
|
|
553
|
+
}
|
|
554
|
+
isEmpty() {
|
|
555
|
+
return this.entries.length === 0;
|
|
556
|
+
}
|
|
557
|
+
toXml() {
|
|
558
|
+
if (!this.entries.length) {
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
const authors = Array.from(new Set(this.entries.map((e) => e.author)));
|
|
562
|
+
const authorsXml = authors
|
|
563
|
+
.map((a) => `<author>${escapeXmlText(a)}</author>`)
|
|
564
|
+
.join("");
|
|
565
|
+
const commentsXml = this.entries
|
|
566
|
+
.map((entry) => {
|
|
567
|
+
const authorId = authors.indexOf(entry.author);
|
|
568
|
+
return `<comment ref="${entry.address}" authorId="${authorId}"><text><r><rPr><b/><sz val="9"/><color indexed="81"/><rFont val="Tahoma"/><charset val="1"/></rPr><t>${escapeXmlText(entry.text)}</t></r></text></comment>`;
|
|
569
|
+
})
|
|
570
|
+
.join("");
|
|
571
|
+
return `${XML_DECLARATION}<comments xmlns="${WORKSHEET_NS}"><authors>${authorsXml}</authors><commentList>${commentsXml}</commentList></comments>`;
|
|
572
|
+
}
|
|
573
|
+
toVmlXml() {
|
|
574
|
+
if (!this.entries.length) {
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
const shapes = this.entries
|
|
578
|
+
.map((entry, index) => {
|
|
579
|
+
const shapeId = `_x0000_s${1026 + index}`;
|
|
580
|
+
// Parse cell address to get row and column
|
|
581
|
+
const match = entry.address.match(/^([A-Z]+)(\d+)$/);
|
|
582
|
+
if (!match)
|
|
583
|
+
return "";
|
|
584
|
+
const col = match[1];
|
|
585
|
+
const row = parseInt(match[2]);
|
|
586
|
+
const colIndex = col
|
|
587
|
+
.split("")
|
|
588
|
+
.reduce((acc, char) => acc * 26 + (char.charCodeAt(0) - 64), 0) - 1;
|
|
589
|
+
return `<v:shape id="${shapeId}" type="#_x0000_t202" style='position:absolute;margin-left:59.25pt;margin-top:1.5pt;width:108pt;height:59.25pt;z-index:${index + 1};visibility:hidden' fillcolor="#ffffe1" o:insetmode="auto"><v:fill color2="#ffffe1"/><v:shadow on="t" color="black" obscured="t"/><v:path o:connecttype="none"/><v:textbox style='mso-direction-alt:auto'><div style='text-align:left'></div></v:textbox><x:ClientData ObjectType="Note"><x:MoveWithCells/><x:SizeWithCells/><x:Anchor>1, 15, 0, 2, 3, 15, 3, 16</x:Anchor><x:AutoFill>False</x:AutoFill><x:Row>${row - 1}</x:Row><x:Column>${colIndex}</x:Column></x:ClientData></v:shape>`;
|
|
590
|
+
})
|
|
591
|
+
.join("");
|
|
592
|
+
return `<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shapetype id="_x0000_t202" coordsize="21600,21600" o:spt="202" path="m,l,21600r21600,l21600,xe"><v:stroke joinstyle="miter"/><v:path gradientshapeok="t" o:connecttype="rect"/></v:shapetype>${shapes}</xml>`;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const INVALID_XML_CHAR_REGEX = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g;
|
|
596
|
+
const sanitizeXmlText = (value) => {
|
|
597
|
+
if (!value)
|
|
598
|
+
return value;
|
|
599
|
+
return value.replace(INVALID_XML_CHAR_REGEX, "");
|
|
600
|
+
};
|
|
601
|
+
class SharedStrings {
|
|
602
|
+
plainIndex = new Map();
|
|
603
|
+
entries = [];
|
|
604
|
+
totalCount = 0;
|
|
605
|
+
/**
|
|
606
|
+
* Compatibility shim: existing callers iterate `sharedStrings.values`
|
|
607
|
+
* (a `string[]`). Plain entries surface their text; rich entries
|
|
608
|
+
* surface the concatenated text so existing consumers still see a
|
|
609
|
+
* sensible plain string.
|
|
610
|
+
*/
|
|
611
|
+
get values() {
|
|
612
|
+
return this.entries.map((e) => e.text);
|
|
613
|
+
}
|
|
614
|
+
add(value) {
|
|
615
|
+
const normalized = sanitizeXmlText(value ?? "");
|
|
616
|
+
const existing = this.plainIndex.get(normalized);
|
|
617
|
+
this.totalCount += 1;
|
|
618
|
+
if (typeof existing === "number") {
|
|
619
|
+
return existing;
|
|
620
|
+
}
|
|
621
|
+
const nextIndex = this.entries.length;
|
|
622
|
+
this.plainIndex.set(normalized, nextIndex);
|
|
623
|
+
this.entries.push({ kind: "plain", text: normalized });
|
|
624
|
+
return nextIndex;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Reserve a fresh sharedStrings slot carrying per-segment runs. Skips
|
|
628
|
+
* dedup so two cells with the same plain text but different runs get
|
|
629
|
+
* separate slots.
|
|
630
|
+
*/
|
|
631
|
+
addRich(value, runs) {
|
|
632
|
+
const normalized = sanitizeXmlText(value ?? "");
|
|
633
|
+
this.totalCount += 1;
|
|
634
|
+
const nextIndex = this.entries.length;
|
|
635
|
+
this.entries.push({ kind: "rich", text: normalized, runs });
|
|
636
|
+
return nextIndex;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const encodeXml = (content) => textEncoder.encode(content);
|
|
640
|
+
const getRowsForSheet = (sheetData, sheetId) => {
|
|
641
|
+
return (sheetData[sheetId] ??
|
|
642
|
+
sheetData[String(sheetId)] ??
|
|
643
|
+
sheetData[Number(sheetId)] ??
|
|
644
|
+
[]);
|
|
645
|
+
};
|
|
646
|
+
const buildWorksheetXml = ({ sheet, rows, sharedStrings, sourceSharedStrings, cellXfs, styleBuilder, tables, relationships, drawingContext, conditionalFormats, dataValidationsById, dataValidations, ignoreFormulaPatterns, citations, comments, personRegistry, slicerRelIds, sheetData, sheets, locale, }) => {
|
|
647
|
+
const tableNameMap = buildTableNameMap(tables);
|
|
648
|
+
const bounds = {
|
|
649
|
+
minRow: Number.POSITIVE_INFINITY,
|
|
650
|
+
maxRow: 0,
|
|
651
|
+
minColumn: Number.POSITIVE_INFINITY,
|
|
652
|
+
maxColumn: 0,
|
|
653
|
+
};
|
|
654
|
+
const rowMetadata = sheet.rowMetadata ?? [];
|
|
655
|
+
const maxRows = Math.max(rows.length, rowMetadata.length);
|
|
656
|
+
const defaultRowHeightPx = getDefaultRowHeightPx(sheet);
|
|
657
|
+
const rowXmlChunks = [];
|
|
658
|
+
const collectedImages = [];
|
|
659
|
+
const dataValidationCollector = new DataValidationCollector();
|
|
660
|
+
const hyperlinkEntries = [];
|
|
661
|
+
const commentsCollector = new CommentsCollector();
|
|
662
|
+
const threadedCommentsCollector = new ThreadedCommentsCollector(comments, personRegistry ?? new PersonRegistry());
|
|
663
|
+
// Process citations for this sheet
|
|
664
|
+
if (citations) {
|
|
665
|
+
for (const citation of citations) {
|
|
666
|
+
if (citation.range.sheetId !== sheet.sheetId)
|
|
667
|
+
continue;
|
|
668
|
+
// Citation ranges are worksheet-based (1-based).
|
|
669
|
+
for (let rowIndex = citation.range.startRowIndex; rowIndex <= citation.range.endRowIndex; rowIndex++) {
|
|
670
|
+
for (let colIndex = citation.range.startColumnIndex; colIndex <= citation.range.endColumnIndex; colIndex++) {
|
|
671
|
+
const address = cellToAddress({ rowIndex, columnIndex: colIndex });
|
|
672
|
+
if (address) {
|
|
673
|
+
commentsCollector.add(address, `${citation.citation_string}`, citation.created_by || "System");
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (dataValidations) {
|
|
680
|
+
for (const rule of dataValidations) {
|
|
681
|
+
if (!rule.ranges)
|
|
682
|
+
continue;
|
|
683
|
+
for (const range of rule.ranges) {
|
|
684
|
+
if (range.sheetId !== sheet.sheetId)
|
|
685
|
+
continue;
|
|
686
|
+
// Ranges from the DV import are already 1-indexed (addressToSelection
|
|
687
|
+
// produces 1-indexed coords). cellRangeToAddress also expects
|
|
688
|
+
// 1-indexed input, so pass through verbatim. The previous `+1` on
|
|
689
|
+
// every coordinate produced sqrefs shifted by one row + one column.
|
|
690
|
+
const address = cellRangeToAddress(undefined, range);
|
|
691
|
+
if (address) {
|
|
692
|
+
dataValidationCollector.add(address, rule);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Pre-scan for valid shared-formula leaders in this sheet. The cell-emit
|
|
698
|
+
// path uses this set to decide whether a follower (`sf.si` without `ref`)
|
|
699
|
+
// can safely emit as `<f t="shared" si="N"/>` or must fall back to its
|
|
700
|
+
// expanded standalone formula. A follower's leader can be missing if:
|
|
701
|
+
// (a) the user edited the leader (its `sf` was dropped by
|
|
702
|
+
// `setFormulaValue`'s invalidation), or
|
|
703
|
+
// (b) the source had a malformed reference and the leader never imported.
|
|
704
|
+
// Emitting an orphaned follower would produce a broken xlsx — Excel would
|
|
705
|
+
// see `<f t="shared" si="N"/>` with no corresponding leader and read the
|
|
706
|
+
// cell as having no formula at all.
|
|
707
|
+
const validSharedFormulaLeaders = new Set();
|
|
708
|
+
for (let rowIndex = 1; rowIndex < maxRows; rowIndex++) {
|
|
709
|
+
const row = rows[rowIndex];
|
|
710
|
+
const rowValues = row?.values;
|
|
711
|
+
if (!Array.isArray(rowValues))
|
|
712
|
+
continue;
|
|
713
|
+
for (const cell of rowValues) {
|
|
714
|
+
const sf = cell?.sf;
|
|
715
|
+
if (sf && sf.ref && Number.isFinite(sf.si)) {
|
|
716
|
+
validSharedFormulaLeaders.add(sf.si);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
for (let rowIndex = 1; rowIndex < maxRows; rowIndex++) {
|
|
721
|
+
const row = rows[rowIndex];
|
|
722
|
+
const metadata = rowMetadata[rowIndex];
|
|
723
|
+
const result = buildWorksheetRow({
|
|
724
|
+
rowNumber: rowIndex,
|
|
725
|
+
row,
|
|
726
|
+
rowMetadata: metadata,
|
|
727
|
+
defaultRowHeightPx,
|
|
728
|
+
sharedStrings,
|
|
729
|
+
sourceSharedStrings,
|
|
730
|
+
cellXfs,
|
|
731
|
+
styleBuilder,
|
|
732
|
+
dataValidationsById,
|
|
733
|
+
tableNameMap,
|
|
734
|
+
tables,
|
|
735
|
+
ignoreFormulaPatterns,
|
|
736
|
+
commentsCollector,
|
|
737
|
+
sheetId: sheet.sheetId,
|
|
738
|
+
sheetData,
|
|
739
|
+
sheets,
|
|
740
|
+
locale,
|
|
741
|
+
validSharedFormulaLeaders,
|
|
742
|
+
onImage: (image) => {
|
|
743
|
+
collectedImages.push(image);
|
|
744
|
+
drawingContext.ensureRel();
|
|
745
|
+
},
|
|
746
|
+
onHyperlink: (entry) => {
|
|
747
|
+
hyperlinkEntries.push(entry);
|
|
748
|
+
},
|
|
749
|
+
onDataValidation: (entry) => {
|
|
750
|
+
dataValidationCollector.add(entry.address, entry.rule);
|
|
751
|
+
},
|
|
752
|
+
onCommentThread: (address, threadId) => {
|
|
753
|
+
threadedCommentsCollector.addCell(address, threadId);
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
if (!result)
|
|
757
|
+
continue;
|
|
758
|
+
rowXmlChunks.push(result.xml);
|
|
759
|
+
if (result.minColumn) {
|
|
760
|
+
bounds.minColumn = Math.min(bounds.minColumn, result.minColumn);
|
|
761
|
+
}
|
|
762
|
+
if (result.maxColumn) {
|
|
763
|
+
bounds.maxColumn = Math.max(bounds.maxColumn, result.maxColumn);
|
|
764
|
+
}
|
|
765
|
+
bounds.minRow = Math.min(bounds.minRow, rowIndex);
|
|
766
|
+
bounds.maxRow = Math.max(bounds.maxRow, rowIndex);
|
|
767
|
+
}
|
|
768
|
+
const merges = normalizeMergeRanges(sheet.merges ?? []).map((range) => {
|
|
769
|
+
bounds.minRow = Math.min(bounds.minRow, range.startRowIndex);
|
|
770
|
+
bounds.maxRow = Math.max(bounds.maxRow, range.endRowIndex);
|
|
771
|
+
bounds.minColumn = Math.min(bounds.minColumn, range.startColumnIndex);
|
|
772
|
+
bounds.maxColumn = Math.max(bounds.maxColumn, range.endColumnIndex);
|
|
773
|
+
return cellRangeToAddress(undefined, range);
|
|
774
|
+
});
|
|
775
|
+
const dimension = buildDimension(bounds);
|
|
776
|
+
const sheetViews = buildSheetViewsXml(sheet);
|
|
777
|
+
const sheetPrXml = buildSheetPropertiesXml(sheet);
|
|
778
|
+
const columnXml = buildColumnsXml(sheet, bounds);
|
|
779
|
+
const mergeXml = merges.length
|
|
780
|
+
? `<mergeCells count="${merges.length}">${merges
|
|
781
|
+
.map((ref) => `<mergeCell ref="${ref}"/>`)
|
|
782
|
+
.join("")}</mergeCells>`
|
|
783
|
+
: "";
|
|
784
|
+
const basicFilterXml = buildWorksheetAutoFilterXml(sheet, rows, styleBuilder);
|
|
785
|
+
const dataValidationsXml = dataValidationCollector.toXml();
|
|
786
|
+
const hyperlinksXml = buildHyperlinksXml(hyperlinkEntries, relationships);
|
|
787
|
+
const conditionalFormattingXml = buildConditionalFormattingXml({
|
|
788
|
+
sheetId: sheet.sheetId,
|
|
789
|
+
conditionalFormats,
|
|
790
|
+
styleBuilder,
|
|
791
|
+
});
|
|
792
|
+
const tablePartsXml = buildTablePartsXml(tables, relationships);
|
|
793
|
+
const drawingXml = drawingContext.hasDrawing()
|
|
794
|
+
? `<drawing r:id="${drawingContext.relId}"/>`
|
|
795
|
+
: "";
|
|
796
|
+
// Threaded comments (Excel's "Comments"). Build the modern part, then push
|
|
797
|
+
// each thread's down-level mirror into the legacy notes collector so the
|
|
798
|
+
// comments part + VML + relationships below cover both. Order matters: feed
|
|
799
|
+
// the mirror BEFORE serializing commentsCollector.
|
|
800
|
+
const threadedCommentsXml = threadedCommentsCollector.toXml();
|
|
801
|
+
if (threadedCommentsXml) {
|
|
802
|
+
for (const entry of threadedCommentsCollector.legacyEntries()) {
|
|
803
|
+
commentsCollector.add(entry.address, entry.text, entry.author);
|
|
804
|
+
}
|
|
805
|
+
relationships.add(THREADED_COMMENT_REL_TYPE, `../threadedComments/threadedComment${sheet.sheetId}.xml`);
|
|
806
|
+
}
|
|
807
|
+
// Generate comments XML (notes) and add relationships if there are comments
|
|
808
|
+
const commentsXml = commentsCollector.toXml();
|
|
809
|
+
let legacyDrawingXml = "";
|
|
810
|
+
if (commentsXml) {
|
|
811
|
+
relationships.add(COMMENTS_REL_TYPE, `../comments${sheet.sheetId}.xml`);
|
|
812
|
+
const legacyDrawingRelId = relationships.add("http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing", `../drawings/vmlDrawing${sheet.sheetId}.vml`);
|
|
813
|
+
legacyDrawingXml = `<legacyDrawing r:id="${legacyDrawingRelId}"/>`;
|
|
814
|
+
}
|
|
815
|
+
const sheetDataXml = `<sheetData>${rowXmlChunks.join("")}</sheetData>`;
|
|
816
|
+
const pageMargins = DEFAULT_PAGE_MARGINS;
|
|
817
|
+
// Build row data map for sparkline detection
|
|
818
|
+
const rowDataMap = {};
|
|
819
|
+
for (let rowIndex = 1; rowIndex < maxRows; rowIndex++) {
|
|
820
|
+
const row = rows[rowIndex];
|
|
821
|
+
if (row?.values) {
|
|
822
|
+
const rowValues = {};
|
|
823
|
+
for (const [colIndex, cell] of Object.entries(row.values)) {
|
|
824
|
+
if (cell) {
|
|
825
|
+
rowValues[parseInt(colIndex, 10)] = cell;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (Object.keys(rowValues).length > 0) {
|
|
829
|
+
rowDataMap[rowIndex] = rowValues;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// Build sparkline XML from SPARKLINE formulas
|
|
834
|
+
const sparklineXml = buildSparklineGroupsXml(sheet, rowDataMap);
|
|
835
|
+
// Build combined extLst with sparklines and slicers
|
|
836
|
+
const extLstXml = buildWorksheetExtLstXml(sparklineXml, slicerRelIds ?? []);
|
|
837
|
+
const defaultRowHeightPt = pixelToPoints(getDefaultRowHeightPx(sheet));
|
|
838
|
+
// Only emit ``defaultColWidth`` when the sheet has an explicit
|
|
839
|
+
// workbook-level override. Emitting the engine's internal default for
|
|
840
|
+
// every export forces Excel to use that value as the workbook default,
|
|
841
|
+
// overriding the user's "Default Width" preference and producing
|
|
842
|
+
// visibly narrower / wider columns than untouched openpyxl output.
|
|
843
|
+
const explicitDefaultColumnWidthPx = sheet
|
|
844
|
+
?.defaultColumnWidth;
|
|
845
|
+
const sheetFormatPrAttrs = [`defaultRowHeight="${defaultRowHeightPt}"`];
|
|
846
|
+
if (typeof explicitDefaultColumnWidthPx === "number" &&
|
|
847
|
+
explicitDefaultColumnWidthPx > 0) {
|
|
848
|
+
sheetFormatPrAttrs.push(`defaultColWidth="${columnPixelToChars(explicitDefaultColumnWidthPx).toFixed(2)}"`);
|
|
849
|
+
}
|
|
850
|
+
const worksheetXml = `${XML_DECLARATION}<worksheet xmlns="${WORKSHEET_NS}" xmlns:r="${REL_NS}">${sheetPrXml}<dimension ref="${dimension}"/>${sheetViews}<sheetFormatPr ${sheetFormatPrAttrs.join(" ")}/>${columnXml}${sheetDataXml}${basicFilterXml}${mergeXml}${conditionalFormattingXml}${dataValidationsXml}${hyperlinksXml}${pageMargins}${drawingXml}${legacyDrawingXml}${tablePartsXml}${extLstXml}</worksheet>`;
|
|
851
|
+
return {
|
|
852
|
+
xml: worksheetXml,
|
|
853
|
+
relationshipsXml: relationships.toXml(),
|
|
854
|
+
images: collectedImages,
|
|
855
|
+
commentsXml,
|
|
856
|
+
vmlDrawingXml: commentsCollector.toVmlXml(),
|
|
857
|
+
threadedCommentsXml,
|
|
858
|
+
};
|
|
859
|
+
};
|
|
860
|
+
/**
|
|
861
|
+
* Build combined extLst XML for worksheet (sparklines + slicers)
|
|
862
|
+
*/
|
|
863
|
+
const buildWorksheetExtLstXml = (sparklineExtXml, slicerRelIds) => {
|
|
864
|
+
const parts = [];
|
|
865
|
+
// Add sparkline ext if present
|
|
866
|
+
if (sparklineExtXml) {
|
|
867
|
+
parts.push(sparklineExtXml);
|
|
868
|
+
}
|
|
869
|
+
// Add slicer ext if present
|
|
870
|
+
if (slicerRelIds.length > 0) {
|
|
871
|
+
const slicersXml = slicerRelIds
|
|
872
|
+
.map((relId) => `<x14:slicer r:id="${relId}"/>`)
|
|
873
|
+
.join("");
|
|
874
|
+
parts.push(`<ext uri="{3A4CF648-6AED-40f4-86FF-DC5316D8AED3}" xmlns:x15="http://schemas.microsoft.com/office/spreadsheetml/2010/11/main"><x14:slicerList xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main">${slicersXml}</x14:slicerList></ext>`);
|
|
875
|
+
}
|
|
876
|
+
if (parts.length === 0) {
|
|
877
|
+
return "";
|
|
878
|
+
}
|
|
879
|
+
return `<extLst>${parts.join("")}</extLst>`;
|
|
880
|
+
};
|
|
881
|
+
const sanitizeMergeRange = (range) => {
|
|
882
|
+
const startRow = sanitizeIndex(range?.startRowIndex);
|
|
883
|
+
const endRow = sanitizeIndex(range?.endRowIndex);
|
|
884
|
+
const startColumn = sanitizeIndex(range?.startColumnIndex);
|
|
885
|
+
const endColumn = sanitizeIndex(range?.endColumnIndex);
|
|
886
|
+
if (startRow == null ||
|
|
887
|
+
endRow == null ||
|
|
888
|
+
startColumn == null ||
|
|
889
|
+
endColumn == null) {
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
if (startRow > endRow || startColumn > endColumn) {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
if (startRow === endRow && startColumn === endColumn) {
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
startRowIndex: startRow,
|
|
900
|
+
endRowIndex: endRow,
|
|
901
|
+
startColumnIndex: startColumn,
|
|
902
|
+
endColumnIndex: endColumn,
|
|
903
|
+
};
|
|
904
|
+
};
|
|
905
|
+
const sanitizeIndex = (value) => {
|
|
906
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
907
|
+
return null;
|
|
908
|
+
return Math.max(1, Math.floor(value));
|
|
909
|
+
};
|
|
910
|
+
const normalizeMergeRanges = (ranges) => {
|
|
911
|
+
if (!ranges?.length)
|
|
912
|
+
return [];
|
|
913
|
+
const sanitized = ranges
|
|
914
|
+
.map((range) => sanitizeMergeRange(range))
|
|
915
|
+
.filter((range) => Boolean(range));
|
|
916
|
+
if (!sanitized.length)
|
|
917
|
+
return [];
|
|
918
|
+
const sorted = sanitized.sort((a, b) => {
|
|
919
|
+
if (a.startRowIndex !== b.startRowIndex) {
|
|
920
|
+
return a.startRowIndex - b.startRowIndex;
|
|
921
|
+
}
|
|
922
|
+
if (a.startColumnIndex !== b.startColumnIndex) {
|
|
923
|
+
return a.startColumnIndex - b.startColumnIndex;
|
|
924
|
+
}
|
|
925
|
+
if (a.endRowIndex !== b.endRowIndex) {
|
|
926
|
+
return b.endRowIndex - a.endRowIndex;
|
|
927
|
+
}
|
|
928
|
+
return b.endColumnIndex - a.endColumnIndex;
|
|
929
|
+
});
|
|
930
|
+
const valid = [];
|
|
931
|
+
let active = [];
|
|
932
|
+
for (const merge of sorted) {
|
|
933
|
+
if (active.length) {
|
|
934
|
+
active = active.filter((entry) => entry.endRowIndex >= merge.startRowIndex);
|
|
935
|
+
}
|
|
936
|
+
const isContained = active.some((entry) => entry.startColumnIndex <= merge.startColumnIndex &&
|
|
937
|
+
entry.endColumnIndex >= merge.endColumnIndex &&
|
|
938
|
+
entry.endRowIndex >= merge.endRowIndex);
|
|
939
|
+
if (!isContained) {
|
|
940
|
+
valid.push(merge);
|
|
941
|
+
active.push(merge);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return valid;
|
|
945
|
+
};
|
|
946
|
+
const convertDataValidationRule = (rule) => {
|
|
947
|
+
if (!rule)
|
|
948
|
+
return undefined;
|
|
949
|
+
const allowBlank = rule.allowBlank !== false;
|
|
950
|
+
const alert = rule.alert;
|
|
951
|
+
const inputMessage = rule.inputMessage;
|
|
952
|
+
const baseConfig = {
|
|
953
|
+
allowBlank,
|
|
954
|
+
showInputMessage: Boolean(inputMessage?.message || inputMessage?.title),
|
|
955
|
+
showErrorMessage: Boolean(alert),
|
|
956
|
+
errorStyle: alert?.style,
|
|
957
|
+
errorTitle: alert?.message?.title ?? undefined,
|
|
958
|
+
error: alert?.message?.message ?? undefined,
|
|
959
|
+
promptTitle: inputMessage?.title ?? undefined,
|
|
960
|
+
prompt: inputMessage?.message ?? undefined,
|
|
961
|
+
};
|
|
962
|
+
// Round-trip path: when the rule was imported from xlsx, replay the
|
|
963
|
+
// verbatim source representation. Preserves type/operator/formulas
|
|
964
|
+
// exactly, including `whole`/`textLength`/`time` types and user-written
|
|
965
|
+
// custom formulas without `INDIRECT("RC",FALSE)` rewriting.
|
|
966
|
+
const src = rule.xlsxSource;
|
|
967
|
+
if (src && src.type && typeof src.formula1 === "string" && src.formula1) {
|
|
968
|
+
return {
|
|
969
|
+
type: src.type,
|
|
970
|
+
operator: src.operator,
|
|
971
|
+
formula1: src.formula1,
|
|
972
|
+
formula2: src.formula2,
|
|
973
|
+
showDropDown: src.type === "list" && rule.displayStyle === "plain" ? true : undefined,
|
|
974
|
+
...baseConfig,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
if (!rule.condition?.type) {
|
|
978
|
+
// Prompt-only DV (no validation rule, just an input-message tooltip
|
|
979
|
+
// and/or an error alert). Emit it with no type/formula so Excel still
|
|
980
|
+
// shows the tooltip when the cell is selected.
|
|
981
|
+
if (baseConfig.showInputMessage || baseConfig.showErrorMessage) {
|
|
982
|
+
return baseConfig;
|
|
983
|
+
}
|
|
984
|
+
return undefined;
|
|
985
|
+
}
|
|
986
|
+
const { condition } = rule;
|
|
987
|
+
const values = condition.values ?? [];
|
|
988
|
+
const currentCell = 'INDIRECT("RC",FALSE)';
|
|
989
|
+
const escapeFormulaText = (value) => value.replace(/"/g, '""');
|
|
990
|
+
const ensureFormula = (value) => value.startsWith("=") ? value : `=${value}`;
|
|
991
|
+
const ifErrorWrap = (formula) => `IFERROR(${formula},TRUE)`;
|
|
992
|
+
if (condition.type === "ONE_OF_LIST") {
|
|
993
|
+
const entries = values
|
|
994
|
+
.map((value) => value.userEnteredValue)
|
|
995
|
+
.filter((value) => typeof value === "string");
|
|
996
|
+
if (!entries.length)
|
|
997
|
+
return undefined;
|
|
998
|
+
const sanitized = entries.map((entry) => entry.replace(/"/g, '""'));
|
|
999
|
+
return {
|
|
1000
|
+
type: "list",
|
|
1001
|
+
formula1: `"${sanitized.join(",")}"`,
|
|
1002
|
+
showDropDown: rule.displayStyle === "plain" ? true : undefined,
|
|
1003
|
+
...baseConfig,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
if (condition.type === "ONE_OF_RANGE") {
|
|
1007
|
+
const references = values
|
|
1008
|
+
.map((value) => value.userEnteredValue)
|
|
1009
|
+
.filter((value) => typeof value === "string");
|
|
1010
|
+
if (!references.length)
|
|
1011
|
+
return undefined;
|
|
1012
|
+
const formula1 = references.join(",");
|
|
1013
|
+
return {
|
|
1014
|
+
type: "list",
|
|
1015
|
+
formula1: formula1.startsWith("=") ? formula1 : `=${formula1}`,
|
|
1016
|
+
showDropDown: rule.displayStyle === "plain" ? true : undefined,
|
|
1017
|
+
...baseConfig,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
if (condition.type === "CUSTOM_FORMULA") {
|
|
1021
|
+
const raw = values[0]?.userEnteredValue ?? "";
|
|
1022
|
+
if (!raw)
|
|
1023
|
+
return undefined;
|
|
1024
|
+
return {
|
|
1025
|
+
type: "custom",
|
|
1026
|
+
formula1: ensureFormula(raw),
|
|
1027
|
+
...baseConfig,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
if (condition.type === "BOOLEAN") {
|
|
1031
|
+
return {
|
|
1032
|
+
type: "custom",
|
|
1033
|
+
formula1: `=OR(${currentCell}=TRUE,${currentCell}=FALSE)`,
|
|
1034
|
+
...baseConfig,
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
if (condition.type === "BLANK") {
|
|
1038
|
+
return {
|
|
1039
|
+
type: "custom",
|
|
1040
|
+
formula1: `=${currentCell}=""`,
|
|
1041
|
+
...baseConfig,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
if (condition.type === "NOT_BLANK") {
|
|
1045
|
+
return {
|
|
1046
|
+
type: "custom",
|
|
1047
|
+
formula1: `=${currentCell}<>""`,
|
|
1048
|
+
...baseConfig,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
if (condition.type === "TEXT_EQ" || condition.type === "TEXT_NOT_EQ") {
|
|
1052
|
+
const raw = values[0]?.userEnteredValue ?? "";
|
|
1053
|
+
if (!raw)
|
|
1054
|
+
return undefined;
|
|
1055
|
+
const op = condition.type === "TEXT_EQ" ? "=" : "<>";
|
|
1056
|
+
return {
|
|
1057
|
+
type: "custom",
|
|
1058
|
+
formula1: `=${currentCell}${op}"${escapeFormulaText(raw)}"`,
|
|
1059
|
+
...baseConfig,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
if (condition.type === "TEXT_CONTAINS") {
|
|
1063
|
+
const raw = values[0]?.userEnteredValue ?? "";
|
|
1064
|
+
if (!raw)
|
|
1065
|
+
return undefined;
|
|
1066
|
+
return {
|
|
1067
|
+
type: "custom",
|
|
1068
|
+
formula1: `=ISNUMBER(SEARCH("${escapeFormulaText(raw)}",${currentCell}))`,
|
|
1069
|
+
...baseConfig,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
if (condition.type === "TEXT_NOT_CONTAINS") {
|
|
1073
|
+
const raw = values[0]?.userEnteredValue ?? "";
|
|
1074
|
+
if (!raw)
|
|
1075
|
+
return undefined;
|
|
1076
|
+
return {
|
|
1077
|
+
type: "custom",
|
|
1078
|
+
formula1: `=ISERROR(SEARCH("${escapeFormulaText(raw)}",${currentCell}))`,
|
|
1079
|
+
...baseConfig,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
if (condition.type === "TEXT_STARTS_WITH") {
|
|
1083
|
+
const raw = values[0]?.userEnteredValue ?? "";
|
|
1084
|
+
if (!raw)
|
|
1085
|
+
return undefined;
|
|
1086
|
+
const text = escapeFormulaText(raw);
|
|
1087
|
+
return {
|
|
1088
|
+
type: "custom",
|
|
1089
|
+
formula1: `=LEFT(${currentCell},LEN("${text}"))="${text}"`,
|
|
1090
|
+
...baseConfig,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
if (condition.type === "TEXT_ENDS_WITH") {
|
|
1094
|
+
const raw = values[0]?.userEnteredValue ?? "";
|
|
1095
|
+
if (!raw)
|
|
1096
|
+
return undefined;
|
|
1097
|
+
const text = escapeFormulaText(raw);
|
|
1098
|
+
return {
|
|
1099
|
+
type: "custom",
|
|
1100
|
+
formula1: `=RIGHT(${currentCell},LEN("${text}"))="${text}"`,
|
|
1101
|
+
...baseConfig,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
if (condition.type === "TEXT_IS_EMAIL") {
|
|
1105
|
+
return {
|
|
1106
|
+
type: "custom",
|
|
1107
|
+
formula1: `=AND(ISNUMBER(SEARCH("@",${currentCell})),ISNUMBER(SEARCH(".",${currentCell})))`,
|
|
1108
|
+
...baseConfig,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
if (condition.type === "TEXT_IS_URL") {
|
|
1112
|
+
return {
|
|
1113
|
+
type: "custom",
|
|
1114
|
+
formula1: `=OR(LEFT(${currentCell},7)="http://",LEFT(${currentCell},8)="https://")`,
|
|
1115
|
+
...baseConfig,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
if (condition.type === "NUMBER_BETWEEN" ||
|
|
1119
|
+
condition.type === "NUMBER_NOT_BETWEEN" ||
|
|
1120
|
+
condition.type === "NUMBER_EQ" ||
|
|
1121
|
+
condition.type === "NUMBER_NOT_EQ" ||
|
|
1122
|
+
condition.type === "NUMBER_GREATER" ||
|
|
1123
|
+
condition.type === "NUMBER_GREATER_THAN_EQ" ||
|
|
1124
|
+
condition.type === "NUMBER_LESS" ||
|
|
1125
|
+
condition.type === "NUMBER_LESS_THAN_EQ") {
|
|
1126
|
+
const first = values[0]?.userEnteredValue ?? "";
|
|
1127
|
+
if (!first)
|
|
1128
|
+
return undefined;
|
|
1129
|
+
const second = values[1]?.userEnteredValue ?? "";
|
|
1130
|
+
const operatorMap = {
|
|
1131
|
+
NUMBER_BETWEEN: "between",
|
|
1132
|
+
NUMBER_NOT_BETWEEN: "notBetween",
|
|
1133
|
+
NUMBER_EQ: "equal",
|
|
1134
|
+
NUMBER_NOT_EQ: "notEqual",
|
|
1135
|
+
NUMBER_GREATER: "greaterThan",
|
|
1136
|
+
NUMBER_GREATER_THAN_EQ: "greaterThanOrEqual",
|
|
1137
|
+
NUMBER_LESS: "lessThan",
|
|
1138
|
+
NUMBER_LESS_THAN_EQ: "lessThanOrEqual",
|
|
1139
|
+
};
|
|
1140
|
+
return {
|
|
1141
|
+
type: "decimal",
|
|
1142
|
+
operator: operatorMap[condition.type],
|
|
1143
|
+
formula1: first,
|
|
1144
|
+
formula2: condition.type === "NUMBER_BETWEEN" ||
|
|
1145
|
+
condition.type === "NUMBER_NOT_BETWEEN"
|
|
1146
|
+
? second
|
|
1147
|
+
: undefined,
|
|
1148
|
+
...baseConfig,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
if (condition.type === "NUMBER_GREATER_THAN_PREVIOUS" ||
|
|
1152
|
+
condition.type === "NUMBER_GREATER_THAN_EQ_PREVIOUS" ||
|
|
1153
|
+
condition.type === "NUMBER_LESS_THAN_PREVIOUS" ||
|
|
1154
|
+
condition.type === "NUMBER_LESS_THAN_EQ_PREVIOUS") {
|
|
1155
|
+
const opMap = {
|
|
1156
|
+
NUMBER_GREATER_THAN_PREVIOUS: ">",
|
|
1157
|
+
NUMBER_GREATER_THAN_EQ_PREVIOUS: ">=",
|
|
1158
|
+
NUMBER_LESS_THAN_PREVIOUS: "<",
|
|
1159
|
+
NUMBER_LESS_THAN_EQ_PREVIOUS: "<=",
|
|
1160
|
+
};
|
|
1161
|
+
const op = opMap[condition.type];
|
|
1162
|
+
return {
|
|
1163
|
+
type: "custom",
|
|
1164
|
+
formula1: `=${ifErrorWrap(`${currentCell}${op}INDIRECT("R[-1]C",FALSE)`)}`,
|
|
1165
|
+
...baseConfig,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
if (condition.type === "DATE_EQ" ||
|
|
1169
|
+
condition.type === "DATE_NOT_EQ" ||
|
|
1170
|
+
condition.type === "DATE_BEFORE" ||
|
|
1171
|
+
condition.type === "DATE_AFTER" ||
|
|
1172
|
+
condition.type === "DATE_ON_OR_BEFORE" ||
|
|
1173
|
+
condition.type === "DATE_ON_OR_AFTER" ||
|
|
1174
|
+
condition.type === "DATE_BETWEEN" ||
|
|
1175
|
+
condition.type === "DATE_NOT_BETWEEN") {
|
|
1176
|
+
const first = values[0]?.userEnteredValue ?? "";
|
|
1177
|
+
if (!first)
|
|
1178
|
+
return undefined;
|
|
1179
|
+
const second = values[1]?.userEnteredValue ?? "";
|
|
1180
|
+
const operatorMap = {
|
|
1181
|
+
DATE_EQ: "equal",
|
|
1182
|
+
DATE_NOT_EQ: "notEqual",
|
|
1183
|
+
DATE_BEFORE: "lessThan",
|
|
1184
|
+
DATE_AFTER: "greaterThan",
|
|
1185
|
+
DATE_ON_OR_BEFORE: "lessThanOrEqual",
|
|
1186
|
+
DATE_ON_OR_AFTER: "greaterThanOrEqual",
|
|
1187
|
+
DATE_BETWEEN: "between",
|
|
1188
|
+
DATE_NOT_BETWEEN: "notBetween",
|
|
1189
|
+
};
|
|
1190
|
+
return {
|
|
1191
|
+
type: "date",
|
|
1192
|
+
operator: operatorMap[condition.type],
|
|
1193
|
+
formula1: first,
|
|
1194
|
+
formula2: condition.type === "DATE_BETWEEN" ||
|
|
1195
|
+
condition.type === "DATE_NOT_BETWEEN"
|
|
1196
|
+
? second
|
|
1197
|
+
: undefined,
|
|
1198
|
+
...baseConfig,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
if (condition.type === "DATE_IS_VALID") {
|
|
1202
|
+
return {
|
|
1203
|
+
type: "custom",
|
|
1204
|
+
formula1: `=ISNUMBER(${currentCell})`,
|
|
1205
|
+
...baseConfig,
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
return undefined;
|
|
1209
|
+
};
|
|
1210
|
+
const getDataValidationKey = (config) => {
|
|
1211
|
+
return [
|
|
1212
|
+
config.type,
|
|
1213
|
+
config.operator ?? "",
|
|
1214
|
+
config.allowBlank ? "1" : "0",
|
|
1215
|
+
config.formula1,
|
|
1216
|
+
config.formula2 ?? "",
|
|
1217
|
+
config.showInputMessage ? "1" : "0",
|
|
1218
|
+
config.showErrorMessage ? "1" : "0",
|
|
1219
|
+
config.errorStyle ?? "",
|
|
1220
|
+
config.errorTitle ?? "",
|
|
1221
|
+
config.error ?? "",
|
|
1222
|
+
config.promptTitle ?? "",
|
|
1223
|
+
config.prompt ?? "",
|
|
1224
|
+
config.showDropDown ? "1" : "0",
|
|
1225
|
+
].join("|");
|
|
1226
|
+
};
|
|
1227
|
+
const buildDataValidationXml = (config, refs) => {
|
|
1228
|
+
const attrs = [];
|
|
1229
|
+
if (config.type) {
|
|
1230
|
+
attrs.push(`type="${config.type}"`);
|
|
1231
|
+
}
|
|
1232
|
+
if (config.operator) {
|
|
1233
|
+
attrs.push(`operator="${config.operator}"`);
|
|
1234
|
+
}
|
|
1235
|
+
if (config.allowBlank) {
|
|
1236
|
+
attrs.push(`allowBlank="1"`);
|
|
1237
|
+
}
|
|
1238
|
+
if (config.showInputMessage) {
|
|
1239
|
+
attrs.push(`showInputMessage="1"`);
|
|
1240
|
+
}
|
|
1241
|
+
if (config.showErrorMessage) {
|
|
1242
|
+
attrs.push(`showErrorMessage="1"`);
|
|
1243
|
+
}
|
|
1244
|
+
if (config.errorStyle) {
|
|
1245
|
+
attrs.push(`errorStyle="${config.errorStyle}"`);
|
|
1246
|
+
}
|
|
1247
|
+
if (config.errorTitle) {
|
|
1248
|
+
attrs.push(`errorTitle="${escapeXmlAttr(config.errorTitle)}"`);
|
|
1249
|
+
}
|
|
1250
|
+
if (config.error) {
|
|
1251
|
+
attrs.push(`error="${escapeXmlAttr(config.error)}"`);
|
|
1252
|
+
}
|
|
1253
|
+
if (config.promptTitle) {
|
|
1254
|
+
attrs.push(`promptTitle="${escapeXmlAttr(config.promptTitle)}"`);
|
|
1255
|
+
}
|
|
1256
|
+
if (config.prompt) {
|
|
1257
|
+
attrs.push(`prompt="${escapeXmlAttr(config.prompt)}"`);
|
|
1258
|
+
}
|
|
1259
|
+
if (config.showDropDown) {
|
|
1260
|
+
attrs.push(`showDropDown="1"`);
|
|
1261
|
+
}
|
|
1262
|
+
const sqref = normalizeSqrefMembers(refs).join(" ");
|
|
1263
|
+
// Prompt-only DVs have no formula; emit a self-closing tag in that case.
|
|
1264
|
+
if (!config.type && !config.formula1) {
|
|
1265
|
+
return `<dataValidation ${attrs.join(" ")} sqref="${sqref}"/>`;
|
|
1266
|
+
}
|
|
1267
|
+
const formula2 = config.formula2
|
|
1268
|
+
? `<formula2>${escapeXmlText(config.formula2)}</formula2>`
|
|
1269
|
+
: "";
|
|
1270
|
+
return `<dataValidation ${attrs.join(" ")} sqref="${sqref}"><formula1>${escapeXmlText(config.formula1 ?? "")}</formula1>${formula2}</dataValidation>`;
|
|
1271
|
+
};
|
|
1272
|
+
const resolveCellDataValidation = (cell, map) => {
|
|
1273
|
+
if (!cell)
|
|
1274
|
+
return undefined;
|
|
1275
|
+
const raw = cell?.dataValidation;
|
|
1276
|
+
if (!raw)
|
|
1277
|
+
return undefined;
|
|
1278
|
+
if (typeof raw === "string" || typeof raw === "number") {
|
|
1279
|
+
return map?.[raw];
|
|
1280
|
+
}
|
|
1281
|
+
return raw;
|
|
1282
|
+
};
|
|
1283
|
+
const buildWorksheetRow = ({ rowNumber, row, rowMetadata, defaultRowHeightPx, sharedStrings, sourceSharedStrings, cellXfs, styleBuilder, onImage, onHyperlink, dataValidationsById, onDataValidation, tableNameMap, tables, ignoreFormulaPatterns, commentsCollector, onCommentThread, sheetId, sheetData, sheets, locale, validSharedFormulaLeaders, }) => {
|
|
1284
|
+
const values = row?.values ?? [];
|
|
1285
|
+
const cellXmlChunks = [];
|
|
1286
|
+
let minColumn = Number.POSITIVE_INFINITY;
|
|
1287
|
+
let maxColumn = 0;
|
|
1288
|
+
for (let columnIndex = 1; columnIndex < values.length; columnIndex++) {
|
|
1289
|
+
const cell = values[columnIndex];
|
|
1290
|
+
const cellXml = buildCellXml({
|
|
1291
|
+
rowNumber,
|
|
1292
|
+
columnIndex,
|
|
1293
|
+
cell,
|
|
1294
|
+
sharedStrings,
|
|
1295
|
+
sourceSharedStrings,
|
|
1296
|
+
cellXfs,
|
|
1297
|
+
styleBuilder,
|
|
1298
|
+
onImage,
|
|
1299
|
+
onHyperlink,
|
|
1300
|
+
dataValidationsById,
|
|
1301
|
+
onDataValidation,
|
|
1302
|
+
tableNameMap,
|
|
1303
|
+
tables,
|
|
1304
|
+
ignoreFormulaPatterns,
|
|
1305
|
+
commentsCollector,
|
|
1306
|
+
onCommentThread,
|
|
1307
|
+
sheetId,
|
|
1308
|
+
sheetData,
|
|
1309
|
+
sheets,
|
|
1310
|
+
locale,
|
|
1311
|
+
validSharedFormulaLeaders,
|
|
1312
|
+
});
|
|
1313
|
+
if (!cellXml)
|
|
1314
|
+
continue;
|
|
1315
|
+
cellXmlChunks.push(cellXml);
|
|
1316
|
+
minColumn = Math.min(minColumn, columnIndex);
|
|
1317
|
+
maxColumn = Math.max(maxColumn, columnIndex);
|
|
1318
|
+
}
|
|
1319
|
+
const hasCells = cellXmlChunks.length > 0;
|
|
1320
|
+
const rowSize = rowMetadata?.size && rowMetadata.size > 0 ? rowMetadata.size : undefined;
|
|
1321
|
+
const hasHiddenFlag = Boolean(rowMetadata?.hiddenByUser ||
|
|
1322
|
+
rowMetadata?.hiddenByFilter ||
|
|
1323
|
+
rowMetadata?.hiddenByGroup);
|
|
1324
|
+
// Emit ``ht`` / ``customHeight`` when either:
|
|
1325
|
+
// (a) the row height differs from the workbook default, OR
|
|
1326
|
+
// (b) the import-side captured the source xlsx's ``customHeight="1"``
|
|
1327
|
+
// marker — Excel preserves that flag on round-trip even when the
|
|
1328
|
+
// value matches the default, and the fidelity comparator catches
|
|
1329
|
+
// its loss as a regression.
|
|
1330
|
+
// The `customHeight="1"` marker is emitted in two distinct cases:
|
|
1331
|
+
// 1. The import-only `customHeight` flag is set — preserves the source
|
|
1332
|
+
// xlsx's `customHeight="1"` verbatim, even when the height equals
|
|
1333
|
+
// the sheet default (xlsx files do carry that combination; Excel
|
|
1334
|
+
// reads it as "user locked this row even though it happens to
|
|
1335
|
+
// match the default").
|
|
1336
|
+
// 2. `resizedByUser=true` AND the height is non-default — covers the
|
|
1337
|
+
// UI/runtime path where a user explicitly resized a row to a
|
|
1338
|
+
// non-default value. We deliberately exclude `resizedByUser` on
|
|
1339
|
+
// default-height rows because that flag is shared with runtime
|
|
1340
|
+
// engine state (autofit chains, column-resize side effects) and
|
|
1341
|
+
// a stray flip there used to stamp every default-height row with
|
|
1342
|
+
// `customHeight="1"`, which Excel reads as "user locked this row"
|
|
1343
|
+
// and refuses to auto-fit.
|
|
1344
|
+
const hasExplicitHeight = Boolean(rowSize && Math.abs(rowSize - defaultRowHeightPx) > 0.01);
|
|
1345
|
+
const hasUserLockedHeight = Boolean(rowSize &&
|
|
1346
|
+
(rowMetadata?.customHeight ||
|
|
1347
|
+
(hasExplicitHeight && rowMetadata?.resizedByUser)));
|
|
1348
|
+
const outlineLevel = rowMetadata?.outlineLevel ?? 0;
|
|
1349
|
+
const isCollapsed = Boolean(rowMetadata?.collapsed);
|
|
1350
|
+
const hasOutlineMetadata = outlineLevel > 0 || isCollapsed;
|
|
1351
|
+
const needsMetadata = hasHiddenFlag ||
|
|
1352
|
+
hasExplicitHeight ||
|
|
1353
|
+
hasUserLockedHeight ||
|
|
1354
|
+
hasOutlineMetadata;
|
|
1355
|
+
if (!hasCells && !needsMetadata) {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
const attrs = [`r="${rowNumber}"`];
|
|
1359
|
+
if ((hasExplicitHeight || hasUserLockedHeight) && rowSize) {
|
|
1360
|
+
const height = pixelToPoints(rowSize);
|
|
1361
|
+
if (hasUserLockedHeight) {
|
|
1362
|
+
attrs.push(`customHeight="1"`);
|
|
1363
|
+
}
|
|
1364
|
+
attrs.push(`ht="${height}"`);
|
|
1365
|
+
}
|
|
1366
|
+
if (hasHiddenFlag) {
|
|
1367
|
+
attrs.push(`hidden="1"`);
|
|
1368
|
+
}
|
|
1369
|
+
if (outlineLevel > 0) {
|
|
1370
|
+
attrs.push(`outlineLevel="${Math.min(7, Math.trunc(outlineLevel))}"`);
|
|
1371
|
+
}
|
|
1372
|
+
if (isCollapsed) {
|
|
1373
|
+
attrs.push(`collapsed="1"`);
|
|
1374
|
+
}
|
|
1375
|
+
const rowXml = `<row ${attrs.join(" ")}>${cellXmlChunks.join("")}</row>`;
|
|
1376
|
+
return {
|
|
1377
|
+
xml: rowXml,
|
|
1378
|
+
minColumn: isFinite(minColumn) ? minColumn : undefined,
|
|
1379
|
+
maxColumn: maxColumn || undefined,
|
|
1380
|
+
};
|
|
1381
|
+
};
|
|
1382
|
+
const buildCellXml = ({ rowNumber, columnIndex, cell, sharedStrings, sourceSharedStrings, cellXfs, styleBuilder, onImage, onHyperlink, dataValidationsById, onDataValidation, tableNameMap, tables, ignoreFormulaPatterns, commentsCollector, onCommentThread, sheetId, sheetData, sheets, locale, validSharedFormulaLeaders, }) => {
|
|
1383
|
+
const normalizedCell = cell;
|
|
1384
|
+
if (!normalizedCell)
|
|
1385
|
+
return undefined;
|
|
1386
|
+
const userEnteredValue = getCellUserEnteredValue(normalizedCell);
|
|
1387
|
+
const effectiveValue = getCellEffectiveValue(normalizedCell) ?? userEnteredValue;
|
|
1388
|
+
// Excel-style: only `uf` is serialized. Read `uf` first, fall back to
|
|
1389
|
+
// legacy `ef` so old saved data still exports with its original format.
|
|
1390
|
+
let effectiveFormat = getCellUserEnteredFormat(normalizedCell) ??
|
|
1391
|
+
getCellEffectiveFormat(normalizedCell);
|
|
1392
|
+
if (effectiveFormat && isStyleReference(effectiveFormat)) {
|
|
1393
|
+
const styleId = effectiveFormat.sid ?? effectiveFormat.styleId;
|
|
1394
|
+
effectiveFormat = styleId ? cellXfs?.get(styleId) : undefined;
|
|
1395
|
+
}
|
|
1396
|
+
let formattedValue = getCellFormattedValue(normalizedCell);
|
|
1397
|
+
// The data-validation Invalid marker is UI-only and must never export as
|
|
1398
|
+
// a real error cell — strip it so the cell's underlying value exports.
|
|
1399
|
+
const valueSource = omitInvalidDataValidationError(effectiveValue) ??
|
|
1400
|
+
omitInvalidDataValidationError(userEnteredValue);
|
|
1401
|
+
const numberValue = getExtendedValueNumber(valueSource);
|
|
1402
|
+
const boolValue = getExtendedValueBool(valueSource);
|
|
1403
|
+
const rawFormulaValue = getExtendedValueFormula(userEnteredValue);
|
|
1404
|
+
const formulaValue = shouldIgnoreFormula(rawFormulaValue, ignoreFormulaPatterns)
|
|
1405
|
+
? undefined
|
|
1406
|
+
: rawFormulaValue;
|
|
1407
|
+
let exportedFormulaValue = formulaValue
|
|
1408
|
+
? rewriteStructuredReferenceFormula(formulaValue, tableNameMap, tables, rowNumber, columnIndex)
|
|
1409
|
+
: formulaValue;
|
|
1410
|
+
// If formula contains property references (e.g., =A1.propertyName),
|
|
1411
|
+
// use formatted value instead of exporting unsupported formula
|
|
1412
|
+
if (hasPropertyReference(exportedFormulaValue)) {
|
|
1413
|
+
exportedFormulaValue = undefined;
|
|
1414
|
+
}
|
|
1415
|
+
const errorValue = getExtendedValueError(valueSource);
|
|
1416
|
+
let stringValue = getExtendedValueString(valueSource);
|
|
1417
|
+
const sharedStringIndex = normalizedCell?.ss;
|
|
1418
|
+
const sharedStringValue = sharedStringIndex != null && sourceSharedStrings
|
|
1419
|
+
? sourceSharedStrings.get(String(sharedStringIndex))
|
|
1420
|
+
: null;
|
|
1421
|
+
if (sharedStringValue != null) {
|
|
1422
|
+
// SharedStrings entries can be either plain strings or rich
|
|
1423
|
+
// `{ text, runs }` objects. Surface the text for the cell value;
|
|
1424
|
+
// the rich runs are picked up separately below to write `<si><r>`.
|
|
1425
|
+
stringValue =
|
|
1426
|
+
typeof sharedStringValue === "string"
|
|
1427
|
+
? sharedStringValue
|
|
1428
|
+
: sharedStringValue.text ?? "";
|
|
1429
|
+
formattedValue = undefined;
|
|
1430
|
+
}
|
|
1431
|
+
const isDateLike = effectiveFormat?.numberFormat?.type === "DATE" ||
|
|
1432
|
+
effectiveFormat?.numberFormat?.type === "DATE_TIME";
|
|
1433
|
+
// Only treat as date string if the number is a valid Excel date serial
|
|
1434
|
+
// Large numbers outside the valid date range should export as numbers
|
|
1435
|
+
if (isDateLike && formattedValue && isValidExcelDateSerial(numberValue)) {
|
|
1436
|
+
stringValue = formattedValue;
|
|
1437
|
+
}
|
|
1438
|
+
if (formulaValue &&
|
|
1439
|
+
(stringValue != null || formattedValue != null) &&
|
|
1440
|
+
stringValue == null) {
|
|
1441
|
+
stringValue = String(formattedValue ?? "");
|
|
1442
|
+
}
|
|
1443
|
+
const hasExplicitValue = !isNil(numberValue) ||
|
|
1444
|
+
!isNil(boolValue) ||
|
|
1445
|
+
!isNil(formulaValue) ||
|
|
1446
|
+
!isNil(errorValue) ||
|
|
1447
|
+
stringValue != null;
|
|
1448
|
+
if (!hasExplicitValue && formattedValue === "") {
|
|
1449
|
+
formattedValue = undefined;
|
|
1450
|
+
}
|
|
1451
|
+
// For formula cells with no explicit number format, derive the format
|
|
1452
|
+
// Excel/Sheets would auto-apply from precedents and pass it into the
|
|
1453
|
+
// style registration. The cellData itself stays untouched. Without this,
|
|
1454
|
+
// Excel displays formula results without their inherited format until
|
|
1455
|
+
// the user manually recalculates.
|
|
1456
|
+
//
|
|
1457
|
+
// Skip cells whose `uf` is a `{ sid }` style reference: that's the shape
|
|
1458
|
+
// xlsx import produces, and an untouched imported cell's source xf already
|
|
1459
|
+
// says what Excel showed — General there is a decision, not a gap, and
|
|
1460
|
+
// deriving from precedents would restyle it away from the source file.
|
|
1461
|
+
const importedStyleRef = typeof normalizedCell.uf === "object" &&
|
|
1462
|
+
normalizedCell.uf !== null &&
|
|
1463
|
+
"sid" in normalizedCell.uf;
|
|
1464
|
+
let derivedNumberFormat;
|
|
1465
|
+
if (formulaValue &&
|
|
1466
|
+
!importedStyleRef &&
|
|
1467
|
+
!effectiveFormat?.numberFormat &&
|
|
1468
|
+
sheetData &&
|
|
1469
|
+
sheets &&
|
|
1470
|
+
sheetId != null) {
|
|
1471
|
+
const sheetIdByName = new Map();
|
|
1472
|
+
for (const s of sheets) {
|
|
1473
|
+
if (s?.title)
|
|
1474
|
+
sheetIdByName.set(s.title, s.sheetId);
|
|
1475
|
+
}
|
|
1476
|
+
// A real CellXfsRegistry so `SheetCell.assign` inside the derive
|
|
1477
|
+
// helper can resolve precedent cells whose `uf` is a `{ sid }` style
|
|
1478
|
+
// reference (the shape xlsx-imported workbooks produce).
|
|
1479
|
+
const cellXfsRegistry = cellXfs ? new CellXfsRegistry(cellXfs) : undefined;
|
|
1480
|
+
derivedNumberFormat =
|
|
1481
|
+
deriveEffectiveNumberFormatFromFormula(formulaValue, sheetId, rowNumber, columnIndex, sheetData, (input) => defaultTokenizer(input), (name) => sheetIdByName.get(name) ?? sheetId, locale, cellXfsRegistry, undefined, typeof numberValue === "number" ? numberValue : null) ?? undefined;
|
|
1482
|
+
}
|
|
1483
|
+
const styleId = styleBuilder.registerCell(normalizedCell, cellXfs, derivedNumberFormat);
|
|
1484
|
+
const address = cellToAddress({
|
|
1485
|
+
rowIndex: rowNumber,
|
|
1486
|
+
columnIndex,
|
|
1487
|
+
});
|
|
1488
|
+
if (!address)
|
|
1489
|
+
return undefined;
|
|
1490
|
+
// Check if cell has a note and add it to comments collector
|
|
1491
|
+
if (normalizedCell.note) {
|
|
1492
|
+
commentsCollector.add(address, normalizedCell.note, "User");
|
|
1493
|
+
}
|
|
1494
|
+
// A cell that anchors a comment thread carries only the thread id; the
|
|
1495
|
+
// resolver matches it against the workbook's threads to emit threaded
|
|
1496
|
+
// comments (+ a down-level note mirror).
|
|
1497
|
+
if (normalizedCell.commentThreadId != null) {
|
|
1498
|
+
onCommentThread?.(address, normalizedCell.commentThreadId);
|
|
1499
|
+
}
|
|
1500
|
+
const hyperlinkValue = normalizeHyperlinkValue(normalizedCell?.hyperlink);
|
|
1501
|
+
if (hyperlinkValue) {
|
|
1502
|
+
onHyperlink({ ref: address, hyperlink: hyperlinkValue });
|
|
1503
|
+
}
|
|
1504
|
+
const validationRule = resolveCellDataValidation(normalizedCell, dataValidationsById);
|
|
1505
|
+
if (validationRule) {
|
|
1506
|
+
onDataValidation?.({ address, rule: validationRule });
|
|
1507
|
+
}
|
|
1508
|
+
if (normalizedCell.imageUrl) {
|
|
1509
|
+
onImage({
|
|
1510
|
+
kind: "cell",
|
|
1511
|
+
imageUrl: normalizedCell.imageUrl,
|
|
1512
|
+
rowIndex: rowNumber,
|
|
1513
|
+
columnIndex,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
const xmlParts = [];
|
|
1517
|
+
let typeAttr = "";
|
|
1518
|
+
if (exportedFormulaValue) {
|
|
1519
|
+
// Array formula anchor: emit <f t="array" ref="..."/>. Spilled cells in
|
|
1520
|
+
// the range carry no `ue` formula (they're populated by the calculator
|
|
1521
|
+
// as side-effects of the anchor's evaluation), so they never reach this
|
|
1522
|
+
// branch and correctly emit only <v>.
|
|
1523
|
+
const arrayRef = normalizedCell.af;
|
|
1524
|
+
// Shared formula: emit `<f t="shared" si="N" ref="A1:A10">FORMULA</f>`
|
|
1525
|
+
// on the leader cell (carries both `si` and `ref`) and
|
|
1526
|
+
// `<f t="shared" si="N"/>` on followers (carries only `si` — no formula
|
|
1527
|
+
// text, no `ref`). Excel rebuilds the followers' formulas from the
|
|
1528
|
+
// leader's expression. Array takes precedence if both somehow apply
|
|
1529
|
+
// since they're mutually exclusive `t="…"` types.
|
|
1530
|
+
const sharedSpec = normalizedCell.sf;
|
|
1531
|
+
if (arrayRef) {
|
|
1532
|
+
xmlParts.push(`<f t="array" ref="${escapeXmlAttr(arrayRef)}">${escapeXmlText(stripFormula(exportedFormulaValue))}</f>`);
|
|
1533
|
+
}
|
|
1534
|
+
else if (sharedSpec && Number.isFinite(sharedSpec.si)) {
|
|
1535
|
+
if (sharedSpec.ref) {
|
|
1536
|
+
// Leader cell — emit full formula plus the shared-range ref.
|
|
1537
|
+
xmlParts.push(`<f t="shared" ref="${escapeXmlAttr(sharedSpec.ref)}" si="${sharedSpec.si}">${escapeXmlText(stripFormula(exportedFormulaValue))}</f>`);
|
|
1538
|
+
}
|
|
1539
|
+
else if (validSharedFormulaLeaders?.has(sharedSpec.si)) {
|
|
1540
|
+
// Follower cell with a known leader — self-closing, no formula text.
|
|
1541
|
+
// Excel reads `si` back through the leader's expression and applies
|
|
1542
|
+
// relative-ref adjustments based on cell position.
|
|
1543
|
+
xmlParts.push(`<f t="shared" si="${sharedSpec.si}"/>`);
|
|
1544
|
+
}
|
|
1545
|
+
else {
|
|
1546
|
+
// Follower whose leader is missing (typically because the leader
|
|
1547
|
+
// was edited by the user and lost its `sf` provenance). Falling
|
|
1548
|
+
// back to the standalone formula keeps the cell evaluating — the
|
|
1549
|
+
// `exportedFormulaValue` was already expanded with the right
|
|
1550
|
+
// relative refs on import via `formulaToRelativeReference`.
|
|
1551
|
+
xmlParts.push(`<f>${escapeXmlText(stripFormula(exportedFormulaValue))}</f>`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
else {
|
|
1555
|
+
xmlParts.push(`<f>${escapeXmlText(stripFormula(exportedFormulaValue))}</f>`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (errorValue) {
|
|
1559
|
+
typeAttr = ` t="e"`;
|
|
1560
|
+
const errorText = typeof errorValue === "string"
|
|
1561
|
+
? errorValue
|
|
1562
|
+
: errorValue?.name ||
|
|
1563
|
+
errorValue?.type ||
|
|
1564
|
+
errorValue?.message ||
|
|
1565
|
+
"#VALUE!";
|
|
1566
|
+
xmlParts.push(`<v>${escapeXmlText(normalizeExcelError(errorText))}</v>`);
|
|
1567
|
+
}
|
|
1568
|
+
else if (!isNil(numberValue)) {
|
|
1569
|
+
xmlParts.push(`<v>${numberValue}</v>`);
|
|
1570
|
+
}
|
|
1571
|
+
else if (!isNil(boolValue)) {
|
|
1572
|
+
typeAttr = ` t="b"`;
|
|
1573
|
+
xmlParts.push(`<v>${boolValue ? 1 : 0}</v>`);
|
|
1574
|
+
}
|
|
1575
|
+
else if (formulaValue && stringValue != null) {
|
|
1576
|
+
typeAttr = ` t="str"`;
|
|
1577
|
+
xmlParts.push(`<v>${escapeXmlText(String(stringValue))}</v>`);
|
|
1578
|
+
}
|
|
1579
|
+
else if (stringValue != null || formattedValue != null) {
|
|
1580
|
+
const text = stringValue ?? formattedValue;
|
|
1581
|
+
if (text == null) {
|
|
1582
|
+
return undefined;
|
|
1583
|
+
}
|
|
1584
|
+
// If this cell references a shared-string entry that carries runs
|
|
1585
|
+
// (rich SST entry in `sourceSharedStrings`), reuse that entry so
|
|
1586
|
+
// the formatting persists; otherwise intern a plain entry.
|
|
1587
|
+
const sourceEntry = normalizedCell.ss != null
|
|
1588
|
+
? sourceSharedStrings?.get(String(normalizedCell.ss))
|
|
1589
|
+
: undefined;
|
|
1590
|
+
const richRuns = sourceEntry &&
|
|
1591
|
+
typeof sourceEntry === "object" &&
|
|
1592
|
+
Array.isArray(sourceEntry.runs)
|
|
1593
|
+
? sourceEntry.runs
|
|
1594
|
+
: undefined;
|
|
1595
|
+
const stringIndex = richRuns
|
|
1596
|
+
? sharedStrings.addRich(String(text), richRuns)
|
|
1597
|
+
: sharedStrings.add(String(text));
|
|
1598
|
+
typeAttr = ` t="s"`;
|
|
1599
|
+
xmlParts.push(`<v>${stringIndex}</v>`);
|
|
1600
|
+
}
|
|
1601
|
+
const styleAttr = styleId ? ` s="${styleId}"` : "";
|
|
1602
|
+
if (!xmlParts.length) {
|
|
1603
|
+
if (!styleId && !hyperlinkValue) {
|
|
1604
|
+
return undefined;
|
|
1605
|
+
}
|
|
1606
|
+
return `<c r="${address}"${styleAttr}/>`;
|
|
1607
|
+
}
|
|
1608
|
+
return `<c r="${address}"${typeAttr}${styleAttr}>${xmlParts.join("")}</c>`;
|
|
1609
|
+
};
|
|
1610
|
+
const normalizeHyperlinkValue = (hyperlink) => {
|
|
1611
|
+
if (!hyperlink) {
|
|
1612
|
+
return undefined;
|
|
1613
|
+
}
|
|
1614
|
+
if (typeof hyperlink === "string") {
|
|
1615
|
+
return { kind: "external", url: hyperlink };
|
|
1616
|
+
}
|
|
1617
|
+
return hyperlink;
|
|
1618
|
+
};
|
|
1619
|
+
// Excel rejects hyperlink targets that aren't valid URIs. Targets seen in the
|
|
1620
|
+
// wild that need normalizing: `<email@host>` (angle-bracket form), bare emails
|
|
1621
|
+
// (`user@host` without `mailto:`), and comma-separated lists. Returns undefined
|
|
1622
|
+
// to skip emitting the hyperlink rel entirely when we can't make it valid.
|
|
1623
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1624
|
+
const sanitizeHyperlinkUrl = (url) => {
|
|
1625
|
+
if (!url || typeof url !== "string")
|
|
1626
|
+
return undefined;
|
|
1627
|
+
let t = url.trim();
|
|
1628
|
+
if (!t)
|
|
1629
|
+
return undefined;
|
|
1630
|
+
if (t.startsWith("<") && t.endsWith(">")) {
|
|
1631
|
+
t = t.slice(1, -1).trim();
|
|
1632
|
+
}
|
|
1633
|
+
if (t.includes(",")) {
|
|
1634
|
+
t = t.split(",")[0].trim();
|
|
1635
|
+
}
|
|
1636
|
+
if (/^(https?|mailto|ftp|file):/i.test(t) || t.startsWith("#")) {
|
|
1637
|
+
return t;
|
|
1638
|
+
}
|
|
1639
|
+
if (EMAIL_RE.test(t)) {
|
|
1640
|
+
return `mailto:${t}`;
|
|
1641
|
+
}
|
|
1642
|
+
return undefined;
|
|
1643
|
+
};
|
|
1644
|
+
const buildHyperlinksXml = (entries, relationships) => {
|
|
1645
|
+
if (!entries.length) {
|
|
1646
|
+
return "";
|
|
1647
|
+
}
|
|
1648
|
+
const items = entries
|
|
1649
|
+
.map((entry) => {
|
|
1650
|
+
const hyperlink = entry.hyperlink;
|
|
1651
|
+
const displayAttr = hyperlink.display
|
|
1652
|
+
? ` display="${escapeXmlAttr(hyperlink.display)}"`
|
|
1653
|
+
: "";
|
|
1654
|
+
const tooltipAttr = hyperlink.tooltip
|
|
1655
|
+
? ` tooltip="${escapeXmlAttr(hyperlink.tooltip)}"`
|
|
1656
|
+
: "";
|
|
1657
|
+
if (hyperlink.kind === "external") {
|
|
1658
|
+
const url = sanitizeHyperlinkUrl(hyperlink.url);
|
|
1659
|
+
if (!url) {
|
|
1660
|
+
return "";
|
|
1661
|
+
}
|
|
1662
|
+
const relId = relationships.add(HYPERLINK_REL_TYPE, url, "External");
|
|
1663
|
+
return `<hyperlink ref="${entry.ref}" r:id="${relId}"${displayAttr}${tooltipAttr}/>`;
|
|
1664
|
+
}
|
|
1665
|
+
if (!hyperlink.location) {
|
|
1666
|
+
return "";
|
|
1667
|
+
}
|
|
1668
|
+
return `<hyperlink ref="${entry.ref}" location="${escapeXmlAttr(hyperlink.location)}"${displayAttr}${tooltipAttr}/>`;
|
|
1669
|
+
})
|
|
1670
|
+
.filter(Boolean)
|
|
1671
|
+
.join("");
|
|
1672
|
+
if (!items) {
|
|
1673
|
+
return "";
|
|
1674
|
+
}
|
|
1675
|
+
return `<hyperlinks>${items}</hyperlinks>`;
|
|
1676
|
+
};
|
|
1677
|
+
function shouldIgnoreFormula(formula, patterns) {
|
|
1678
|
+
if (!formula)
|
|
1679
|
+
return false;
|
|
1680
|
+
// Always ignore SPARKLINE formulas - they're exported as native Excel sparklines
|
|
1681
|
+
if (formula.toUpperCase().startsWith("=SPARKLINE(")) {
|
|
1682
|
+
return true;
|
|
1683
|
+
}
|
|
1684
|
+
if (!patterns)
|
|
1685
|
+
return false;
|
|
1686
|
+
const normalized = formula.startsWith("=") ? formula.slice(1) : formula;
|
|
1687
|
+
const list = Array.isArray(patterns) ? patterns : [patterns];
|
|
1688
|
+
return list.some((pattern) => pattern.test(formula) || pattern.test(normalized));
|
|
1689
|
+
}
|
|
1690
|
+
function stripFormula(formula) {
|
|
1691
|
+
const trimmed = formula.startsWith("=") ? formula.slice(1) : formula;
|
|
1692
|
+
return sanitizeFormulaText(trimmed);
|
|
1693
|
+
}
|
|
1694
|
+
function hasPropertyReference(formula) {
|
|
1695
|
+
if (!formula)
|
|
1696
|
+
return false;
|
|
1697
|
+
// Detects cell references with property access: A1.property, $A$1.nested.value, etc.
|
|
1698
|
+
const propertyReferencePattern = /[$]?[A-Za-z]{1,3}[$]?[1-9][0-9]*\./;
|
|
1699
|
+
return propertyReferencePattern.test(formula);
|
|
1700
|
+
}
|
|
1701
|
+
// Excel date serial: 1 = Jan 1, 1900, max ~2958465 = Dec 31, 9999
|
|
1702
|
+
// Numbers outside this range should not be treated as dates
|
|
1703
|
+
const MIN_EXCEL_DATE_SERIAL = 0;
|
|
1704
|
+
const MAX_EXCEL_DATE_SERIAL = 2958465;
|
|
1705
|
+
function isValidExcelDateSerial(value) {
|
|
1706
|
+
if (value == null || !Number.isFinite(value))
|
|
1707
|
+
return false;
|
|
1708
|
+
return value >= MIN_EXCEL_DATE_SERIAL && value <= MAX_EXCEL_DATE_SERIAL;
|
|
1709
|
+
}
|
|
1710
|
+
function rewriteTableNamesInFormula(formula, tableNameMap) {
|
|
1711
|
+
if (!tableNameMap?.size)
|
|
1712
|
+
return formula;
|
|
1713
|
+
let normalized = formula;
|
|
1714
|
+
for (const [tableName, exportedName] of tableNameMap) {
|
|
1715
|
+
if (!tableName || tableName === exportedName)
|
|
1716
|
+
continue;
|
|
1717
|
+
const escaped = escapeCharacters(tableName);
|
|
1718
|
+
if (!escaped)
|
|
1719
|
+
continue;
|
|
1720
|
+
normalized = normalized.replace(new RegExp(`${escaped}(?=\\[)`, "g"), exportedName);
|
|
1721
|
+
}
|
|
1722
|
+
return normalized;
|
|
1723
|
+
}
|
|
1724
|
+
function findTableForCell(tables, rowNumber, columnIndex) {
|
|
1725
|
+
if (!tables?.length)
|
|
1726
|
+
return undefined;
|
|
1727
|
+
return tables.find((table) => {
|
|
1728
|
+
const range = table.range;
|
|
1729
|
+
if (!range)
|
|
1730
|
+
return false;
|
|
1731
|
+
return (rowNumber >= range.startRowIndex &&
|
|
1732
|
+
rowNumber <= range.endRowIndex &&
|
|
1733
|
+
columnIndex >= range.startColumnIndex &&
|
|
1734
|
+
columnIndex <= range.endColumnIndex);
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
function prefixUnqualifiedStructuredReferences(formula, tableName) {
|
|
1738
|
+
if (!tableName)
|
|
1739
|
+
return formula;
|
|
1740
|
+
let normalized = "";
|
|
1741
|
+
let inString = false;
|
|
1742
|
+
let depth = 0;
|
|
1743
|
+
for (let i = 0; i < formula.length; i++) {
|
|
1744
|
+
const char = formula[i];
|
|
1745
|
+
if (char === '"') {
|
|
1746
|
+
normalized += char;
|
|
1747
|
+
if (inString) {
|
|
1748
|
+
if (formula[i + 1] === '"') {
|
|
1749
|
+
normalized += '"';
|
|
1750
|
+
i += 1;
|
|
1751
|
+
}
|
|
1752
|
+
else {
|
|
1753
|
+
inString = false;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
else {
|
|
1757
|
+
inString = true;
|
|
1758
|
+
}
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
if (inString) {
|
|
1762
|
+
normalized += char;
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
if (char === "[") {
|
|
1766
|
+
const prev = i > 0 ? formula[i - 1] : "";
|
|
1767
|
+
if (depth === 0 && !/[A-Za-z0-9_\]\[]/.test(prev)) {
|
|
1768
|
+
normalized += tableName;
|
|
1769
|
+
}
|
|
1770
|
+
depth += 1;
|
|
1771
|
+
normalized += char;
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
if (char === "]" && depth > 0) {
|
|
1775
|
+
depth -= 1;
|
|
1776
|
+
normalized += char;
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
normalized += char;
|
|
1780
|
+
}
|
|
1781
|
+
return normalized;
|
|
1782
|
+
}
|
|
1783
|
+
function normalizeThisRowStructuredReferences(formula, tableName) {
|
|
1784
|
+
if (!tableName)
|
|
1785
|
+
return formula;
|
|
1786
|
+
let normalized = formula;
|
|
1787
|
+
const escapedTable = escapeCharacters(tableName);
|
|
1788
|
+
if (!escapedTable)
|
|
1789
|
+
return normalized;
|
|
1790
|
+
const bracketed = new RegExp(`${escapedTable}\\[\\@\\[([^\\]]+)\\]\\]`, "g");
|
|
1791
|
+
const simple = new RegExp(`${escapedTable}\\[\\@([^\\]]+)\\]`, "g");
|
|
1792
|
+
normalized = normalized.replace(bracketed, `${tableName}[[#This Row],[$1]]`);
|
|
1793
|
+
normalized = normalized.replace(simple, `${tableName}[[#This Row],[$1]]`);
|
|
1794
|
+
return normalized;
|
|
1795
|
+
}
|
|
1796
|
+
function normalizeCalculatedColumnFormula(formula, table) {
|
|
1797
|
+
const tableNameMap = new Map();
|
|
1798
|
+
const tableTitle = table.title ?? table.name;
|
|
1799
|
+
if (tableTitle && tableTitle !== table.name) {
|
|
1800
|
+
tableNameMap.set(tableTitle, table.name);
|
|
1801
|
+
}
|
|
1802
|
+
let normalized = rewriteTableNamesInFormula(formula, tableNameMap.size ? tableNameMap : undefined);
|
|
1803
|
+
normalized = prefixUnqualifiedStructuredReferences(normalized, table.name);
|
|
1804
|
+
normalized = normalizeThisRowStructuredReferences(normalized, table.name);
|
|
1805
|
+
return normalized;
|
|
1806
|
+
}
|
|
1807
|
+
function rewriteStructuredReferenceFormula(formula, tableNameMap, tables, rowNumber, columnIndex) {
|
|
1808
|
+
const normalized = rewriteTableNamesInFormula(formula, tableNameMap);
|
|
1809
|
+
const table = findTableForCell(tables, rowNumber, columnIndex);
|
|
1810
|
+
if (!table?.name)
|
|
1811
|
+
return normalized;
|
|
1812
|
+
const qualified = prefixUnqualifiedStructuredReferences(normalized, table.name);
|
|
1813
|
+
return normalizeThisRowStructuredReferences(qualified, table.name);
|
|
1814
|
+
}
|
|
1815
|
+
function sanitizeFormulaText(formula) {
|
|
1816
|
+
const normalized = sanitizeXmlText(formula);
|
|
1817
|
+
return qualifyFutureFunctions(normalized.replace(/\r\n|\r|\n/g, " "));
|
|
1818
|
+
}
|
|
1819
|
+
function escapeXmlBase(value) {
|
|
1820
|
+
return sanitizeXmlText(value)
|
|
1821
|
+
.replace(/&/g, "&")
|
|
1822
|
+
.replace(/</g, "<")
|
|
1823
|
+
.replace(/>/g, ">")
|
|
1824
|
+
.replace(/"/g, """)
|
|
1825
|
+
.replace(/'/g, "'");
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Escape a value for an XML attribute. Newlines must be written as
|
|
1829
|
+
* character references (` `) because attribute-value normalization
|
|
1830
|
+
* folds raw line breaks into spaces.
|
|
1831
|
+
*/
|
|
1832
|
+
function escapeXmlAttr(value) {
|
|
1833
|
+
return escapeXmlBase(value)
|
|
1834
|
+
.replace(/\r\n/g, " ")
|
|
1835
|
+
.replace(/\r/g, " ")
|
|
1836
|
+
.replace(/\n/g, " ");
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Escape a value for XML element text. Raw LF is legal and preserved
|
|
1840
|
+
* verbatim in text nodes (Excel, rust_xlsxwriter, and the ODS exporter
|
|
1841
|
+
* all write raw newlines there); emitting ` ` instead leaks a
|
|
1842
|
+
* literal entity into importers that don't decode numeric character
|
|
1843
|
+
* references.
|
|
1844
|
+
*/
|
|
1845
|
+
function escapeXmlText(value) {
|
|
1846
|
+
return escapeXmlBase(value).replace(/\r\n|\r/g, "\n");
|
|
1847
|
+
}
|
|
1848
|
+
const EXCEL_ERROR_VALUES = new Set([
|
|
1849
|
+
"#NULL!",
|
|
1850
|
+
"#DIV/0!",
|
|
1851
|
+
"#VALUE!",
|
|
1852
|
+
"#REF!",
|
|
1853
|
+
"#NAME?",
|
|
1854
|
+
"#NUM!",
|
|
1855
|
+
"#N/A",
|
|
1856
|
+
"#GETTING_DATA",
|
|
1857
|
+
"#SPILL!",
|
|
1858
|
+
"#CALC!",
|
|
1859
|
+
"#CONNECT!",
|
|
1860
|
+
"#BLOCKED!",
|
|
1861
|
+
"#FIELD!",
|
|
1862
|
+
]);
|
|
1863
|
+
function normalizeExcelError(value) {
|
|
1864
|
+
if (!value)
|
|
1865
|
+
return "#VALUE!";
|
|
1866
|
+
const trimmed = value.trim();
|
|
1867
|
+
if (!trimmed)
|
|
1868
|
+
return "#VALUE!";
|
|
1869
|
+
if (EXCEL_ERROR_VALUES.has(trimmed)) {
|
|
1870
|
+
return trimmed;
|
|
1871
|
+
}
|
|
1872
|
+
return "#VALUE!";
|
|
1873
|
+
}
|
|
1874
|
+
const serializeColorXml = (color) => {
|
|
1875
|
+
if (!color)
|
|
1876
|
+
return undefined;
|
|
1877
|
+
if (typeof color === "string") {
|
|
1878
|
+
const hex = normalizeHex(color);
|
|
1879
|
+
return `rgb="${hex}"`;
|
|
1880
|
+
}
|
|
1881
|
+
const tintAttr = typeof color.tint === "number" ? ` tint="${color.tint}"` : "";
|
|
1882
|
+
return `theme="${color.theme}"${tintAttr}`;
|
|
1883
|
+
};
|
|
1884
|
+
const normalizeHex = (color) => {
|
|
1885
|
+
const normalized = color.startsWith("#") ? color.slice(1) : color;
|
|
1886
|
+
return normalized.length === 6 ? `FF${normalized}` : normalized;
|
|
1887
|
+
};
|
|
1888
|
+
const buildDimension = (bounds) => {
|
|
1889
|
+
if (!isFinite(bounds.minRow) ||
|
|
1890
|
+
!isFinite(bounds.minColumn) ||
|
|
1891
|
+
bounds.maxRow === 0 ||
|
|
1892
|
+
bounds.maxColumn === 0) {
|
|
1893
|
+
return "A1";
|
|
1894
|
+
}
|
|
1895
|
+
const startCol = columnIndexToLetter(bounds.minColumn);
|
|
1896
|
+
const endCol = columnIndexToLetter(bounds.maxColumn);
|
|
1897
|
+
return `${startCol}${bounds.minRow}:${endCol}${bounds.maxRow}`;
|
|
1898
|
+
};
|
|
1899
|
+
const buildColumnsXml = (sheet, bounds) => {
|
|
1900
|
+
const metadata = sheet.columnMetadata ?? [];
|
|
1901
|
+
const maxColumns = Math.max(metadata.length - 1, bounds.maxColumn || 0);
|
|
1902
|
+
if (!maxColumns) {
|
|
1903
|
+
return "";
|
|
1904
|
+
}
|
|
1905
|
+
// Only emit ``<col>`` entries for columns the caller actually customized
|
|
1906
|
+
// (explicit width, hidden, or outline level). Emitting a `<col>` with the
|
|
1907
|
+
// engine's internal default width for every column — and stamping it with
|
|
1908
|
+
// ``customWidth="1"`` — overrides Excel's natural default-column-width
|
|
1909
|
+
// behavior and makes columns visibly narrower / wider than the equivalent
|
|
1910
|
+
// untouched openpyxl output. This matches stock openpyxl, which omits
|
|
1911
|
+
// ``<col>`` for non-customized columns and relies on Excel's default.
|
|
1912
|
+
const cols = [];
|
|
1913
|
+
for (let columnIndex = 1; columnIndex <= maxColumns; columnIndex += 1) {
|
|
1914
|
+
const props = metadata[columnIndex];
|
|
1915
|
+
if (!props)
|
|
1916
|
+
continue;
|
|
1917
|
+
const hasExplicitSize = Boolean(props.size && props.size > 0);
|
|
1918
|
+
const hasHiddenFlag = Boolean(props.hiddenByUser || props.hiddenByFilter || props.hiddenByGroup);
|
|
1919
|
+
const outlineLevel = props.outlineLevel ?? 0;
|
|
1920
|
+
const isCollapsed = Boolean(props.collapsed);
|
|
1921
|
+
const hasOutlineMetadata = outlineLevel > 0 || isCollapsed;
|
|
1922
|
+
if (!hasExplicitSize && !hasHiddenFlag && !hasOutlineMetadata)
|
|
1923
|
+
continue;
|
|
1924
|
+
const attributes = [`min="${columnIndex}"`, `max="${columnIndex}"`];
|
|
1925
|
+
if (hasExplicitSize) {
|
|
1926
|
+
const width = columnPixelToChars(getColumnWidthPx(sheet, columnIndex - 1));
|
|
1927
|
+
attributes.push(`width="${width.toFixed(2)}"`);
|
|
1928
|
+
attributes.push(`customWidth="1"`);
|
|
1929
|
+
}
|
|
1930
|
+
if (hasHiddenFlag) {
|
|
1931
|
+
attributes.push(`hidden="1"`);
|
|
1932
|
+
}
|
|
1933
|
+
if (outlineLevel > 0) {
|
|
1934
|
+
attributes.push(`outlineLevel="${Math.min(7, Math.trunc(outlineLevel))}"`);
|
|
1935
|
+
}
|
|
1936
|
+
if (isCollapsed) {
|
|
1937
|
+
attributes.push(`collapsed="1"`);
|
|
1938
|
+
}
|
|
1939
|
+
cols.push(`<col ${attributes.join(" ")}/>`);
|
|
1940
|
+
}
|
|
1941
|
+
if (!cols.length)
|
|
1942
|
+
return "";
|
|
1943
|
+
return `<cols>${cols.join("")}</cols>`;
|
|
1944
|
+
};
|
|
1945
|
+
const buildTablePartsXml = (tables, relationships) => {
|
|
1946
|
+
if (!tables.length) {
|
|
1947
|
+
return "";
|
|
1948
|
+
}
|
|
1949
|
+
const parts = tables
|
|
1950
|
+
.map((table) => {
|
|
1951
|
+
const relId = relationships.add(TABLE_REL_TYPE, table.target);
|
|
1952
|
+
return `<tablePart r:id="${relId}"/>`;
|
|
1953
|
+
})
|
|
1954
|
+
.join("");
|
|
1955
|
+
return `<tableParts count="${tables.length}">${parts}</tableParts>`;
|
|
1956
|
+
};
|
|
1957
|
+
const buildSheetViewsXml = (sheet) => {
|
|
1958
|
+
const showGridLines = sheet.showGridLines ?? true;
|
|
1959
|
+
const showRowColHeaders = sheet.showRowColHeaders ?? true;
|
|
1960
|
+
const showZeros = sheet.showZeros ?? true;
|
|
1961
|
+
const zoomScale = sheet.zoomScale;
|
|
1962
|
+
const attrs = [`workbookViewId="0"`];
|
|
1963
|
+
if (!showGridLines) {
|
|
1964
|
+
attrs.push(`showGridLines="0"`);
|
|
1965
|
+
}
|
|
1966
|
+
if (!showRowColHeaders) {
|
|
1967
|
+
attrs.push(`showRowColHeaders="0"`);
|
|
1968
|
+
}
|
|
1969
|
+
if (!showZeros) {
|
|
1970
|
+
attrs.push(`showZeros="0"`);
|
|
1971
|
+
}
|
|
1972
|
+
// Only emit zoomScale when set to a non-default value (Excel default = 100).
|
|
1973
|
+
if (typeof zoomScale === "number" &&
|
|
1974
|
+
zoomScale >= 10 &&
|
|
1975
|
+
zoomScale <= 400 &&
|
|
1976
|
+
zoomScale !== 100) {
|
|
1977
|
+
attrs.push(`zoomScale="${zoomScale}"`);
|
|
1978
|
+
}
|
|
1979
|
+
const panes = [];
|
|
1980
|
+
const frozenRows = sheet.frozenRowCount ?? 0;
|
|
1981
|
+
const frozenColumns = sheet.frozenColumnCount ?? 0;
|
|
1982
|
+
if (frozenRows > 0 || frozenColumns > 0) {
|
|
1983
|
+
const paneAttrs = [`state="frozen"`];
|
|
1984
|
+
if (frozenColumns > 0) {
|
|
1985
|
+
paneAttrs.push(`xSplit="${frozenColumns}"`);
|
|
1986
|
+
}
|
|
1987
|
+
if (frozenRows > 0) {
|
|
1988
|
+
paneAttrs.push(`ySplit="${frozenRows}"`);
|
|
1989
|
+
}
|
|
1990
|
+
// Default view anchor = the first unfrozen cell. A preserved source
|
|
1991
|
+
// scroll position (`sheet.paneTopLeftCell`) wins when it's a valid A1
|
|
1992
|
+
// ref at or beyond that anchor, so an untouched import/export keeps the
|
|
1993
|
+
// user's saved scroll instead of snapping back to the freeze corner.
|
|
1994
|
+
let topLeftCell = `${columnIndexToLetter(Math.max(1, frozenColumns + 1))}${frozenRows + 1}`;
|
|
1995
|
+
const savedTopLeft = sheet
|
|
1996
|
+
.paneTopLeftCell;
|
|
1997
|
+
if (typeof savedTopLeft === "string") {
|
|
1998
|
+
const m = /^([A-Z]+)([0-9]+)$/.exec(savedTopLeft);
|
|
1999
|
+
if (m) {
|
|
2000
|
+
const col = m[1]
|
|
2001
|
+
.split("")
|
|
2002
|
+
.reduce((acc, ch) => acc * 26 + (ch.charCodeAt(0) - 64), 0);
|
|
2003
|
+
const row = Number(m[2]);
|
|
2004
|
+
if (row >= frozenRows + 1 && col >= Math.max(1, frozenColumns + 1)) {
|
|
2005
|
+
topLeftCell = savedTopLeft;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
paneAttrs.push(`topLeftCell="${topLeftCell}"`);
|
|
2010
|
+
// Anchor active selection in the unfrozen quadrant. Excel writes
|
|
2011
|
+
// `pane="bottomRight"` when both rows AND columns are frozen,
|
|
2012
|
+
// `bottomLeft` when only rows, `topRight` when only columns. Without
|
|
2013
|
+
// this the cursor lands in A1 (the frozen region) on first open.
|
|
2014
|
+
let activePane;
|
|
2015
|
+
if (frozenRows > 0 && frozenColumns > 0) {
|
|
2016
|
+
activePane = "bottomRight";
|
|
2017
|
+
}
|
|
2018
|
+
else if (frozenRows > 0) {
|
|
2019
|
+
activePane = "bottomLeft";
|
|
2020
|
+
}
|
|
2021
|
+
else {
|
|
2022
|
+
activePane = "topRight";
|
|
2023
|
+
}
|
|
2024
|
+
paneAttrs.push(`activePane="${activePane}"`);
|
|
2025
|
+
panes.push(`<pane ${paneAttrs.join(" ")}/>`);
|
|
2026
|
+
panes.push(`<selection pane="${activePane}" activeCell="${topLeftCell}" sqref="${topLeftCell}"/>`);
|
|
2027
|
+
}
|
|
2028
|
+
return `<sheetViews><sheetView ${attrs.join(" ")}>${panes.join("")}</sheetView></sheetViews>`;
|
|
2029
|
+
};
|
|
2030
|
+
const columnIndexToLetter = (index) => {
|
|
2031
|
+
let dividend = Math.max(1, index);
|
|
2032
|
+
let columnName = "";
|
|
2033
|
+
while (dividend > 0) {
|
|
2034
|
+
const modulo = (dividend - 1) % 26;
|
|
2035
|
+
columnName = String.fromCharCode(65 + modulo) + columnName;
|
|
2036
|
+
dividend = Math.floor((dividend - modulo) / 26);
|
|
2037
|
+
}
|
|
2038
|
+
return columnName;
|
|
2039
|
+
};
|
|
2040
|
+
/**
|
|
2041
|
+
* Build the `<rPr>` block for one rich-text run. Maps our internal
|
|
2042
|
+
* TextFormat keys to OOXML run-properties. Only emits properties that
|
|
2043
|
+
* differ from the cell-level base — Excel falls back to the parent
|
|
2044
|
+
* cell's `<xf>` style for anything absent, matching what users expect.
|
|
2045
|
+
*/
|
|
2046
|
+
const buildRichRunProperties = (format) => {
|
|
2047
|
+
const parts = [];
|
|
2048
|
+
if (format?.bold)
|
|
2049
|
+
parts.push(`<b/>`);
|
|
2050
|
+
if (format?.italic)
|
|
2051
|
+
parts.push(`<i/>`);
|
|
2052
|
+
if (format?.underline)
|
|
2053
|
+
parts.push(`<u/>`);
|
|
2054
|
+
if (format?.strikethrough)
|
|
2055
|
+
parts.push(`<strike/>`);
|
|
2056
|
+
if (format?.baselineOffset === "superscript") {
|
|
2057
|
+
parts.push(`<vertAlign val="superscript"/>`);
|
|
2058
|
+
}
|
|
2059
|
+
else if (format?.baselineOffset === "subscript") {
|
|
2060
|
+
parts.push(`<vertAlign val="subscript"/>`);
|
|
2061
|
+
}
|
|
2062
|
+
if (typeof format?.fontSize === "number") {
|
|
2063
|
+
parts.push(`<sz val="${format.fontSize}"/>`);
|
|
2064
|
+
}
|
|
2065
|
+
if (typeof format?.fontFamily === "string" && format.fontFamily) {
|
|
2066
|
+
parts.push(`<rFont val="${escapeXmlAttr(format.fontFamily)}"/>`);
|
|
2067
|
+
}
|
|
2068
|
+
if (format?.color) {
|
|
2069
|
+
// Color may be a string ("#RRGGBB" or "rgb(...)") or a theme/tint
|
|
2070
|
+
// object. We only emit the simple `rgb=` form for now — theme
|
|
2071
|
+
// colors round-trip lossless via `<color theme="N" tint="X"/>`
|
|
2072
|
+
// but require additional resolution we don't need yet.
|
|
2073
|
+
const colorValue = format.color;
|
|
2074
|
+
if (typeof colorValue === "string") {
|
|
2075
|
+
const hex = colorValue.replace(/^#/, "").toUpperCase();
|
|
2076
|
+
// Excel expects ARGB; default alpha = FF if a 6-digit hex.
|
|
2077
|
+
const rgb = hex.length === 6 ? `FF${hex}` : hex;
|
|
2078
|
+
if (/^[0-9A-F]{8}$/.test(rgb)) {
|
|
2079
|
+
parts.push(`<color rgb="${rgb}"/>`);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
else if (typeof colorValue === "object" && colorValue !== null) {
|
|
2083
|
+
const c = colorValue;
|
|
2084
|
+
if (typeof c.rgba === "string") {
|
|
2085
|
+
const hex = c.rgba.replace(/^#/, "").toUpperCase();
|
|
2086
|
+
const rgb = hex.length === 6 ? `FF${hex}` : hex;
|
|
2087
|
+
if (/^[0-9A-F]{8}$/.test(rgb)) {
|
|
2088
|
+
parts.push(`<color rgb="${rgb}"/>`);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
else if (typeof c.theme === "number") {
|
|
2092
|
+
const tintAttr = typeof c.tint === "number" && c.tint !== 0
|
|
2093
|
+
? ` tint="${c.tint}"`
|
|
2094
|
+
: "";
|
|
2095
|
+
parts.push(`<color theme="${c.theme}"${tintAttr}/>`);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
return parts.length ? `<rPr>${parts.join("")}</rPr>` : "";
|
|
2100
|
+
};
|
|
2101
|
+
/**
|
|
2102
|
+
* Slice a value string into `<r>` blocks based on the run array. Runs
|
|
2103
|
+
* are assumed non-overlapping and sorted; gaps between runs become
|
|
2104
|
+
* unformatted `<r><t>...</t></r>` blocks (matching how Excel emits
|
|
2105
|
+
* sequential plain segments). Newline characters survive verbatim in
|
|
2106
|
+
* `<t>` — Excel honours them only when the cell carries the wrap
|
|
2107
|
+
* style flag, but that's a cell-style concern, not a sharedString one.
|
|
2108
|
+
*/
|
|
2109
|
+
const buildRichTextRuns = (text, runs) => {
|
|
2110
|
+
const sorted = [...runs].sort((a, b) => a.startIndex - b.startIndex);
|
|
2111
|
+
const out = [];
|
|
2112
|
+
let cursor = 0;
|
|
2113
|
+
const emit = (segment, rPr) => {
|
|
2114
|
+
if (!segment)
|
|
2115
|
+
return;
|
|
2116
|
+
const needsSpace = /^\s|\s$|[\r\n]/.test(segment);
|
|
2117
|
+
const spaceAttr = needsSpace ? ' xml:space="preserve"' : "";
|
|
2118
|
+
out.push(`<r>${rPr}<t${spaceAttr}>${escapeXmlText(segment)}</t></r>`);
|
|
2119
|
+
};
|
|
2120
|
+
for (const run of sorted) {
|
|
2121
|
+
const start = Math.max(0, Math.min(text.length, run.startIndex));
|
|
2122
|
+
const end = Math.max(start, Math.min(text.length, run.endIndex));
|
|
2123
|
+
if (start > cursor) {
|
|
2124
|
+
emit(text.slice(cursor, start), "");
|
|
2125
|
+
}
|
|
2126
|
+
if (start < end) {
|
|
2127
|
+
if (run.nodeType === "mention") {
|
|
2128
|
+
// Mentions don't carry inline formatting on the XLSX side —
|
|
2129
|
+
// emit as plain text (their label is already inline in the value).
|
|
2130
|
+
emit(text.slice(start, end), "");
|
|
2131
|
+
}
|
|
2132
|
+
else {
|
|
2133
|
+
emit(text.slice(start, end), buildRichRunProperties(run.format));
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
if (end > cursor)
|
|
2137
|
+
cursor = end;
|
|
2138
|
+
}
|
|
2139
|
+
if (cursor < text.length) {
|
|
2140
|
+
emit(text.slice(cursor), "");
|
|
2141
|
+
}
|
|
2142
|
+
return out.join("");
|
|
2143
|
+
};
|
|
2144
|
+
const buildSharedStringsXml = (sharedStrings) => {
|
|
2145
|
+
const entries = sharedStrings.entries
|
|
2146
|
+
.map((entry) => {
|
|
2147
|
+
if (entry.kind === "rich") {
|
|
2148
|
+
return `<si>${buildRichTextRuns(entry.text, entry.runs)}</si>`;
|
|
2149
|
+
}
|
|
2150
|
+
const needsSpace = /^\s|\s$|[\r\n]/.test(entry.text);
|
|
2151
|
+
const spaceAttr = needsSpace ? ' xml:space="preserve"' : "";
|
|
2152
|
+
return `<si><t${spaceAttr}>${escapeXmlText(entry.text)}</t></si>`;
|
|
2153
|
+
})
|
|
2154
|
+
.join("");
|
|
2155
|
+
return `${XML_DECLARATION}<sst xmlns="${WORKSHEET_NS}" count="${sharedStrings.totalCount}" uniqueCount="${sharedStrings.entries.length}">${entries}</sst>`;
|
|
2156
|
+
};
|
|
2157
|
+
const buildTableNameMap = (tables) => {
|
|
2158
|
+
const map = new Map();
|
|
2159
|
+
for (const table of tables) {
|
|
2160
|
+
const tableTitle = table.title ?? table.name;
|
|
2161
|
+
if (!tableTitle || tableTitle === table.name)
|
|
2162
|
+
continue;
|
|
2163
|
+
map.set(tableTitle, table.name);
|
|
2164
|
+
}
|
|
2165
|
+
return map;
|
|
2166
|
+
};
|
|
2167
|
+
// Excel rejects tables whose range starts at row/column 0 (1-indexed only) or
|
|
2168
|
+
// whose box intersects another table on the same sheet. Drop both before
|
|
2169
|
+
// emitting so the workbook opens without "We found a problem" recovery prompts.
|
|
2170
|
+
const filterExportableTables = (tables) => {
|
|
2171
|
+
const isValidBox = (range) => {
|
|
2172
|
+
if (!range)
|
|
2173
|
+
return false;
|
|
2174
|
+
const { startRowIndex, startColumnIndex, endRowIndex, endColumnIndex } = range;
|
|
2175
|
+
if (typeof startRowIndex !== "number" ||
|
|
2176
|
+
typeof startColumnIndex !== "number" ||
|
|
2177
|
+
typeof endRowIndex !== "number" ||
|
|
2178
|
+
typeof endColumnIndex !== "number") {
|
|
2179
|
+
return false;
|
|
2180
|
+
}
|
|
2181
|
+
if (startRowIndex < 1 || startColumnIndex < 1)
|
|
2182
|
+
return false;
|
|
2183
|
+
if (endRowIndex < startRowIndex || endColumnIndex < startColumnIndex) {
|
|
2184
|
+
return false;
|
|
2185
|
+
}
|
|
2186
|
+
return true;
|
|
2187
|
+
};
|
|
2188
|
+
const boxesIntersect = (a, b) => !(a.endRowIndex < b.startRowIndex ||
|
|
2189
|
+
b.endRowIndex < a.startRowIndex ||
|
|
2190
|
+
a.endColumnIndex < b.startColumnIndex ||
|
|
2191
|
+
b.endColumnIndex < a.startColumnIndex);
|
|
2192
|
+
const keptBySheet = new Map();
|
|
2193
|
+
const result = [];
|
|
2194
|
+
for (const table of tables) {
|
|
2195
|
+
if (!isValidBox(table.range))
|
|
2196
|
+
continue;
|
|
2197
|
+
const sheetKept = keptBySheet.get(table.sheetId) ?? [];
|
|
2198
|
+
if (sheetKept.some((r) => boxesIntersect(table.range, r)))
|
|
2199
|
+
continue;
|
|
2200
|
+
sheetKept.push(table.range);
|
|
2201
|
+
keptBySheet.set(table.sheetId, sheetKept);
|
|
2202
|
+
result.push(table);
|
|
2203
|
+
}
|
|
2204
|
+
return result;
|
|
2205
|
+
};
|
|
2206
|
+
const prepareTables = (tables) => {
|
|
2207
|
+
const result = [];
|
|
2208
|
+
tables.forEach((table, index) => {
|
|
2209
|
+
const ref = gridRangeToRef(table.range);
|
|
2210
|
+
if (!ref)
|
|
2211
|
+
return;
|
|
2212
|
+
const id = index + 1;
|
|
2213
|
+
const sanitized = sanitizeTableName(table.title ?? `Table${id}`);
|
|
2214
|
+
result.push({
|
|
2215
|
+
...table,
|
|
2216
|
+
id,
|
|
2217
|
+
ref,
|
|
2218
|
+
path: `xl/tables/table${id}.xml`,
|
|
2219
|
+
target: `../tables/table${id}.xml`,
|
|
2220
|
+
name: sanitized,
|
|
2221
|
+
displayName: sanitized,
|
|
2222
|
+
});
|
|
2223
|
+
});
|
|
2224
|
+
return result;
|
|
2225
|
+
};
|
|
2226
|
+
const sanitizeTableName = (name) => {
|
|
2227
|
+
const cleaned = name.replace(/[^A-Za-z0-9_]/g, "") || "Table";
|
|
2228
|
+
if (/^\d/.test(cleaned)) {
|
|
2229
|
+
return `T${cleaned}`;
|
|
2230
|
+
}
|
|
2231
|
+
return cleaned;
|
|
2232
|
+
};
|
|
2233
|
+
const buildTableXml = (table, rows, sourceSharedStrings, styleBuilder) => {
|
|
2234
|
+
const rangeRef = table.ref;
|
|
2235
|
+
const columnCount = Math.max(table.columns?.length ?? 0, (table.range.endColumnIndex ?? table.range.startColumnIndex) -
|
|
2236
|
+
table.range.startColumnIndex +
|
|
2237
|
+
1);
|
|
2238
|
+
const headerRowCount = table.headerRow === false ? 0 : 1;
|
|
2239
|
+
const totalsRowCount = table.totalRow ? 1 : 0;
|
|
2240
|
+
const headerRowIndex = table.range.startRowIndex;
|
|
2241
|
+
const columnNames = Array.from({ length: columnCount }, (_, index) => {
|
|
2242
|
+
// Always read from cell data first - this is the source of truth for what Excel sees
|
|
2243
|
+
if (headerRowCount > 0) {
|
|
2244
|
+
const columnIndex = table.range.startColumnIndex + index;
|
|
2245
|
+
const cell = rows[headerRowIndex]?.values?.[columnIndex];
|
|
2246
|
+
// Check shared string first - this matches what gets written to the cell XML
|
|
2247
|
+
const sharedStringIndex = cell?.ss;
|
|
2248
|
+
if (sharedStringIndex != null && sourceSharedStrings) {
|
|
2249
|
+
const raw = sourceSharedStrings.get(String(sharedStringIndex));
|
|
2250
|
+
const sharedStringValue = typeof raw === "string"
|
|
2251
|
+
? raw
|
|
2252
|
+
: raw && typeof raw === "object"
|
|
2253
|
+
? raw.text
|
|
2254
|
+
: undefined;
|
|
2255
|
+
if (sharedStringValue != null && sharedStringValue.trim() !== "") {
|
|
2256
|
+
return sharedStringValue;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
// Try formattedValue first, then extract string from effectiveValue/userEnteredValue
|
|
2260
|
+
const formattedValue = getCellFormattedValue(cell);
|
|
2261
|
+
if (formattedValue != null && String(formattedValue).trim() !== "") {
|
|
2262
|
+
return String(formattedValue);
|
|
2263
|
+
}
|
|
2264
|
+
const effectiveValue = getCellEffectiveValue(cell);
|
|
2265
|
+
const effectiveString = getExtendedValueString(effectiveValue);
|
|
2266
|
+
if (effectiveString != null && effectiveString.trim() !== "") {
|
|
2267
|
+
return effectiveString;
|
|
2268
|
+
}
|
|
2269
|
+
const userEnteredValue = getCellUserEnteredValue(cell);
|
|
2270
|
+
const userEnteredString = getExtendedValueString(userEnteredValue);
|
|
2271
|
+
if (userEnteredString != null && userEnteredString.trim() !== "") {
|
|
2272
|
+
return userEnteredString;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
// Fallback to table.columns name or generic name
|
|
2276
|
+
return table.columns?.[index]?.name ?? `Column${index + 1}`;
|
|
2277
|
+
});
|
|
2278
|
+
const tableColumns = `<tableColumns count="${columnCount}">${columnNames
|
|
2279
|
+
.map((name, idx) => {
|
|
2280
|
+
const columnFormula = table.columns?.[idx]?.formula;
|
|
2281
|
+
const normalizedFormula = columnFormula
|
|
2282
|
+
? normalizeCalculatedColumnFormula(columnFormula, table)
|
|
2283
|
+
: undefined;
|
|
2284
|
+
const formulaXml = normalizedFormula
|
|
2285
|
+
? `<calculatedColumnFormula>${escapeXmlText(stripFormula(normalizedFormula))}</calculatedColumnFormula>`
|
|
2286
|
+
: "";
|
|
2287
|
+
return `<tableColumn id="${idx + 1}" name="${escapeXmlAttr(name)}">${formulaXml}</tableColumn>`;
|
|
2288
|
+
})
|
|
2289
|
+
.join("")}</tableColumns>`;
|
|
2290
|
+
const isBuiltInStyle = table.theme && /^TableStyle(Light|Medium|Dark)\d+$/.test(table.theme);
|
|
2291
|
+
const tableStyleName = isBuiltInStyle ? table.theme : "TableStyleMedium2";
|
|
2292
|
+
const tableStyleInfo = `<tableStyleInfo name="${tableStyleName}" showFirstColumn="${table.showFirstColumn ? 1 : 0}" showLastColumn="${table.showLastColumn ? 1 : 0}" showRowStripes="${table.showRowStripes ? 1 : 0}" showColumnStripes="${table.showColumnStripes ? 1 : 0}"/>`;
|
|
2293
|
+
const autoFilter = headerRowCount > 0 && table.filterButton !== false
|
|
2294
|
+
? (() => {
|
|
2295
|
+
const filterColumns = buildFilterColumnsXml(table.filterSpecs ?? [], table.range, rows, table.headerRow ?? true, table.totalRow ?? false, styleBuilder);
|
|
2296
|
+
return filterColumns
|
|
2297
|
+
? `<autoFilter ref="${rangeRef}">${filterColumns}</autoFilter>`
|
|
2298
|
+
: `<autoFilter ref="${rangeRef}"/>`;
|
|
2299
|
+
})()
|
|
2300
|
+
: "";
|
|
2301
|
+
return `${XML_DECLARATION}<table xmlns="${WORKSHEET_NS}" id="${table.id}" name="${escapeXmlAttr(table.name)}" displayName="${escapeXmlAttr(table.displayName)}" ref="${rangeRef}" totalsRowCount="${totalsRowCount}" headerRowCount="${headerRowCount}">${autoFilter}${tableColumns}${tableStyleInfo}</table>`;
|
|
2302
|
+
};
|
|
2303
|
+
const getFilterVisibleValues = (spec, range, rows, headerRow = true, totalRow = false) => {
|
|
2304
|
+
const values = new Set();
|
|
2305
|
+
const startRowIndex = range.startRowIndex + (headerRow ? 1 : 0);
|
|
2306
|
+
const endRowIndex = range.endRowIndex - (totalRow ? 1 : 0);
|
|
2307
|
+
const columnIndex = range.startColumnIndex + spec.columnIndex;
|
|
2308
|
+
for (let rowIndex = startRowIndex; rowIndex <= endRowIndex; rowIndex++) {
|
|
2309
|
+
const cell = rows[rowIndex]?.values?.[columnIndex];
|
|
2310
|
+
const formattedValue = getCellFormattedValue(cell);
|
|
2311
|
+
const fallbackValue = formattedValue ??
|
|
2312
|
+
getCellEffectiveValue(cell) ??
|
|
2313
|
+
getCellUserEnteredValue(cell);
|
|
2314
|
+
if (isNil(fallbackValue) || fallbackValue === "") {
|
|
2315
|
+
values.add(BLANK_LABEL);
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
values.add(String(fallbackValue));
|
|
2319
|
+
}
|
|
2320
|
+
return [...values];
|
|
2321
|
+
};
|
|
2322
|
+
const buildWorksheetAutoFilterXml = (sheet, rows, styleBuilder) => {
|
|
2323
|
+
const basicFilter = sheet.basicFilter;
|
|
2324
|
+
if (!basicFilter?.range)
|
|
2325
|
+
return "";
|
|
2326
|
+
const ref = cellRangeToAddress(undefined, basicFilter.range);
|
|
2327
|
+
if (!ref)
|
|
2328
|
+
return "";
|
|
2329
|
+
const filterColumns = buildFilterColumnsXml(basicFilter.filterSpecs, basicFilter.range, rows, true, false, styleBuilder);
|
|
2330
|
+
if (filterColumns) {
|
|
2331
|
+
return `<autoFilter ref="${ref}">${filterColumns}</autoFilter>`;
|
|
2332
|
+
}
|
|
2333
|
+
return `<autoFilter ref="${ref}"/>`;
|
|
2334
|
+
};
|
|
2335
|
+
const buildSheetPropertiesXml = (sheet) => {
|
|
2336
|
+
const tabColor = serializeColorXml(sheet.tabColor ?? undefined);
|
|
2337
|
+
// <outlinePr> mirrors the sheet's `outlinePr` setting; both attrs
|
|
2338
|
+
// default to true in Excel, so only emit when explicitly false. If
|
|
2339
|
+
// the user only customised one half, the other half stays implicit.
|
|
2340
|
+
const outlinePr = sheet.outlinePr;
|
|
2341
|
+
const outlineAttrs = [];
|
|
2342
|
+
if (outlinePr?.summaryBelow === false) {
|
|
2343
|
+
outlineAttrs.push(`summaryBelow="0"`);
|
|
2344
|
+
}
|
|
2345
|
+
if (outlinePr?.summaryRight === false) {
|
|
2346
|
+
outlineAttrs.push(`summaryRight="0"`);
|
|
2347
|
+
}
|
|
2348
|
+
const outlineXml = outlineAttrs.length
|
|
2349
|
+
? `<outlinePr ${outlineAttrs.join(" ")}/>`
|
|
2350
|
+
: "";
|
|
2351
|
+
if (!tabColor && !outlineXml)
|
|
2352
|
+
return "";
|
|
2353
|
+
const inner = `${tabColor ? `<tabColor ${tabColor}/>` : ""}${outlineXml}`;
|
|
2354
|
+
return `<sheetPr>${inner}</sheetPr>`;
|
|
2355
|
+
};
|
|
2356
|
+
const buildFilterColumnsXml = (filterSpecs, range, rows, headerRow = true, totalRow = false, styleBuilder) => {
|
|
2357
|
+
if (!filterSpecs?.length)
|
|
2358
|
+
return "";
|
|
2359
|
+
const entries = filterSpecs
|
|
2360
|
+
.map((spec) => buildFilterColumnXml(spec, range, rows, headerRow, totalRow, styleBuilder))
|
|
2361
|
+
.filter((entry) => Boolean(entry));
|
|
2362
|
+
return entries.join("");
|
|
2363
|
+
};
|
|
2364
|
+
const buildFilterColumnXml = (spec, range, rows, headerRow = true, totalRow = false, styleBuilder) => {
|
|
2365
|
+
const parts = [];
|
|
2366
|
+
// <colorFilter dxfId="N" cellColor="1|0"/> — Excel matches against the
|
|
2367
|
+
// dxf table. We register the color as a dxf and emit the resulting id.
|
|
2368
|
+
const colorFilter = spec.filterCriteria?.colorFilter;
|
|
2369
|
+
if (colorFilter && styleBuilder) {
|
|
2370
|
+
const dxfId = colorFilter.mode === "cellColor"
|
|
2371
|
+
? styleBuilder.registerDifferentialFormat({
|
|
2372
|
+
backgroundColor: colorFilter.color,
|
|
2373
|
+
})
|
|
2374
|
+
: styleBuilder.registerDifferentialFormat({
|
|
2375
|
+
textFormat: { color: colorFilter.color },
|
|
2376
|
+
});
|
|
2377
|
+
if (typeof dxfId === "number") {
|
|
2378
|
+
const cellColorAttr = colorFilter.mode === "cellColor" ? "1" : "0";
|
|
2379
|
+
parts.push(`<colorFilter dxfId="${dxfId}" cellColor="${cellColorAttr}"/>`);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
// <iconFilter iconSet="…" iconId="…"/>
|
|
2383
|
+
const iconFilter = spec.filterCriteria?.iconFilter;
|
|
2384
|
+
if (iconFilter?.iconSet) {
|
|
2385
|
+
parts.push(`<iconFilter iconSet="${escapeXmlAttr(iconFilter.iconSet)}" iconId="${iconFilter.iconId ?? 0}"/>`);
|
|
2386
|
+
}
|
|
2387
|
+
// A condition filter (greater-than / contains / between / relative-date /
|
|
2388
|
+
// above-average …) takes precedence over a value-list and is emitted as
|
|
2389
|
+
// <customFilters> or <dynamicFilter>. Falls back to the value list when the
|
|
2390
|
+
// condition has no AutoFilter representation.
|
|
2391
|
+
const conditionXml = buildAutoFilterConditionXml(spec.filterCriteria?.condition);
|
|
2392
|
+
if (conditionXml) {
|
|
2393
|
+
parts.push(conditionXml);
|
|
2394
|
+
}
|
|
2395
|
+
else {
|
|
2396
|
+
// <filters><filter val=…/></filters>
|
|
2397
|
+
const values = spec.filterCriteria?.visibleValues ??
|
|
2398
|
+
getFilterVisibleValues(spec, range, rows, headerRow, totalRow);
|
|
2399
|
+
if (values?.length) {
|
|
2400
|
+
const filters = values
|
|
2401
|
+
.map((value) => `<filter val="${escapeXmlAttr(value)}"/>`)
|
|
2402
|
+
.join("");
|
|
2403
|
+
parts.push(`<filters>${filters}</filters>`);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
if (!parts.length)
|
|
2407
|
+
return "";
|
|
2408
|
+
return `<filterColumn colId="${spec.columnIndex}">${parts.join("")}</filterColumn>`;
|
|
2409
|
+
};
|
|
2410
|
+
const gridRangeToRef = (range) => {
|
|
2411
|
+
if (!range)
|
|
2412
|
+
return undefined;
|
|
2413
|
+
try {
|
|
2414
|
+
const ref = cellRangeToAddress(undefined, range);
|
|
2415
|
+
return ref ?? undefined;
|
|
2416
|
+
}
|
|
2417
|
+
catch {
|
|
2418
|
+
return undefined;
|
|
2419
|
+
}
|
|
2420
|
+
};
|
|
2421
|
+
const groupEmbedsBySheet = (embeds) => {
|
|
2422
|
+
const result = new Map();
|
|
2423
|
+
for (const embed of embeds ?? []) {
|
|
2424
|
+
if (!embed.position?.overlayPosition)
|
|
2425
|
+
continue;
|
|
2426
|
+
if (embed.spec?.type !== "image")
|
|
2427
|
+
continue;
|
|
2428
|
+
const overlay = embed.position.overlayPosition;
|
|
2429
|
+
const placement = {
|
|
2430
|
+
kind: "embed",
|
|
2431
|
+
imageUrl: embed.spec.imageUrl,
|
|
2432
|
+
rowIndex: overlay.anchorCell.rowIndex,
|
|
2433
|
+
columnIndex: overlay.anchorCell.columnIndex,
|
|
2434
|
+
offsetX: overlay.offsetXPixels ?? 0,
|
|
2435
|
+
offsetY: overlay.offsetYPixels ?? 0,
|
|
2436
|
+
width: overlay.widthPixels ?? 120,
|
|
2437
|
+
height: overlay.heightPixels ?? 120,
|
|
2438
|
+
};
|
|
2439
|
+
const list = result.get(embed.position.sheetId) ?? [];
|
|
2440
|
+
list.push(placement);
|
|
2441
|
+
result.set(embed.position.sheetId, list);
|
|
2442
|
+
}
|
|
2443
|
+
return result;
|
|
2444
|
+
};
|
|
2445
|
+
const groupTextBoxesBySheet = (embeds) => {
|
|
2446
|
+
const result = new Map();
|
|
2447
|
+
for (const embed of embeds ?? []) {
|
|
2448
|
+
if (!embed.position?.overlayPosition)
|
|
2449
|
+
continue;
|
|
2450
|
+
if (embed.spec?.type !== "textbox")
|
|
2451
|
+
continue;
|
|
2452
|
+
const overlay = embed.position.overlayPosition;
|
|
2453
|
+
const placement = {
|
|
2454
|
+
textBox: embed,
|
|
2455
|
+
rowIndex: overlay.anchorCell.rowIndex,
|
|
2456
|
+
columnIndex: overlay.anchorCell.columnIndex,
|
|
2457
|
+
offsetX: overlay.offsetXPixels ?? 0,
|
|
2458
|
+
offsetY: overlay.offsetYPixels ?? 0,
|
|
2459
|
+
width: overlay.widthPixels ?? 120,
|
|
2460
|
+
height: overlay.heightPixels ?? 60,
|
|
2461
|
+
};
|
|
2462
|
+
const list = result.get(embed.position.sheetId) ?? [];
|
|
2463
|
+
list.push(placement);
|
|
2464
|
+
result.set(embed.position.sheetId, list);
|
|
2465
|
+
}
|
|
2466
|
+
return result;
|
|
2467
|
+
};
|
|
2468
|
+
const groupShapesBySheet = (embeds) => {
|
|
2469
|
+
const result = new Map();
|
|
2470
|
+
for (const embed of embeds ?? []) {
|
|
2471
|
+
if (!embed.position?.overlayPosition)
|
|
2472
|
+
continue;
|
|
2473
|
+
if (embed.spec?.type !== "shape")
|
|
2474
|
+
continue;
|
|
2475
|
+
const overlay = embed.position.overlayPosition;
|
|
2476
|
+
const placement = {
|
|
2477
|
+
shape: embed,
|
|
2478
|
+
rowIndex: overlay.anchorCell.rowIndex,
|
|
2479
|
+
columnIndex: overlay.anchorCell.columnIndex,
|
|
2480
|
+
offsetX: overlay.offsetXPixels ?? 0,
|
|
2481
|
+
offsetY: overlay.offsetYPixels ?? 0,
|
|
2482
|
+
width: overlay.widthPixels ?? 120,
|
|
2483
|
+
height: overlay.heightPixels ?? 60,
|
|
2484
|
+
};
|
|
2485
|
+
const list = result.get(embed.position.sheetId) ?? [];
|
|
2486
|
+
list.push(placement);
|
|
2487
|
+
result.set(embed.position.sheetId, list);
|
|
2488
|
+
}
|
|
2489
|
+
return result;
|
|
2490
|
+
};
|
|
2491
|
+
const groupChartsBySheet = (charts) => {
|
|
2492
|
+
const result = new Map();
|
|
2493
|
+
for (const chart of charts ?? []) {
|
|
2494
|
+
const overlay = chart.position?.overlayPosition;
|
|
2495
|
+
if (!overlay)
|
|
2496
|
+
continue;
|
|
2497
|
+
const placement = {
|
|
2498
|
+
chart,
|
|
2499
|
+
rowIndex: overlay.anchorCell.rowIndex,
|
|
2500
|
+
columnIndex: overlay.anchorCell.columnIndex,
|
|
2501
|
+
offsetX: overlay.offsetXPixels ?? 0,
|
|
2502
|
+
offsetY: overlay.offsetYPixels ?? 0,
|
|
2503
|
+
width: overlay.widthPixels ?? 300,
|
|
2504
|
+
height: overlay.heightPixels ?? 200,
|
|
2505
|
+
};
|
|
2506
|
+
const list = result.get(chart.position.sheetId) ?? [];
|
|
2507
|
+
list.push(placement);
|
|
2508
|
+
result.set(chart.position.sheetId, list);
|
|
2509
|
+
}
|
|
2510
|
+
return result;
|
|
2511
|
+
};
|
|
2512
|
+
const groupSlicersBySheet = (slicers) => {
|
|
2513
|
+
const result = new Map();
|
|
2514
|
+
for (const slicer of slicers ?? []) {
|
|
2515
|
+
const overlay = slicer.position?.overlayPosition;
|
|
2516
|
+
if (!overlay || typeof slicer.position !== "object")
|
|
2517
|
+
continue;
|
|
2518
|
+
const placement = {
|
|
2519
|
+
slicer,
|
|
2520
|
+
rowIndex: overlay.anchorCell.rowIndex,
|
|
2521
|
+
columnIndex: overlay.anchorCell.columnIndex,
|
|
2522
|
+
offsetX: overlay.offsetXPixels ?? 0,
|
|
2523
|
+
offsetY: overlay.offsetYPixels ?? 0,
|
|
2524
|
+
width: overlay.widthPixels ?? 200,
|
|
2525
|
+
height: overlay.heightPixels ?? 200,
|
|
2526
|
+
};
|
|
2527
|
+
const list = result.get(slicer.position.sheetId) ?? [];
|
|
2528
|
+
list.push(placement);
|
|
2529
|
+
result.set(slicer.position.sheetId, list);
|
|
2530
|
+
}
|
|
2531
|
+
return result;
|
|
2532
|
+
};
|
|
2533
|
+
const buildConditionalFormattingXml = ({ conditionalFormats, sheetId, styleBuilder, }) => {
|
|
2534
|
+
if (!conditionalFormats?.length) {
|
|
2535
|
+
return "";
|
|
2536
|
+
}
|
|
2537
|
+
let priority = 1;
|
|
2538
|
+
const orderedRules = conditionalFormats
|
|
2539
|
+
.map((rule, index) => ({ rule, index }))
|
|
2540
|
+
.sort((left, right) => {
|
|
2541
|
+
const leftPriority = left.rule.priority;
|
|
2542
|
+
const rightPriority = right.rule.priority;
|
|
2543
|
+
if (leftPriority == null && rightPriority == null) {
|
|
2544
|
+
return left.index - right.index;
|
|
2545
|
+
}
|
|
2546
|
+
if (leftPriority == null) {
|
|
2547
|
+
return 1;
|
|
2548
|
+
}
|
|
2549
|
+
if (rightPriority == null) {
|
|
2550
|
+
return -1;
|
|
2551
|
+
}
|
|
2552
|
+
if (leftPriority === rightPriority) {
|
|
2553
|
+
return left.index - right.index;
|
|
2554
|
+
}
|
|
2555
|
+
return leftPriority - rightPriority;
|
|
2556
|
+
})
|
|
2557
|
+
.map(({ rule }) => rule);
|
|
2558
|
+
const parts = [];
|
|
2559
|
+
for (const rule of orderedRules) {
|
|
2560
|
+
if (rule.enabled === false)
|
|
2561
|
+
continue;
|
|
2562
|
+
const ranges = (rule.ranges ?? []).filter((range) => range.sheetId === sheetId);
|
|
2563
|
+
const sqrefParts = ranges
|
|
2564
|
+
.map((range) => cellRangeToAddress(undefined, range))
|
|
2565
|
+
.filter((ref) => Boolean(ref));
|
|
2566
|
+
if (!sqrefParts.length)
|
|
2567
|
+
continue;
|
|
2568
|
+
const baseCellAddress = ranges.length
|
|
2569
|
+
? cellToAddress({
|
|
2570
|
+
rowIndex: ranges[0].startRowIndex,
|
|
2571
|
+
columnIndex: ranges[0].startColumnIndex,
|
|
2572
|
+
}) ?? undefined
|
|
2573
|
+
: undefined;
|
|
2574
|
+
const basePriority = rule.priority ?? priority;
|
|
2575
|
+
const cfRulesXml = buildConditionalFormattingRuleXml({
|
|
2576
|
+
rule,
|
|
2577
|
+
styleBuilder,
|
|
2578
|
+
priority: basePriority,
|
|
2579
|
+
baseCellAddress,
|
|
2580
|
+
});
|
|
2581
|
+
if (!cfRulesXml?.length)
|
|
2582
|
+
continue;
|
|
2583
|
+
const ruleXmlEntries = Array.isArray(cfRulesXml)
|
|
2584
|
+
? cfRulesXml
|
|
2585
|
+
: [cfRulesXml];
|
|
2586
|
+
priority = Math.max(priority, basePriority + ruleXmlEntries.length);
|
|
2587
|
+
parts.push(`<conditionalFormatting sqref="${sqrefParts.join(" ")}">${ruleXmlEntries.join("")}</conditionalFormatting>`);
|
|
2588
|
+
}
|
|
2589
|
+
return parts.join("");
|
|
2590
|
+
};
|
|
2591
|
+
// Lazy-loaded XMLBuilder for re-serializing opaque cfRule subtrees
|
|
2592
|
+
// (colorScale/dataBar/iconSet) captured from the source xlsx during import.
|
|
2593
|
+
let _cfChildBuilder = null;
|
|
2594
|
+
const getCfChildBuilder = () => {
|
|
2595
|
+
if (_cfChildBuilder)
|
|
2596
|
+
return _cfChildBuilder;
|
|
2597
|
+
// Defer the require so this module stays tree-shakeable when the cf
|
|
2598
|
+
// passthrough path isn't exercised.
|
|
2599
|
+
const { XMLBuilder } = require("fast-xml-parser");
|
|
2600
|
+
_cfChildBuilder = new XMLBuilder({
|
|
2601
|
+
ignoreAttributes: false,
|
|
2602
|
+
attributesGroupName: "a$",
|
|
2603
|
+
textNodeName: "t$",
|
|
2604
|
+
suppressEmptyNode: true,
|
|
2605
|
+
format: false,
|
|
2606
|
+
});
|
|
2607
|
+
return _cfChildBuilder;
|
|
2608
|
+
};
|
|
2609
|
+
const buildXlsxSourceCfRuleXml = (rule, styleBuilder, priority) => {
|
|
2610
|
+
// Round-trip path for imported rules: build the cfRule XML directly from
|
|
2611
|
+
// the verbatim source attrs/formulas in `xlsxSource`. Preserves the
|
|
2612
|
+
// original cfRule type, operator, formula text, and `text`/`timePeriod`/
|
|
2613
|
+
// `rank`/`percent`/`aboveAverage`/etc. attributes that the synthesizer
|
|
2614
|
+
// would otherwise drop or rewrite.
|
|
2615
|
+
const src = rule.xlsxSource;
|
|
2616
|
+
if (!src?.type)
|
|
2617
|
+
return undefined;
|
|
2618
|
+
// Format → dxfId via the existing CellFormat path. We pull the format from
|
|
2619
|
+
// whichever rule shape currently holds it (booleanRule/topBottomRule/
|
|
2620
|
+
// distinctRule) — the import always populates one of these alongside
|
|
2621
|
+
// xlsxSource for non-colorScale rules, so we get a faithful dxfId.
|
|
2622
|
+
const format = rule.booleanRule?.format ??
|
|
2623
|
+
rule.topBottomRule?.format ??
|
|
2624
|
+
rule.distinctRule?.format;
|
|
2625
|
+
const dxfId = format !== undefined
|
|
2626
|
+
? styleBuilder.registerDifferentialFormat(format)
|
|
2627
|
+
: undefined;
|
|
2628
|
+
const stopIfTrueAttr = rule.stopIfTrue ? ' stopIfTrue="1"' : "";
|
|
2629
|
+
const attrs = [`type="${src.type}"`];
|
|
2630
|
+
if (dxfId != null) {
|
|
2631
|
+
attrs.push(`dxfId="${dxfId}"`);
|
|
2632
|
+
}
|
|
2633
|
+
attrs.push(`priority="${priority}"`);
|
|
2634
|
+
if (src.operator) {
|
|
2635
|
+
attrs.push(`operator="${src.operator}"`);
|
|
2636
|
+
}
|
|
2637
|
+
if (src.text !== undefined) {
|
|
2638
|
+
attrs.push(`text="${escapeXmlAttr(src.text)}"`);
|
|
2639
|
+
}
|
|
2640
|
+
if (src.timePeriod) {
|
|
2641
|
+
attrs.push(`timePeriod="${src.timePeriod}"`);
|
|
2642
|
+
}
|
|
2643
|
+
if (typeof src.rank === "number") {
|
|
2644
|
+
attrs.push(`rank="${src.rank}"`);
|
|
2645
|
+
}
|
|
2646
|
+
if (src.percent) {
|
|
2647
|
+
attrs.push(`percent="1"`);
|
|
2648
|
+
}
|
|
2649
|
+
if (src.bottom) {
|
|
2650
|
+
attrs.push(`bottom="1"`);
|
|
2651
|
+
}
|
|
2652
|
+
if (src.aboveAverage === false) {
|
|
2653
|
+
attrs.push(`aboveAverage="0"`);
|
|
2654
|
+
}
|
|
2655
|
+
if (src.equalAverage) {
|
|
2656
|
+
attrs.push(`equalAverage="1"`);
|
|
2657
|
+
}
|
|
2658
|
+
if (typeof src.stdDev === "number") {
|
|
2659
|
+
attrs.push(`stdDev="${src.stdDev}"`);
|
|
2660
|
+
}
|
|
2661
|
+
if (rule.stopIfTrue) {
|
|
2662
|
+
// already added via stopIfTrueAttr below — placeholder kept to mirror
|
|
2663
|
+
// intent if attr ordering changes
|
|
2664
|
+
}
|
|
2665
|
+
const formulasXml = src.formulas
|
|
2666
|
+
?.map((f) => `<formula>${escapeXmlText(f)}</formula>`)
|
|
2667
|
+
.join("") ?? "";
|
|
2668
|
+
// Re-emit any opaque child subtrees (colorScale/dataBar/iconSet) captured
|
|
2669
|
+
// verbatim during import. These elements have rich structure (cfvo stops,
|
|
2670
|
+
// colors, value thresholds, icon set IDs) that the high-level model
|
|
2671
|
+
// can't fully represent — the only way to round-trip them losslessly is
|
|
2672
|
+
// to serialize the parsed XML back as-is.
|
|
2673
|
+
const builder = src.colorScale || src.dataBar || src.iconSet
|
|
2674
|
+
? getCfChildBuilder()
|
|
2675
|
+
: null;
|
|
2676
|
+
const childXmlParts = [];
|
|
2677
|
+
if (src.colorScale && builder) {
|
|
2678
|
+
childXmlParts.push(builder.build({ colorScale: src.colorScale }));
|
|
2679
|
+
}
|
|
2680
|
+
if (src.dataBar && builder) {
|
|
2681
|
+
childXmlParts.push(builder.build({ dataBar: src.dataBar }));
|
|
2682
|
+
}
|
|
2683
|
+
if (src.iconSet && builder) {
|
|
2684
|
+
childXmlParts.push(builder.build({ iconSet: src.iconSet }));
|
|
2685
|
+
}
|
|
2686
|
+
return `<cfRule ${attrs.join(" ")}${stopIfTrueAttr}>${formulasXml}${childXmlParts.join("")}</cfRule>`;
|
|
2687
|
+
};
|
|
2688
|
+
const buildConditionalFormattingRuleXml = ({ rule, styleBuilder, priority, baseCellAddress, }) => {
|
|
2689
|
+
// Round-trip via xlsxSource when present (imported rules). Falls through
|
|
2690
|
+
// to synthesizer for rules without source metadata (UI-created).
|
|
2691
|
+
if (rule.xlsxSource?.type) {
|
|
2692
|
+
const xml = buildXlsxSourceCfRuleXml(rule, styleBuilder, priority);
|
|
2693
|
+
if (xml)
|
|
2694
|
+
return xml;
|
|
2695
|
+
}
|
|
2696
|
+
const rules = [];
|
|
2697
|
+
const baseCell = baseCellAddress ?? undefined;
|
|
2698
|
+
const stopIfTrueAttr = rule.stopIfTrue ? ' stopIfTrue="1"' : "";
|
|
2699
|
+
const escapeFormulaText = (value) => value.replace(/"/g, '""');
|
|
2700
|
+
const buildCellIsRule = (dxfId, operator, formula1, formula2) => {
|
|
2701
|
+
const parts = [
|
|
2702
|
+
`<cfRule type="cellIs" operator="${operator}" dxfId="${dxfId}" priority="${priority + rules.length}"${stopIfTrueAttr}>`,
|
|
2703
|
+
`<formula>${escapeXmlText(formula1)}</formula>`,
|
|
2704
|
+
];
|
|
2705
|
+
if (formula2) {
|
|
2706
|
+
parts.push(`<formula>${escapeXmlText(formula2)}</formula>`);
|
|
2707
|
+
}
|
|
2708
|
+
parts.push(`</cfRule>`);
|
|
2709
|
+
return parts.join("");
|
|
2710
|
+
};
|
|
2711
|
+
const buildExpressionRule = (dxfId, formula) => `<cfRule type="expression" dxfId="${dxfId}" priority="${priority + rules.length}"${stopIfTrueAttr}><formula>${escapeXmlText(formula)}</formula></cfRule>`;
|
|
2712
|
+
const buildBooleanRuleXml = () => {
|
|
2713
|
+
const booleanRule = rule.booleanRule;
|
|
2714
|
+
if (!booleanRule?.condition?.type) {
|
|
2715
|
+
return undefined;
|
|
2716
|
+
}
|
|
2717
|
+
const dxfId = styleBuilder.registerDifferentialFormat(booleanRule.format);
|
|
2718
|
+
if (dxfId == null)
|
|
2719
|
+
return undefined;
|
|
2720
|
+
const { condition } = booleanRule;
|
|
2721
|
+
const values = condition.values ?? [];
|
|
2722
|
+
if (condition.type === "CUSTOM_FORMULA") {
|
|
2723
|
+
const formula = condition.values?.[0]?.userEnteredValue ?? booleanRule.formula;
|
|
2724
|
+
if (!formula)
|
|
2725
|
+
return undefined;
|
|
2726
|
+
const cleanedFormula = stripFormula(formula);
|
|
2727
|
+
return buildExpressionRule(dxfId, cleanedFormula);
|
|
2728
|
+
}
|
|
2729
|
+
if (condition.type === "NUMBER_ABOVE_AVERAGE" ||
|
|
2730
|
+
condition.type === "NUMBER_BELOW_AVERAGE") {
|
|
2731
|
+
const attrs = [
|
|
2732
|
+
`type="aboveAverage"`,
|
|
2733
|
+
`dxfId="${dxfId}"`,
|
|
2734
|
+
`priority="${priority + rules.length}"`,
|
|
2735
|
+
];
|
|
2736
|
+
if (rule.stopIfTrue) {
|
|
2737
|
+
attrs.push(`stopIfTrue="1"`);
|
|
2738
|
+
}
|
|
2739
|
+
if (condition.type === "NUMBER_BELOW_AVERAGE") {
|
|
2740
|
+
attrs.push(`aboveAverage="0"`);
|
|
2741
|
+
}
|
|
2742
|
+
return `<cfRule ${attrs.join(" ")}/>`;
|
|
2743
|
+
}
|
|
2744
|
+
if (condition.type === "ERROR" || condition.type === "NOT_ERROR") {
|
|
2745
|
+
const type = condition.type === "ERROR" ? "containsErrors" : "notContainsErrors";
|
|
2746
|
+
return `<cfRule type="${type}" dxfId="${dxfId}" priority="${priority + rules.length}"${stopIfTrueAttr}/>`;
|
|
2747
|
+
}
|
|
2748
|
+
if (condition.type === "DATE_TODAY" ||
|
|
2749
|
+
condition.type === "DATE_YESTERDAY" ||
|
|
2750
|
+
condition.type === "DATE_TOMORROW" ||
|
|
2751
|
+
condition.type === "DATE_LAST_7_DAYS" ||
|
|
2752
|
+
condition.type === "DATE_LAST_WEEK" ||
|
|
2753
|
+
condition.type === "DATE_THIS_WEEK" ||
|
|
2754
|
+
condition.type === "DATE_NEXT_WEEK" ||
|
|
2755
|
+
condition.type === "DATE_LAST_MONTH" ||
|
|
2756
|
+
condition.type === "DATE_THIS_MONTH" ||
|
|
2757
|
+
condition.type === "DATE_NEXT_MONTH") {
|
|
2758
|
+
if (!baseCell)
|
|
2759
|
+
return undefined;
|
|
2760
|
+
const timePeriodMap = {
|
|
2761
|
+
DATE_TODAY: "today",
|
|
2762
|
+
DATE_YESTERDAY: "yesterday",
|
|
2763
|
+
DATE_TOMORROW: "tomorrow",
|
|
2764
|
+
DATE_LAST_7_DAYS: "last7Days",
|
|
2765
|
+
DATE_LAST_WEEK: "lastWeek",
|
|
2766
|
+
DATE_THIS_WEEK: "thisWeek",
|
|
2767
|
+
DATE_NEXT_WEEK: "nextWeek",
|
|
2768
|
+
DATE_LAST_MONTH: "lastMonth",
|
|
2769
|
+
DATE_THIS_MONTH: "thisMonth",
|
|
2770
|
+
DATE_NEXT_MONTH: "nextMonth",
|
|
2771
|
+
};
|
|
2772
|
+
const normalizedCell = `INT(${baseCell})`;
|
|
2773
|
+
const weekStart = "TODAY()-WEEKDAY(TODAY(),2)+1";
|
|
2774
|
+
const periodFormulaMap = {
|
|
2775
|
+
DATE_TODAY: `${normalizedCell}=TODAY()`,
|
|
2776
|
+
DATE_YESTERDAY: `${normalizedCell}=TODAY()-1`,
|
|
2777
|
+
DATE_TOMORROW: `${normalizedCell}=TODAY()+1`,
|
|
2778
|
+
DATE_LAST_7_DAYS: `AND(${normalizedCell}>=TODAY()-6,${normalizedCell}<=TODAY())`,
|
|
2779
|
+
DATE_THIS_WEEK: `AND(${normalizedCell}>=${weekStart},${normalizedCell}<=${weekStart}+6)`,
|
|
2780
|
+
DATE_LAST_WEEK: `AND(${normalizedCell}>=${weekStart}-7,${normalizedCell}<=${weekStart}-1)`,
|
|
2781
|
+
DATE_NEXT_WEEK: `AND(${normalizedCell}>=${weekStart}+7,${normalizedCell}<=${weekStart}+13)`,
|
|
2782
|
+
DATE_THIS_MONTH: `AND(${normalizedCell}>=EOMONTH(TODAY(),-1)+1,${normalizedCell}<=EOMONTH(TODAY(),0))`,
|
|
2783
|
+
DATE_LAST_MONTH: `AND(${normalizedCell}>=EOMONTH(TODAY(),-2)+1,${normalizedCell}<=EOMONTH(TODAY(),-1))`,
|
|
2784
|
+
DATE_NEXT_MONTH: `AND(${normalizedCell}>=EOMONTH(TODAY(),0)+1,${normalizedCell}<=EOMONTH(TODAY(),1))`,
|
|
2785
|
+
};
|
|
2786
|
+
const timePeriod = timePeriodMap[condition.type];
|
|
2787
|
+
const periodFormula = periodFormulaMap[condition.type];
|
|
2788
|
+
if (!timePeriod || !periodFormula)
|
|
2789
|
+
return undefined;
|
|
2790
|
+
return `<cfRule type="timePeriod" timePeriod="${timePeriod}" dxfId="${dxfId}" priority="${priority + rules.length}"${stopIfTrueAttr}><formula>${escapeXmlText(periodFormula)}</formula></cfRule>`;
|
|
2791
|
+
}
|
|
2792
|
+
if (condition.type === "TEXT_EQ" || condition.type === "TEXT_NOT_EQ") {
|
|
2793
|
+
const raw = values[0]?.userEnteredValue ?? "";
|
|
2794
|
+
if (!raw)
|
|
2795
|
+
return undefined;
|
|
2796
|
+
const op = condition.type === "TEXT_EQ" ? "equal" : "notEqual";
|
|
2797
|
+
const text = `"${escapeFormulaText(raw)}"`;
|
|
2798
|
+
return buildCellIsRule(dxfId, op, text);
|
|
2799
|
+
}
|
|
2800
|
+
if (condition.type === "NUMBER_BETWEEN" ||
|
|
2801
|
+
condition.type === "NUMBER_NOT_BETWEEN" ||
|
|
2802
|
+
condition.type === "NUMBER_EQ" ||
|
|
2803
|
+
condition.type === "NUMBER_NOT_EQ" ||
|
|
2804
|
+
condition.type === "NUMBER_GREATER" ||
|
|
2805
|
+
condition.type === "NUMBER_GREATER_THAN_EQ" ||
|
|
2806
|
+
condition.type === "NUMBER_LESS" ||
|
|
2807
|
+
condition.type === "NUMBER_LESS_THAN_EQ") {
|
|
2808
|
+
const firstRaw = values[0]?.userEnteredValue ?? "";
|
|
2809
|
+
if (!firstRaw)
|
|
2810
|
+
return undefined;
|
|
2811
|
+
const secondRaw = values[1]?.userEnteredValue ?? "";
|
|
2812
|
+
const operatorMap = {
|
|
2813
|
+
NUMBER_BETWEEN: "between",
|
|
2814
|
+
NUMBER_NOT_BETWEEN: "notBetween",
|
|
2815
|
+
NUMBER_EQ: "equal",
|
|
2816
|
+
NUMBER_NOT_EQ: "notEqual",
|
|
2817
|
+
NUMBER_GREATER: "greaterThan",
|
|
2818
|
+
NUMBER_GREATER_THAN_EQ: "greaterThanOrEqual",
|
|
2819
|
+
NUMBER_LESS: "lessThan",
|
|
2820
|
+
NUMBER_LESS_THAN_EQ: "lessThanOrEqual",
|
|
2821
|
+
};
|
|
2822
|
+
const operator = operatorMap[condition.type];
|
|
2823
|
+
const first = stripFormula(firstRaw);
|
|
2824
|
+
const second = stripFormula(secondRaw);
|
|
2825
|
+
if ((operator === "between" || operator === "notBetween") && !secondRaw) {
|
|
2826
|
+
return undefined;
|
|
2827
|
+
}
|
|
2828
|
+
return buildCellIsRule(dxfId, operator, first, operator === "between" || operator === "notBetween"
|
|
2829
|
+
? second
|
|
2830
|
+
: undefined);
|
|
2831
|
+
}
|
|
2832
|
+
if (condition.type === "DATE_EQ" ||
|
|
2833
|
+
condition.type === "DATE_NOT_EQ" ||
|
|
2834
|
+
condition.type === "DATE_BEFORE" ||
|
|
2835
|
+
condition.type === "DATE_AFTER" ||
|
|
2836
|
+
condition.type === "DATE_ON_OR_BEFORE" ||
|
|
2837
|
+
condition.type === "DATE_ON_OR_AFTER" ||
|
|
2838
|
+
condition.type === "DATE_BETWEEN" ||
|
|
2839
|
+
condition.type === "DATE_NOT_BETWEEN") {
|
|
2840
|
+
const firstRaw = values[0]?.userEnteredValue ?? "";
|
|
2841
|
+
if (!firstRaw)
|
|
2842
|
+
return undefined;
|
|
2843
|
+
const secondRaw = values[1]?.userEnteredValue ?? "";
|
|
2844
|
+
const operatorMap = {
|
|
2845
|
+
DATE_EQ: "equal",
|
|
2846
|
+
DATE_NOT_EQ: "notEqual",
|
|
2847
|
+
DATE_BEFORE: "lessThan",
|
|
2848
|
+
DATE_AFTER: "greaterThan",
|
|
2849
|
+
DATE_ON_OR_BEFORE: "lessThanOrEqual",
|
|
2850
|
+
DATE_ON_OR_AFTER: "greaterThanOrEqual",
|
|
2851
|
+
DATE_BETWEEN: "between",
|
|
2852
|
+
DATE_NOT_BETWEEN: "notBetween",
|
|
2853
|
+
};
|
|
2854
|
+
const normalizeDateValue = (raw) => {
|
|
2855
|
+
const trimmed = raw.trim();
|
|
2856
|
+
if (!trimmed)
|
|
2857
|
+
return "";
|
|
2858
|
+
if (trimmed.startsWith("=")) {
|
|
2859
|
+
return stripFormula(trimmed);
|
|
2860
|
+
}
|
|
2861
|
+
if (!isNaN(Number(trimmed))) {
|
|
2862
|
+
return trimmed;
|
|
2863
|
+
}
|
|
2864
|
+
return `DATEVALUE("${escapeFormulaText(trimmed)}")`;
|
|
2865
|
+
};
|
|
2866
|
+
const first = normalizeDateValue(firstRaw);
|
|
2867
|
+
const second = normalizeDateValue(secondRaw);
|
|
2868
|
+
const operator = operatorMap[condition.type];
|
|
2869
|
+
if ((operator === "between" || operator === "notBetween") && !secondRaw) {
|
|
2870
|
+
return undefined;
|
|
2871
|
+
}
|
|
2872
|
+
return buildCellIsRule(dxfId, operator, first, operator === "between" || operator === "notBetween"
|
|
2873
|
+
? second
|
|
2874
|
+
: undefined);
|
|
2875
|
+
}
|
|
2876
|
+
if (!baseCell)
|
|
2877
|
+
return undefined;
|
|
2878
|
+
const buildExpressionFormula = () => {
|
|
2879
|
+
const raw = values[0]?.userEnteredValue ?? "";
|
|
2880
|
+
const text = raw ? `"${escapeFormulaText(raw)}"` : '""';
|
|
2881
|
+
switch (condition.type) {
|
|
2882
|
+
case "TEXT_CONTAINS":
|
|
2883
|
+
return raw ? `ISNUMBER(SEARCH(${text},${baseCell}))` : undefined;
|
|
2884
|
+
case "TEXT_NOT_CONTAINS":
|
|
2885
|
+
return raw ? `ISERROR(SEARCH(${text},${baseCell}))` : undefined;
|
|
2886
|
+
case "TEXT_STARTS_WITH":
|
|
2887
|
+
return raw ? `LEFT(${baseCell},LEN(${text}))=${text}` : undefined;
|
|
2888
|
+
case "TEXT_ENDS_WITH":
|
|
2889
|
+
return raw ? `RIGHT(${baseCell},LEN(${text}))=${text}` : undefined;
|
|
2890
|
+
case "TEXT_IS_EMAIL":
|
|
2891
|
+
return `AND(ISNUMBER(SEARCH("@",${baseCell})),ISNUMBER(SEARCH(".",${baseCell})))`;
|
|
2892
|
+
case "TEXT_IS_URL":
|
|
2893
|
+
return `OR(LEFT(${baseCell},7)="http://",LEFT(${baseCell},8)="https://")`;
|
|
2894
|
+
case "BLANK":
|
|
2895
|
+
return `${baseCell}=""`;
|
|
2896
|
+
case "NOT_BLANK":
|
|
2897
|
+
return `${baseCell}<>""`;
|
|
2898
|
+
case "BOOLEAN":
|
|
2899
|
+
return `OR(${baseCell}=TRUE,${baseCell}=FALSE)`;
|
|
2900
|
+
case "DATE_IS_VALID":
|
|
2901
|
+
return `ISNUMBER(${baseCell})`;
|
|
2902
|
+
case "NUMBER_GREATER_THAN_PREVIOUS":
|
|
2903
|
+
return `IFERROR(${baseCell}>INDIRECT("R[-1]C",FALSE),TRUE)`;
|
|
2904
|
+
case "NUMBER_GREATER_THAN_EQ_PREVIOUS":
|
|
2905
|
+
return `IFERROR(${baseCell}>=INDIRECT("R[-1]C",FALSE),TRUE)`;
|
|
2906
|
+
case "NUMBER_LESS_THAN_PREVIOUS":
|
|
2907
|
+
return `IFERROR(${baseCell}<INDIRECT("R[-1]C",FALSE),TRUE)`;
|
|
2908
|
+
case "NUMBER_LESS_THAN_EQ_PREVIOUS":
|
|
2909
|
+
return `IFERROR(${baseCell}<=INDIRECT("R[-1]C",FALSE),TRUE)`;
|
|
2910
|
+
case "ONE_OF_LIST": {
|
|
2911
|
+
const entries = values
|
|
2912
|
+
.map((value) => value.userEnteredValue)
|
|
2913
|
+
.filter((value) => typeof value === "string");
|
|
2914
|
+
if (!entries.length)
|
|
2915
|
+
return undefined;
|
|
2916
|
+
const conditions = entries.map((entry) => {
|
|
2917
|
+
if (entry.startsWith("=")) {
|
|
2918
|
+
return `${baseCell}=${stripFormula(entry)}`;
|
|
2919
|
+
}
|
|
2920
|
+
if (!isNaN(Number(entry))) {
|
|
2921
|
+
return `${baseCell}=${entry}`;
|
|
2922
|
+
}
|
|
2923
|
+
return `${baseCell}="${escapeFormulaText(entry)}"`;
|
|
2924
|
+
});
|
|
2925
|
+
return `OR(${conditions.join(",")})`;
|
|
2926
|
+
}
|
|
2927
|
+
case "ONE_OF_RANGE": {
|
|
2928
|
+
const rangeRef = values[0]?.userEnteredValue ?? "";
|
|
2929
|
+
if (!rangeRef)
|
|
2930
|
+
return undefined;
|
|
2931
|
+
return `COUNTIF(${stripFormula(rangeRef)},${baseCell})>0`;
|
|
2932
|
+
}
|
|
2933
|
+
default:
|
|
2934
|
+
return undefined;
|
|
2935
|
+
}
|
|
2936
|
+
};
|
|
2937
|
+
const expression = buildExpressionFormula();
|
|
2938
|
+
return expression ? buildExpressionRule(dxfId, expression) : undefined;
|
|
2939
|
+
};
|
|
2940
|
+
const buildGradientRuleXml = () => {
|
|
2941
|
+
const gradientRule = rule.gradientRule;
|
|
2942
|
+
if (!gradientRule)
|
|
2943
|
+
return undefined;
|
|
2944
|
+
const points = [
|
|
2945
|
+
gradientRule.minpoint,
|
|
2946
|
+
gradientRule.midpoint,
|
|
2947
|
+
gradientRule.maxpoint,
|
|
2948
|
+
].filter(Boolean);
|
|
2949
|
+
if (!points.length)
|
|
2950
|
+
return undefined;
|
|
2951
|
+
const typeMap = {
|
|
2952
|
+
MIN: "min",
|
|
2953
|
+
MAX: "max",
|
|
2954
|
+
NUMBER: "num",
|
|
2955
|
+
PERCENT: "percent",
|
|
2956
|
+
PERCENTILE: "percentile",
|
|
2957
|
+
};
|
|
2958
|
+
const cfvos = [];
|
|
2959
|
+
const colors = [];
|
|
2960
|
+
points.forEach((point) => {
|
|
2961
|
+
if (!point?.type || !point.color)
|
|
2962
|
+
return;
|
|
2963
|
+
const type = typeMap[String(point.type).toUpperCase()] ?? "num";
|
|
2964
|
+
const valueAttr = point.value !== undefined && point.value !== null
|
|
2965
|
+
? ` val="${escapeXmlAttr(String(point.value))}"`
|
|
2966
|
+
: "";
|
|
2967
|
+
cfvos.push(`<cfvo type="${type}"${valueAttr}/>`);
|
|
2968
|
+
const colorAttr = serializeColorXml(point.color);
|
|
2969
|
+
if (colorAttr) {
|
|
2970
|
+
colors.push(`<color ${colorAttr}/>`);
|
|
2971
|
+
}
|
|
2972
|
+
});
|
|
2973
|
+
if (!cfvos.length || !colors.length)
|
|
2974
|
+
return undefined;
|
|
2975
|
+
return `<cfRule type="colorScale" priority="${priority + rules.length}"><colorScale>${cfvos.join("")}${colors.join("")}</colorScale></cfRule>`;
|
|
2976
|
+
};
|
|
2977
|
+
const buildTopBottomRuleXml = () => {
|
|
2978
|
+
const topBottomRule = rule.topBottomRule;
|
|
2979
|
+
if (!topBottomRule)
|
|
2980
|
+
return undefined;
|
|
2981
|
+
const dxfId = styleBuilder.registerDifferentialFormat(topBottomRule.format);
|
|
2982
|
+
if (dxfId == null)
|
|
2983
|
+
return undefined;
|
|
2984
|
+
const rank = Number.isFinite(topBottomRule.rank)
|
|
2985
|
+
? Math.max(1, topBottomRule.rank)
|
|
2986
|
+
: 10;
|
|
2987
|
+
const attrs = [
|
|
2988
|
+
`type="top10"`,
|
|
2989
|
+
`dxfId="${dxfId}"`,
|
|
2990
|
+
`priority="${priority + rules.length}"`,
|
|
2991
|
+
`rank="${rank}"`,
|
|
2992
|
+
];
|
|
2993
|
+
if (rule.stopIfTrue) {
|
|
2994
|
+
attrs.push(`stopIfTrue="1"`);
|
|
2995
|
+
}
|
|
2996
|
+
if (topBottomRule.isPercent) {
|
|
2997
|
+
attrs.push(`percent="1"`);
|
|
2998
|
+
}
|
|
2999
|
+
if (topBottomRule.type === "BOTTOM") {
|
|
3000
|
+
attrs.push(`bottom="1"`);
|
|
3001
|
+
}
|
|
3002
|
+
return `<cfRule ${attrs.join(" ")}/>`;
|
|
3003
|
+
};
|
|
3004
|
+
const buildDistinctRuleXml = () => {
|
|
3005
|
+
const distinctRule = rule.distinctRule;
|
|
3006
|
+
if (!distinctRule)
|
|
3007
|
+
return undefined;
|
|
3008
|
+
const dxfId = styleBuilder.registerDifferentialFormat(distinctRule.format);
|
|
3009
|
+
if (dxfId == null)
|
|
3010
|
+
return undefined;
|
|
3011
|
+
const type = distinctRule.type === "UNIQUE" ? "uniqueValues" : "duplicateValues";
|
|
3012
|
+
return `<cfRule type="${type}" dxfId="${dxfId}" priority="${priority + rules.length}"${stopIfTrueAttr}/>`;
|
|
3013
|
+
};
|
|
3014
|
+
const cfThresholdTypeMap = {
|
|
3015
|
+
MIN: "min",
|
|
3016
|
+
MAX: "max",
|
|
3017
|
+
NUMBER: "num",
|
|
3018
|
+
PERCENT: "percent",
|
|
3019
|
+
PERCENTILE: "percentile",
|
|
3020
|
+
FORMULA: "formula",
|
|
3021
|
+
};
|
|
3022
|
+
const buildCfvo = (t) => {
|
|
3023
|
+
if (!t?.type)
|
|
3024
|
+
return "";
|
|
3025
|
+
const type = cfThresholdTypeMap[String(t.type).toUpperCase()] ?? "num";
|
|
3026
|
+
const valueAttr = t.value !== undefined && t.value !== null
|
|
3027
|
+
? ` val="${escapeXmlAttr(String(t.value))}"`
|
|
3028
|
+
: "";
|
|
3029
|
+
return `<cfvo type="${type}"${valueAttr}/>`;
|
|
3030
|
+
};
|
|
3031
|
+
const buildIconSetRuleXml = () => {
|
|
3032
|
+
const iconSetRule = rule.iconSetRule;
|
|
3033
|
+
if (!iconSetRule)
|
|
3034
|
+
return undefined;
|
|
3035
|
+
// OOXML expects (iconCount) cfvos with a leading 0% min stop. Our engine
|
|
3036
|
+
// model keeps only the (iconCount - 1) user-meaningful thresholds, so we
|
|
3037
|
+
// prepend a synthetic 0% cfvo on export.
|
|
3038
|
+
const cfvos = [
|
|
3039
|
+
`<cfvo type="percent" val="0"/>`,
|
|
3040
|
+
...iconSetRule.thresholds.map(buildCfvo),
|
|
3041
|
+
].join("");
|
|
3042
|
+
const attrs = [
|
|
3043
|
+
`iconSet="${iconSetRule.iconSet}"`,
|
|
3044
|
+
...(iconSetRule.reverse ? [`reverse="1"`] : []),
|
|
3045
|
+
...(iconSetRule.showValue === false ? [`showValue="0"`] : []),
|
|
3046
|
+
];
|
|
3047
|
+
return `<cfRule type="iconSet" priority="${priority + rules.length}"${stopIfTrueAttr}><iconSet ${attrs.join(" ")}>${cfvos}</iconSet></cfRule>`;
|
|
3048
|
+
};
|
|
3049
|
+
const buildDataBarRuleXml = () => {
|
|
3050
|
+
const dataBarRule = rule.dataBarRule;
|
|
3051
|
+
if (!dataBarRule)
|
|
3052
|
+
return undefined;
|
|
3053
|
+
const cfvos = buildCfvo(dataBarRule.minpoint) + buildCfvo(dataBarRule.maxpoint);
|
|
3054
|
+
const colorAttr = serializeColorXml(dataBarRule.color);
|
|
3055
|
+
if (!colorAttr)
|
|
3056
|
+
return undefined;
|
|
3057
|
+
const showValueAttr = dataBarRule.showValue === false ? ` showValue="0"` : "";
|
|
3058
|
+
return `<cfRule type="dataBar" priority="${priority + rules.length}"${stopIfTrueAttr}><dataBar${showValueAttr}>${cfvos}<color ${colorAttr}/></dataBar></cfRule>`;
|
|
3059
|
+
};
|
|
3060
|
+
const booleanXml = buildBooleanRuleXml();
|
|
3061
|
+
if (booleanXml)
|
|
3062
|
+
rules.push(booleanXml);
|
|
3063
|
+
const gradientXml = buildGradientRuleXml();
|
|
3064
|
+
if (gradientXml)
|
|
3065
|
+
rules.push(gradientXml);
|
|
3066
|
+
const topBottomXml = buildTopBottomRuleXml();
|
|
3067
|
+
if (topBottomXml)
|
|
3068
|
+
rules.push(topBottomXml);
|
|
3069
|
+
const distinctXml = buildDistinctRuleXml();
|
|
3070
|
+
if (distinctXml)
|
|
3071
|
+
rules.push(distinctXml);
|
|
3072
|
+
const iconSetXml = buildIconSetRuleXml();
|
|
3073
|
+
if (iconSetXml)
|
|
3074
|
+
rules.push(iconSetXml);
|
|
3075
|
+
const dataBarXml = buildDataBarRuleXml();
|
|
3076
|
+
if (dataBarXml)
|
|
3077
|
+
rules.push(dataBarXml);
|
|
3078
|
+
return rules.length ? rules : undefined;
|
|
3079
|
+
};
|
|
3080
|
+
const buildDrawingParts = async ({ placements, textBoxes, shapes = [], startImageIndex, startChartIndex, charts, slicers, sheet, sheetNameById, }) => {
|
|
3081
|
+
if (!placements.length &&
|
|
3082
|
+
!textBoxes.length &&
|
|
3083
|
+
!shapes.length &&
|
|
3084
|
+
!charts.length &&
|
|
3085
|
+
!slicers.length)
|
|
3086
|
+
return null;
|
|
3087
|
+
const anchors = [];
|
|
3088
|
+
const relationships = new RelationshipsBuilder();
|
|
3089
|
+
const media = [];
|
|
3090
|
+
let imageIndex = startImageIndex;
|
|
3091
|
+
let chartIndex = startChartIndex;
|
|
3092
|
+
let pictureId = 1;
|
|
3093
|
+
let frameId = 1;
|
|
3094
|
+
const chartEntries = [];
|
|
3095
|
+
for (const placement of charts) {
|
|
3096
|
+
const chartXml = buildChartXml(placement.chart, sheetNameById);
|
|
3097
|
+
if (!chartXml)
|
|
3098
|
+
continue;
|
|
3099
|
+
const relId = relationships.add(CHART_REL_TYPE, `../charts/chart${chartIndex}.xml`);
|
|
3100
|
+
anchors.push(createChartAnchor({
|
|
3101
|
+
sheet,
|
|
3102
|
+
placement,
|
|
3103
|
+
relId,
|
|
3104
|
+
frameId,
|
|
3105
|
+
}));
|
|
3106
|
+
chartEntries.push({
|
|
3107
|
+
path: `xl/charts/chart${chartIndex}.xml`,
|
|
3108
|
+
xml: chartXml,
|
|
3109
|
+
});
|
|
3110
|
+
chartIndex += 1;
|
|
3111
|
+
frameId += 1;
|
|
3112
|
+
}
|
|
3113
|
+
for (const placement of slicers) {
|
|
3114
|
+
anchors.push(createSlicerAnchor({
|
|
3115
|
+
sheet,
|
|
3116
|
+
placement,
|
|
3117
|
+
frameId,
|
|
3118
|
+
}));
|
|
3119
|
+
frameId += 1;
|
|
3120
|
+
}
|
|
3121
|
+
for (const placement of textBoxes) {
|
|
3122
|
+
anchors.push(createTextBoxAnchor({
|
|
3123
|
+
sheet,
|
|
3124
|
+
placement,
|
|
3125
|
+
frameId,
|
|
3126
|
+
}));
|
|
3127
|
+
frameId += 1;
|
|
3128
|
+
}
|
|
3129
|
+
for (const placement of shapes) {
|
|
3130
|
+
anchors.push(createShapeAnchor({
|
|
3131
|
+
sheet,
|
|
3132
|
+
placement,
|
|
3133
|
+
frameId,
|
|
3134
|
+
}));
|
|
3135
|
+
frameId += 1;
|
|
3136
|
+
}
|
|
3137
|
+
for (const placement of placements) {
|
|
3138
|
+
const dataUrl = await resolveImageDataUrl(placement.imageUrl);
|
|
3139
|
+
if (!dataUrl)
|
|
3140
|
+
continue;
|
|
3141
|
+
const buffer = dataUriToBuffer(dataUrl, placement.imageUrl);
|
|
3142
|
+
if (!buffer)
|
|
3143
|
+
continue;
|
|
3144
|
+
const mediaPath = `xl/media/image${imageIndex}.${buffer.extension}`;
|
|
3145
|
+
media.push({
|
|
3146
|
+
path: mediaPath,
|
|
3147
|
+
data: buffer.data,
|
|
3148
|
+
extension: buffer.extension,
|
|
3149
|
+
});
|
|
3150
|
+
const relId = relationships.add(IMAGE_REL_TYPE, `../media/image${imageIndex}.${buffer.extension}`);
|
|
3151
|
+
anchors.push(createTwoCellAnchor({
|
|
3152
|
+
sheet,
|
|
3153
|
+
placement,
|
|
3154
|
+
relId,
|
|
3155
|
+
pictureId,
|
|
3156
|
+
}));
|
|
3157
|
+
imageIndex += 1;
|
|
3158
|
+
pictureId += 1;
|
|
3159
|
+
}
|
|
3160
|
+
const drawingXml = `${XML_DECLARATION}<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="${REL_NS}">${anchors.join("")}</xdr:wsDr>`;
|
|
3161
|
+
return {
|
|
3162
|
+
xml: drawingXml,
|
|
3163
|
+
relsXml: relationships.toXml(),
|
|
3164
|
+
media,
|
|
3165
|
+
charts: chartEntries,
|
|
3166
|
+
nextImageIndex: imageIndex,
|
|
3167
|
+
nextChartIndex: chartIndex,
|
|
3168
|
+
};
|
|
3169
|
+
};
|
|
3170
|
+
const createTwoCellAnchor = ({ placement, relId, pictureId, sheet, }) => {
|
|
3171
|
+
const fromRow = Math.max(0, placement.rowIndex - 1);
|
|
3172
|
+
const fromColumn = Math.max(0, placement.columnIndex - 1);
|
|
3173
|
+
const offsetX = Math.max(0, placement.offsetX ?? 0);
|
|
3174
|
+
const offsetY = Math.max(0, placement.offsetY ?? 0);
|
|
3175
|
+
const defaultWidth = placement.kind === "cell" ? getColumnWidthPx(sheet, fromColumn) : 120;
|
|
3176
|
+
const defaultHeight = placement.kind === "cell" ? getRowHeightPx(sheet, fromRow) : 120;
|
|
3177
|
+
const widthPx = Math.max(5, placement.width ?? defaultWidth);
|
|
3178
|
+
const heightPx = Math.max(5, placement.height ?? defaultHeight);
|
|
3179
|
+
const toColumn = resolveAnchorEndpoint({
|
|
3180
|
+
startIndex: fromColumn,
|
|
3181
|
+
startOffset: offsetX,
|
|
3182
|
+
span: widthPx,
|
|
3183
|
+
getSize: (index) => getColumnWidthPx(sheet, index, true),
|
|
3184
|
+
});
|
|
3185
|
+
const toRow = resolveAnchorEndpoint({
|
|
3186
|
+
startIndex: fromRow,
|
|
3187
|
+
startOffset: offsetY,
|
|
3188
|
+
span: heightPx,
|
|
3189
|
+
getSize: (index) => getRowHeightPx(sheet, index, true),
|
|
3190
|
+
});
|
|
3191
|
+
const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
|
|
3192
|
+
const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
|
|
3193
|
+
return `<xdr:twoCellAnchor editAs="oneCell">${fromXml}${toXml}<xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${pictureId}" name="Picture ${pictureId}"/><xdr:cNvPicPr/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="${relId}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic><xdr:clientData/></xdr:twoCellAnchor>`;
|
|
3194
|
+
};
|
|
3195
|
+
const createChartAnchor = ({ placement, relId, frameId, sheet, }) => {
|
|
3196
|
+
const fromRow = Math.max(0, placement.rowIndex - 1);
|
|
3197
|
+
const fromColumn = Math.max(0, placement.columnIndex - 1);
|
|
3198
|
+
const offsetX = Math.max(0, placement.offsetX ?? 0);
|
|
3199
|
+
const offsetY = Math.max(0, placement.offsetY ?? 0);
|
|
3200
|
+
const widthPx = Math.max(5, placement.width ?? 300);
|
|
3201
|
+
const heightPx = Math.max(5, placement.height ?? 200);
|
|
3202
|
+
const toColumn = resolveAnchorEndpoint({
|
|
3203
|
+
startIndex: fromColumn,
|
|
3204
|
+
startOffset: offsetX,
|
|
3205
|
+
span: widthPx,
|
|
3206
|
+
getSize: (index) => getColumnWidthPx(sheet, index, true),
|
|
3207
|
+
});
|
|
3208
|
+
const toRow = resolveAnchorEndpoint({
|
|
3209
|
+
startIndex: fromRow,
|
|
3210
|
+
startOffset: offsetY,
|
|
3211
|
+
span: heightPx,
|
|
3212
|
+
getSize: (index) => getRowHeightPx(sheet, index, true),
|
|
3213
|
+
});
|
|
3214
|
+
const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
|
|
3215
|
+
const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
|
|
3216
|
+
return `<xdr:twoCellAnchor>${fromXml}${toXml}<xdr:graphicFrame><xdr:nvGraphicFramePr><xdr:cNvPr id="${frameId}" name="Chart ${frameId}"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="${REL_NS}" r:id="${relId}"/></a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor>`;
|
|
3217
|
+
};
|
|
3218
|
+
const createSlicerAnchor = ({ placement, frameId, sheet, }) => {
|
|
3219
|
+
const fromRow = Math.max(0, placement.rowIndex - 1);
|
|
3220
|
+
const fromColumn = Math.max(0, placement.columnIndex - 1);
|
|
3221
|
+
const offsetX = Math.max(0, placement.offsetX ?? 0);
|
|
3222
|
+
const offsetY = Math.max(0, placement.offsetY ?? 0);
|
|
3223
|
+
const widthPx = Math.max(50, placement.width ?? 200);
|
|
3224
|
+
const heightPx = Math.max(50, placement.height ?? 200);
|
|
3225
|
+
const toColumn = resolveAnchorEndpoint({
|
|
3226
|
+
startIndex: fromColumn,
|
|
3227
|
+
startOffset: offsetX,
|
|
3228
|
+
span: widthPx,
|
|
3229
|
+
getSize: (index) => getColumnWidthPx(sheet, index, true),
|
|
3230
|
+
});
|
|
3231
|
+
const toRow = resolveAnchorEndpoint({
|
|
3232
|
+
startIndex: fromRow,
|
|
3233
|
+
startOffset: offsetY,
|
|
3234
|
+
span: heightPx,
|
|
3235
|
+
getSize: (index) => getRowHeightPx(sheet, index, true),
|
|
3236
|
+
});
|
|
3237
|
+
const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
|
|
3238
|
+
const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
|
|
3239
|
+
return `<xdr:twoCellAnchor editAs="absolute">${fromXml}${toXml}<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:sle15="http://schemas.microsoft.com/office/drawing/2012/slicer" Requires="sle15"><xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="${frameId}" name="${escapeXmlAttr(placement.title)}"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr><xdr:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></xdr:xfrm><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/drawing/2010/slicer"><sle:slicer xmlns:sle="http://schemas.microsoft.com/office/drawing/2010/slicer" name="${escapeXmlAttr(placement.title)}"/></a:graphicData></a:graphic></xdr:graphicFrame></mc:Choice></mc:AlternateContent><xdr:clientData/></xdr:twoCellAnchor>`;
|
|
3240
|
+
};
|
|
3241
|
+
const createTextBoxAnchor = ({ placement, frameId, sheet, }) => {
|
|
3242
|
+
const fromRow = Math.max(0, placement.rowIndex - 1);
|
|
3243
|
+
const fromColumn = Math.max(0, placement.columnIndex - 1);
|
|
3244
|
+
const offsetX = Math.max(0, placement.offsetX ?? 0);
|
|
3245
|
+
const offsetY = Math.max(0, placement.offsetY ?? 0);
|
|
3246
|
+
const widthPx = Math.max(50, placement.width ?? 200);
|
|
3247
|
+
const heightPx = Math.max(50, placement.height ?? 60);
|
|
3248
|
+
const toColumn = resolveAnchorEndpoint({
|
|
3249
|
+
startIndex: fromColumn,
|
|
3250
|
+
startOffset: offsetX,
|
|
3251
|
+
span: widthPx,
|
|
3252
|
+
getSize: (index) => getColumnWidthPx(sheet, index, true),
|
|
3253
|
+
});
|
|
3254
|
+
const toRow = resolveAnchorEndpoint({
|
|
3255
|
+
startIndex: fromRow,
|
|
3256
|
+
startOffset: offsetY,
|
|
3257
|
+
span: heightPx,
|
|
3258
|
+
getSize: (index) => getRowHeightPx(sheet, index, true),
|
|
3259
|
+
});
|
|
3260
|
+
const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
|
|
3261
|
+
const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
|
|
3262
|
+
const spec = placement.textBox.spec;
|
|
3263
|
+
const textBodyXml = buildTextBodyXml(spec);
|
|
3264
|
+
const shapePropertiesXml = buildShapePropertiesXml(spec, widthPx, heightPx);
|
|
3265
|
+
return `<xdr:twoCellAnchor editAs="oneCell">${fromXml}${toXml}<xdr:sp><xdr:nvSpPr><xdr:cNvPr id="${frameId}" name="TextBox ${frameId}"/><xdr:cNvSpPr txBox="1"/></xdr:nvSpPr>${shapePropertiesXml}${textBodyXml}</xdr:sp><xdr:clientData/></xdr:twoCellAnchor>`;
|
|
3266
|
+
};
|
|
3267
|
+
/** ColorIndex slots → canonical DrawingML scheme names (inverse of the importer's map). */
|
|
3268
|
+
const THEME_INDEX_TO_SCHEME = [
|
|
3269
|
+
"lt1",
|
|
3270
|
+
"dk1",
|
|
3271
|
+
"lt2",
|
|
3272
|
+
"dk2",
|
|
3273
|
+
"accent1",
|
|
3274
|
+
"accent2",
|
|
3275
|
+
"accent3",
|
|
3276
|
+
"accent4",
|
|
3277
|
+
"accent5",
|
|
3278
|
+
"accent6",
|
|
3279
|
+
"hlink",
|
|
3280
|
+
];
|
|
3281
|
+
/**
|
|
3282
|
+
* Serialize a `Color` (+optional 0–1 opacity) as a DrawingML color element.
|
|
3283
|
+
* Theme colors keep their scheme reference; a positive tint exports as
|
|
3284
|
+
* Excel's lumMod/lumOff pair ("lighter N%"), a negative one as lumMod alone
|
|
3285
|
+
* ("darker N%") — the exact encodings the importer maps back onto `tint`.
|
|
3286
|
+
*/
|
|
3287
|
+
const buildDrawingColorXml = (color, opacity) => {
|
|
3288
|
+
const mods = [];
|
|
3289
|
+
if (opacity !== undefined && opacity !== null && opacity < 1) {
|
|
3290
|
+
mods.push(`<a:alpha val="${Math.round(opacity * 100000)}"/>`);
|
|
3291
|
+
}
|
|
3292
|
+
if (typeof color === "string") {
|
|
3293
|
+
const val = escapeXmlAttr(color.replace("#", "").toUpperCase());
|
|
3294
|
+
return mods.length
|
|
3295
|
+
? `<a:srgbClr val="${val}">${mods.join("")}</a:srgbClr>`
|
|
3296
|
+
: `<a:srgbClr val="${val}"/>`;
|
|
3297
|
+
}
|
|
3298
|
+
const scheme = THEME_INDEX_TO_SCHEME[color.theme] ?? "accent1";
|
|
3299
|
+
const tint = color.tint ?? 0;
|
|
3300
|
+
if (tint > 0) {
|
|
3301
|
+
mods.push(`<a:lumMod val="${Math.round((1 - tint) * 100000)}"/>`, `<a:lumOff val="${Math.round(tint * 100000)}"/>`);
|
|
3302
|
+
}
|
|
3303
|
+
else if (tint < 0) {
|
|
3304
|
+
mods.push(`<a:lumMod val="${Math.round((1 + tint) * 100000)}"/>`);
|
|
3305
|
+
}
|
|
3306
|
+
return mods.length
|
|
3307
|
+
? `<a:schemeClr val="${scheme}">${mods.join("")}</a:schemeClr>`
|
|
3308
|
+
: `<a:schemeClr val="${scheme}"/>`;
|
|
3309
|
+
};
|
|
3310
|
+
/** Renderer preset names → OOXML `prst` values where they differ. */
|
|
3311
|
+
const PRESET_TO_OOXML = {
|
|
3312
|
+
rightTriangle: "rtTriangle",
|
|
3313
|
+
snipSingleCornerRect: "snip1Rect",
|
|
3314
|
+
};
|
|
3315
|
+
const SHAPE_DASH_TO_OOXML = {
|
|
3316
|
+
solid: "solid",
|
|
3317
|
+
dot: "dot",
|
|
3318
|
+
dash: "dash",
|
|
3319
|
+
dashDot: "dashDot",
|
|
3320
|
+
longDash: "lgDash",
|
|
3321
|
+
longDashDot: "lgDashDot",
|
|
3322
|
+
longDashDotDot: "lgDashDotDot",
|
|
3323
|
+
};
|
|
3324
|
+
const buildShapeFillXml = (fill) => {
|
|
3325
|
+
if (!fill)
|
|
3326
|
+
return "";
|
|
3327
|
+
switch (fill.kind) {
|
|
3328
|
+
case "none":
|
|
3329
|
+
return "<a:noFill/>";
|
|
3330
|
+
case "solid":
|
|
3331
|
+
return `<a:solidFill>${buildDrawingColorXml(fill.color, fill.opacity)}</a:solidFill>`;
|
|
3332
|
+
case "gradient": {
|
|
3333
|
+
const stops = fill.stops
|
|
3334
|
+
.map((stop) => `<a:gs pos="${Math.round(stop.position * 100000)}">${buildDrawingColorXml(stop.color, stop.opacity)}</a:gs>`)
|
|
3335
|
+
.join("");
|
|
3336
|
+
const direction = fill.shading === "radial"
|
|
3337
|
+
? `<a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path>`
|
|
3338
|
+
: `<a:lin ang="${Math.round((fill.angle ?? 90) * 60000)}" scaled="1"/>`;
|
|
3339
|
+
return `<a:gradFill><a:gsLst>${stops}</a:gsLst>${direction}</a:gradFill>`;
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
};
|
|
3343
|
+
const buildShapeOutlineXml = (outline) => {
|
|
3344
|
+
if (!outline)
|
|
3345
|
+
return "";
|
|
3346
|
+
if (outline.width === 0)
|
|
3347
|
+
return "<a:ln><a:noFill/></a:ln>";
|
|
3348
|
+
const wAttr = outline.width
|
|
3349
|
+
? ` w="${Math.round(outline.width * EMUS_PER_PIXEL)}"`
|
|
3350
|
+
: "";
|
|
3351
|
+
const fill = outline.color
|
|
3352
|
+
? `<a:solidFill>${buildDrawingColorXml(outline.color, outline.opacity)}</a:solidFill>`
|
|
3353
|
+
: "";
|
|
3354
|
+
const dash = outline.dash
|
|
3355
|
+
? `<a:prstDash val="${SHAPE_DASH_TO_OOXML[outline.dash]}"/>`
|
|
3356
|
+
: "";
|
|
3357
|
+
return `<a:ln${wAttr}>${fill}${dash}</a:ln>`;
|
|
3358
|
+
};
|
|
3359
|
+
const buildShapeShadowXml = (shadow) => {
|
|
3360
|
+
if (!shadow)
|
|
3361
|
+
return "";
|
|
3362
|
+
const dx = shadow.offsetXPixels ?? 0;
|
|
3363
|
+
const dy = shadow.offsetYPixels ?? 0;
|
|
3364
|
+
const dist = Math.round(Math.hypot(dx, dy) * EMUS_PER_PIXEL);
|
|
3365
|
+
const dirDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
|
|
3366
|
+
const dir = Math.round(((dirDeg + 360) % 360) * 60000);
|
|
3367
|
+
const blur = Math.round((shadow.blurRadius ?? 0) * EMUS_PER_PIXEL);
|
|
3368
|
+
const color = buildDrawingColorXml(shadow.color ?? "#000000", shadow.opacity);
|
|
3369
|
+
return `<a:effectLst><a:outerShdw blurRad="${blur}" dist="${dist}" dir="${dir}">${color}</a:outerShdw></a:effectLst>`;
|
|
3370
|
+
};
|
|
3371
|
+
const buildShapeStyleRefXml = (tag, ref) => ref
|
|
3372
|
+
? `<a:${tag} idx="${ref.index}">${buildDrawingColorXml(ref.color)}</a:${tag}>`
|
|
3373
|
+
: `<a:${tag} idx="0"/>`;
|
|
3374
|
+
/** Export an `EmbedShapeSpec` embed as an `<xdr:sp>` two-cell anchor. */
|
|
3375
|
+
const createShapeAnchor = ({ placement, frameId, sheet, }) => {
|
|
3376
|
+
const spec = placement.shape.spec;
|
|
3377
|
+
const fromRow = Math.max(0, placement.rowIndex - 1);
|
|
3378
|
+
const fromColumn = Math.max(0, placement.columnIndex - 1);
|
|
3379
|
+
const offsetX = Math.max(0, placement.offsetX ?? 0);
|
|
3380
|
+
const offsetY = Math.max(0, placement.offsetY ?? 0);
|
|
3381
|
+
const widthPx = Math.max(10, placement.width ?? 120);
|
|
3382
|
+
const heightPx = Math.max(10, placement.height ?? 60);
|
|
3383
|
+
const toColumn = resolveAnchorEndpoint({
|
|
3384
|
+
startIndex: fromColumn,
|
|
3385
|
+
startOffset: offsetX,
|
|
3386
|
+
span: widthPx,
|
|
3387
|
+
getSize: (index) => getColumnWidthPx(sheet, index, true),
|
|
3388
|
+
});
|
|
3389
|
+
const toRow = resolveAnchorEndpoint({
|
|
3390
|
+
startIndex: fromRow,
|
|
3391
|
+
startOffset: offsetY,
|
|
3392
|
+
span: heightPx,
|
|
3393
|
+
getSize: (index) => getRowHeightPx(sheet, index, true),
|
|
3394
|
+
});
|
|
3395
|
+
const fromXml = `<xdr:from><xdr:col>${fromColumn}</xdr:col><xdr:colOff>${Math.round(offsetX * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${fromRow}</xdr:row><xdr:rowOff>${Math.round(offsetY * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:from>`;
|
|
3396
|
+
const toXml = `<xdr:to><xdr:col>${toColumn.index}</xdr:col><xdr:colOff>${Math.round(toColumn.offset * EMUS_PER_PIXEL)}</xdr:colOff><xdr:row>${toRow.index}</xdr:row><xdr:rowOff>${Math.round(toRow.offset * EMUS_PER_PIXEL)}</xdr:rowOff></xdr:to>`;
|
|
3397
|
+
const xfrmAttrs = [
|
|
3398
|
+
placement.shape.rotation
|
|
3399
|
+
? ` rot="${Math.round(placement.shape.rotation * 60000)}"`
|
|
3400
|
+
: "",
|
|
3401
|
+
spec.flipHorizontal ? ' flipH="1"' : "",
|
|
3402
|
+
spec.flipVertical ? ' flipV="1"' : "",
|
|
3403
|
+
].join("");
|
|
3404
|
+
const xfrm = `<a:xfrm${xfrmAttrs}><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></a:xfrm>`;
|
|
3405
|
+
const prst = PRESET_TO_OOXML[spec.geometry.preset] ?? spec.geometry.preset;
|
|
3406
|
+
const adjustments = spec.geometry.adjustments ?? {};
|
|
3407
|
+
const gds = Object.entries(adjustments)
|
|
3408
|
+
.map(([gdName, value]) => `<a:gd name="${escapeXmlAttr(gdName)}" fmla="val ${Math.round(value)}"/>`)
|
|
3409
|
+
.join("");
|
|
3410
|
+
const geometry = `<a:prstGeom prst="${escapeXmlAttr(prst)}"><a:avLst>${gds}</a:avLst></a:prstGeom>`;
|
|
3411
|
+
const spPr = `<xdr:spPr>${xfrm}${geometry}${buildShapeFillXml(spec.fill)}${buildShapeOutlineXml(spec.outline)}${buildShapeShadowXml(spec.shadow)}</xdr:spPr>`;
|
|
3412
|
+
// <xdr:style> child order is fixed by the schema: ln, fill, effect, font.
|
|
3413
|
+
let styleXml = "";
|
|
3414
|
+
if (spec.styleRefs) {
|
|
3415
|
+
const font = spec.styleRefs.font;
|
|
3416
|
+
const fontXml = `<a:fontRef idx="${font?.scheme ?? "minor"}">${font?.color ? buildDrawingColorXml(font.color) : ""}</a:fontRef>`;
|
|
3417
|
+
styleXml = `<xdr:style>${buildShapeStyleRefXml("lnRef", spec.styleRefs.line)}${buildShapeStyleRefXml("fillRef", spec.styleRefs.fill)}${buildShapeStyleRefXml("effectRef", spec.styleRefs.effect)}${fontXml}</xdr:style>`;
|
|
3418
|
+
}
|
|
3419
|
+
const textBodyXml = spec.textBody
|
|
3420
|
+
? buildTextBodyXml(spec.textBody)
|
|
3421
|
+
: `<xdr:txBody><a:bodyPr vertOverflow="clip" horzOverflow="clip" rtlCol="0" anchor="t"/><a:lstStyle/><a:p><a:endParaRPr lang="en-US"/></a:p></xdr:txBody>`;
|
|
3422
|
+
const name = escapeXmlAttr(spec.name ?? `Shape ${frameId}`);
|
|
3423
|
+
return `<xdr:twoCellAnchor editAs="oneCell">${fromXml}${toXml}<xdr:sp><xdr:nvSpPr><xdr:cNvPr id="${frameId}" name="${name}"/><xdr:cNvSpPr/></xdr:nvSpPr>${spPr}${styleXml}${textBodyXml}</xdr:sp><xdr:clientData/></xdr:twoCellAnchor>`;
|
|
3424
|
+
};
|
|
3425
|
+
const buildTextWithTabs = (text, rPrXml) => {
|
|
3426
|
+
const parts = text.split("\t");
|
|
3427
|
+
if (parts.length === 1) {
|
|
3428
|
+
return `<a:r>${rPrXml}<a:t>${escapeXmlText(text)}</a:t></a:r>`;
|
|
3429
|
+
}
|
|
3430
|
+
return parts
|
|
3431
|
+
.map((part, index) => {
|
|
3432
|
+
if (index === 0) {
|
|
3433
|
+
return `<a:r>${rPrXml}<a:t>${escapeXmlText(part)}</a:t></a:r>`;
|
|
3434
|
+
}
|
|
3435
|
+
return `<a:r>${rPrXml}<a:tab/></a:r><a:r>${rPrXml}<a:t>${escapeXmlText(part)}</a:t></a:r>`;
|
|
3436
|
+
})
|
|
3437
|
+
.join("");
|
|
3438
|
+
};
|
|
3439
|
+
const buildTextBodyXml = (spec) => {
|
|
3440
|
+
const richText = spec.richText || [];
|
|
3441
|
+
const plainText = spec.text || "";
|
|
3442
|
+
// Build vertical alignment and wrap attributes
|
|
3443
|
+
let bodyPrAttrs = "";
|
|
3444
|
+
if (spec.verticalAlignment === "top") {
|
|
3445
|
+
bodyPrAttrs += ' anchor="t"';
|
|
3446
|
+
}
|
|
3447
|
+
else if (spec.verticalAlignment === "bottom") {
|
|
3448
|
+
bodyPrAttrs += ' anchor="b"';
|
|
3449
|
+
}
|
|
3450
|
+
else if (spec.verticalAlignment === "middle") {
|
|
3451
|
+
bodyPrAttrs += ' anchor="ctr"';
|
|
3452
|
+
}
|
|
3453
|
+
// Add wrap strategy
|
|
3454
|
+
if (spec.wrapStrategy === "wrap") {
|
|
3455
|
+
bodyPrAttrs += ' wrap="square"';
|
|
3456
|
+
}
|
|
3457
|
+
else if (spec.wrapStrategy === "overflow") {
|
|
3458
|
+
bodyPrAttrs += ' wrap="none"';
|
|
3459
|
+
}
|
|
3460
|
+
// Add padding (convert pixels to EMUs)
|
|
3461
|
+
if (spec.padding) {
|
|
3462
|
+
const paddingEmu = Math.round(spec.padding * EMUS_PER_PIXEL);
|
|
3463
|
+
bodyPrAttrs += ` lIns="${paddingEmu}" rIns="${paddingEmu}" tIns="${paddingEmu}" bIns="${paddingEmu}"`;
|
|
3464
|
+
}
|
|
3465
|
+
let paragraphsXml = "";
|
|
3466
|
+
if (richText.length > 0) {
|
|
3467
|
+
// Group runs into paragraphs by newlines
|
|
3468
|
+
const paragraphs = [[]];
|
|
3469
|
+
for (const run of richText) {
|
|
3470
|
+
if (run.text === "\n") {
|
|
3471
|
+
paragraphs.push([]);
|
|
3472
|
+
}
|
|
3473
|
+
else {
|
|
3474
|
+
paragraphs[paragraphs.length - 1].push(run);
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
paragraphsXml = paragraphs
|
|
3478
|
+
.filter((runs) => runs.length > 0)
|
|
3479
|
+
.map((runs) => {
|
|
3480
|
+
// Detect indentation and bullets from first run
|
|
3481
|
+
const firstRun = runs[0];
|
|
3482
|
+
let pPrXml = "";
|
|
3483
|
+
let processedRuns = runs;
|
|
3484
|
+
if (firstRun && firstRun.text) {
|
|
3485
|
+
const text = firstRun.text;
|
|
3486
|
+
// Detect bullet (expanded to support more types)
|
|
3487
|
+
const bulletMatch = text.match(/^([•\-◦▪○●✓✔✖✗➢►▶→⇒❯»*+>§¶])\s/);
|
|
3488
|
+
if (bulletMatch) {
|
|
3489
|
+
pPrXml = `<a:pPr><a:buChar char="${escapeXmlAttr(bulletMatch[1])}"/></a:pPr>`;
|
|
3490
|
+
// Remove bullet from text
|
|
3491
|
+
processedRuns = [
|
|
3492
|
+
{ ...firstRun, text: text.substring(bulletMatch[0].length) },
|
|
3493
|
+
...runs.slice(1),
|
|
3494
|
+
];
|
|
3495
|
+
}
|
|
3496
|
+
else {
|
|
3497
|
+
// Detect indentation (leading spaces)
|
|
3498
|
+
const spaceMatch = text.match(/^(\s+)/);
|
|
3499
|
+
if (spaceMatch) {
|
|
3500
|
+
const spaces = spaceMatch[1].length;
|
|
3501
|
+
const indentEmu = Math.round(spaces * 90000);
|
|
3502
|
+
pPrXml = `<a:pPr marL="${indentEmu}"/>`;
|
|
3503
|
+
// Remove leading spaces
|
|
3504
|
+
processedRuns = [
|
|
3505
|
+
{ ...firstRun, text: text.substring(spaces) },
|
|
3506
|
+
...runs.slice(1),
|
|
3507
|
+
];
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
const runsXml = processedRuns
|
|
3512
|
+
.map((run) => {
|
|
3513
|
+
const rPrXml = buildRunPropertiesXml(run.format);
|
|
3514
|
+
return buildTextWithTabs(run.text, rPrXml);
|
|
3515
|
+
})
|
|
3516
|
+
.join("");
|
|
3517
|
+
return `<a:p>${pPrXml}${runsXml}</a:p>`;
|
|
3518
|
+
})
|
|
3519
|
+
.join("");
|
|
3520
|
+
}
|
|
3521
|
+
else {
|
|
3522
|
+
// Plain text - split by newlines
|
|
3523
|
+
const lines = plainText.split("\n");
|
|
3524
|
+
paragraphsXml = lines
|
|
3525
|
+
.map((line) => {
|
|
3526
|
+
let pPrXml = "";
|
|
3527
|
+
let processedText = line;
|
|
3528
|
+
// Detect bullet (expanded to support more types)
|
|
3529
|
+
const bulletMatch = line.match(/^([•\-◦▪○●✓✔✖✗➢►▶→⇒❯»*+>§¶])\s/);
|
|
3530
|
+
if (bulletMatch) {
|
|
3531
|
+
pPrXml = `<a:pPr><a:buChar char="${escapeXmlAttr(bulletMatch[1])}"/></a:pPr>`;
|
|
3532
|
+
processedText = line.substring(bulletMatch[0].length);
|
|
3533
|
+
}
|
|
3534
|
+
else {
|
|
3535
|
+
// Detect indentation
|
|
3536
|
+
const spaceMatch = line.match(/^(\s+)/);
|
|
3537
|
+
if (spaceMatch) {
|
|
3538
|
+
const spaces = spaceMatch[1].length;
|
|
3539
|
+
const indentEmu = Math.round(spaces * 90000);
|
|
3540
|
+
pPrXml = `<a:pPr marL="${indentEmu}"/>`;
|
|
3541
|
+
processedText = line.substring(spaces);
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
const rPrXml = buildRunPropertiesXml(spec.textFormat);
|
|
3545
|
+
const runsXml = buildTextWithTabs(processedText, rPrXml);
|
|
3546
|
+
return `<a:p>${pPrXml}${runsXml}</a:p>`;
|
|
3547
|
+
})
|
|
3548
|
+
.join("");
|
|
3549
|
+
}
|
|
3550
|
+
return `<xdr:txBody><a:bodyPr${bodyPrAttrs}/><a:lstStyle/>${paragraphsXml}</xdr:txBody>`;
|
|
3551
|
+
};
|
|
3552
|
+
const buildRunPropertiesXml = (format) => {
|
|
3553
|
+
if (!format)
|
|
3554
|
+
return "<a:rPr/>";
|
|
3555
|
+
let attrs = "";
|
|
3556
|
+
let childElements = "";
|
|
3557
|
+
// Font size (convert points to hundredths of a point)
|
|
3558
|
+
if (format.fontSize) {
|
|
3559
|
+
attrs += ` sz="${Math.round(format.fontSize * 100)}"`;
|
|
3560
|
+
}
|
|
3561
|
+
// Bold
|
|
3562
|
+
if (format.bold) {
|
|
3563
|
+
attrs += ' b="1"';
|
|
3564
|
+
}
|
|
3565
|
+
// Italic
|
|
3566
|
+
if (format.italic) {
|
|
3567
|
+
attrs += ' i="1"';
|
|
3568
|
+
}
|
|
3569
|
+
// Underline
|
|
3570
|
+
if (format.underline) {
|
|
3571
|
+
attrs += ' u="sng"';
|
|
3572
|
+
}
|
|
3573
|
+
// Strikethrough
|
|
3574
|
+
if (format.strikethrough) {
|
|
3575
|
+
attrs += ' strike="sngStrike"';
|
|
3576
|
+
}
|
|
3577
|
+
// Font family
|
|
3578
|
+
if (format.fontFamily) {
|
|
3579
|
+
childElements += `<a:latin typeface="${escapeXmlAttr(format.fontFamily)}"/>`;
|
|
3580
|
+
}
|
|
3581
|
+
// Text color
|
|
3582
|
+
if (format.color) {
|
|
3583
|
+
let colorHex = "000000";
|
|
3584
|
+
if (typeof format.color === "string") {
|
|
3585
|
+
colorHex = format.color.replace("#", "");
|
|
3586
|
+
}
|
|
3587
|
+
else if (format.color.rgbColor) {
|
|
3588
|
+
colorHex = format.color.rgbColor.replace("#", "");
|
|
3589
|
+
}
|
|
3590
|
+
else if (format.color.red !== undefined &&
|
|
3591
|
+
format.color.green !== undefined &&
|
|
3592
|
+
format.color.blue !== undefined) {
|
|
3593
|
+
const r = Math.round(format.color.red * 255)
|
|
3594
|
+
.toString(16)
|
|
3595
|
+
.padStart(2, "0");
|
|
3596
|
+
const g = Math.round(format.color.green * 255)
|
|
3597
|
+
.toString(16)
|
|
3598
|
+
.padStart(2, "0");
|
|
3599
|
+
const b = Math.round(format.color.blue * 255)
|
|
3600
|
+
.toString(16)
|
|
3601
|
+
.padStart(2, "0");
|
|
3602
|
+
colorHex = `${r}${g}${b}`.toUpperCase();
|
|
3603
|
+
}
|
|
3604
|
+
childElements += `<a:solidFill><a:srgbClr val="${colorHex}"/></a:solidFill>`;
|
|
3605
|
+
}
|
|
3606
|
+
return `<a:rPr${attrs}>${childElements}</a:rPr>`;
|
|
3607
|
+
};
|
|
3608
|
+
const buildShapePropertiesXml = (spec, widthPx, heightPx) => {
|
|
3609
|
+
let fillXml = "";
|
|
3610
|
+
// Background color
|
|
3611
|
+
if (spec.backgroundColor) {
|
|
3612
|
+
let bgColor = "FFFFFF";
|
|
3613
|
+
if (typeof spec.backgroundColor === "string") {
|
|
3614
|
+
bgColor = spec.backgroundColor.replace("#", "");
|
|
3615
|
+
}
|
|
3616
|
+
else if (spec.backgroundColor.rgbColor) {
|
|
3617
|
+
bgColor = spec.backgroundColor.rgbColor.replace("#", "");
|
|
3618
|
+
}
|
|
3619
|
+
else if (spec.backgroundColor.red !== undefined &&
|
|
3620
|
+
spec.backgroundColor.green !== undefined &&
|
|
3621
|
+
spec.backgroundColor.blue !== undefined) {
|
|
3622
|
+
const r = Math.round(spec.backgroundColor.red * 255)
|
|
3623
|
+
.toString(16)
|
|
3624
|
+
.padStart(2, "0");
|
|
3625
|
+
const g = Math.round(spec.backgroundColor.green * 255)
|
|
3626
|
+
.toString(16)
|
|
3627
|
+
.padStart(2, "0");
|
|
3628
|
+
const b = Math.round(spec.backgroundColor.blue * 255)
|
|
3629
|
+
.toString(16)
|
|
3630
|
+
.padStart(2, "0");
|
|
3631
|
+
bgColor = `${r}${g}${b}`.toUpperCase();
|
|
3632
|
+
}
|
|
3633
|
+
fillXml = `<a:solidFill><a:srgbClr val="${bgColor}"/></a:solidFill>`;
|
|
3634
|
+
}
|
|
3635
|
+
else {
|
|
3636
|
+
fillXml = "<a:noFill/>";
|
|
3637
|
+
}
|
|
3638
|
+
// Borders (outline)
|
|
3639
|
+
let outlineXml = "<a:ln><a:noFill/></a:ln>";
|
|
3640
|
+
if (spec.borders) {
|
|
3641
|
+
// For simplicity, use the first available border style
|
|
3642
|
+
const border = spec.borders.top ||
|
|
3643
|
+
spec.borders.right ||
|
|
3644
|
+
spec.borders.bottom ||
|
|
3645
|
+
spec.borders.left;
|
|
3646
|
+
if (border) {
|
|
3647
|
+
let borderColor = "000000";
|
|
3648
|
+
if (typeof border.color === "string") {
|
|
3649
|
+
borderColor = border.color.replace("#", "");
|
|
3650
|
+
}
|
|
3651
|
+
else if (border.color?.rgbColor) {
|
|
3652
|
+
borderColor = border.color.rgbColor.replace("#", "");
|
|
3653
|
+
}
|
|
3654
|
+
else if (border.color?.red !== undefined &&
|
|
3655
|
+
border.color?.green !== undefined &&
|
|
3656
|
+
border.color?.blue !== undefined) {
|
|
3657
|
+
const r = Math.round(border.color.red * 255)
|
|
3658
|
+
.toString(16)
|
|
3659
|
+
.padStart(2, "0");
|
|
3660
|
+
const g = Math.round(border.color.green * 255)
|
|
3661
|
+
.toString(16)
|
|
3662
|
+
.padStart(2, "0");
|
|
3663
|
+
const b = Math.round(border.color.blue * 255)
|
|
3664
|
+
.toString(16)
|
|
3665
|
+
.padStart(2, "0");
|
|
3666
|
+
borderColor = `${r}${g}${b}`.toUpperCase();
|
|
3667
|
+
}
|
|
3668
|
+
const borderWidth = Math.round((border.width || 1) * 12700); // Convert to EMUs
|
|
3669
|
+
outlineXml = `<a:ln w="${borderWidth}"><a:solidFill><a:srgbClr val="${borderColor}"/></a:solidFill></a:ln>`;
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
return `<xdr:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${Math.round(widthPx * EMUS_PER_PIXEL)}" cy="${Math.round(heightPx * EMUS_PER_PIXEL)}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>${fillXml}${outlineXml}</xdr:spPr>`;
|
|
3673
|
+
};
|
|
3674
|
+
const resolveAnchorEndpoint = ({ startIndex, startOffset, span, getSize, }) => {
|
|
3675
|
+
let index = startIndex;
|
|
3676
|
+
let offset = Math.max(0, startOffset);
|
|
3677
|
+
let remaining = Math.max(0, span);
|
|
3678
|
+
while (remaining > 0) {
|
|
3679
|
+
const rawSize = getSize(index);
|
|
3680
|
+
// Hidden rows/columns have 0 size - skip over them
|
|
3681
|
+
if (rawSize <= 0) {
|
|
3682
|
+
index += 1;
|
|
3683
|
+
offset = 0;
|
|
3684
|
+
continue;
|
|
3685
|
+
}
|
|
3686
|
+
const size = Math.max(5, rawSize);
|
|
3687
|
+
const available = Math.max(0, size - offset);
|
|
3688
|
+
if (remaining <= available) {
|
|
3689
|
+
return {
|
|
3690
|
+
index,
|
|
3691
|
+
offset: Math.min(size, offset + remaining),
|
|
3692
|
+
};
|
|
3693
|
+
}
|
|
3694
|
+
remaining -= available;
|
|
3695
|
+
index += 1;
|
|
3696
|
+
offset = 0;
|
|
3697
|
+
}
|
|
3698
|
+
return {
|
|
3699
|
+
index,
|
|
3700
|
+
offset,
|
|
3701
|
+
};
|
|
3702
|
+
};
|
|
3703
|
+
const getDefaultColumnWidthPx = (sheet) => {
|
|
3704
|
+
if (sheet?.defaultColumnWidth) {
|
|
3705
|
+
return Number(sheet.defaultColumnWidth) || DEFAULT_COLUMN_WIDTH_PX;
|
|
3706
|
+
}
|
|
3707
|
+
return DEFAULT_COLUMN_WIDTH_PX;
|
|
3708
|
+
};
|
|
3709
|
+
const getDefaultRowHeightPx = (sheet) => {
|
|
3710
|
+
if (sheet?.defaultRowHeight) {
|
|
3711
|
+
return Number(sheet.defaultRowHeight) || DEFAULT_ROW_HEIGHT_PX;
|
|
3712
|
+
}
|
|
3713
|
+
return DEFAULT_ROW_HEIGHT_PX;
|
|
3714
|
+
};
|
|
3715
|
+
const getColumnWidthPx = (sheet, columnIndex, forAnchor = false) => {
|
|
3716
|
+
const metadata = sheet.columnMetadata?.[columnIndex + 1];
|
|
3717
|
+
// For anchor calculations, hidden columns have 0 width
|
|
3718
|
+
if (forAnchor && (metadata?.hiddenByUser || metadata?.hiddenByFilter)) {
|
|
3719
|
+
return 0;
|
|
3720
|
+
}
|
|
3721
|
+
if (metadata?.size && metadata.size > 0) {
|
|
3722
|
+
return metadata.size;
|
|
3723
|
+
}
|
|
3724
|
+
return getDefaultColumnWidthPx(sheet);
|
|
3725
|
+
};
|
|
3726
|
+
const getRowHeightPx = (sheet, rowIndex, forAnchor = false) => {
|
|
3727
|
+
const metadata = sheet.rowMetadata?.[rowIndex + 1];
|
|
3728
|
+
// For anchor calculations, hidden rows have 0 height
|
|
3729
|
+
if (forAnchor && (metadata?.hiddenByUser || metadata?.hiddenByFilter)) {
|
|
3730
|
+
return 0;
|
|
3731
|
+
}
|
|
3732
|
+
if (metadata?.size && metadata.size > 0) {
|
|
3733
|
+
return metadata.size;
|
|
3734
|
+
}
|
|
3735
|
+
return getDefaultRowHeightPx(sheet);
|
|
3736
|
+
};
|
|
3737
|
+
const resolveImageDataUrl = async (imageUrl) => {
|
|
3738
|
+
if (imageUrl.startsWith("data:")) {
|
|
3739
|
+
return imageUrl;
|
|
3740
|
+
}
|
|
3741
|
+
try {
|
|
3742
|
+
return await imageUrlToBase64(imageUrl);
|
|
3743
|
+
}
|
|
3744
|
+
catch {
|
|
3745
|
+
return undefined;
|
|
3746
|
+
}
|
|
3747
|
+
};
|
|
3748
|
+
const dataUriToBuffer = (dataUrl, fallbackName) => {
|
|
3749
|
+
const match = /^data:(.+?);base64,(.+)$/.exec(dataUrl);
|
|
3750
|
+
if (!match)
|
|
3751
|
+
return undefined;
|
|
3752
|
+
const [, mime, base64] = match;
|
|
3753
|
+
const binaryString = decodeBase64(base64);
|
|
3754
|
+
const buffer = new Uint8Array(binaryString.length);
|
|
3755
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
3756
|
+
buffer[i] = binaryString.charCodeAt(i);
|
|
3757
|
+
}
|
|
3758
|
+
const extension = mime.split("/")[1] ?? fallbackName?.split(".").pop() ?? "bin";
|
|
3759
|
+
return {
|
|
3760
|
+
data: buffer,
|
|
3761
|
+
extension: extension.toLowerCase(),
|
|
3762
|
+
contentType: mime,
|
|
3763
|
+
};
|
|
3764
|
+
};
|
|
3765
|
+
const decodeBase64 = (value) => {
|
|
3766
|
+
if (typeof atob === "function") {
|
|
3767
|
+
return atob(value);
|
|
3768
|
+
}
|
|
3769
|
+
const bufferCtor = globalThis.Buffer;
|
|
3770
|
+
if (bufferCtor) {
|
|
3771
|
+
return bufferCtor.from(value, "base64").toString("binary");
|
|
3772
|
+
}
|
|
3773
|
+
throw new Error("Base64 decoding is not supported in this environment");
|
|
3774
|
+
};
|
|
3775
|
+
const sanitizeSheetName = (name, used, fallback) => {
|
|
3776
|
+
const invalidChars = /[\\/:?*\[\]]/g;
|
|
3777
|
+
const controlChars = /[\u0000-\u001F]/g;
|
|
3778
|
+
let cleaned = sanitizeXmlText(name)
|
|
3779
|
+
.replace(invalidChars, " ")
|
|
3780
|
+
.replace(controlChars, "")
|
|
3781
|
+
.replace(/^'+|'+$/g, "");
|
|
3782
|
+
// Preserve trailing/internal whitespace in sheet names (Excel allows
|
|
3783
|
+
// them — "Total Bookings " is a valid name and round-trip identity
|
|
3784
|
+
// lookups (shared-formula keys, named-range refs) depend on byte-equal
|
|
3785
|
+
// sheet names). Fall back to default only when the result is fully empty
|
|
3786
|
+
// after whitespace.
|
|
3787
|
+
if (!cleaned.trim())
|
|
3788
|
+
cleaned = fallback;
|
|
3789
|
+
if (cleaned.length > 31) {
|
|
3790
|
+
cleaned = cleaned.slice(0, 31);
|
|
3791
|
+
}
|
|
3792
|
+
let unique = cleaned;
|
|
3793
|
+
let suffix = 1;
|
|
3794
|
+
while (used.has(unique)) {
|
|
3795
|
+
suffix += 1;
|
|
3796
|
+
const tail = ` (${suffix})`;
|
|
3797
|
+
const baseLength = Math.max(0, 31 - tail.length);
|
|
3798
|
+
unique = `${cleaned.slice(0, baseLength)}${tail}`;
|
|
3799
|
+
}
|
|
3800
|
+
used.add(unique);
|
|
3801
|
+
return unique;
|
|
3802
|
+
};
|
|
3803
|
+
const buildWorkbookSheetNames = (sheets) => {
|
|
3804
|
+
const usedNames = new Set();
|
|
3805
|
+
return sheets.map((sheet, index) => {
|
|
3806
|
+
const sheetId = index + 1;
|
|
3807
|
+
const fallback = `Sheet${sheetId}`;
|
|
3808
|
+
return sanitizeSheetName(sheet.title ?? fallback, usedNames, fallback);
|
|
3809
|
+
});
|
|
3810
|
+
};
|
|
3811
|
+
const buildSlicerCacheName = (value) => {
|
|
3812
|
+
const normalized = sanitizeXmlText(value)
|
|
3813
|
+
.replace(/[^A-Za-z0-9_]/g, "_")
|
|
3814
|
+
.replace(/^_+|_+$/g, "");
|
|
3815
|
+
const base = normalized || "Slicer";
|
|
3816
|
+
return base.startsWith("Slicer_") ? base : `Slicer_${base}`;
|
|
3817
|
+
};
|
|
3818
|
+
const buildCalcPrXml = (iterativeCalculationOptions) => {
|
|
3819
|
+
if (!iterativeCalculationOptions)
|
|
3820
|
+
return "";
|
|
3821
|
+
const attrs = [
|
|
3822
|
+
`iterate="${iterativeCalculationOptions.enabled ? 1 : 0}"`,
|
|
3823
|
+
];
|
|
3824
|
+
if (typeof iterativeCalculationOptions.maxIterations === "number") {
|
|
3825
|
+
attrs.push(`iterateCount="${Math.max(0, Math.floor(iterativeCalculationOptions.maxIterations))}"`);
|
|
3826
|
+
}
|
|
3827
|
+
if (typeof iterativeCalculationOptions.maxChange === "number") {
|
|
3828
|
+
attrs.push(`iterateDelta="${iterativeCalculationOptions.maxChange}"`);
|
|
3829
|
+
}
|
|
3830
|
+
return `<calcPr ${attrs.join(" ")}/>`;
|
|
3831
|
+
};
|
|
3832
|
+
const buildWorkbookXml = (sheets, sheetRelIds, sheetNames, namedRanges, iterativeCalculationOptions, slicerCacheRelIds = [], use1904 = false, externalLinkRelIds = [], pivotCacheRelIds = []) => {
|
|
3833
|
+
const sheetEntries = sheets
|
|
3834
|
+
.map((sheet, index) => {
|
|
3835
|
+
const relId = sheetRelIds[index];
|
|
3836
|
+
// Preserve the source's sheetId verbatim. Real-world workbooks have
|
|
3837
|
+
// non-sequential sheetIds (gaps left behind after sheet deletions —
|
|
3838
|
+
// Excel never renumbers), and tools that compare bytes (diff,
|
|
3839
|
+
// sensitivity-label scanners, version control) flag any renumber.
|
|
3840
|
+
// Display order is controlled by iteration order, not by sheetId
|
|
3841
|
+
// value, so preserving it has no UX impact. Fall back to index+1
|
|
3842
|
+
// only for newly-created sheets that don't carry one yet.
|
|
3843
|
+
const sheetId = sheet.sheetId ?? index + 1;
|
|
3844
|
+
const fallbackName = `Sheet${index + 1}`;
|
|
3845
|
+
const name = sheetNames[index] ?? fallbackName;
|
|
3846
|
+
// visibility is the rich source of truth (visible/hidden/veryHidden).
|
|
3847
|
+
// Fall back to the legacy `hidden` boolean when the field isn't set.
|
|
3848
|
+
const state = sheet.visibility === "veryHidden"
|
|
3849
|
+
? "veryHidden"
|
|
3850
|
+
: sheet.visibility === "hidden" || sheet.hidden
|
|
3851
|
+
? "hidden"
|
|
3852
|
+
: "visible";
|
|
3853
|
+
return `<sheet name="${escapeXmlAttr(name)}" sheetId="${sheetId}" r:id="${relId}" state="${state}"/>`;
|
|
3854
|
+
})
|
|
3855
|
+
.join("");
|
|
3856
|
+
const sheetNameById = new Map();
|
|
3857
|
+
sheets.forEach((sheet, index) => {
|
|
3858
|
+
sheetNameById.set(sheet.sheetId ?? index + 1, sheetNames[index]);
|
|
3859
|
+
});
|
|
3860
|
+
const definedNamesXml = buildDefinedNamesXml(namedRanges, sheetNameById);
|
|
3861
|
+
const calcPrXml = buildCalcPrXml(iterativeCalculationOptions);
|
|
3862
|
+
const slicerCachesXml = buildWorkbookSlicerCachesXml(slicerCacheRelIds);
|
|
3863
|
+
// Phase D2 — <pivotCaches> element. Each <pivotCache> pairs a
|
|
3864
|
+
// workbook-unique cacheId (we use 1-based sequential matching the
|
|
3865
|
+
// order pivotCacheDefinition<N>.xml files were emitted) with the
|
|
3866
|
+
// workbook-level relationship rId pointing at that cache XML.
|
|
3867
|
+
const pivotCachesXml = pivotCacheRelIds.length
|
|
3868
|
+
? `<pivotCaches>${pivotCacheRelIds
|
|
3869
|
+
.map((rId, i) => `<pivotCache cacheId="${i + 1}" r:id="${rId}"/>`)
|
|
3870
|
+
.join("")}</pivotCaches>`
|
|
3871
|
+
: "";
|
|
3872
|
+
// External-link references — needed so Excel knows `[1]Sheet!A1`
|
|
3873
|
+
// formulas resolve through the relationships we wrote into the rels
|
|
3874
|
+
// file. The position in the externalLinkRelIds array determines the
|
|
3875
|
+
// formula's `[N]` index (1-based).
|
|
3876
|
+
const externalReferencesXml = externalLinkRelIds.length
|
|
3877
|
+
? `<externalReferences>${externalLinkRelIds
|
|
3878
|
+
.map((id) => `<externalReference r:id="${id}"/>`)
|
|
3879
|
+
.join("")}</externalReferences>`
|
|
3880
|
+
: "";
|
|
3881
|
+
// `date1904="1"` shifts every date serial by 1462 days. Emit only when
|
|
3882
|
+
// the workbook is explicitly 1904-anchored so the default file matches
|
|
3883
|
+
// Excel's own writer (no attribute → 1900 epoch).
|
|
3884
|
+
const workbookPrXml = use1904
|
|
3885
|
+
? `<workbookPr date1904="1"/>`
|
|
3886
|
+
: `<workbookPr/>`;
|
|
3887
|
+
return `${XML_DECLARATION}<workbook xmlns="${WORKSHEET_NS}" xmlns:r="${REL_NS}">${workbookPrXml}${calcPrXml}<bookViews><workbookView activeTab="0" firstSheet="0"/></bookViews><sheets>${sheetEntries}</sheets>${definedNamesXml}${externalReferencesXml}${pivotCachesXml}${slicerCachesXml}</workbook>`;
|
|
3888
|
+
};
|
|
3889
|
+
const buildDefinedNamesXml = (namedRanges, sheetNameById) => {
|
|
3890
|
+
if (!namedRanges?.length)
|
|
3891
|
+
return "";
|
|
3892
|
+
const entries = [];
|
|
3893
|
+
for (const namedRange of namedRanges) {
|
|
3894
|
+
if (!namedRange?.name)
|
|
3895
|
+
continue;
|
|
3896
|
+
const hiddenAttr = namedRange.hidden ? ` hidden="1"` : "";
|
|
3897
|
+
if (namedRange.range?.sheetId) {
|
|
3898
|
+
const sheetName = sheetNameById.get(namedRange.range.sheetId);
|
|
3899
|
+
if (!sheetName)
|
|
3900
|
+
continue;
|
|
3901
|
+
const ref = cellRangeToAddress(undefined, namedRange.range);
|
|
3902
|
+
if (!ref)
|
|
3903
|
+
continue;
|
|
3904
|
+
const address = `${escapeSheetName(sheetName)}!${toAbsoluteRef(ref)}`;
|
|
3905
|
+
entries.push(`<definedName name="${escapeXmlAttr(namedRange.name)}"${hiddenAttr}>${escapeXmlText(address)}</definedName>`);
|
|
3906
|
+
continue;
|
|
3907
|
+
}
|
|
3908
|
+
const rawValue = namedRange.value;
|
|
3909
|
+
if (typeof rawValue !== "string")
|
|
3910
|
+
continue;
|
|
3911
|
+
const trimmed = rawValue.trim();
|
|
3912
|
+
if (!trimmed)
|
|
3913
|
+
continue;
|
|
3914
|
+
const expression = trimmed.startsWith("=") ? trimmed.slice(1) : trimmed;
|
|
3915
|
+
entries.push(`<definedName name="${escapeXmlAttr(namedRange.name)}"${hiddenAttr}>${escapeXmlText(expression)}</definedName>`);
|
|
3916
|
+
}
|
|
3917
|
+
if (!entries.length)
|
|
3918
|
+
return "";
|
|
3919
|
+
return `<definedNames>${entries.join("")}</definedNames>`;
|
|
3920
|
+
};
|
|
3921
|
+
const toAbsoluteRef = (ref) => {
|
|
3922
|
+
return ref
|
|
3923
|
+
.split(":")
|
|
3924
|
+
.map((part) => part.replace(/(\$?)([A-Za-z]+)(\$?)(\d+)/g, (_match, _c, col, _r, row) => {
|
|
3925
|
+
return `$${col}$${row}`;
|
|
3926
|
+
}))
|
|
3927
|
+
.join(":");
|
|
3928
|
+
};
|
|
3929
|
+
const escapeSheetName = (name) => {
|
|
3930
|
+
if (/[^A-Za-z0-9_]/.test(name)) {
|
|
3931
|
+
return `'${name.replace(/'/g, "''")}'`;
|
|
3932
|
+
}
|
|
3933
|
+
return name;
|
|
3934
|
+
};
|
|
3935
|
+
const buildWorkbookSlicerCachesXml = (slicerCacheRelIds) => {
|
|
3936
|
+
if (!slicerCacheRelIds.length) {
|
|
3937
|
+
return "";
|
|
3938
|
+
}
|
|
3939
|
+
const cacheEntries = slicerCacheRelIds
|
|
3940
|
+
.map((relId) => `<x14:slicerCache xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" r:id="${relId}"/>`)
|
|
3941
|
+
.join("");
|
|
3942
|
+
return `<extLst><ext uri="{46BE6895-7355-4a93-B00E-2C351335B9C9}" xmlns:x15="http://schemas.microsoft.com/office/spreadsheetml/2010/11/main"><x15:slicerCaches xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main">${cacheEntries}</x15:slicerCaches></ext></extLst>`;
|
|
3943
|
+
};
|
|
3944
|
+
const buildWorkbookRelationships = (sheetCount, slicerCacheCount = 0, externalLinkCount = 0, pivotCacheCount = 0, hasPersons = false) => {
|
|
3945
|
+
const relationships = [];
|
|
3946
|
+
const sheetRelIds = [];
|
|
3947
|
+
const slicerCacheRelIds = [];
|
|
3948
|
+
for (let i = 0; i < sheetCount; i++) {
|
|
3949
|
+
const relId = `rId${i + 1}`;
|
|
3950
|
+
sheetRelIds.push(relId);
|
|
3951
|
+
relationships.push(`<Relationship Id="${relId}" Type="${REL_NS}/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`);
|
|
3952
|
+
}
|
|
3953
|
+
const stylesId = `rId${sheetCount + 1}`;
|
|
3954
|
+
const themeId = `rId${sheetCount + 2}`;
|
|
3955
|
+
const sharedStringsId = `rId${sheetCount + 3}`;
|
|
3956
|
+
relationships.push(`<Relationship Id="${stylesId}" Type="${REL_NS}/styles" Target="styles.xml"/>`);
|
|
3957
|
+
relationships.push(`<Relationship Id="${themeId}" Type="${REL_NS}/theme" Target="theme/theme1.xml"/>`);
|
|
3958
|
+
relationships.push(`<Relationship Id="${sharedStringsId}" Type="${REL_NS}/sharedStrings" Target="sharedStrings.xml"/>`);
|
|
3959
|
+
let nextRelId = sheetCount + 4;
|
|
3960
|
+
for (let i = 0; i < slicerCacheCount; i++) {
|
|
3961
|
+
const relId = `rId${nextRelId++}`;
|
|
3962
|
+
slicerCacheRelIds.push(relId);
|
|
3963
|
+
relationships.push(`<Relationship Id="${relId}" Type="${SLICER_CACHE_REL_TYPE}" Target="slicerCaches/slicerCache${i + 1}.xml"/>`);
|
|
3964
|
+
}
|
|
3965
|
+
// Add external link relationships. Capture the rIds so the workbook
|
|
3966
|
+
// XML can reference them in <externalReferences>; without that element
|
|
3967
|
+
// Excel can't resolve `[1]Sheet!A1` formulas even though the files exist.
|
|
3968
|
+
const externalLinkRelIds = [];
|
|
3969
|
+
for (let i = 0; i < externalLinkCount; i++) {
|
|
3970
|
+
const relId = `rId${nextRelId++}`;
|
|
3971
|
+
externalLinkRelIds.push(relId);
|
|
3972
|
+
relationships.push(`<Relationship Id="${relId}" Type="${REL_NS}/externalLink" Target="externalLinks/externalLink${i + 1}.xml"/>`);
|
|
3973
|
+
}
|
|
3974
|
+
// Phase D2 — pivotCacheDefinition relationships. workbook.xml's
|
|
3975
|
+
// <pivotCaches> element references these by rId. The corresponding
|
|
3976
|
+
// <pivotCache> entry pairs cacheId (sequential, starts at 1) with
|
|
3977
|
+
// r:id (the relationship rId we collect here).
|
|
3978
|
+
const pivotCacheRelIds = [];
|
|
3979
|
+
for (let i = 0; i < pivotCacheCount; i++) {
|
|
3980
|
+
const relId = `rId${nextRelId++}`;
|
|
3981
|
+
pivotCacheRelIds.push(relId);
|
|
3982
|
+
relationships.push(`<Relationship Id="${relId}" Type="${PIVOT_CACHE_DEFINITION_REL_TYPE}" Target="pivotCache/pivotCacheDefinition${i + 1}.xml"/>`);
|
|
3983
|
+
}
|
|
3984
|
+
// Workbook → persons part (threaded-comment authors).
|
|
3985
|
+
if (hasPersons) {
|
|
3986
|
+
relationships.push(`<Relationship Id="rId${nextRelId++}" Type="${PERSON_REL_TYPE}" Target="persons/person.xml"/>`);
|
|
3987
|
+
}
|
|
3988
|
+
return {
|
|
3989
|
+
xml: `${XML_DECLARATION}<Relationships xmlns="${PACKAGE_REL_NS}">${relationships.join("")}</Relationships>`,
|
|
3990
|
+
sheetRelationshipIds: sheetRelIds,
|
|
3991
|
+
pivotCacheRelationshipIds: pivotCacheRelIds,
|
|
3992
|
+
externalLinkRelationshipIds: externalLinkRelIds,
|
|
3993
|
+
slicerCacheRelationshipIds: slicerCacheRelIds,
|
|
3994
|
+
};
|
|
3995
|
+
};
|
|
3996
|
+
const buildContentTypesXml = ({ sheetCount, tableCount, drawingSheets, mediaExtensions, chartCount, slicerCount = 0, slicerCacheCount = 0, pivotTableCount = 0, pivotCacheCount = 0, commentsSheets = [], threadedCommentsSheets = [], hasPersons = false, externalLinkCount = 0, }) => {
|
|
3997
|
+
const sheetOverrides = Array.from({ length: sheetCount }, (_, index) => {
|
|
3998
|
+
return `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`;
|
|
3999
|
+
}).join("");
|
|
4000
|
+
const tableOverrides = Array.from({ length: tableCount }, (_, index) => {
|
|
4001
|
+
return `<Override PartName="/xl/tables/table${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>`;
|
|
4002
|
+
}).join("");
|
|
4003
|
+
const drawingOverrides = drawingSheets
|
|
4004
|
+
.map((sheetIndex) => `<Override PartName="/xl/drawings/drawing${sheetIndex}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>`)
|
|
4005
|
+
.join("");
|
|
4006
|
+
const chartOverrides = Array.from({ length: chartCount }, (_, index) => {
|
|
4007
|
+
return `<Override PartName="/xl/charts/chart${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>`;
|
|
4008
|
+
}).join("");
|
|
4009
|
+
const slicerOverrides = Array.from({ length: slicerCount }, (_, index) => {
|
|
4010
|
+
return `<Override PartName="/xl/slicers/slicer${index + 1}.xml" ContentType="application/vnd.ms-excel.slicer+xml"/>`;
|
|
4011
|
+
}).join("");
|
|
4012
|
+
const slicerCacheOverrides = Array.from({ length: slicerCacheCount }, (_, index) => {
|
|
4013
|
+
return `<Override PartName="/xl/slicerCaches/slicerCache${index + 1}.xml" ContentType="application/vnd.ms-excel.slicerCache+xml"/>`;
|
|
4014
|
+
}).join("");
|
|
4015
|
+
// Phase D2 — pivotTable + pivotCacheDefinition overrides.
|
|
4016
|
+
const pivotTableOverrides = Array.from({ length: pivotTableCount }, (_, index) => `<Override PartName="/xl/pivotTables/pivotTable${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"/>`).join("");
|
|
4017
|
+
const pivotCacheOverrides = Array.from({ length: pivotCacheCount }, (_, index) => `<Override PartName="/xl/pivotCache/pivotCacheDefinition${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"/>`).join("");
|
|
4018
|
+
const commentsOverrides = commentsSheets
|
|
4019
|
+
.map((sheetId) => `<Override PartName="/xl/comments${sheetId}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/>`)
|
|
4020
|
+
.join("");
|
|
4021
|
+
const threadedCommentsOverrides = threadedCommentsSheets
|
|
4022
|
+
.map((sheetId) => `<Override PartName="/xl/threadedComments/threadedComment${sheetId}.xml" ContentType="${THREADED_COMMENTS_CONTENT_TYPE}"/>`)
|
|
4023
|
+
.join("");
|
|
4024
|
+
const personOverride = hasPersons
|
|
4025
|
+
? `<Override PartName="/xl/persons/person.xml" ContentType="${PERSON_CONTENT_TYPE}"/>`
|
|
4026
|
+
: "";
|
|
4027
|
+
const externalLinkOverrides = Array.from({ length: externalLinkCount }, (_, index) => {
|
|
4028
|
+
return `<Override PartName="/xl/externalLinks/externalLink${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml"/>`;
|
|
4029
|
+
}).join("");
|
|
4030
|
+
const mediaDefaults = mediaExtensions
|
|
4031
|
+
.map((extension) => `<Default Extension="${extension}" ContentType="image/${extension}"/>`)
|
|
4032
|
+
.join("");
|
|
4033
|
+
const vmlDefault = commentsSheets.length
|
|
4034
|
+
? `<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>`
|
|
4035
|
+
: "";
|
|
4036
|
+
return `${XML_DECLARATION}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${vmlDefault}${mediaDefaults}<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>${sheetOverrides}${tableOverrides}${drawingOverrides}${chartOverrides}${slicerOverrides}${slicerCacheOverrides}${pivotTableOverrides}${pivotCacheOverrides}${externalLinkOverrides}${commentsOverrides}${threadedCommentsOverrides}${personOverride}<Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>`;
|
|
4037
|
+
};
|
|
4038
|
+
const buildRootRelationshipsXml = () => {
|
|
4039
|
+
return `${XML_DECLARATION}<Relationships xmlns="${PACKAGE_REL_NS}"><Relationship Id="rId1" Type="${REL_NS}/officeDocument" Target="xl/workbook.xml"/><Relationship Id="rId2" Type="${REL_NS}/extended-properties" Target="docProps/app.xml"/><Relationship Id="rId3" Type="${REL_NS}/core-properties" Target="docProps/core.xml"/></Relationships>`;
|
|
4040
|
+
};
|
|
4041
|
+
const buildAppXml = (sheetNames, properties) => {
|
|
4042
|
+
const titles = sheetNames
|
|
4043
|
+
.map((name) => `<vt:lpstr>${escapeXmlText(name)}</vt:lpstr>`)
|
|
4044
|
+
.join("");
|
|
4045
|
+
const application = properties?.application ?? CREATOR;
|
|
4046
|
+
const companyTag = properties?.company
|
|
4047
|
+
? `<Company>${escapeXmlText(properties.company)}</Company>`
|
|
4048
|
+
: "";
|
|
4049
|
+
const managerTag = properties?.manager
|
|
4050
|
+
? `<Manager>${escapeXmlText(properties.manager)}</Manager>`
|
|
4051
|
+
: "";
|
|
4052
|
+
return `${XML_DECLARATION}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>${escapeXmlText(application)}</Application><DocSecurity>0</DocSecurity><ScaleCrop>false</ScaleCrop><HeadingPairs><vt:vector size="2" baseType="variant"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>${sheetNames.length}</vt:i4></vt:variant></vt:vector></HeadingPairs><TitlesOfParts><vt:vector size="${sheetNames.length}" baseType="lpstr">${titles}</vt:vector></TitlesOfParts>${companyTag}${managerTag}</Properties>`;
|
|
4053
|
+
};
|
|
4054
|
+
const buildCoreXml = (properties) => {
|
|
4055
|
+
const now = new Date().toISOString();
|
|
4056
|
+
const creator = properties?.creator ?? CREATOR;
|
|
4057
|
+
const lastModifiedBy = properties?.lastModifiedBy ?? creator;
|
|
4058
|
+
const created = properties?.created ?? now;
|
|
4059
|
+
const modified = properties?.modified ?? now;
|
|
4060
|
+
const tag = (name, value) => value ? `<${name}>${escapeXmlText(value)}</${name}>` : "";
|
|
4061
|
+
return `${XML_DECLARATION}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">${tag("dc:title", properties?.title)}${tag("dc:subject", properties?.subject)}<dc:creator>${escapeXmlText(creator)}</dc:creator>${tag("cp:keywords", properties?.keywords)}${tag("dc:description", properties?.description)}<cp:lastModifiedBy>${escapeXmlText(lastModifiedBy)}</cp:lastModifiedBy>${tag("cp:category", properties?.category)}<dcterms:created xsi:type="dcterms:W3CDTF">${escapeXmlText(created)}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">${escapeXmlText(modified)}</dcterms:modified></cp:coreProperties>`;
|
|
4062
|
+
};
|