@svgrid/enterprise 3.0.2 → 3.0.3
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 +94 -24
- package/README.md +36 -4
- package/dist/SvAdvancedFilter.svelte +1 -1
- package/dist/SvAlertsManager.svelte +2 -1
- package/dist/SvBoard.svelte +2 -1
- package/dist/SvExportMenu.svelte +4 -1
- package/dist/SvExportMenu.svelte.d.ts +0 -13
- package/dist/SvExpressionEditor.svelte +3 -3
- package/dist/SvFormulaBar.svelte +94 -20
- package/dist/SvFormulaBar.svelte.d.ts +7 -0
- package/dist/SvGridAlerts.svelte +49 -9
- package/dist/SvGridBoard.svelte +13 -8
- package/dist/SvGridBulkEditDrawer.svelte +26 -2
- package/dist/SvGridEditPanel.svelte +3 -0
- package/dist/SvGridScheduler.svelte +69 -40
- package/dist/SvGridSelectionBar.svelte +10 -3
- package/dist/SvPivotDesigner.svelte +198 -40
- package/dist/SvPivotDesigner.svelte.d.ts +27 -20
- package/dist/SvSheet.svelte +4043 -243
- package/dist/SvSheet.svelte.d.ts +76 -3
- package/dist/SvSheetChartSetup.svelte +151 -0
- package/dist/SvSheetChartSetup.svelte.d.ts +18 -0
- package/dist/SvSheetComment.svelte +216 -31
- package/dist/SvSheetComment.svelte.d.ts +11 -11
- package/dist/SvSheetConditionalFormat.svelte +28 -31
- package/dist/SvSheetDataValidation.svelte +66 -42
- package/dist/SvSheetEditRanges.svelte +217 -0
- package/dist/SvSheetEditRanges.svelte.d.ts +15 -0
- package/dist/SvSheetErrors.svelte +125 -0
- package/dist/SvSheetErrors.svelte.d.ts +20 -0
- package/dist/SvSheetEvaluate.svelte +133 -0
- package/dist/SvSheetEvaluate.svelte.d.ts +15 -0
- package/dist/SvSheetFilterMenu.svelte +137 -34
- package/dist/SvSheetFilterMenu.svelte.d.ts +5 -0
- package/dist/SvSheetFindReplace.svelte +19 -19
- package/dist/SvSheetFormatCells.svelte +125 -81
- package/dist/SvSheetGoalSeek.svelte +17 -16
- package/dist/SvSheetInsertFunction.svelte +15 -13
- package/dist/SvSheetIteration.svelte +81 -0
- package/dist/SvSheetIteration.svelte.d.ts +10 -0
- package/dist/SvSheetLink.svelte +99 -0
- package/dist/SvSheetLink.svelte.d.ts +17 -0
- package/dist/SvSheetListPicker.svelte +4 -2
- package/dist/SvSheetListPicker.svelte.d.ts +0 -6
- package/dist/SvSheetManageRules.svelte +20 -18
- package/dist/SvSheetNameManager.svelte +17 -15
- package/dist/SvSheetPageSetup.svelte +159 -0
- package/dist/SvSheetPageSetup.svelte.d.ts +13 -0
- package/dist/SvSheetPasteSpecial.svelte +22 -20
- package/dist/SvSheetPivot.svelte +220 -0
- package/dist/SvSheetPivot.svelte.d.ts +21 -0
- package/dist/SvSheetProtectSheet.svelte +81 -0
- package/dist/SvSheetProtectSheet.svelte.d.ts +11 -0
- package/dist/SvSheetRemoveDuplicates.svelte +13 -10
- package/dist/SvSheetRibbon.svelte +104 -51
- package/dist/SvSheetSizeDialog.svelte +4 -2
- package/dist/SvSheetSort.svelte +218 -0
- package/dist/SvSheetSort.svelte.d.ts +28 -0
- package/dist/SvSheetSparklines.svelte +135 -0
- package/dist/SvSheetSparklines.svelte.d.ts +14 -0
- package/dist/SvSheetTable.svelte +160 -0
- package/dist/SvSheetTable.svelte.d.ts +24 -0
- package/dist/SvSheetTabs.svelte +161 -31
- package/dist/SvSheetTabs.svelte.d.ts +22 -8
- package/dist/SvSheetTextToColumns.svelte +20 -20
- package/dist/SvSheetValidationAlert.svelte +8 -6
- package/dist/advanced-filter-enable.js +1 -1
- package/dist/alerts/alert-engine.d.ts +6 -0
- package/dist/alerts/alert-engine.js +21 -4
- package/dist/alerts/alert-formats.js +2 -0
- package/dist/alerts/alert-types.d.ts +1 -1
- package/dist/alerts.d.ts +1 -1
- package/dist/alerts.js +2 -2
- package/dist/board.js +2 -2
- package/dist/bulk-edit.d.ts +3 -1
- package/dist/bulk-edit.js +1 -1
- package/dist/cdn/svgrid-enterprise.svelte-external.js +39827 -16429
- package/dist/designer/assets/GridMenus-8gTNssgV.js +8 -0
- package/dist/designer/assets/{SvChartMenu-D9hEeHar.js → SvChartMenu-BDoeqGz6.js} +1 -1
- package/dist/designer/assets/{SvDateTimePicker-DARvKUL2.js → SvDateTimePicker-DCPg7WGN.js} +1 -1
- package/dist/designer/assets/SvGridCellEditor-DMoWJjEI.js +4 -0
- package/dist/designer/assets/{SvGridChart-B8j1SSy3.js → SvGridChart-Cy1ATrWh.js} +2 -2
- package/dist/designer/assets/SvGridChartBuilder-eQ3mn0L9.js +1 -0
- package/dist/designer/assets/{SvGridChartPanel-pT6ITc2P.js → SvGridChartPanel-CATdVc9X.js} +3 -3
- package/dist/designer/assets/{SvGridChartView-bg281UA1.js → SvGridChartView-CmsZtCED.js} +1 -1
- package/dist/designer/assets/SvGridDropdown-Cjz0H-fI.js +1 -0
- package/dist/designer/assets/{SvTabs-UFCMTHXS.js → SvTabs-2mHC6oaz.js} +1 -1
- package/dist/designer/assets/cell-formatting-BbubJTVz.js +1 -0
- package/dist/designer/assets/{chart-cIPUsbM5.js → chart-Cn7MnNqA.js} +1 -1
- package/dist/designer/assets/{chart-panel-messages-DtTxpBUU.js → chart-panel-messages-Dz7PTIqT.js} +1 -1
- package/dist/designer/assets/{chart-summary-Czu_5epb.js → chart-summary-BD1qYB2R.js} +1 -1
- package/dist/designer/assets/{chart-trend-D5I5VEe8.js → chart-trend-B8xr5LuD.js} +1 -1
- package/dist/designer/assets/{dismissable-BO547MH9.js → dismissable-ChWRPkoa.js} +1 -1
- package/dist/designer/assets/{export-format-D-eP4b2l.js → export-format-DGckmgKr.js} +1 -1
- package/dist/designer/assets/{focus-trap-CAreAhlU.js → focus-trap-V2-6Fy9I.js} +1 -1
- package/dist/designer/assets/{index-DwU7CAMm.js → index-D5bVbuzI.js} +317 -315
- package/dist/designer/assets/index-_JVFxt02.css +1 -0
- package/dist/designer/assets/row-resize-BInFWCpr.js +1 -0
- package/dist/designer/assets/server-block-cache-CXJKkJpy.js +1 -0
- package/dist/designer/assets/src-DUQ4xE3x.js +58 -0
- package/dist/designer/assets/validate-C3B8QKsM.js +1 -0
- package/dist/designer/index.html +17 -16
- package/dist/export-pdf.d.ts +75 -0
- package/dist/export-pdf.js +216 -15
- package/dist/export.js +10 -2
- package/dist/gantt/SvGridGantt.svelte +2890 -0
- package/dist/gantt/SvGridGantt.svelte.d.ts +30 -0
- package/dist/gantt/gantt-config.d.ts +73 -0
- package/dist/gantt/gantt-config.js +1 -0
- package/dist/gantt/gantt-critical-path.d.ts +67 -0
- package/dist/gantt/gantt-critical-path.js +207 -0
- package/dist/gantt/gantt-model.d.ts +257 -0
- package/dist/gantt/gantt-model.js +651 -0
- package/dist/gantt/gantt-resources.d.ts +59 -0
- package/dist/gantt/gantt-resources.js +71 -0
- package/dist/gantt/gantt.d.ts +7 -0
- package/dist/gantt/gantt.js +36 -0
- package/dist/gantt/index.d.ts +15 -0
- package/dist/gantt/index.js +22 -0
- package/dist/gantt/timeline-arrows.d.ts +102 -0
- package/dist/gantt/timeline-arrows.js +181 -0
- package/dist/import.d.ts +2 -2
- package/dist/import.js +1 -1
- package/dist/index.d.ts +34 -7
- package/dist/index.js +44 -7
- package/dist/install.js +8 -1
- package/dist/license-core.d.ts +17 -1
- package/dist/license-core.js +16 -5
- package/dist/license.d.ts +18 -2
- package/dist/license.js +71 -6
- package/dist/node/studio.js +140 -8
- package/dist/pivot-enable.js +2 -2
- package/dist/scheduler-dependencies.d.ts +35 -0
- package/dist/scheduler-dependencies.js +36 -1
- package/dist/scheduler.js +2 -2
- package/dist/selection-bar.js +2 -2
- package/dist/server/SvGroupCell.svelte +213 -0
- package/dist/server/SvGroupCell.svelte.d.ts +62 -0
- package/dist/server/SvRowGroupPanel.svelte +322 -0
- package/dist/server/SvRowGroupPanel.svelte.d.ts +37 -0
- package/dist/server/enable.d.ts +17 -0
- package/dist/server/enable.js +49 -0
- package/dist/server/index.d.ts +22 -0
- package/dist/server/index.js +24 -0
- package/dist/server/messages.d.ts +45 -0
- package/dist/server/messages.js +35 -0
- package/dist/server/server-group-model.d.ts +104 -0
- package/dist/server/server-group-model.js +272 -0
- package/dist/server/server-pivot.d.ts +46 -0
- package/dist/server/server-pivot.js +57 -0
- package/dist/server/server-row-model.d.ts +547 -0
- package/dist/server/server-row-model.js +1551 -0
- package/dist/server/server-selection.d.ts +99 -0
- package/dist/server/server-selection.js +215 -0
- package/dist/server/svgrid-adapter.d.ts +155 -0
- package/dist/server/svgrid-adapter.js +202 -0
- package/dist/sheet/ast.d.ts +45 -1
- package/dist/sheet/ast.js +14 -0
- package/dist/sheet/auto-filter.d.ts +58 -4
- package/dist/sheet/auto-filter.js +159 -11
- package/dist/sheet/autocomplete.d.ts +9 -0
- package/dist/sheet/autocomplete.js +90 -4
- package/dist/sheet/cell-images.d.ts +39 -0
- package/dist/sheet/cell-images.js +90 -0
- package/dist/sheet/cell-types.d.ts +58 -0
- package/dist/sheet/cell-types.js +96 -0
- package/dist/sheet/coerce.d.ts +40 -0
- package/dist/sheet/coerce.js +170 -3
- package/dist/sheet/comments.d.ts +43 -9
- package/dist/sheet/comments.js +85 -27
- package/dist/sheet/conditional-formats.d.ts +29 -6
- package/dist/sheet/conditional-formats.js +37 -12
- package/dist/sheet/csv.d.ts +32 -0
- package/dist/sheet/csv.js +158 -0
- package/dist/sheet/culture.d.ts +87 -0
- package/dist/sheet/culture.js +247 -0
- package/dist/sheet/delta.d.ts +102 -0
- package/dist/sheet/delta.js +203 -0
- package/dist/sheet/deps.d.ts +45 -1
- package/dist/sheet/deps.js +77 -2
- package/dist/sheet/document.d.ts +106 -4
- package/dist/sheet/document.js +204 -3
- package/dist/sheet/engine.d.ts +67 -0
- package/dist/sheet/engine.js +19 -0
- package/dist/sheet/entry.d.ts +8 -4
- package/dist/sheet/entry.js +171 -21
- package/dist/sheet/error-check.d.ts +49 -0
- package/dist/sheet/error-check.js +83 -0
- package/dist/sheet/evaluate-steps.d.ts +48 -0
- package/dist/sheet/evaluate-steps.js +196 -0
- package/dist/sheet/evaluate.d.ts +67 -0
- package/dist/sheet/evaluate.js +725 -13
- package/dist/sheet/filter-files.d.ts +37 -0
- package/dist/sheet/filter-files.js +465 -0
- package/dist/sheet/format-store.d.ts +15 -0
- package/dist/sheet/format-store.js +11 -1
- package/dist/sheet/from-rows.d.ts +14 -0
- package/dist/sheet/from-rows.js +50 -0
- package/dist/sheet/function-catalog.d.ts +1 -1
- package/dist/sheet/function-catalog.js +296 -5
- package/dist/sheet/functions.js +363 -91
- package/dist/sheet/index.d.ts +35 -8
- package/dist/sheet/index.js +35 -8
- package/dist/sheet/links.d.ts +57 -0
- package/dist/sheet/links.js +174 -0
- package/dist/sheet/messages.d.ts +778 -0
- package/dist/sheet/messages.js +806 -0
- package/dist/sheet/number-format.d.ts +52 -5
- package/dist/sheet/number-format.js +465 -68
- package/dist/sheet/objects.d.ts +106 -0
- package/dist/sheet/objects.js +149 -0
- package/dist/sheet/ods-document.d.ts +44 -0
- package/dist/sheet/ods-document.js +1275 -0
- package/dist/sheet/open-file.d.ts +26 -0
- package/dist/sheet/open-file.js +56 -0
- package/dist/sheet/outline.d.ts +82 -0
- package/dist/sheet/outline.js +228 -0
- package/dist/sheet/packs/array.d.ts +16 -0
- package/dist/sheet/packs/array.js +172 -0
- package/dist/sheet/packs/database.d.ts +42 -0
- package/dist/sheet/packs/database.js +142 -0
- package/dist/sheet/packs/engineering.d.ts +2 -0
- package/dist/sheet/packs/engineering.js +296 -0
- package/dist/sheet/packs/financial.d.ts +5 -0
- package/dist/sheet/packs/financial.js +220 -0
- package/dist/sheet/packs/math-stats.d.ts +2 -0
- package/dist/sheet/packs/math-stats.js +372 -0
- package/dist/sheet/packs/special.d.ts +44 -0
- package/dist/sheet/packs/special.js +187 -0
- package/dist/sheet/packs/stats-dist.d.ts +2 -0
- package/dist/sheet/packs/stats-dist.js +785 -0
- package/dist/sheet/packs/text-date.d.ts +2 -0
- package/dist/sheet/packs/text-date.js +332 -0
- package/dist/sheet/packs/trig.d.ts +2 -0
- package/dist/sheet/packs/trig.js +73 -0
- package/dist/sheet/page-setup.d.ts +73 -0
- package/dist/sheet/page-setup.js +73 -0
- package/dist/sheet/parse.js +42 -10
- package/dist/sheet/paste-special.d.ts +22 -0
- package/dist/sheet/paste-special.js +14 -5
- package/dist/sheet/pivot-range.d.ts +160 -0
- package/dist/sheet/pivot-range.js +333 -0
- package/dist/sheet/presence.d.ts +73 -0
- package/dist/sheet/presence.js +79 -0
- package/dist/sheet/print.d.ts +104 -0
- package/dist/sheet/print.js +169 -0
- package/dist/sheet/protection.d.ts +55 -1
- package/dist/sheet/protection.js +55 -2
- package/dist/sheet/rects.d.ts +1 -1
- package/dist/sheet/refs.d.ts +48 -0
- package/dist/sheet/refs.js +155 -8
- package/dist/sheet/ribbon-icons.d.ts +1 -1
- package/dist/sheet/ribbon-icons.js +186 -0
- package/dist/sheet/ribbon.d.ts +1 -1
- package/dist/sheet/ribbon.js +324 -23
- package/dist/sheet/sheet-kinds.d.ts +74 -0
- package/dist/sheet/sheet-kinds.js +91 -0
- package/dist/sheet/shortcuts.d.ts +13 -3
- package/dist/sheet/shortcuts.js +63 -12
- package/dist/sheet/sort.d.ts +51 -0
- package/dist/sheet/sort.js +107 -0
- package/dist/sheet/sparklines.d.ts +111 -0
- package/dist/sheet/sparklines.js +204 -0
- package/dist/sheet/table-styles.d.ts +74 -0
- package/dist/sheet/table-styles.js +152 -0
- package/dist/sheet/tables.d.ts +19 -0
- package/dist/sheet/tables.js +31 -0
- package/dist/sheet/tokenize.d.ts +18 -1
- package/dist/sheet/tokenize.js +60 -9
- package/dist/sheet/validation.d.ts +16 -0
- package/dist/sheet/validation.js +37 -0
- package/dist/sheet/workbook.d.ts +148 -3
- package/dist/sheet/workbook.js +742 -31
- package/dist/sheet/xls-cfb.d.ts +32 -0
- package/dist/sheet/xls-cfb.js +290 -0
- package/dist/sheet/xls-document.d.ts +10 -0
- package/dist/sheet/xls-document.js +1243 -0
- package/dist/sheet/xls-formula.d.ts +40 -0
- package/dist/sheet/xls-formula.js +569 -0
- package/dist/sheet/xlsx-document.d.ts +108 -0
- package/dist/sheet/xlsx-document.js +1849 -0
- package/dist/sheet/xlsx-drawing.d.ts +99 -0
- package/dist/sheet/xlsx-drawing.js +420 -0
- package/dist/sheet-enable.js +6 -7
- package/dist/sheet-text.d.ts +7 -0
- package/dist/sheet-text.js +29 -0
- package/dist/sources/auth-supabase.js +2 -2
- package/dist/sources/realtime-supabase.js +2 -2
- package/dist/sources/rest.d.ts +6 -2
- package/dist/sources/rest.js +42 -7
- package/dist/sources/supabase.d.ts +10 -1
- package/dist/sources/supabase.js +73 -40
- package/dist/studio/emit-project.js +14 -1
- package/dist/studio/index.js +1 -1
- package/dist/studio/project.d.ts +11 -2
- package/dist/studio/project.js +5 -2
- package/dist/studio/ui-components.generated.js +107 -2
- package/dist/sveltekit/in-memory.d.ts +2 -2
- package/dist/sveltekit/in-memory.js +125 -4
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/index.js +1 -1
- package/dist/sveltekit/query-plan.d.ts +50 -4
- package/dist/sveltekit/query-plan.js +25 -3
- package/dist/sveltekit/sql-source.d.ts +6 -0
- package/dist/sveltekit/sql-source.js +34 -6
- package/dist/sveltekit/sql.d.ts +31 -0
- package/dist/sveltekit/sql.js +135 -25
- package/dist/sveltekit/transport.d.ts +10 -2
- package/dist/sveltekit/transport.js +30 -6
- package/dist/upgrade-prompt.d.ts +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/watermark.js +35 -2
- package/dist/wc/GridMenus-By9vPuq0.js +1088 -0
- package/dist/wc/SvChartMenu-CQNrQGT2.js +308 -0
- package/dist/wc/SvDateRangeInput-DR6m7bSM.js +199 -0
- package/dist/wc/SvDateTimePicker-D-vRnaMo.js +771 -0
- package/dist/wc/SvField-B_kurx_P.js +149 -0
- package/dist/wc/SvGrid.helpers-BdVDtjUm.js +135 -0
- package/dist/wc/SvGridCellEditor-Bnf1Kr5C.js +560 -0
- package/dist/wc/SvGridChart-BqUJh2pG.js +3572 -0
- package/dist/wc/SvGridChartBuilder-CGycC5cn.js +988 -0
- package/dist/wc/SvGridChartPanel-CRnizsHD.js +1746 -0
- package/dist/wc/SvGridChartView-D1pa_xMy.js +47 -0
- package/dist/wc/SvGridDropdown-45WsObLL.js +259 -0
- package/dist/wc/SvModal-DrfOYYyX.js +189 -0
- package/dist/wc/cell-formatting-CBfgahv1.js +86 -0
- package/dist/wc/chart-6OXlybVT.js +4341 -0
- package/dist/wc/chart-export-CeSADGfi.js +135 -0
- package/dist/wc/chart-export-pdf-BGko7MER.js +104 -0
- package/dist/wc/chart-summary-2m3ia1-n.js +137 -0
- package/dist/wc/chart-trend-CQOQtTMl.js +50 -0
- package/dist/wc/chart-validate-CD6ptiwg.js +180 -0
- package/dist/wc/client-DQIzjDBG.js +3064 -0
- package/dist/wc/column-resize-DUPgynvc.js +101 -0
- package/dist/wc/date-format-DS-z_h9v.js +1130 -0
- package/dist/wc/dismissable-CQ6XFXsk.js +45 -0
- package/dist/wc/editor-contract-peBDxir_.js +22 -0
- package/dist/wc/editor-registry-DxGKqcvx.js +24 -0
- package/dist/wc/focus-trap-Dbb2tJxb.js +65 -0
- package/dist/wc/list-option-DrRyivWp.js +82 -0
- package/dist/wc/popover-WsGlvtQv.js +69 -0
- package/dist/wc/positioning-BiKLfu1E.js +112 -0
- package/dist/wc/react.d.ts +41 -0
- package/dist/wc/react.js +90 -0
- package/dist/wc/rolldown-runtime-Dy4uBu1J.js +11 -0
- package/dist/wc/row-drag-touch-CycRR65n.js +93 -0
- package/dist/wc/row-resize-limG_N1z.js +111 -0
- package/dist/wc/sv-sheet-element.d.ts +97 -0
- package/dist/wc/sv-sheet-element.js +44213 -0
- package/dist/wc/svelte-virtualizer.svelte-CXtQy2TQ.js +530 -0
- package/dist/wc/validate-BqDthkNr.js +88 -0
- package/dist/wc/vue.d.ts +31 -0
- package/dist/wc/vue.js +75 -0
- package/package.json +30 -5
- package/dist/designer/assets/GridMenus-BlsvL_aU.js +0 -8
- package/dist/designer/assets/SvGridCellEditor-C7uQXYds.js +0 -4
- package/dist/designer/assets/SvGridChartBuilder-kvyIU_Rl.js +0 -1
- package/dist/designer/assets/SvGridDropdown-Bb3HH2Aj.js +0 -1
- package/dist/designer/assets/cell-formatting-wr5wm7US.js +0 -1
- package/dist/designer/assets/index-CMFgAoVV.css +0 -1
- package/dist/designer/assets/row-resize-DLktcqUK.js +0 -1
- package/dist/designer/assets/src-EdI3Xqds.js +0 -58
- package/dist/designer/assets/validate-h-aslRV9.js +0 -1
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenDocument Spreadsheet, the format LibreOffice Calc saves by default and
|
|
3
|
+
* one of the files Google Sheets hands back.
|
|
4
|
+
*
|
|
5
|
+
* The same document as the .xlsx side, in the other file format: cells with
|
|
6
|
+
* their formulas, the number formats, the cell looks, column widths and row
|
|
7
|
+
* heights, merges, hidden lines and a sheet's hyperlinks and notes.
|
|
8
|
+
*
|
|
9
|
+
* Two things make ODF different enough to be worth a module of its own.
|
|
10
|
+
* A value carries its TYPE (`office:value-type`) beside its text, so a date
|
|
11
|
+
* is an ISO date and a number is a number without a format having to say so;
|
|
12
|
+
* and a formula is written in ODF's own reference grammar
|
|
13
|
+
* (`of:=SUM([.A1:.A3])`), which has to be translated both ways. Everything
|
|
14
|
+
* else is the same document this package already keeps.
|
|
15
|
+
*/
|
|
16
|
+
import { colToLetters, lettersToCol } from './address.js';
|
|
17
|
+
import { tokenize } from './tokenize.js';
|
|
18
|
+
import { formatKeyAt } from './format-store.js';
|
|
19
|
+
import { linkAt } from './links.js';
|
|
20
|
+
import { threadAt } from './comments.js';
|
|
21
|
+
import { loadZip } from './xlsx-document.js';
|
|
22
|
+
import { accountingParts, accountingPattern } from './number-format.js';
|
|
23
|
+
import { autoFilterOds, filteredRows, filtersFromOds } from './filter-files.js';
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// XML helpers. Local names throughout, so a document that spells its
|
|
26
|
+
// namespaces differently reads the same.
|
|
27
|
+
function parseXml(xml, what) {
|
|
28
|
+
if (typeof DOMParser === 'undefined') {
|
|
29
|
+
throw new Error(`@svgrid/enterprise: reading an .ods needs DOMParser, which this environment does not have (${what})`);
|
|
30
|
+
}
|
|
31
|
+
const doc = new DOMParser().parseFromString(xml.replace(/^/, ''), 'application/xml');
|
|
32
|
+
if (doc.getElementsByTagName('parsererror').length)
|
|
33
|
+
throw new Error(`@svgrid/enterprise: ${what} is not well-formed XML`);
|
|
34
|
+
return doc;
|
|
35
|
+
}
|
|
36
|
+
function kids(node, name) {
|
|
37
|
+
if (!node)
|
|
38
|
+
return [];
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const child of Array.from(node.children))
|
|
41
|
+
if (child.localName === name)
|
|
42
|
+
out.push(child);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
const kid = (node, name) => kids(node, name)[0] ?? null;
|
|
46
|
+
/** An attribute by LOCAL name: `table:style-name` and `style-name` alike. */
|
|
47
|
+
function attr(el, name) {
|
|
48
|
+
if (!el)
|
|
49
|
+
return null;
|
|
50
|
+
for (const a of Array.from(el.attributes))
|
|
51
|
+
if (a.localName === name)
|
|
52
|
+
return a.value;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const numAttr = (el, name) => {
|
|
56
|
+
const v = attr(el, name);
|
|
57
|
+
if (v === null)
|
|
58
|
+
return null;
|
|
59
|
+
const n = Number(v);
|
|
60
|
+
return Number.isFinite(n) ? n : null;
|
|
61
|
+
};
|
|
62
|
+
/** A length as CSS writes it (`1.9445in`, `2.54cm`, `12pt`) in pixels. */
|
|
63
|
+
export function odfLength(text) {
|
|
64
|
+
if (!text)
|
|
65
|
+
return null;
|
|
66
|
+
const m = /^(-?[\d.]+)\s*(cm|mm|in|pt|pc|px)?$/.exec(text.trim());
|
|
67
|
+
if (!m)
|
|
68
|
+
return null;
|
|
69
|
+
const n = Number(m[1]);
|
|
70
|
+
if (!Number.isFinite(n))
|
|
71
|
+
return null;
|
|
72
|
+
switch (m[2]) {
|
|
73
|
+
case 'cm': return Math.round(n * 96 / 2.54);
|
|
74
|
+
case 'mm': return Math.round(n * 96 / 25.4);
|
|
75
|
+
case 'in': return Math.round(n * 96);
|
|
76
|
+
case 'pt': return Math.round(n * 96 / 72);
|
|
77
|
+
case 'pc': return Math.round(n * 16);
|
|
78
|
+
default: return Math.round(n);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Formulas.
|
|
83
|
+
/**
|
|
84
|
+
* An ODF formula as A1.
|
|
85
|
+
*
|
|
86
|
+
* `of:=SUM([.A1:.A3])` is `=SUM(A1:A3)`; a reference into another sheet is
|
|
87
|
+
* `[$Sheet2.A1]` or `[$'Price list'.A1]`; arguments are separated by `;`.
|
|
88
|
+
* The newer functions carry a vendor prefix, which is the same function
|
|
89
|
+
* under a longer name.
|
|
90
|
+
*/
|
|
91
|
+
export function formulaFromOdf(text) {
|
|
92
|
+
let body = text.replace(/^(of|oooc|ooow):?=?/, '');
|
|
93
|
+
if (body.startsWith('='))
|
|
94
|
+
body = body.slice(1);
|
|
95
|
+
let out = '';
|
|
96
|
+
let i = 0;
|
|
97
|
+
while (i < body.length) {
|
|
98
|
+
const ch = body[i];
|
|
99
|
+
if (ch === '"') {
|
|
100
|
+
const end = findQuoteEnd(body, i);
|
|
101
|
+
out += body.slice(i, end);
|
|
102
|
+
i = end;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === '[') {
|
|
106
|
+
const end = body.indexOf(']', i);
|
|
107
|
+
if (end < 0) {
|
|
108
|
+
out += ch;
|
|
109
|
+
i += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
out += referenceFromOdf(body.slice(i + 1, end));
|
|
113
|
+
i = end + 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === ';') {
|
|
117
|
+
out += ',';
|
|
118
|
+
i += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
out += ch;
|
|
122
|
+
i += 1;
|
|
123
|
+
}
|
|
124
|
+
// COM.MICROSOFT.XLOOKUP and friends are the plain names here, and a file
|
|
125
|
+
// that came to LibreOffice from Excel keeps Excel's own _xlfn. prefix.
|
|
126
|
+
out = out.replace(/\b(COM\.MICROSOFT|ORG\.OPENOFFICE|COM\.SUN\.STAR)\./gi, '').replace(/_xlfn\.|_xlws\./gi, '');
|
|
127
|
+
return `=${out}`;
|
|
128
|
+
}
|
|
129
|
+
function findQuoteEnd(text, start) {
|
|
130
|
+
let i = start + 1;
|
|
131
|
+
while (i < text.length) {
|
|
132
|
+
if (text[i] === '"') {
|
|
133
|
+
if (text[i + 1] === '"') {
|
|
134
|
+
i += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
return i + 1;
|
|
138
|
+
}
|
|
139
|
+
i += 1;
|
|
140
|
+
}
|
|
141
|
+
return text.length;
|
|
142
|
+
}
|
|
143
|
+
/** The inside of an ODF `[...]`: `.A1`, `.A1:.B2`, `$Sheet2.A1`, `$'a b'.A1`. */
|
|
144
|
+
function referenceFromOdf(inside) {
|
|
145
|
+
const parts = inside.split(':');
|
|
146
|
+
const one = (part) => {
|
|
147
|
+
const text = part.trim();
|
|
148
|
+
if (text === '' || text === '#REF!')
|
|
149
|
+
return '#REF!';
|
|
150
|
+
const dot = lastUnquotedDot(text);
|
|
151
|
+
if (dot < 0)
|
|
152
|
+
return text.replace(/^\./, '');
|
|
153
|
+
let sheet = text.slice(0, dot).replace(/^\$/, '');
|
|
154
|
+
const cell = text.slice(dot + 1);
|
|
155
|
+
if (sheet === '')
|
|
156
|
+
return cell;
|
|
157
|
+
if (sheet.startsWith("'") && sheet.endsWith("'"))
|
|
158
|
+
sheet = sheet.slice(1, -1).replace(/''/g, "'");
|
|
159
|
+
return `${/^[A-Za-z_][A-Za-z0-9_.]*$/.test(sheet) ? sheet : `'${sheet.replace(/'/g, "''")}'`}!${cell}`;
|
|
160
|
+
};
|
|
161
|
+
if (parts.length === 2) {
|
|
162
|
+
const from = one(parts[0]);
|
|
163
|
+
// The second half repeats the sheet; A1:B2 wants it once.
|
|
164
|
+
const to = one(parts[1]).replace(/^.*!/, '');
|
|
165
|
+
return `${from}:${to}`;
|
|
166
|
+
}
|
|
167
|
+
return one(inside);
|
|
168
|
+
}
|
|
169
|
+
function lastUnquotedDot(text) {
|
|
170
|
+
let quoted = false;
|
|
171
|
+
let found = -1;
|
|
172
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
173
|
+
const ch = text[i];
|
|
174
|
+
if (ch === "'") {
|
|
175
|
+
quoted = !quoted;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (ch === '.' && !quoted)
|
|
179
|
+
found = i;
|
|
180
|
+
}
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* An A1 formula as ODF writes one: every reference in brackets, `;` between
|
|
185
|
+
* arguments. Rebuilt from the tokens rather than by substitution, so a comma
|
|
186
|
+
* inside a string stays a comma and `A1` inside one is not a reference.
|
|
187
|
+
*
|
|
188
|
+
* Returns null when the formula cannot be spelled in ODF - a structured
|
|
189
|
+
* reference with no workbook to resolve it - so the caller can write the
|
|
190
|
+
* cell's value on its own rather than a formula the reader would refuse.
|
|
191
|
+
*/
|
|
192
|
+
export function formulaToOdf(text, resolve) {
|
|
193
|
+
const body = text.startsWith('=') ? text.slice(1) : text;
|
|
194
|
+
let tokens;
|
|
195
|
+
try {
|
|
196
|
+
tokens = tokenize(body);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// Not something this engine can read: no formula rather than a broken one.
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const ref = (r) => {
|
|
203
|
+
const sheet = r.sheet === null ? '' : `$${/^[A-Za-z_][A-Za-z0-9_.]*$/.test(r.sheet) ? r.sheet : `'${r.sheet.replace(/'/g, "''")}'`}`;
|
|
204
|
+
const col = `${r.colAbs ? '$' : ''}${colToLetters(r.col)}`;
|
|
205
|
+
const row = r.row === null ? '' : `${r.rowAbs ? '$' : ''}${r.row + 1}`;
|
|
206
|
+
return `${sheet}.${col}${row}`;
|
|
207
|
+
};
|
|
208
|
+
let out = '';
|
|
209
|
+
for (const token of tokens) {
|
|
210
|
+
switch (token.t) {
|
|
211
|
+
case 'num':
|
|
212
|
+
out += String(token.v);
|
|
213
|
+
break;
|
|
214
|
+
case 'str':
|
|
215
|
+
out += `"${token.v.replace(/"/g, '""')}"`;
|
|
216
|
+
break;
|
|
217
|
+
case 'bool':
|
|
218
|
+
out += token.v ? 'TRUE()' : 'FALSE()';
|
|
219
|
+
break;
|
|
220
|
+
case 'err':
|
|
221
|
+
out += token.v;
|
|
222
|
+
break;
|
|
223
|
+
case 'ref':
|
|
224
|
+
out += `[${ref(token.ref)}]`;
|
|
225
|
+
break;
|
|
226
|
+
// A range repeats the dot on the far side: [.A1:.B2].
|
|
227
|
+
case 'range':
|
|
228
|
+
out += `[${ref(token.from)}:.${cellPart(ref(token.to))}]`;
|
|
229
|
+
break;
|
|
230
|
+
case 'name':
|
|
231
|
+
out += token.v;
|
|
232
|
+
break;
|
|
233
|
+
// The name only: the `(` arrives as its own token. Plain names, not
|
|
234
|
+
// the COM.MICROSOFT. spelling LibreOffice uses for Excel's newer
|
|
235
|
+
// functions: it resolves a plain name for everything it implements,
|
|
236
|
+
// and the vendor prefix buys nothing for the rest.
|
|
237
|
+
case 'fn':
|
|
238
|
+
out += token.v;
|
|
239
|
+
break;
|
|
240
|
+
case 'op':
|
|
241
|
+
out += token.v;
|
|
242
|
+
break;
|
|
243
|
+
case 'lparen':
|
|
244
|
+
out += '(';
|
|
245
|
+
break;
|
|
246
|
+
case 'rparen':
|
|
247
|
+
out += ')';
|
|
248
|
+
break;
|
|
249
|
+
case 'comma':
|
|
250
|
+
out += ';';
|
|
251
|
+
break;
|
|
252
|
+
// A structured reference has no ODF spelling: it goes as the
|
|
253
|
+
// rectangle it names, or the formula does not go at all.
|
|
254
|
+
case 'table': {
|
|
255
|
+
const rect = resolve?.(token);
|
|
256
|
+
if (!rect)
|
|
257
|
+
return null;
|
|
258
|
+
const where = rect.sheet === null
|
|
259
|
+
? ''
|
|
260
|
+
: `$${/^[A-Za-z_][A-Za-z0-9_.]*$/.test(rect.sheet) ? rect.sheet : `'${rect.sheet.replace(/'/g, "''")}'`}`;
|
|
261
|
+
const from = `${where}.${colToLetters(rect.c1)}${rect.r1 + 1}`;
|
|
262
|
+
out += rect.r1 === rect.r2 && rect.c1 === rect.c2
|
|
263
|
+
? `[${from}]`
|
|
264
|
+
: `[${from}:.${colToLetters(rect.c2)}${rect.r2 + 1}]`;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
default: return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return `of:=${out}`;
|
|
271
|
+
}
|
|
272
|
+
/** `$Sheet.B2` without the sheet: the far end of an ODF range. */
|
|
273
|
+
const cellPart = (text) => text.slice(text.lastIndexOf('.') + 1);
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Number formats. ODF spells a format as a tree of elements rather than as a
|
|
276
|
+
// pattern, so each side is a translation.
|
|
277
|
+
/** An ODF data style as the pattern a cell's number format speaks. */
|
|
278
|
+
function patternFromDataStyle(style) {
|
|
279
|
+
const kids = Array.from(style.children);
|
|
280
|
+
// Excel's accounting format is a currency (or number) style with a fill:
|
|
281
|
+
// the symbol at the left edge, the figure at the right, spaces between.
|
|
282
|
+
// Read as the one pattern `accountingPattern` spells, so the cell keeps
|
|
283
|
+
// the alignment it had rather than becoming a currency amount.
|
|
284
|
+
if ((style.localName === 'currency-style' || style.localName === 'number-style') && kids.some((k) => k.localName === 'fill-character' && (k.textContent ?? '') === ' ')) {
|
|
285
|
+
const number = kids.find((k) => k.localName === 'number');
|
|
286
|
+
if (number)
|
|
287
|
+
return accountingPattern(kids.find((k) => k.localName === 'currency-symbol')?.textContent ?? '', numAttr(number, 'decimal-places') ?? 0);
|
|
288
|
+
}
|
|
289
|
+
const parts = [];
|
|
290
|
+
let sawSomething = false;
|
|
291
|
+
for (const node of kids) {
|
|
292
|
+
switch (node.localName) {
|
|
293
|
+
case 'text':
|
|
294
|
+
parts.push(quoteLiteral(node.textContent ?? ''));
|
|
295
|
+
break;
|
|
296
|
+
// A run of one character to the cell's edge: Excel's `*` fill.
|
|
297
|
+
case 'fill-character':
|
|
298
|
+
parts.push(`*${(node.textContent ?? ' ').slice(0, 1) || ' '}`);
|
|
299
|
+
break;
|
|
300
|
+
case 'number': {
|
|
301
|
+
sawSomething = true;
|
|
302
|
+
const decimals = numAttr(node, 'decimal-places') ?? 0;
|
|
303
|
+
const min = numAttr(node, 'min-integer-digits') ?? 1;
|
|
304
|
+
const grouping = attr(node, 'grouping') === 'true';
|
|
305
|
+
const whole = grouping ? '#,##0' : '0'.repeat(Math.max(min, 1));
|
|
306
|
+
parts.push(decimals > 0 ? `${whole}.${'0'.repeat(decimals)}` : whole);
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
case 'scientific-number': {
|
|
310
|
+
sawSomething = true;
|
|
311
|
+
const decimals = numAttr(node, 'decimal-places') ?? 2;
|
|
312
|
+
parts.push(`0${decimals > 0 ? `.${'0'.repeat(decimals)}` : ''}E+00`);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
case 'fraction': {
|
|
316
|
+
sawSomething = true;
|
|
317
|
+
const den = numAttr(node, 'max-denominator-value');
|
|
318
|
+
parts.push(den ? `# ?/${den}` : '# ?/?');
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case 'currency-symbol':
|
|
322
|
+
parts.push(quoteLiteral(node.textContent ?? ''));
|
|
323
|
+
break;
|
|
324
|
+
case 'year':
|
|
325
|
+
sawSomething = true;
|
|
326
|
+
parts.push(attr(node, 'style') === 'long' ? 'yyyy' : 'yy');
|
|
327
|
+
break;
|
|
328
|
+
case 'month':
|
|
329
|
+
sawSomething = true;
|
|
330
|
+
parts.push(attr(node, 'textual') === 'true'
|
|
331
|
+
? (attr(node, 'style') === 'long' ? 'mmmm' : 'mmm')
|
|
332
|
+
: (attr(node, 'style') === 'long' ? 'mm' : 'm'));
|
|
333
|
+
break;
|
|
334
|
+
case 'day':
|
|
335
|
+
sawSomething = true;
|
|
336
|
+
parts.push(attr(node, 'style') === 'long' ? 'dd' : 'd');
|
|
337
|
+
break;
|
|
338
|
+
case 'day-of-week':
|
|
339
|
+
sawSomething = true;
|
|
340
|
+
parts.push(attr(node, 'style') === 'long' ? 'dddd' : 'ddd');
|
|
341
|
+
break;
|
|
342
|
+
case 'hours':
|
|
343
|
+
sawSomething = true;
|
|
344
|
+
parts.push(attr(node, 'style') === 'long' ? 'hh' : 'h');
|
|
345
|
+
break;
|
|
346
|
+
case 'minutes':
|
|
347
|
+
sawSomething = true;
|
|
348
|
+
parts.push(attr(node, 'style') === 'long' ? 'mm' : 'm');
|
|
349
|
+
break;
|
|
350
|
+
case 'seconds': {
|
|
351
|
+
sawSomething = true;
|
|
352
|
+
const decimals = numAttr(node, 'decimal-places') ?? 0;
|
|
353
|
+
parts.push((attr(node, 'style') === 'long' ? 'ss' : 's') + (decimals > 0 ? `.${'0'.repeat(decimals)}` : ''));
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case 'am-pm':
|
|
357
|
+
parts.push('AM/PM');
|
|
358
|
+
break;
|
|
359
|
+
case 'boolean':
|
|
360
|
+
sawSomething = true;
|
|
361
|
+
parts.push('General');
|
|
362
|
+
break;
|
|
363
|
+
case 'text-content':
|
|
364
|
+
parts.push('@');
|
|
365
|
+
break;
|
|
366
|
+
default: break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (!sawSomething)
|
|
370
|
+
return undefined;
|
|
371
|
+
const body = parts.join('');
|
|
372
|
+
// A percentage style carries the sign as a literal; Excel's `%` is the
|
|
373
|
+
// token, and the value is the same fraction in both.
|
|
374
|
+
return body === '' ? undefined : body;
|
|
375
|
+
}
|
|
376
|
+
/** A literal run inside a pattern: quoted unless it is harmless as it is. */
|
|
377
|
+
function quoteLiteral(text) {
|
|
378
|
+
if (text === '')
|
|
379
|
+
return '';
|
|
380
|
+
if (text === '%')
|
|
381
|
+
return '%';
|
|
382
|
+
if (/^[\s.,:/-]+$/.test(text))
|
|
383
|
+
return text;
|
|
384
|
+
return `"${text.replace(/"/g, '')}"`;
|
|
385
|
+
}
|
|
386
|
+
function readStyles(root, extra) {
|
|
387
|
+
const cell = new Map();
|
|
388
|
+
const colWidth = new Map();
|
|
389
|
+
const rowHeight = new Map();
|
|
390
|
+
const patterns = new Map();
|
|
391
|
+
// Data styles first: a cell style may name one that is defined after it,
|
|
392
|
+
// and a pattern found too late is a format lost.
|
|
393
|
+
const collectData = (where) => {
|
|
394
|
+
if (!where)
|
|
395
|
+
return;
|
|
396
|
+
for (const node of Array.from(where.children)) {
|
|
397
|
+
const name = attr(node, 'name');
|
|
398
|
+
if (!name || node.localName === 'style' || !/-style$/.test(node.localName))
|
|
399
|
+
continue;
|
|
400
|
+
const pattern = patternFromDataStyle(node);
|
|
401
|
+
if (!pattern)
|
|
402
|
+
continue;
|
|
403
|
+
patterns.set(name, node.localName === 'percentage-style' && !pattern.includes('%') ? `${pattern}%` : pattern);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
const collect = (where) => {
|
|
407
|
+
if (!where)
|
|
408
|
+
return;
|
|
409
|
+
for (const node of Array.from(where.children)) {
|
|
410
|
+
const name = attr(node, 'name');
|
|
411
|
+
if (!name)
|
|
412
|
+
continue;
|
|
413
|
+
if (node.localName !== 'style')
|
|
414
|
+
continue;
|
|
415
|
+
const family = attr(node, 'family');
|
|
416
|
+
if (family === 'table-column') {
|
|
417
|
+
const px = odfLength(attr(kid(node, 'table-column-properties'), 'column-width'));
|
|
418
|
+
if (px !== null)
|
|
419
|
+
colWidth.set(name, px);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (family === 'table-row') {
|
|
423
|
+
const px = odfLength(attr(kid(node, 'table-row-properties'), 'row-height'));
|
|
424
|
+
if (px !== null)
|
|
425
|
+
rowHeight.set(name, px);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (family !== 'table-cell')
|
|
429
|
+
continue;
|
|
430
|
+
const entry = {};
|
|
431
|
+
const text = kid(node, 'text-properties');
|
|
432
|
+
const cellProps = kid(node, 'table-cell-properties');
|
|
433
|
+
const paragraph = kid(node, 'paragraph-properties');
|
|
434
|
+
if (attr(text, 'font-weight') === 'bold')
|
|
435
|
+
entry.bold = true;
|
|
436
|
+
if (attr(text, 'font-style') === 'italic')
|
|
437
|
+
entry.italic = true;
|
|
438
|
+
const underline = attr(text, 'text-underline-style');
|
|
439
|
+
if (underline && underline !== 'none')
|
|
440
|
+
entry.underline = true;
|
|
441
|
+
const strike = attr(text, 'text-line-through-style');
|
|
442
|
+
if (strike && strike !== 'none')
|
|
443
|
+
entry.strike = true;
|
|
444
|
+
const colour = attr(text, 'color');
|
|
445
|
+
if (colour && colour.startsWith('#'))
|
|
446
|
+
entry.color = colour.toLowerCase();
|
|
447
|
+
const size = odfLength(attr(text, 'font-size'));
|
|
448
|
+
if (size !== null && attr(text, 'font-size')?.endsWith('pt'))
|
|
449
|
+
entry.fontSize = Math.round(size * 0.75);
|
|
450
|
+
const fill = attr(cellProps, 'background-color');
|
|
451
|
+
if (fill && fill.startsWith('#'))
|
|
452
|
+
entry.fill = fill.toLowerCase();
|
|
453
|
+
if (attr(cellProps, 'wrap-option') === 'wrap')
|
|
454
|
+
entry.wrap = true;
|
|
455
|
+
const align = attr(paragraph, 'text-align');
|
|
456
|
+
if (align === 'start' || align === 'left')
|
|
457
|
+
entry.align = 'left';
|
|
458
|
+
else if (align === 'center')
|
|
459
|
+
entry.align = 'center';
|
|
460
|
+
else if (align === 'end' || align === 'right')
|
|
461
|
+
entry.align = 'right';
|
|
462
|
+
const border = readBorder(cellProps);
|
|
463
|
+
if (border)
|
|
464
|
+
entry.border = border;
|
|
465
|
+
const data = attr(node, 'data-style-name');
|
|
466
|
+
if (data) {
|
|
467
|
+
const pattern = patterns.get(data);
|
|
468
|
+
if (pattern)
|
|
469
|
+
entry.numFmt = pattern;
|
|
470
|
+
}
|
|
471
|
+
// The locked flag is the protection story, not a look.
|
|
472
|
+
if (Object.keys(entry).length)
|
|
473
|
+
cell.set(name, entry);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
for (const where of [kid(root, 'automatic-styles'), kid(root, 'styles'), extra ? kid(extra, 'styles') : null, extra ? kid(extra, 'automatic-styles') : null]) {
|
|
477
|
+
collectData(where);
|
|
478
|
+
}
|
|
479
|
+
for (const where of [kid(root, 'automatic-styles'), kid(root, 'styles'), extra ? kid(extra, 'styles') : null, extra ? kid(extra, 'automatic-styles') : null]) {
|
|
480
|
+
collect(where);
|
|
481
|
+
}
|
|
482
|
+
return { cell, colWidth, rowHeight };
|
|
483
|
+
}
|
|
484
|
+
function readBorder(props) {
|
|
485
|
+
if (!props)
|
|
486
|
+
return undefined;
|
|
487
|
+
const sides = { left: 'border-left', right: 'border-right', top: 'border-top', bottom: 'border-bottom' };
|
|
488
|
+
const all = attr(props, 'border');
|
|
489
|
+
const out = {};
|
|
490
|
+
for (const [side, name] of Object.entries(sides)) {
|
|
491
|
+
const text = attr(props, name) ?? all;
|
|
492
|
+
if (!text || text === 'none')
|
|
493
|
+
continue;
|
|
494
|
+
// `0.06pt solid #94a3b8`
|
|
495
|
+
const parts = text.trim().split(/\s+/);
|
|
496
|
+
const width = odfLength(parts[0] ?? null);
|
|
497
|
+
const style = parts[1] ?? 'solid';
|
|
498
|
+
const colour = parts.find((p) => p.startsWith('#'));
|
|
499
|
+
const spec = {};
|
|
500
|
+
if (style === 'dashed')
|
|
501
|
+
spec.style = 'dashed';
|
|
502
|
+
else if (style === 'dotted')
|
|
503
|
+
spec.style = 'dotted';
|
|
504
|
+
else if (style === 'double')
|
|
505
|
+
spec.style = 'double';
|
|
506
|
+
spec.width = width && width >= 3 ? 3 : width && width >= 2 ? 2 : 1;
|
|
507
|
+
if (colour)
|
|
508
|
+
spec.color = colour.toLowerCase();
|
|
509
|
+
out[side] = spec;
|
|
510
|
+
}
|
|
511
|
+
return Object.keys(out).length ? out : undefined;
|
|
512
|
+
}
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Reading.
|
|
515
|
+
/** How far a repeat is taken at face value. ODF fills the sheet to its edge
|
|
516
|
+
* with repeated empty cells - LibreOffice writes 16378 of them on every
|
|
517
|
+
* row - and none of that is data. */
|
|
518
|
+
const REPEAT_CAP = 4096;
|
|
519
|
+
/** The document an .ods holds, from its unzipped parts. */
|
|
520
|
+
export function sheetStateFromOds(parts) {
|
|
521
|
+
const contentXml = parts['content.xml'];
|
|
522
|
+
if (!contentXml)
|
|
523
|
+
throw new Error('@svgrid/enterprise: not an .ods package: content.xml is missing');
|
|
524
|
+
const content = parseXml(contentXml, 'content.xml').documentElement;
|
|
525
|
+
const stylesRoot = parts['styles.xml'] ? parseXml(parts['styles.xml'], 'styles.xml').documentElement : undefined;
|
|
526
|
+
const styles = readStyles(content, stylesRoot);
|
|
527
|
+
const body = kid(kid(content, 'body'), 'spreadsheet');
|
|
528
|
+
const sheets = [];
|
|
529
|
+
const entries = {};
|
|
530
|
+
const names = {};
|
|
531
|
+
for (const table of kids(body, 'table')) {
|
|
532
|
+
const name = attr(table, 'name') ?? `Sheet${sheets.length + 1}`;
|
|
533
|
+
const cells = [];
|
|
534
|
+
const formats = {};
|
|
535
|
+
const links = {};
|
|
536
|
+
const notes = {};
|
|
537
|
+
const merges = [];
|
|
538
|
+
const columnWidths = {};
|
|
539
|
+
const rowHeights = [];
|
|
540
|
+
const hidden = { rows: [], cols: [] };
|
|
541
|
+
// Columns: width, whether they are shown, and the look their cells take
|
|
542
|
+
// when they carry none of their own - which is where LibreOffice puts a
|
|
543
|
+
// whole column's date or currency format.
|
|
544
|
+
const columnLook = [];
|
|
545
|
+
let col = 0;
|
|
546
|
+
for (const column of kids(table, 'table-column')) {
|
|
547
|
+
const repeat = Math.min(numAttr(column, 'number-columns-repeated') ?? 1, REPEAT_CAP);
|
|
548
|
+
const width = styles.colWidth.get(attr(column, 'style-name') ?? '');
|
|
549
|
+
const shown = attr(column, 'visibility');
|
|
550
|
+
const look = styles.cell.get(attr(column, 'default-cell-style-name') ?? '');
|
|
551
|
+
for (let i = 0; i < repeat; i += 1, col += 1) {
|
|
552
|
+
// A run to the sheet's edge is the default, not a decision.
|
|
553
|
+
if (repeat > 64)
|
|
554
|
+
continue;
|
|
555
|
+
columnLook[col] = look;
|
|
556
|
+
if (width !== undefined)
|
|
557
|
+
columnWidths[colToLetters(col)] = width;
|
|
558
|
+
if (shown === 'collapse' || shown === 'filter')
|
|
559
|
+
hidden.cols.push(col);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
let row = 0;
|
|
563
|
+
for (const line of kids(table, 'table-row')) {
|
|
564
|
+
const repeat = Math.min(numAttr(line, 'number-rows-repeated') ?? 1, REPEAT_CAP);
|
|
565
|
+
const height = styles.rowHeight.get(attr(line, 'style-name') ?? '');
|
|
566
|
+
const shown = attr(line, 'visibility');
|
|
567
|
+
const cellNodes = Array.from(line.children).filter((n) => n.localName === 'table-cell' || n.localName === 'covered-table-cell');
|
|
568
|
+
const rowLook = styles.cell.get(attr(line, 'default-cell-style-name') ?? '');
|
|
569
|
+
const blank = cellNodes.every((n) => n.localName === 'covered-table-cell' || (!attr(n, 'value-type') && !attr(n, 'formula') && (n.textContent ?? '') === ''));
|
|
570
|
+
// A run of empty rows is the sheet's blank space: it moves the cursor
|
|
571
|
+
// and nothing else, which is what keeps a file with 1,048,576 rows in
|
|
572
|
+
// its XML from becoming a million rows of nothing here.
|
|
573
|
+
const times = blank ? 1 : repeat;
|
|
574
|
+
for (let n = 0; n < times; n += 1, row += 1) {
|
|
575
|
+
if (height !== undefined && !blank)
|
|
576
|
+
rowHeights.push([row, height]);
|
|
577
|
+
// A row the filter folded is hidden too; the document releases the
|
|
578
|
+
// ones its criteria fold again (see releaseFilteredRows).
|
|
579
|
+
if (shown === 'collapse' || shown === 'filter')
|
|
580
|
+
hidden.rows.push(row);
|
|
581
|
+
let c = 0;
|
|
582
|
+
for (const cellNode of cellNodes) {
|
|
583
|
+
const span = Math.min(numAttr(cellNode, 'number-columns-repeated') ?? 1, REPEAT_CAP);
|
|
584
|
+
const covered = cellNode.localName === 'covered-table-cell';
|
|
585
|
+
const text = readCellText(cellNode);
|
|
586
|
+
const formula = attr(cellNode, 'formula');
|
|
587
|
+
const styleName = attr(cellNode, 'style-name') ?? '';
|
|
588
|
+
// The cell's own look, or the row's, or the column's: ODF lets a
|
|
589
|
+
// format be stated once for a whole column, and a date column
|
|
590
|
+
// written that way is the common case.
|
|
591
|
+
const ownLook = styles.cell.get(styleName);
|
|
592
|
+
const value = formula ? formulaFromOdf(formula) : text;
|
|
593
|
+
for (let k = 0; k < span; k += 1, c += 1) {
|
|
594
|
+
const here = ownLook ?? rowLook ?? columnLook[c];
|
|
595
|
+
if (value === '' && !here)
|
|
596
|
+
continue;
|
|
597
|
+
if (value !== '') {
|
|
598
|
+
while (cells.length <= row)
|
|
599
|
+
cells.push([]);
|
|
600
|
+
const cellRow = cells[row];
|
|
601
|
+
while (cellRow.length <= c)
|
|
602
|
+
cellRow.push('');
|
|
603
|
+
cellRow[c] = value;
|
|
604
|
+
}
|
|
605
|
+
if (here)
|
|
606
|
+
formats[formatKeyAt(row, c)] = { ...here };
|
|
607
|
+
}
|
|
608
|
+
if (!covered) {
|
|
609
|
+
const across = numAttr(cellNode, 'number-columns-spanned') ?? 1;
|
|
610
|
+
const down = numAttr(cellNode, 'number-rows-spanned') ?? 1;
|
|
611
|
+
const first = c - span;
|
|
612
|
+
if (across > 1 || down > 1)
|
|
613
|
+
merges.push([row, first, row + down - 1, first + across - 1]);
|
|
614
|
+
const href = attr(kid(kid(cellNode, 'p'), 'a'), 'href');
|
|
615
|
+
if (href)
|
|
616
|
+
links[`r${row}`] = { ...(links[`r${row}`] ?? {}), [colToLetters(first)]: { target: href } };
|
|
617
|
+
const note = kid(cellNode, 'annotation');
|
|
618
|
+
if (note) {
|
|
619
|
+
const noteText = kids(note, 'p').map((p) => p.textContent ?? '').join('\n').trim();
|
|
620
|
+
if (noteText) {
|
|
621
|
+
notes[`r${row}`] = {
|
|
622
|
+
...(notes[`r${row}`] ?? {}),
|
|
623
|
+
[colToLetters(first)]: {
|
|
624
|
+
text: noteText,
|
|
625
|
+
author: kid(note, 'creator')?.textContent ?? '',
|
|
626
|
+
at: kid(note, 'date')?.textContent ?? '',
|
|
627
|
+
resolved: false,
|
|
628
|
+
replies: [],
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
sheets.push({ name, cells });
|
|
638
|
+
entries[name] = {
|
|
639
|
+
formats,
|
|
640
|
+
columnWidths,
|
|
641
|
+
rowHeights: tallRows(rowHeights),
|
|
642
|
+
hidden,
|
|
643
|
+
freeze: { rows: 0, cols: 0 },
|
|
644
|
+
comments: notes,
|
|
645
|
+
protected: attr(table, 'protected') === 'true',
|
|
646
|
+
links,
|
|
647
|
+
merges,
|
|
648
|
+
validation: [],
|
|
649
|
+
conditionalFormats: [],
|
|
650
|
+
autoFilter: null,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
// The filter regions: ODF keeps them beside the tables rather than inside
|
|
654
|
+
// one, which is where LibreOffice leaves a sheet's filter arrows.
|
|
655
|
+
for (const range of kids(kid(body, 'database-ranges'), 'database-range')) {
|
|
656
|
+
const target = attr(range, 'target-range-address');
|
|
657
|
+
if (!target || attr(range, 'display-filter-buttons') === 'false')
|
|
658
|
+
continue;
|
|
659
|
+
const [fromText, toText] = target.split(':');
|
|
660
|
+
const from = odfCell(fromText ?? '');
|
|
661
|
+
const to = odfCell(toText ?? '');
|
|
662
|
+
if (!from || !to || !from.sheet)
|
|
663
|
+
continue;
|
|
664
|
+
const found = Object.entries(entries).find(([name]) => name === from.sheet || name.toLowerCase() === from.sheet.toLowerCase());
|
|
665
|
+
const entry = found?.[1];
|
|
666
|
+
if (!entry || entry.autoFilter)
|
|
667
|
+
continue;
|
|
668
|
+
const rect = [from.row, from.col, to.row, to.col];
|
|
669
|
+
const filterEl = kid(range, 'filter');
|
|
670
|
+
entry.autoFilter = { range: rect, filters: filterEl ? filtersFromOds(filterEl, rect).filters : {} };
|
|
671
|
+
}
|
|
672
|
+
// Defined names: `table:named-expressions` beside the tables.
|
|
673
|
+
for (const range of kids(kid(body, 'named-expressions'), 'named-range')) {
|
|
674
|
+
const name = attr(range, 'name');
|
|
675
|
+
const target = attr(range, 'cell-range-address');
|
|
676
|
+
// A named range is a bare reference rather than a formula: $Data.$E$5.
|
|
677
|
+
if (name && target)
|
|
678
|
+
names[name] = `=${referenceFromOdf(target)}`;
|
|
679
|
+
}
|
|
680
|
+
for (const expr of kids(kid(body, 'named-expressions'), 'named-expression')) {
|
|
681
|
+
const name = attr(expr, 'name');
|
|
682
|
+
const target = attr(expr, 'expression');
|
|
683
|
+
if (name && target)
|
|
684
|
+
names[name] = formulaFromOdf(target);
|
|
685
|
+
}
|
|
686
|
+
if (!sheets.length)
|
|
687
|
+
sheets.push({ name: 'Sheet1', cells: [] });
|
|
688
|
+
const active = sheets[0].name;
|
|
689
|
+
if (!entries[active]) {
|
|
690
|
+
entries[active] = {
|
|
691
|
+
formats: {}, columnWidths: {}, rowHeights: [], hidden: { rows: [], cols: [] },
|
|
692
|
+
freeze: { rows: 0, cols: 0 }, comments: {}, protected: false,
|
|
693
|
+
merges: [], validation: [], conditionalFormats: [], autoFilter: null,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return { version: 1, workbook: { sheets, active, names }, sheets: entries };
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* What one ODF cell holds, as the text this workbook stores.
|
|
700
|
+
*
|
|
701
|
+
* The type decides: a float is its number, a date its ISO date (which is how
|
|
702
|
+
* a date is written here), a time a fraction of a day, a boolean TRUE or
|
|
703
|
+
* FALSE, and everything else the text of its paragraphs.
|
|
704
|
+
*/
|
|
705
|
+
function readCellText(cell) {
|
|
706
|
+
const type = attr(cell, 'value-type');
|
|
707
|
+
const paragraphs = kids(cell, 'p').map((p) => p.textContent ?? '');
|
|
708
|
+
const shown = paragraphs.join('\n');
|
|
709
|
+
switch (type) {
|
|
710
|
+
case 'float':
|
|
711
|
+
case 'percentage':
|
|
712
|
+
case 'currency': {
|
|
713
|
+
const value = attr(cell, 'value');
|
|
714
|
+
return value ?? shown;
|
|
715
|
+
}
|
|
716
|
+
case 'date': {
|
|
717
|
+
const value = attr(cell, 'date-value') ?? '';
|
|
718
|
+
// A date with a time keeps both; a plain date is the ten characters.
|
|
719
|
+
return value.length > 10 && !value.endsWith('T00:00:00') ? value.replace('T', ' ') : value.slice(0, 10);
|
|
720
|
+
}
|
|
721
|
+
case 'time': {
|
|
722
|
+
const value = attr(cell, 'time-value') ?? '';
|
|
723
|
+
const seconds = durationSeconds(value);
|
|
724
|
+
return seconds === null ? shown : String(Math.round((seconds / 86400) * 1e10) / 1e10);
|
|
725
|
+
}
|
|
726
|
+
case 'boolean': {
|
|
727
|
+
const value = attr(cell, 'boolean-value');
|
|
728
|
+
return value === 'true' ? 'TRUE' : value === 'false' ? 'FALSE' : shown;
|
|
729
|
+
}
|
|
730
|
+
default:
|
|
731
|
+
return shown;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/** `PT12H30M15S` in seconds. */
|
|
735
|
+
function durationSeconds(text) {
|
|
736
|
+
const m = /^-?P(?:(\d+)D)?T(?:([\d.]+)H)?(?:([\d.]+)M)?(?:([\d.]+)S)?$/.exec(text.trim());
|
|
737
|
+
if (!m)
|
|
738
|
+
return null;
|
|
739
|
+
const days = Number(m[1] ?? 0);
|
|
740
|
+
const hours = Number(m[2] ?? 0);
|
|
741
|
+
const minutes = Number(m[3] ?? 0);
|
|
742
|
+
const seconds = Number(m[4] ?? 0);
|
|
743
|
+
const total = days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
|
744
|
+
return Number.isFinite(total) ? total : null;
|
|
745
|
+
}
|
|
746
|
+
/** The document an .ods file holds, as a saved state. */
|
|
747
|
+
export async function documentFromOds(file, JSZip) {
|
|
748
|
+
const Zip = await loadZip(JSZip);
|
|
749
|
+
const zip = await Zip.loadAsync(file);
|
|
750
|
+
const parts = {};
|
|
751
|
+
const pending = [];
|
|
752
|
+
zip.forEach((path, entry) => {
|
|
753
|
+
if (entry.dir)
|
|
754
|
+
return;
|
|
755
|
+
if (!/\.(xml)$/i.test(path))
|
|
756
|
+
return;
|
|
757
|
+
const clean = path.replace(/^\//, '');
|
|
758
|
+
pending.push(entry.async('string').then((text) => { parts[clean] = text; }));
|
|
759
|
+
});
|
|
760
|
+
await Promise.all(pending);
|
|
761
|
+
return sheetStateFromOds(parts);
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* The row heights worth keeping: the ones that differ from the height most
|
|
765
|
+
* rows have. ODF gives every row a style, so a file arrives saying all
|
|
766
|
+
* thousand of its rows are 0.178in tall, which is the default said a
|
|
767
|
+
* thousand times rather than a decision about any of them.
|
|
768
|
+
*/
|
|
769
|
+
function tallRows(heights) {
|
|
770
|
+
if (heights.length < 4)
|
|
771
|
+
return heights;
|
|
772
|
+
const counts = new Map();
|
|
773
|
+
for (const [, px] of heights)
|
|
774
|
+
counts.set(px, (counts.get(px) ?? 0) + 1);
|
|
775
|
+
let usual = 0;
|
|
776
|
+
let most = 0;
|
|
777
|
+
for (const [px, n] of counts)
|
|
778
|
+
if (n > most) {
|
|
779
|
+
most = n;
|
|
780
|
+
usual = px;
|
|
781
|
+
}
|
|
782
|
+
// Only when it really is the usual one: a sheet of genuinely varied rows
|
|
783
|
+
// keeps all of them.
|
|
784
|
+
if (most < heights.length / 2)
|
|
785
|
+
return heights;
|
|
786
|
+
return heights.filter(([, px]) => px !== usual);
|
|
787
|
+
}
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
// Writing.
|
|
790
|
+
const NS = [
|
|
791
|
+
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"',
|
|
792
|
+
'xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"',
|
|
793
|
+
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"',
|
|
794
|
+
'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"',
|
|
795
|
+
'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"',
|
|
796
|
+
'xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"',
|
|
797
|
+
'xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2"',
|
|
798
|
+
'xmlns:dc="http://purl.org/dc/elements/1.1/"',
|
|
799
|
+
'xmlns:xlink="http://www.w3.org/1999/xlink"',
|
|
800
|
+
'xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0"',
|
|
801
|
+
].join(' ');
|
|
802
|
+
const XML_HEAD = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
803
|
+
const esc = (text) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
804
|
+
const inches = (px) => `${Math.round((px / 96) * 10000) / 10000}in`;
|
|
805
|
+
/** An ISO date, which is how this workbook writes one. */
|
|
806
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/;
|
|
807
|
+
/** The error codes a cell can hold, so one goes out as an error rather than
|
|
808
|
+
* as text that merely reads like one. */
|
|
809
|
+
const ERROR_TEXT = /^#(REF!|DIV\/0!|VALUE!|NAME\?|NUM!|N\/A|NULL!|SPILL!|CALC!|CYCLE!)$/i;
|
|
810
|
+
/**
|
|
811
|
+
* An Excel number format as an ODF data style, or null for one this writer
|
|
812
|
+
* does not translate. The common patterns - a percentage, decimals, a
|
|
813
|
+
* grouped number, a currency amount, a date, a clock - cover what a sheet
|
|
814
|
+
* made here carries; anything else is left to the reader's own default,
|
|
815
|
+
* which is better than a style that says something untrue.
|
|
816
|
+
*/
|
|
817
|
+
function dataStyleXml(pattern, name) {
|
|
818
|
+
// Accounting: the symbol at the left edge, the figure at the right, and
|
|
819
|
+
// ODF's fill character for the space between, which is what LibreOffice
|
|
820
|
+
// writes for its own. Written as a plain currency style it came back as
|
|
821
|
+
// one, and the demo's "survives all three formats" was untrue for .ods.
|
|
822
|
+
const accounting = accountingParts(pattern);
|
|
823
|
+
if (accounting) {
|
|
824
|
+
const number = `<number:number number:decimal-places="${accounting.decimals}" number:min-decimal-places="${accounting.decimals}" number:min-integer-digits="1" number:grouping="true"/>`;
|
|
825
|
+
const fill = '<number:fill-character> </number:fill-character>';
|
|
826
|
+
return accounting.symbol
|
|
827
|
+
? `<number:currency-style style:name="${name}"><number:currency-symbol>${esc(accounting.symbol)}</number:currency-symbol>${fill}${number}</number:currency-style>`
|
|
828
|
+
: `<number:number-style style:name="${name}">${fill}${number}</number:number-style>`;
|
|
829
|
+
}
|
|
830
|
+
const body = pattern.split(';')[0].trim();
|
|
831
|
+
if (body === '' || /^general$/i.test(body))
|
|
832
|
+
return null;
|
|
833
|
+
const dateLike = /[ymdhs]/i.test(body.replace(/"[^"]*"/g, '').replace(/\[[^\]]*\]/g, ''))
|
|
834
|
+
&& !/[#0?]/.test(body.replace(/"[^"]*"/g, ''));
|
|
835
|
+
if (dateLike)
|
|
836
|
+
return dateStyleXml(body, name);
|
|
837
|
+
const percent = body.includes('%');
|
|
838
|
+
const currency = /[$€£¥]/.test(body);
|
|
839
|
+
const grouping = body.includes(',');
|
|
840
|
+
const decimals = /\.([0#]+)/.exec(body)?.[1]?.length ?? 0;
|
|
841
|
+
const symbol = /[$€£¥]/.exec(body)?.[0];
|
|
842
|
+
const number = `<number:number number:decimal-places="${decimals}" number:min-decimal-places="${decimals}"`
|
|
843
|
+
+ ` number:min-integer-digits="1"${grouping ? ' number:grouping="true"' : ''}/>`;
|
|
844
|
+
if (percent) {
|
|
845
|
+
return `<number:percentage-style style:name="${name}">${number}<number:text>%</number:text></number:percentage-style>`;
|
|
846
|
+
}
|
|
847
|
+
if (currency && symbol) {
|
|
848
|
+
return `<number:currency-style style:name="${name}">`
|
|
849
|
+
+ `<number:currency-symbol>${esc(symbol)}</number:currency-symbol>${number}</number:currency-style>`;
|
|
850
|
+
}
|
|
851
|
+
return `<number:number-style style:name="${name}">${number}</number:number-style>`;
|
|
852
|
+
}
|
|
853
|
+
/** A date or clock pattern as ODF's tree of date elements. */
|
|
854
|
+
function dateStyleXml(pattern, name) {
|
|
855
|
+
const out = [];
|
|
856
|
+
const twelveHour = /am\/pm|a\/p/i.test(pattern);
|
|
857
|
+
let i = 0;
|
|
858
|
+
let sawDate = false;
|
|
859
|
+
let sawClock = false;
|
|
860
|
+
let lastWasHour = false;
|
|
861
|
+
while (i < pattern.length) {
|
|
862
|
+
const rest = pattern.slice(i);
|
|
863
|
+
const run = (letter) => {
|
|
864
|
+
let n = 0;
|
|
865
|
+
while (pattern[i + n]?.toLowerCase() === letter)
|
|
866
|
+
n += 1;
|
|
867
|
+
return n;
|
|
868
|
+
};
|
|
869
|
+
const ch = pattern[i];
|
|
870
|
+
const lower = ch.toLowerCase();
|
|
871
|
+
if (lower === 'y') {
|
|
872
|
+
const n = run('y');
|
|
873
|
+
out.push(`<number:year number:style="${n >= 3 ? 'long' : 'short'}"/>`);
|
|
874
|
+
sawDate = true;
|
|
875
|
+
i += n;
|
|
876
|
+
lastWasHour = false;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (lower === 'd') {
|
|
880
|
+
const n = run('d');
|
|
881
|
+
out.push(n >= 3
|
|
882
|
+
? `<number:day-of-week number:style="${n >= 4 ? 'long' : 'short'}"/>`
|
|
883
|
+
: `<number:day number:style="${n >= 2 ? 'long' : 'short'}"/>`);
|
|
884
|
+
sawDate = true;
|
|
885
|
+
i += n;
|
|
886
|
+
lastWasHour = false;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (lower === 'h') {
|
|
890
|
+
const n = run('h');
|
|
891
|
+
out.push(`<number:hours number:style="${n >= 2 ? 'long' : 'short'}"/>`);
|
|
892
|
+
sawClock = true;
|
|
893
|
+
lastWasHour = true;
|
|
894
|
+
i += n;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
if (lower === 's') {
|
|
898
|
+
const n = run('s');
|
|
899
|
+
out.push(`<number:seconds number:style="${n >= 2 ? 'long' : 'short'}"/>`);
|
|
900
|
+
sawClock = true;
|
|
901
|
+
lastWasHour = false;
|
|
902
|
+
i += n;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (lower === 'm') {
|
|
906
|
+
const n = run('m');
|
|
907
|
+
// `m` is minutes next to a clock and months otherwise, as in Excel.
|
|
908
|
+
const minutes = lastWasHour || (sawClock && !sawDate);
|
|
909
|
+
out.push(minutes
|
|
910
|
+
? `<number:minutes number:style="${n >= 2 ? 'long' : 'short'}"/>`
|
|
911
|
+
: n >= 4 ? '<number:month number:style="long" number:textual="true"/>'
|
|
912
|
+
: n === 3 ? '<number:month number:style="short" number:textual="true"/>'
|
|
913
|
+
: `<number:month number:style="${n >= 2 ? 'long' : 'short'}"/>`);
|
|
914
|
+
if (!minutes)
|
|
915
|
+
sawDate = true;
|
|
916
|
+
lastWasHour = false;
|
|
917
|
+
i += n;
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (/^am\/pm/i.test(rest)) {
|
|
921
|
+
out.push('<number:am-pm/>');
|
|
922
|
+
i += 5;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (/^a\/p/i.test(rest)) {
|
|
926
|
+
out.push('<number:am-pm/>');
|
|
927
|
+
i += 3;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (ch === '"') {
|
|
931
|
+
const end = pattern.indexOf('"', i + 1);
|
|
932
|
+
const text = pattern.slice(i + 1, end < 0 ? pattern.length : end);
|
|
933
|
+
out.push(`<number:text>${esc(text)}</number:text>`);
|
|
934
|
+
i = end < 0 ? pattern.length : end + 1;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
if (ch === '\\') {
|
|
938
|
+
out.push(`<number:text>${esc(pattern[i + 1] ?? '')}</number:text>`);
|
|
939
|
+
i += 2;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
if (ch === '[') {
|
|
943
|
+
i = pattern.indexOf(']', i) + 1 || pattern.length;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
out.push(`<number:text>${esc(ch)}</number:text>`);
|
|
947
|
+
i += 1;
|
|
948
|
+
}
|
|
949
|
+
const element = sawDate ? 'date-style' : 'time-style';
|
|
950
|
+
// No `automatic-order`: it invites the reader to rearrange the parts into
|
|
951
|
+
// its own locale, so a sheet written as yyyy-mm-dd opens as mm/dd/yyyy.
|
|
952
|
+
const clock = sawDate || twelveHour ? '' : ' number:truncate-on-overflow="true"';
|
|
953
|
+
return `<number:${element} style:name="${name}"${clock}>${out.join('')}</number:${element}>`;
|
|
954
|
+
}
|
|
955
|
+
/** The ODF text properties, cell properties and paragraph properties of a look. */
|
|
956
|
+
function cellStyleXml(entry, name, dataStyle) {
|
|
957
|
+
const text = [];
|
|
958
|
+
if (entry.bold)
|
|
959
|
+
text.push('fo:font-weight="bold" style:font-weight-asian="bold"');
|
|
960
|
+
if (entry.italic)
|
|
961
|
+
text.push('fo:font-style="italic"');
|
|
962
|
+
if (entry.underline)
|
|
963
|
+
text.push('style:text-underline-style="solid" style:text-underline-width="auto"');
|
|
964
|
+
if (entry.strike)
|
|
965
|
+
text.push('style:text-line-through-style="solid"');
|
|
966
|
+
if (entry.color)
|
|
967
|
+
text.push(`fo:color="${esc(entry.color)}"`);
|
|
968
|
+
if (entry.fontSize)
|
|
969
|
+
text.push(`fo:font-size="${entry.fontSize}pt"`);
|
|
970
|
+
if (entry.fontFamily)
|
|
971
|
+
text.push(`style:font-name="${esc(entry.fontFamily)}"`);
|
|
972
|
+
const cell = [];
|
|
973
|
+
if (entry.fill)
|
|
974
|
+
cell.push(`fo:background-color="${esc(entry.fill)}"`);
|
|
975
|
+
if (entry.wrap)
|
|
976
|
+
cell.push('fo:wrap-option="wrap"');
|
|
977
|
+
for (const [side, spec] of Object.entries(entry.border ?? {})) {
|
|
978
|
+
if (!spec)
|
|
979
|
+
continue;
|
|
980
|
+
const width = spec.width ?? 1;
|
|
981
|
+
const style = spec.style === 'dashed' ? 'dashed' : spec.style === 'dotted' ? 'dotted' : spec.style === 'double' ? 'double' : 'solid';
|
|
982
|
+
cell.push(`fo:border-${side}="${Math.round(width * 0.75 * 100) / 100}pt ${style} ${esc(spec.color ?? '#000000')}"`);
|
|
983
|
+
}
|
|
984
|
+
const paragraph = [];
|
|
985
|
+
if (entry.align)
|
|
986
|
+
paragraph.push(`fo:text-align="${entry.align === 'left' ? 'start' : entry.align === 'right' ? 'end' : 'center'}" style:justify-single-word="false"`);
|
|
987
|
+
if (entry.indent)
|
|
988
|
+
paragraph.push(`fo:margin-left="${entry.indent * 0.1}in"`);
|
|
989
|
+
return `<style:style style:name="${name}" style:family="table-cell" style:parent-style-name="Default"`
|
|
990
|
+
+ `${dataStyle ? ` style:data-style-name="${dataStyle}"` : ''}>`
|
|
991
|
+
+ (cell.length ? `<style:table-cell-properties ${cell.join(' ')}/>` : '')
|
|
992
|
+
+ (paragraph.length ? `<style:paragraph-properties ${paragraph.join(' ')}/>` : '')
|
|
993
|
+
+ (text.length ? `<style:text-properties ${text.join(' ')}/>` : '')
|
|
994
|
+
+ '</style:style>';
|
|
995
|
+
}
|
|
996
|
+
/** The parts of an .ods package, as text. */
|
|
997
|
+
export function documentToOdsParts(doc) {
|
|
998
|
+
const wb = doc.workbook;
|
|
999
|
+
const styles = [];
|
|
1000
|
+
const cellStyles = new Map();
|
|
1001
|
+
const dataStyles = new Map();
|
|
1002
|
+
const columnStyles = new Map();
|
|
1003
|
+
const rowStyles = new Map();
|
|
1004
|
+
/** A style name for a look, made once and shared by every cell wearing it. */
|
|
1005
|
+
const lookName = (entry) => {
|
|
1006
|
+
if (!entry || !Object.keys(entry).length)
|
|
1007
|
+
return null;
|
|
1008
|
+
const key = JSON.stringify(entry);
|
|
1009
|
+
const already = cellStyles.get(key);
|
|
1010
|
+
if (already)
|
|
1011
|
+
return already;
|
|
1012
|
+
let dataName = null;
|
|
1013
|
+
if (entry.numFmt) {
|
|
1014
|
+
const dataKey = entry.numFmt;
|
|
1015
|
+
dataName = dataStyles.get(dataKey) ?? null;
|
|
1016
|
+
if (!dataName) {
|
|
1017
|
+
const candidate = `N${dataStyles.size + 100}`;
|
|
1018
|
+
const xml = dataStyleXml(entry.numFmt, candidate);
|
|
1019
|
+
if (xml) {
|
|
1020
|
+
styles.push(xml);
|
|
1021
|
+
dataStyles.set(dataKey, candidate);
|
|
1022
|
+
dataName = candidate;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const name = `ce${cellStyles.size + 1}`;
|
|
1027
|
+
styles.push(cellStyleXml(entry, name, dataName));
|
|
1028
|
+
cellStyles.set(key, name);
|
|
1029
|
+
return name;
|
|
1030
|
+
};
|
|
1031
|
+
const columnName = (px) => {
|
|
1032
|
+
const already = columnStyles.get(px);
|
|
1033
|
+
if (already)
|
|
1034
|
+
return already;
|
|
1035
|
+
const name = `co${columnStyles.size + 1}`;
|
|
1036
|
+
styles.push(`<style:style style:name="${name}" style:family="table-column">`
|
|
1037
|
+
+ `<style:table-column-properties style:column-width="${inches(px)}" fo:break-before="auto"/></style:style>`);
|
|
1038
|
+
columnStyles.set(px, name);
|
|
1039
|
+
return name;
|
|
1040
|
+
};
|
|
1041
|
+
const rowName = (px) => {
|
|
1042
|
+
const already = rowStyles.get(px);
|
|
1043
|
+
if (already)
|
|
1044
|
+
return already;
|
|
1045
|
+
const name = `ro${rowStyles.size + 1}`;
|
|
1046
|
+
styles.push(`<style:style style:name="${name}" style:family="table-row">`
|
|
1047
|
+
+ `<style:table-row-properties style:row-height="${inches(px)}" fo:break-before="auto" style:use-optimal-row-height="false"/></style:style>`);
|
|
1048
|
+
rowStyles.set(px, name);
|
|
1049
|
+
return name;
|
|
1050
|
+
};
|
|
1051
|
+
const tables = wb.sheets.map((name) => {
|
|
1052
|
+
const state = doc.get(name);
|
|
1053
|
+
// The rows the AutoFilter folds away: ODF marks them as filtered, apart
|
|
1054
|
+
// from the ones hidden by hand, and the criteria ride in the database range.
|
|
1055
|
+
const filtered = filteredRows(doc, name);
|
|
1056
|
+
const rows = Math.max(wb.rowCount(name), ...[...filtered].map((r) => r + 1));
|
|
1057
|
+
const cols = wb.colCount(name);
|
|
1058
|
+
const covered = new Set();
|
|
1059
|
+
const spans = new Map();
|
|
1060
|
+
for (const [r1, c1, r2, c2] of state.merges) {
|
|
1061
|
+
spans.set(`${r1}:${c1}`, { across: c2 - c1 + 1, down: r2 - r1 + 1 });
|
|
1062
|
+
for (let r = r1; r <= r2; r += 1)
|
|
1063
|
+
for (let c = c1; c <= c2; c += 1)
|
|
1064
|
+
if (r !== r1 || c !== c1)
|
|
1065
|
+
covered.add(`${r}:${c}`);
|
|
1066
|
+
}
|
|
1067
|
+
const columns = [];
|
|
1068
|
+
for (let c = 0; c < Math.max(cols, 1); c += 1) {
|
|
1069
|
+
const width = state.widths[colToLetters(c)];
|
|
1070
|
+
const style = width ? ` table:style-name="${columnName(width)}"` : '';
|
|
1071
|
+
const hiddenCol = state.hidden.cols.has(c) ? ' table:visibility="collapse"' : '';
|
|
1072
|
+
columns.push(`<table:table-column${style}${hiddenCol} table:default-cell-style-name="Default"/>`);
|
|
1073
|
+
}
|
|
1074
|
+
const lines = [];
|
|
1075
|
+
for (let r = 0; r < rows; r += 1) {
|
|
1076
|
+
const height = state.heights.get(r);
|
|
1077
|
+
const style = height ? ` table:style-name="${rowName(height)}"` : '';
|
|
1078
|
+
const hiddenRow = state.hidden.rows.has(r) ? ' table:visibility="collapse"' : filtered.has(r) ? ' table:visibility="filter"' : '';
|
|
1079
|
+
const cells = [];
|
|
1080
|
+
for (let c = 0; c < Math.max(cols, 1); c += 1) {
|
|
1081
|
+
if (covered.has(`${r}:${c}`)) {
|
|
1082
|
+
cells.push('<table:covered-table-cell/>');
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
const look = state.formats.get(`r${r}`, colToLetters(c));
|
|
1086
|
+
cells.push(cellXml(doc, name, r, c, lookName(look), spans.get(`${r}:${c}`), currencyOf(look?.numFmt)));
|
|
1087
|
+
}
|
|
1088
|
+
lines.push(`<table:table-row${style}${hiddenRow}>${cells.join('')}</table:table-row>`);
|
|
1089
|
+
}
|
|
1090
|
+
const protection = state.protected ? ' table:protected="true"' : '';
|
|
1091
|
+
return `<table:table table:name="${esc(name)}"${protection}>${columns.join('')}${lines.join('')}</table:table>`;
|
|
1092
|
+
}).join('');
|
|
1093
|
+
const names = wb.names.list().map((entry) => {
|
|
1094
|
+
const written = formulaToOdf(entry.refersTo);
|
|
1095
|
+
if (!written)
|
|
1096
|
+
return '';
|
|
1097
|
+
const target = written.replace(/^of:=/, '');
|
|
1098
|
+
const bare = /^\[(.+)\]$/.exec(target);
|
|
1099
|
+
const address = bare ? bare[1] : target;
|
|
1100
|
+
return `<table:named-range table:name="${esc(entry.name)}" table:base-cell-address="${esc(address.split(':')[0] ?? address)}"`
|
|
1101
|
+
+ ` table:cell-range-address="${esc(address)}"/>`;
|
|
1102
|
+
}).join('');
|
|
1103
|
+
// The filter regions, which ODF keeps beside the tables.
|
|
1104
|
+
const filters = wb.sheets.map((name) => {
|
|
1105
|
+
const filter = doc.get(name).autoFilter;
|
|
1106
|
+
if (!filter)
|
|
1107
|
+
return '';
|
|
1108
|
+
const [r1, c1, r2, c2] = filter.range;
|
|
1109
|
+
const where = (r, c) => `${esc(name)}.${colToLetters(c)}${r + 1}`;
|
|
1110
|
+
const criteria = autoFilterOds(doc, name);
|
|
1111
|
+
return `<table:database-range table:name="__Anonymous_Sheet_DB__${wb.sheets.indexOf(name)}"`
|
|
1112
|
+
+ ` table:target-range-address="${where(r1, c1)}:${where(r2, c2)}" table:display-filter-buttons="true"${criteria ? `>${criteria}</table:database-range>` : '/>'}`;
|
|
1113
|
+
}).join('');
|
|
1114
|
+
const content = `${XML_HEAD}<office:document-content ${NS} office:version="1.3">`
|
|
1115
|
+
+ `<office:automatic-styles>${styles.join('')}</office:automatic-styles>`
|
|
1116
|
+
+ `<office:body><office:spreadsheet>`
|
|
1117
|
+
+ (filters ? `<table:database-ranges>${filters}</table:database-ranges>` : '')
|
|
1118
|
+
+ tables
|
|
1119
|
+
+ (names ? `<table:named-expressions>${names}</table:named-expressions>` : '')
|
|
1120
|
+
+ '</office:spreadsheet></office:body></office:document-content>';
|
|
1121
|
+
return {
|
|
1122
|
+
mimetype: ODS_MIME,
|
|
1123
|
+
'META-INF/manifest.xml': `${XML_HEAD}<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">`
|
|
1124
|
+
+ `<manifest:file-entry manifest:full-path="/" manifest:version="1.3" manifest:media-type="${ODS_MIME}"/>`
|
|
1125
|
+
+ '<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>'
|
|
1126
|
+
+ '<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>'
|
|
1127
|
+
+ '<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>'
|
|
1128
|
+
+ '</manifest:manifest>',
|
|
1129
|
+
'content.xml': content,
|
|
1130
|
+
'styles.xml': `${XML_HEAD}<office:document-styles ${NS} office:version="1.3">`
|
|
1131
|
+
+ '<office:styles><style:style style:name="Default" style:family="table-cell"/></office:styles>'
|
|
1132
|
+
+ '</office:document-styles>',
|
|
1133
|
+
'meta.xml': `${XML_HEAD}<office:document-meta ${NS} office:version="1.3"><office:meta>`
|
|
1134
|
+
+ '<meta:generator xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0">sv-grid</meta:generator>'
|
|
1135
|
+
+ '</office:meta></office:document-meta>',
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
const ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet';
|
|
1139
|
+
/** The ISO code a currency symbol stands for, for the cells that carry one. */
|
|
1140
|
+
const CURRENCY_CODES = { $: 'USD', '€': 'EUR', '£': 'GBP', '¥': 'JPY' };
|
|
1141
|
+
/** The currency a number format is written in, or null when it is not money.
|
|
1142
|
+
* ODF ties a currency STYLE to a currency VALUE: a float cell under one
|
|
1143
|
+
* shows a plain number, which is how a $7,979 total lands as 7979. */
|
|
1144
|
+
function currencyOf(pattern) {
|
|
1145
|
+
if (!pattern)
|
|
1146
|
+
return null;
|
|
1147
|
+
const symbol = /[$€£¥]/.exec(pattern.replace(/\[[^\]]*\]/g, ''))?.[0];
|
|
1148
|
+
return symbol ? CURRENCY_CODES[symbol] ?? 'USD' : null;
|
|
1149
|
+
}
|
|
1150
|
+
/** One cell: its value with its type, its formula, and what it shows. */
|
|
1151
|
+
function cellXml(doc, sheet, row, col, styleName, span, currency) {
|
|
1152
|
+
const raw = doc.workbook.getRaw(sheet, row, col);
|
|
1153
|
+
const style = styleName ? ` table:style-name="${styleName}"` : '';
|
|
1154
|
+
const spanned = span ? ` table:number-columns-spanned="${span.across}" table:number-rows-spanned="${span.down}"` : '';
|
|
1155
|
+
if (raw === '')
|
|
1156
|
+
return `<table:table-cell${style}${spanned}/>`;
|
|
1157
|
+
// A formula ODF cannot spell is left off, and the value stands on its own:
|
|
1158
|
+
// a reader shows the right number instead of refusing the cell.
|
|
1159
|
+
const odf = raw.startsWith('=') ? formulaToOdf(raw, tableResolver(doc, sheet, row, col)) : null;
|
|
1160
|
+
const formula = odf ? ` table:formula="${esc(odf)}"` : '';
|
|
1161
|
+
const value = doc.workbook.getValue(sheet, row, col);
|
|
1162
|
+
const link = linkAt(doc.get(sheet).links, row, col);
|
|
1163
|
+
const note = noteXml(doc, sheet, row, col);
|
|
1164
|
+
const open = `<table:table-cell${style}${spanned}${formula}`;
|
|
1165
|
+
const paragraph = (text) => {
|
|
1166
|
+
const body = esc(text);
|
|
1167
|
+
return `<text:p>${link ? `<text:a xlink:href="${esc(link.target)}" xlink:type="simple">${body}</text:a>` : body}</text:p>`;
|
|
1168
|
+
};
|
|
1169
|
+
if (typeof value === 'number') {
|
|
1170
|
+
const type = currency
|
|
1171
|
+
? `office:value-type="currency" office:currency="${currency}" office:value="${value}" calcext:value-type="currency"`
|
|
1172
|
+
: `office:value-type="float" office:value="${value}" calcext:value-type="float"`;
|
|
1173
|
+
return `${open} ${type}>${note}${paragraph(String(value))}</table:table-cell>`;
|
|
1174
|
+
}
|
|
1175
|
+
if (typeof value === 'boolean') {
|
|
1176
|
+
return `${open} office:value-type="boolean" office:boolean-value="${value}" calcext:value-type="boolean">${note}${paragraph(value ? 'TRUE' : 'FALSE')}</table:table-cell>`;
|
|
1177
|
+
}
|
|
1178
|
+
if (value && typeof value === 'object' && 'error' in value) {
|
|
1179
|
+
// ODF has no error value; LibreOffice writes the text with a calcext
|
|
1180
|
+
// type beside it, and reads the same back as an error.
|
|
1181
|
+
return `${open} office:value-type="string" calcext:value-type="error">${note}${paragraph(value.error)}</table:table-cell>`;
|
|
1182
|
+
}
|
|
1183
|
+
const text = String(value ?? '');
|
|
1184
|
+
if (ISO_DATE.test(text)) {
|
|
1185
|
+
const iso = text.includes(' ') ? text.replace(' ', 'T') : text;
|
|
1186
|
+
return `${open} office:value-type="date" office:date-value="${esc(iso)}" calcext:value-type="date">${note}${paragraph(text)}</table:table-cell>`;
|
|
1187
|
+
}
|
|
1188
|
+
if (ERROR_TEXT.test(text)) {
|
|
1189
|
+
return `${open} office:value-type="string" calcext:value-type="error">${note}${paragraph(text)}</table:table-cell>`;
|
|
1190
|
+
}
|
|
1191
|
+
return `${open} office:value-type="string" calcext:value-type="string">${note}${paragraph(text)}</table:table-cell>`;
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* What `Orders[Amount]`, `[@Qty]` and their kin point at, in the sheet the
|
|
1195
|
+
* formula sits on. ODF has no structured references, so this is what lets
|
|
1196
|
+
* one cross into an .ods at all: the table's registry gives the block, the
|
|
1197
|
+
* header row gives the column, and the specifier picks the rows.
|
|
1198
|
+
*/
|
|
1199
|
+
function tableResolver(doc, sheet, row, col) {
|
|
1200
|
+
return (token) => {
|
|
1201
|
+
const tables = doc.workbook.tables;
|
|
1202
|
+
const table = token.table ? tables.get(token.table) : tables.at(sheet, row, col);
|
|
1203
|
+
if (!table)
|
|
1204
|
+
return null;
|
|
1205
|
+
// A reference on its own sheet needs no sheet name, as in A1.
|
|
1206
|
+
const where = table.sheet.toLowerCase() === sheet.toLowerCase() ? null : table.sheet;
|
|
1207
|
+
const columnAt = (name) => {
|
|
1208
|
+
if (!name)
|
|
1209
|
+
return null;
|
|
1210
|
+
const wanted = name.trim().toLowerCase();
|
|
1211
|
+
for (let c = table.firstCol; c <= table.lastCol; c += 1) {
|
|
1212
|
+
if (doc.workbook.getRaw(table.sheet, table.headerRow, c).trim().toLowerCase() === wanted)
|
|
1213
|
+
return c;
|
|
1214
|
+
}
|
|
1215
|
+
return null;
|
|
1216
|
+
};
|
|
1217
|
+
const c1 = columnAt(token.column) ?? table.firstCol;
|
|
1218
|
+
const c2 = columnAt(token.columnTo) ?? c1;
|
|
1219
|
+
const totalsRow = table.hasTotals ? table.lastRow + 1 : null;
|
|
1220
|
+
switch (token.specifier) {
|
|
1221
|
+
case '#Headers': return { sheet: where, r1: table.headerRow, c1, r2: table.headerRow, c2 };
|
|
1222
|
+
case '#Totals':
|
|
1223
|
+
return totalsRow === null ? null : { sheet: where, r1: totalsRow, c1, r2: totalsRow, c2 };
|
|
1224
|
+
case '#All':
|
|
1225
|
+
return { sheet: where, r1: table.headerRow, c1, r2: totalsRow ?? table.lastRow, c2 };
|
|
1226
|
+
case '#ThisRow':
|
|
1227
|
+
// Only inside the table, as in Excel.
|
|
1228
|
+
if (where !== null)
|
|
1229
|
+
return null;
|
|
1230
|
+
if (row <= table.headerRow || row > (totalsRow ?? table.lastRow))
|
|
1231
|
+
return null;
|
|
1232
|
+
return { sheet: where, r1: row, c1, r2: row, c2 };
|
|
1233
|
+
default:
|
|
1234
|
+
return { sheet: where, r1: table.headerRow + 1, c1, r2: table.lastRow, c2 };
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
/** A cell's note as ODF's annotation. */
|
|
1239
|
+
function noteXml(doc, sheet, row, col) {
|
|
1240
|
+
const thread = threadAt(doc.get(sheet).notes, row, col);
|
|
1241
|
+
if (!thread)
|
|
1242
|
+
return '';
|
|
1243
|
+
const lines = [thread.text, ...(thread.replies ?? []).map((reply) => `${reply.author}: ${reply.text}`)];
|
|
1244
|
+
return '<office:annotation>'
|
|
1245
|
+
+ (thread.author ? `<dc:creator>${esc(thread.author)}</dc:creator>` : '')
|
|
1246
|
+
+ (thread.at ? `<dc:date>${esc(thread.at.replace(/Z$/, ''))}</dc:date>` : '')
|
|
1247
|
+
+ lines.map((line) => `<text:p>${esc(line)}</text:p>`).join('')
|
|
1248
|
+
+ '</office:annotation>';
|
|
1249
|
+
}
|
|
1250
|
+
/** The document as an .ods Blob: what File > Save As writes for LibreOffice. */
|
|
1251
|
+
export async function documentToOds(doc, JSZip) {
|
|
1252
|
+
const Zip = await loadZip(JSZip);
|
|
1253
|
+
const zip = new Zip();
|
|
1254
|
+
const parts = documentToOdsParts(doc);
|
|
1255
|
+
// The mimetype goes in first and uncompressed: that is what lets a reader
|
|
1256
|
+
// know the kind of package from its first bytes.
|
|
1257
|
+
zip.file('mimetype', parts.mimetype, { compression: 'STORE' });
|
|
1258
|
+
for (const [path, content] of Object.entries(parts)) {
|
|
1259
|
+
if (path === 'mimetype')
|
|
1260
|
+
continue;
|
|
1261
|
+
zip.file(path, content);
|
|
1262
|
+
}
|
|
1263
|
+
return zip.generateAsync({ type: 'blob', mimeType: ODS_MIME });
|
|
1264
|
+
}
|
|
1265
|
+
/** `Data.A1`, `$Data.$A$1` or `.A1` as a sheet and a position. */
|
|
1266
|
+
function odfCell(text) {
|
|
1267
|
+
const cleaned = text.trim().replace(/^\$/, '');
|
|
1268
|
+
const dot = cleaned.lastIndexOf('.');
|
|
1269
|
+
const sheetPart = dot < 0 ? '' : cleaned.slice(0, dot).replace(/^\$/, '').replace(/^'|'$/g, '');
|
|
1270
|
+
const cell = (dot < 0 ? cleaned : cleaned.slice(dot + 1)).replace(/\$/g, '');
|
|
1271
|
+
const m = /^([A-Za-z]+)(\d+)$/.exec(cell);
|
|
1272
|
+
if (!m)
|
|
1273
|
+
return null;
|
|
1274
|
+
return { sheet: sheetPart || null, row: Number(m[2]) - 1, col: lettersToCol(m[1]) };
|
|
1275
|
+
}
|