diff --git a/CHANGELOG.md b/CHANGELOG.md index e24996b..f076a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ - Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header row / first column toggles, named table styles (see Styles); a table splits cleanly across page boundaries - Repeat the header row: the first row is drawn again at the top of every page the table continues on, as in Word and LibreOffice. Round-trips to ODF `table:table-header-rows` and Word's `w:tblHeader`. A row that fits a page never splits anyway (the editor paginates a table between its rows), so Word's "don't allow row to break" needs nothing - Sort a table's rows (LibreOffice's Table ▸ Sort, Word's Layout ▸ Sort): the three keys both dialogs offer, each by any column, ascending or descending and with its own sort type — automatic (a cell that reads as a number sorts numerically, anything else by the document language's collation), numerically, or alphanumerically, which is the plain collation that puts "10" before "2". A later key decides only where the ones before it tie, rows alike in every key keep the order they were typed in, and the first row can be kept in place as a header +- A formula cell's own number format (in the Formula dialog, as Word has it): the general format, whole numbers, two decimals, grouped thousands, and the two percentages. ODF keeps it on the cell's style as a data style LibreOffice regenerates the value from, Word on the field as its `\#` switch — LibreOffice's own DOCX filter neither reads nor writes that switch, so the format survives the ODF leg and our own DOCX round trip - Formulas in a cell (LibreOffice's Table ▸ Formula, Word's Layout ▸ Formula): `=SUM(ABOVE)`, `=AVERAGE(A1:A3)`, `=A1*2` — SUM, PRODUCT, AVERAGE, MIN, MAX, COUNT, ABS, INT, SIGN, MOD and ROUND over cell references, ranges and Word's directions, with arithmetic and parentheses. A formula cell shows its result on the field shade both word processors use and recomputes as the cells it reads change, a formula reading another formula included. It rides the cell as ODF's `table:formula` in LibreOffice's own language (`ooow:sum `, a direction resolved to the range it stands for) with the result cached as `office:value`, and as Word's `=` field inside the cell — both re-read - Number recognition (LibreOffice's Table ▸ Number Recognition): with it on, a cell whose text reads as a number is rewritten in the document language's number format when the cursor leaves it — `007,50` becomes `7,5`. **Off** by default, as it is in LibreOffice, and the parsing a formula does is independent of it, exactly as there - Table border control (Word/LibreOffice-style): per-side cell borders with presets (all / outside / inside / single edges / none), line width and color; buttons show active states matching the current pen and toggle borders off; round-trips to ODF `fo:border-*` and DOCX `w:tcBorders` @@ -158,7 +159,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14. **Missing while writing** - Hyphenation's zone and ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither -- A formula's own number format (LibreOffice's Number Format dialog per cell — the general format is the only one here), and a formula reaching into another table +- A formula's own currency and date formats (the number and percentage ones are implemented), and a formula reaching into another table — LibreOffice's `` has no counterpart in Word's field language, so it could not survive the DOCX leg - A list level's own hanging indent: the marker sits at the 0.635 cm both exports write (`LIST_HANGING_CM`), so a wider *left*-set marker overflows where Word moves the text to the next list tab (a **right**-set one — `w:lvlJc`, which is what the built-in Roman numberings use — grows into the margin and is fine). Reading the value back naively also moves the markers of our own ODT exports — LibreOffice draws its own flat hanging at exactly the value odf-kit writes for level 2; see `tests/render-parity/README.md` before building on it - Linked / chained text frames - Find & Replace by font, size or colour — the weight/slant/underline and paragraph-style half is implemented, but LibreOffice's Format dialog is the whole character dialog diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index 7717f39..64ef871 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -17,6 +17,7 @@ Covers the `table*.ts` extensions in `src/lib/editor/extensions/` and the table- - **`tableHeaderRow.ts`** (`TableHeaderRow`) — "header row" as a **styling preset** (not the structural ODF header that repeats per page, by design): `toggleHeaderRowStyle` sets/clears a **light-grey fill (`HEADER_SHADE` `#F2F2F2`)** on the first row's cells; `isHeaderStyled(state, axis)` drives the toolbar toggle's active state. **A table carrying a table style owns these two areas through its Table Style Options instead**: there `toggleHeaderStyle`/`isHeaderStyled` read and write `headerRow`/`firstColumn` in the table's `tableLook` (delegating to `setTableLook`), so the toolbar button and the gallery's checkbox are one state and can't disagree. Both also read their label from the same key (`styles.regions.*`), so they can't drift apart in wording either — the button used to say "header column" where the checkbox said "first column". The fill is the *marker* and **round-trips natively** as `fo:background-color`, so the toggle state survives reopen with no `table:table-header-rows` (⇒ Word/LibreOffice don't repeat it). **Bold is presentational, exactly like a heading**: `tableCellBackground.ts` tags a header-shaded cell with `class="cell-header"` and `editor.css` renders `.cell-header` bold — so *all* its text is bold (existing, typed, pasted, IME) without storing marks, and it stays **editable** via the `fontWeight:'normal'` override (the `Toolbar.svelte` Bold button treats `isInHeaderCell` like a heading: toggles `setFontWeight('normal')`/`unsetFontWeight`). Export (`export/odt.ts`) **bakes** bold onto header-shaded cells' runs (`applyRuns(..., forceBold)`, skipping `fontWeight:'normal'` runs) so Word/LibreOffice match; import (`import/odt.ts`) converts those cells' runs with `boldByDefault` (bold run → no mark, normal run → `fontWeight:'normal'`), mirroring headings. `TableToolbar.svelte` exposes the toggle button. - **`tableFormula.ts`** (`TableFormula`) — a cell's **formula**, on the cell itself, which is where ODF puts it. The stored language is **Word's** (`SUM(ABOVE)`, `A1*2`, no leading `=`), the wider of the two; `utils/tableFormula.ts` holds the whole pure half — the tokenizer, the recursive-descent evaluator (`SUM PRODUCT AVERAGE MIN MAX COUNT ABS INT SIGN MOD ROUND`, ranges, Word's four directions, `+ - * / %` and parentheses) and the two translations. A **direction stops at the first cell holding no number**, a range does not. `evaluateTable` fills every formula cell in one pass, resolving a formula that reads another recursively behind a visited set, so a chain settles at once and a cycle yields 0 rather than looping. The `appendTransaction` recomputes on any doc change and writes the result as the cell's own text (keeping the first run's marks), **off the undo stack**; `editor.css` shades `td[data-formula]` the way both word processors shade a field, since typing over a result does not last. **ODF**: `applyCellFormulas` writes `table:formula="ooow:sum "` (ABOVE resolved to the range it stands for — LibreOffice has no word for it) plus `office:value-type="float"`/`office:value`. The `ooow:` in that value is a **namespace prefix**: undeclared, LibreOffice reads it as part of the formula and prints "Expression is faulty", so `ensureOoowNamespace` declares it (probed both ways). **DOCX**: the cell's first paragraph becomes one `SimpleField` `=SUM(ABOVE)` with the result cached; import reads the instruction back off the cell. LibreOffice's own formula engine parses a cell's **text** in its locale regardless of number recognition (probed), which is why the editor formats a result in the document language. + A formula cell can print its result in a **number format** of its own (`utils/cellFormat.ts`: the general one, whole numbers, two decimals, grouped, and the two percentages — LibreOffice's currency and date formats are not offered). ODF keeps it on the cell's **style**, as a `style:data-style-name` reference to a minted ``/``; odf-kit shares one cell style across every cell, so `applyCellFormulas` mints a copy of it per format used. LibreOffice writes both back out unchanged (probed) — including from our `.docx`, which it does *not* read the format from: Word's `\#` field switch is where the DOCX leg keeps it, and LibreOffice's filter neither reads nor writes that switch. - **`tableSort.ts`** (`TableSort`) — LibreOffice's Table ▸ Sort / Word's Layout ▸ Sort with the three keys both offer, each with its own direction and sort type; a later key decides only where the ones before it tie, and column `-1` is the key that is off. `sortedRows` is pure (rows in, rows out) and stable, so rows alike in every key keep the order they were typed in. The type is `auto` (numeric where both cells parse as numbers, else the collation), `number` — where a cell that is no number sorts last either way, as an empty cell does in a spreadsheet — or `text`, which is the **plain** collation: "10" before "2" is the whole point of choosing it, so `sortCollators` builds one collator with `numeric: true` and one without. `canSortTable` refuses a table with a vertically merged cell — such a cell belongs to more than one row, so there are no rows to reorder. The command rebuilds the table node in one transaction. - **Number recognition** (`storage/tableOptions.svelte.ts`) — LibreOffice's Table ▸ Number Recognition, **off** by default there (its `Writer/Table/Input/NumberRecognition` schema default) and so here. On, a cell whose text reads as a number is rewritten in the document language's general number format when the **cursor leaves it**, which is where LibreOffice applies it. It is an app option, not part of the file — LibreOffice keeps it in its own registry too — so only its effect travels. `setTableLanguage` is pushed from `App.svelte`'s language effect; the exporters take the same locale from the `language` argument instead, staying store-free. - **`tableCellAlign.ts`** (`TableCellAlign`) — `verticalAlign` (`middle`/`bottom`, null = top, the ODF and Word default) on `tableCell`/`tableHeader`, rendered as the cell's CSS `vertical-align`. A callout table's label sits mid-cell with it, at the row's top without. Round-trips to ODF `style:vertical-align` (import via `StyleResolver.cellVerticalAlign`, export via odf-kit's `CellOptions.verticalAlign` — it mints one cell style per property set, so a differing cell gets its own) and DOCX `w:vAlign` (Word's `center` is our `middle`; `top`/`both` stay the default). Set from `TableToolbar.svelte`'s three buttons via `setCellAttribute`, which covers a whole `CellSelection`. diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index c0eba03..107652c 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -8,7 +8,8 @@ import { spellErrorAt } from '../editor/extensions/spellCheck'; import { spellController } from '../spell/controller'; import { isInTable, selectedRect } from '@tiptap/pm/tables'; - import { currentCellFormula, currentCellName, guessFormula } from '../editor/extensions/tableFormula'; + import { currentCellFormat, currentCellFormula, currentCellName, guessFormula } from '../editor/extensions/tableFormula'; + import type { CellFormat } from '../utils/cellFormat'; import TableToolbar from './TableToolbar.svelte'; import TableSplitDialog from './TableSplitDialog.svelte'; import TableSortDialog from './TableSortDialog.svelte'; @@ -484,7 +485,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; // The popover the table toolbar has open, in its place. One at a time. let tableDialog = $state<'split' | 'sort' | 'formula' | null>(null); // What the sort and formula popovers open on: the grid around the cursor's cell. - const NO_TABLE = { columns: 1, column: 0, headerRow: false, cell: null as string | null, formula: '=SUM(ABOVE)' }; + const NO_TABLE = { columns: 1, column: 0, headerRow: false, cell: null as string | null, formula: '=SUM(ABOVE)', format: null as CellFormat | null }; let tableGrid = $derived.by(() => { const state = editor?.state; if (tick < 0 || !state || !isInTable(state)) return NO_TABLE; @@ -496,6 +497,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; || rect.table.attrs.repeatHeader === true, cell: currentCellName(state), formula: currentCellFormula(state) || guessFormula(state), + format: currentCellFormat(state), }; }); // Drop the dialog if the selection leaves the table (toolbar hidden). @@ -1149,8 +1151,9 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; { - editor?.chain().focus().setCellFormula(formula).run(); + initialFormat={tableGrid.format} + onApply={(formula, format) => { + editor?.chain().focus().setCellFormula(formula, format).run(); tableDialog = null; }} onClose={() => (tableDialog = null)} diff --git a/src/lib/components/TableFormulaDialog.svelte b/src/lib/components/TableFormulaDialog.svelte index 7d4d272..f18b831 100644 --- a/src/lib/components/TableFormulaDialog.svelte +++ b/src/lib/components/TableFormulaDialog.svelte @@ -2,23 +2,28 @@ import { untrack } from 'svelte'; import { t } from '../i18n/i18n.svelte'; import { FORMULA_FUNCTIONS } from '../utils/tableFormula'; + import { CELL_FORMATS, type CellFormat } from '../utils/cellFormat'; // Word's Table ▸ Formula: the guessed formula, editable, plus the function list to - // paste from. The parent mounts it and holds the cell the formula is set on. + // paste from and the number format its result is printed in (Word's own dialog has + // both). The parent mounts it and holds the cell the formula is set on. let { cell = null, initial = '=SUM(ABOVE)', + initialFormat = null, onApply, onClose, }: { /** The cell's name (A1), which is how a formula refers to it. */ cell?: string | null; initial?: string; - onApply: (formula: string) => void; + initialFormat?: CellFormat | null; + onApply: (formula: string, format: CellFormat | null) => void; onClose: () => void; } = $props(); // Seeded from the cell the dialog opened on; from there the field is the user's. let text = $state(untrack(() => initial)); + let format = $state(untrack(() => initialFormat ?? 'general')); let field = $state(null); $effect(() => { @@ -28,7 +33,7 @@ // A formula the cell no longer has is an empty field, which clears it. function apply() { - onApply(text.trim().replace(/^=/, '')); + onApply(text.trim().replace(/^=/, ''), format === 'general' ? null : format); } function paste(fn: string) { @@ -47,6 +52,12 @@ {cell ? t().table.formulaCell(cell) : t().table.formulaField} +
{#each FORMULA_FUNCTIONS as fn} @@ -80,6 +91,15 @@ color: var(--color-text); } + select { + padding: 0.3rem 0.4rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); + color: var(--color-text); + font-size: 0.85rem; + } + input { box-sizing: border-box; width: 100%; diff --git a/src/lib/components/ribbon/tabs/TableTabs.svelte b/src/lib/components/ribbon/tabs/TableTabs.svelte index 9057844..c6bc0f6 100644 --- a/src/lib/components/ribbon/tabs/TableTabs.svelte +++ b/src/lib/components/ribbon/tabs/TableTabs.svelte @@ -15,7 +15,8 @@ import { isHeaderStyled } from '../../../editor/extensions/tableHeaderRow'; import { DEFAULT_CELL_PADDING, parseCellPadding, type CellPadding } from '../../../editor/extensions/tableCellPadding'; import type { CellVerticalAlign } from '../../../editor/extensions/tableCellAlign'; - import { currentCellFormula, currentCellName, guessFormula } from '../../../editor/extensions/tableFormula'; + import { currentCellFormat, currentCellFormula, currentCellName, guessFormula } from '../../../editor/extensions/tableFormula'; + import type { CellFormat } from '../../../utils/cellFormat'; import { numberRecognition, setNumberRecognition } from '../../../storage/tableOptions.svelte'; import { t } from '../../../i18n/i18n.svelte'; @@ -35,7 +36,7 @@ const canMerge = $derived(tick >= 0 && !!editor && editor.can().mergeCells()); const inTable = $derived(tick >= 0 && !!editor && isInTable(editor.state)); // What the sort and formula popovers open on: the grid around the cursor's cell. - const NO_TABLE = { columns: 1, column: 0, headerRow: false, cell: null as string | null, formula: '=SUM(ABOVE)' }; + const NO_TABLE = { columns: 1, column: 0, headerRow: false, cell: null as string | null, formula: '=SUM(ABOVE)', format: null as CellFormat | null }; const grid = $derived.by(() => { if (!inTable || !editor) return NO_TABLE; const rect = selectedRect(editor.state); @@ -46,6 +47,7 @@ || rect.table.attrs.repeatHeader === true, cell: currentCellName(editor.state), formula: currentCellFormula(editor.state) || guessFormula(editor.state), + format: currentCellFormat(editor.state), }; }); const isHeaderRow = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'row')); @@ -284,7 +286,8 @@ { closeMenu(); editor?.chain().focus().setCellFormula(formula).run(); }} + initialFormat={grid.format} + onApply={(formula, format) => { closeMenu(); editor?.chain().focus().setCellFormula(formula, format).run(); }} onClose={closeMenu} />
diff --git a/src/lib/editor/extensions/tableFormula.ts b/src/lib/editor/extensions/tableFormula.ts index c0a29ad..1de9568 100644 --- a/src/lib/editor/extensions/tableFormula.ts +++ b/src/lib/editor/extensions/tableFormula.ts @@ -8,7 +8,8 @@ import { evalFormula, parseCellNumber, formatCellNumber, refName, type CellRef, type NumberLocale, } from '../../utils/tableFormula'; -import { numberRecognition, tableNumberLocale } from '../../storage/tableOptions.svelte'; +import { formatCellValue, isCellFormat, type CellFormat } from '../../utils/cellFormat'; +import { numberRecognition, tableLanguage, tableNumberLocale } from '../../storage/tableOptions.svelte'; import { t } from '../../i18n/i18n.svelte'; // A cell's formula, on the cell itself — which is where ODF puts it @@ -18,7 +19,7 @@ import { t } from '../../i18n/i18n.svelte'; declare module '@tiptap/core' { interface Commands { tableFormula: { - setCellFormula: (formula: string | null) => ReturnType; + setCellFormula: (formula: string | null, format?: CellFormat | null) => ReturnType; }; } } @@ -50,7 +51,7 @@ export function cellGrid(table: PMNode): CellGrid { // Every formula cell's result text, keyed by the cell's offset in the table. A // reference to another formula resolves through it, so a chain settles in one pass; // a cycle yields the error text rather than looping. -export function evaluateTable(table: PMNode, loc: NumberLocale, error: string): Map { +export function evaluateTable(table: PMNode, loc: NumberLocale, error: string, lang = 'en'): Map { const grid = cellGrid(table); const results = new Map(); const cache = new Map(); @@ -70,7 +71,8 @@ export function evaluateTable(table: PMNode, loc: NumberLocale, error: string): }); busy.delete(off); cache.set(off, value); - results.set(off, value == null ? error : formatCellNumber(value, loc)); + const format = isCellFormat(node.attrs.cellFormat) ? node.attrs.cellFormat : 'general'; + results.set(off, value == null ? error : formatCellValue(value, format, loc, lang)); return value; }; @@ -104,14 +106,14 @@ function setCellText(tr: Transaction, cellPos: number, cell: PMNode, text: strin type Edit = { pos: number; cell: PMNode; text: string }; -function formulaEdits(doc: PMNode, loc: NumberLocale): Edit[] { +function formulaEdits(doc: PMNode, loc: NumberLocale, lang: string): Edit[] { const edits: Edit[] = []; doc.descendants((node, pos) => { // This runs on every edit, so the walk stops at the blocks a table cannot be in — // otherwise it visits every run of the document per keystroke. if (node.isTextblock) return false; if (node.type.name !== 'table') return; - for (const [off, text] of evaluateTable(node, loc, t().table.formulaError)) { + for (const [off, text] of evaluateTable(node, loc, t().table.formulaError, lang)) { const cell = node.nodeAt(off); if (cell && cell.textContent !== text) edits.push({ pos: pos + 1 + off, cell, text }); } @@ -146,6 +148,13 @@ export const TableFormula = Extension.create({ renderHTML: (attributes: Record) => attributes.formula ? { 'data-formula': attributes.formula } : {}, }, + // The number format the cell prints its value in; absent = the general one. + cellFormat: { + default: null, + parseHTML: (element: HTMLElement) => element.getAttribute('data-cell-format'), + renderHTML: (attributes: Record) => + attributes.cellFormat ? { 'data-cell-format': attributes.cellFormat } : {}, + }, }, }, ]; @@ -154,7 +163,7 @@ export const TableFormula = Extension.create({ addCommands() { return { setCellFormula: - (formula: string | null) => + (formula: string | null, format: CellFormat | null = null) => ({ state, dispatch }: CommandProps) => { if (!isInTable(state)) return false; const rect = selectedRect(state); @@ -164,7 +173,9 @@ export const TableFormula = Extension.create({ const cell = state.doc.nodeAt(pos); if (!cell) return false; if (dispatch) { - dispatch(state.tr.setNodeMarkup(pos, undefined, { ...cell.attrs, formula: formula || null })); + dispatch(state.tr.setNodeMarkup(pos, undefined, { + ...cell.attrs, formula: formula || null, cellFormat: format, + })); } return true; }, @@ -194,7 +205,7 @@ export const TableFormula = Extension.create({ } if (!recognized && !trs.some((t) => t.docChanged)) return null; // Descending, so an earlier edit's positions still hold for the next one. - for (const edit of formulaEdits(tr.doc, loc).reverse()) { + for (const edit of formulaEdits(tr.doc, loc, tableLanguage()).reverse()) { setCellText(tr, edit.pos, edit.cell, edit.text); } return tr.steps.length ? tr.setMeta('addToHistory', false) : null; @@ -236,3 +247,11 @@ export function currentCellFormula(state: EditorState): string { const formula = node?.attrs.formula; return typeof formula === 'string' && formula ? `=${formula}` : ''; } + +/** The number format the cell prints its result in, null for the general one. */ +export function currentCellFormat(state: EditorState): CellFormat | null { + if (!isInTable(state)) return null; + const rect = selectedRect(state); + const node = cellGrid(rect.table).nodeAt({ row: rect.top, col: rect.left }); + return isCellFormat(node?.attrs.cellFormat) ? node.attrs.cellFormat : null; +} diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 966af25..c9ecda0 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -37,7 +37,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`. - **Tracked changes** (`replaceRevisions`/`applyRevisions`): ODF splits the pair. An insertion keeps its text inline and is bracketed by `TCI` sentinels → ``/``; a deletion's text is **cut out of the run** and left as one `TCD` → ``, the text itself going into the `` registry `applyRevisions` builds right after `` (a `` holds the deleted paragraphs, a `` only its ``). The registry's ``/`` need `ensureDcNamespace` — odf-kit declares no `dc:` prefix in content.xml. DOCX needs no string pass: `InsertedTextRun`/`DeletedTextRun` from the `docx` package emit `w:ins`/`w:del` + `w:delText`, with `docRevisionId` numbering them per document as it does comments. - **Bibliography** (`replaceBibEntries`/`applyBibEntries`, `applyBibliographyDocx`): a `BIB` sentinel becomes a `` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `` index writes an entry template per type actually cited, spelling out the same "key: author, title, year" the node view prints — LibreOffice regenerates the rows from it, so a template that said anything else would rewrite them. DOCX cites by tag (`CITATION "key"`) and keeps the sources in a **custom-XML part** the `docx` package knows nothing about, so the post-pack pass mints all four pieces itself: `customXml/item1.xml` (`b:Sources`), its `itemProps1.xml`, both relationships and the properties part's content type. (Probed: LibreOffice's own DOCX export writes the `CITATION` field but no sources at all.) - **Ruby** (`replaceRuby`/`applyRuby`, `applyRubyDocx`): an `RBY` sentinel becomes a `` holding both halves plus one minted `style:family="ruby"` automatic style (centred, above — probed: LibreOffice writes that style straight back out, adding only its own `loext:` mirror of the position). Word's is a `` run the `docx` package cannot emit, so a post-pack pass swaps the sentinel run for it; the `w:rubyPr` sizes are half-points of the 12pt body default, which is what LibreOffice's own export writes. -- **Cell formulas** (`applyCellFormulas`, `formulaCellContent`): ODF puts the formula on the **cell**, so the pass walks `` sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 0bb1db9..49555d9 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -17,6 +17,7 @@ import type { import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; import { SHAPES, isShapeKind, type ShapeKind } from '../utils/shapes'; +import { CELL_FORMAT_CODES, isCellFormat } from '../utils/cellFormat'; import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; import { pageDimsCm, PAGE_FORMAT_CM, type PageFormat } from '../storage/pageFormat'; @@ -569,7 +570,11 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline } else if (node.type === FORMULA_CELL) { // Word keeps a table formula in a field inside the cell, where ODF puts it on // the cell; `formulaCellContent` mints this node for a cell that carries one. - out.push(new SimpleField(`=${node.attrs?.formula ?? ''}`, String(node.attrs?.text ?? ''))); + // Word's number format is the field's own `\#` switch, its picture the same one + // its dialog shows. + const picture = isCellFormat(node.attrs?.format) ? CELL_FORMAT_CODES[node.attrs.format] : ''; + const switches = picture ? ` \\# "${picture}"` : ''; + out.push(new SimpleField(`=${node.attrs?.formula ?? ''}${switches}`, String(node.attrs?.text ?? ''))); } else if (node.type === 'formula') { const latex = typeof node.attrs?.latex === 'string' ? node.attrs.latex : ''; if (latex) { @@ -1526,7 +1531,8 @@ function formulaCellContent(cell: TiptapNode): TiptapNode[] | null { const text = (first.content ?? []).map(function textOf(n: TiptapNode): string { return n.type === 'text' ? n.text ?? '' : (n.content ?? []).map(textOf).join(''); }).join(''); - const field: TiptapNode = { type: FORMULA_CELL, attrs: { formula, text } }; + const format = isCellFormat(cell.attrs?.cellFormat) ? cell.attrs.cellFormat : null; + const field: TiptapNode = { type: FORMULA_CELL, attrs: { formula, text, format } }; return [{ ...first, content: [field] }, ...(cell.content ?? []).slice(1)]; } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 4d72c5e..fa6ebeb 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -33,6 +33,7 @@ import { indexKindOf, INDEX_TITLES, type IndexKind } from '../editor/extensions/ import { citationText, isBibType } from '../editor/extensions/bibliographyEntry'; import { isCitationStyle, rowTemplate, type CitationStyle } from '../utils/citationStyle'; import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes'; +import { CELL_FORMAT_SPECS, isCellFormat, type CellFormat } from '../utils/cellFormat'; import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime'; import { parseLatex } from '../math/latex'; import { mathmlDocument } from '../math/mathml'; @@ -3299,7 +3300,7 @@ function tablePropsOf(node: TiptapNode, contentWidthCm: number): TableProps | nu // A formula cell as ODF writes it: LibreOffice's own formula language behind its // `ooow:` prefix, plus the cached result as the cell's value. -type CellFormulaExport = { formula: string; value: number | null }; +type CellFormulaExport = { formula: string; value: number | null; format: CellFormat | null }; function ensureOoowNamespace(content: string): string { if (content.includes('xmlns:ooow=')) return content; @@ -3315,15 +3316,42 @@ function applyCellFormulas(odtBytes: Uint8Array, cells: (CellFormulaExport | nul if (!contentBytes) return odtBytes; let idx = 0; - const content = strFromU8(contentBytes).replace( + // A number format lives on the cell *style*, and odf-kit shares one style across + // every cell — so a formatted cell gets a copy of it carrying the data style. + const minted: string[] = []; + const derived = new Map(); + let source = strFromU8(contentBytes); + const styleFor = (base: string, format: CellFormat): string => { + const key = `${base}|${format}`; + const existing = derived.get(key); + if (existing) return existing; + const name = `Cf${derived.size + 1}`; + const dataName = `Ncell${derived.size + 1}`; + derived.set(key, name); + minted.push(odfCellNumberStyle(format, dataName)); + const props = new RegExp(`]*>([\\s\\S]*?)`) + .exec(source)?.[1] ?? ''; + minted.push( + `` + + `${props}`, + ); + return name; + }; + + let content = source.replace( /]*?)(\/?)>/g, (match, attrs: string, selfClose: string) => { const cell = cells[idx++]; if (!cell) return match; const value = cell.value == null ? '' : ` office:value-type="float" office:value="${cell.value}"`; - return ``; + const base = /table:style-name="([^"]+)"/.exec(attrs)?.[1]; + const styled = cell.format && base + ? attrs.replace(`table:style-name="${base}"`, `table:style-name="${styleFor(base, cell.format)}"`) + : attrs; + return ``; }, ); + if (minted.length) content = ensureNumberNamespace(injectAutomaticStyles(content, minted.join(''))); // `ooow:` in the value is a namespace prefix, so the reader resolves it against the // declarations: undeclared, LibreOffice reads the prefix as part of the formula and // prints "Expression is faulty" (probed). @@ -3331,6 +3359,18 @@ function applyCellFormulas(odtBytes: Uint8Array, cells: (CellFormulaExport | nul return rezipOdt(files); } +// The data style a formatted cell points at: how many decimals, whether the thousands +// are grouped, and — for a percentage — the style family that appends the sign. +function odfCellNumberStyle(format: CellFormat, name: string): string { + const spec = CELL_FORMAT_SPECS[format]; + const number = ``; + return spec.percent + ? `${number}%` + : `${number}`; +} + // A table's grid: where every real cell sits and what each slot reads, merged cells // filling the box they span — so a formula's A1 is the cell the editor calls A1. type CellGridJson = { @@ -3382,7 +3422,11 @@ function cellFormulaOf(cell: TiptapNode, grid: CellGridJson, loc: NumberLocale): rows: grid.rows, cols: grid.cols, self, valueAt: (ref: CellRef) => parseCellNumber(grid.text(ref), loc), }; - return { formula: toWriterFormula(formula, ctx), value: parseCellNumber(grid.text(self), loc) }; + return { + formula: toWriterFormula(formula, ctx), + value: parseCellNumber(grid.text(self), loc), + format: isCellFormat(cell.attrs?.cellFormat) ? cell.attrs.cellFormat : null, + }; } // Build an ODF table from a CUST_TABLE node, bypassing odf-kit's native walkTable to diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index ce17bab..16edb53 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -416,6 +416,11 @@ const de: Messages = { sortAscending: 'Aufsteigend', sortDescending: 'Absteigend', sortHeaderRow: 'Erste Zeile ist eine Kopfzeile', + numberFormat: 'Zahlenformat', + numberFormats: { + general: 'Allgemein', int: '1235', dec2: '1234,57', + group2: '1.234,57', percent: '123457 %', percent2: '123456,79 %', + } as Record, formula: 'Formel…', formulaAria: 'Formel', formulaDialogLabel: 'Tabellenformel', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 4924b31..2f1206c 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -420,6 +420,11 @@ const en = { sortAscending: 'Ascending', sortDescending: 'Descending', sortHeaderRow: 'First row is a header', + numberFormat: 'Number format', + numberFormats: { + general: 'General', int: '1235', dec2: '1234.57', + group2: '1,234.57', percent: '123457%', percent2: '123456.79%', + } as Record, formula: 'Formula…', formulaAria: 'Formula', formulaDialogLabel: 'Table formula', diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 65aa9c1..4aaec1d 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -31,6 +31,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame. - **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text. - **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default. +- **A cell's number format** is on its *style* in ODF (`style:data-style-name` → a `number:number-style`/`percentage-style`, read for its decimals, grouping and percent sign) and on the field in DOCX (the `\#` picture, which is not part of the formula and is cut out of it). Only a formula cell keeps one. - **Ruby**: `text:ruby` and `w:ruby` are one atom here (`ruby.ts`), so only the two texts are read — the base out of `text:ruby-base` / `w:rubyBase`, the reading out of `text:ruby-text` / `w:rt`. A file's own alignment and position are dropped; both products' defaults are what the exports write. - **Bibliography**: a `text:bibliography-mark`'s every `text:` attribute but the identifier and the type is a source field, whatever the file names it, so nothing is lost on a format we have no UI for. Word's `CITATION` names a tag only — the record comes from `customXml/item*.xml` (each is probed for a `b:Sources` root), and a tag no source declares still cites with the field's cached text. `BIBLIOGRAPHY` and `text:bibliography` both become the index node. - **Tracked changes**: ODF points at a registry, so `odfRevisions(body)` reads `` first and the body walk resolves each `text:change-start`/`-end` and `text:change` against it (the open insertions live in `Ctx` — a range may close in a later paragraph). A `text:change` is a deletion whose text is *only* in the registry: it is emitted there and then, marked. Word needs no registry — `w:ins`/`w:del` wrap their runs, whose text sits in `w:delText` rather than `w:t` for a deletion. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index deaefe6..4b0e3aa 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -28,6 +28,7 @@ import { chartDataUrl } from './chart'; import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts'; import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; import { fromWriterFormula } from '../utils/tableFormula'; +import { cellFormatFromCode, type CellFormat } from '../utils/cellFormat'; import { ODF_SEQ_CATEGORY } from '../editor/extensions/caption'; import type { IndexKind } from '../editor/extensions/tableOfContents'; import { bibTypeFromDocx, DOCX_BIB_FIELD, type BibSource } from '../editor/extensions/bibliographyEntry'; @@ -360,14 +361,20 @@ function tocIndexKind(instr: string): IndexKind { const instrTextOf = (el: Element): string => Array.from(el.getElementsByTagNameNS(W, 'instrText')).map(i => i.textContent ?? '').join(''); -// A cell's own formula field: `=SUM(ABOVE)`, written either way a field can be. -function cellFormulaOf(tc: Element): string { +// A cell's own formula field: `=SUM(ABOVE)`, written either way a field can be. Its +// `\#` switch is the number format, and it is not part of the formula. +function cellFormulaOf(tc: Element): { formula: string; format: CellFormat | null } { const instrs = [ instrTextOf(tc), ...Array.from(tc.getElementsByTagNameNS(W, 'fldSimple')).map(f => f.getAttributeNS(W, 'instr') ?? ''), ]; const found = instrs.find(i => /^\s*=\s*\S/.test(i)); - return found ? fromWriterFormula(found) : ''; + if (!found) return { formula: '', format: null }; + const picture = /\\#\s*"([^"]*)"/.exec(found); + return { + formula: fromWriterFormula(found.replace(/\\#\s*"[^"]*"/, '')), + format: picture ? cellFormatFromCode(picture[1]) : null, + }; } function scanTocField(p: Element, st: TocFieldState): { emit: boolean } { @@ -2234,8 +2241,9 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { if (useWeights) attrs.colwidth = useWeights.slice(col, col + colspan); // A `= …` field in the cell is Word's table formula; its cached result is already // the cell's text, so only the formula itself moves onto the cell. - const formula = cellFormulaOf(tc); + const { formula, format } = cellFormulaOf(tc); if (formula) attrs.formula = formula; + if (formula && format) attrs.cellFormat = format; const cell: Node = { type: 'tableCell', attrs, content: blocks.length ? blocks : [{ type: 'paragraph' }] }; cells.push(cell); for (let c = col; c < col + colspan; c++) pending[c] = vMerge === 'restart' ? cell : null; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 66e043c..e60eaa8 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -34,6 +34,7 @@ import type { LineNumbering } from '../storage/lineNumbering'; import type { EmbeddedFont } from '../fonts/embeddedFonts'; import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; import { fromWriterFormula } from '../utils/tableFormula'; +import { cellFormatFromSpec, type CellFormat } from '../utils/cellFormat'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; import { getSchema } from '@tiptap/core'; import type { Schema } from '@tiptap/pm/model'; @@ -2395,6 +2396,21 @@ export function borderAttrFromOdf(raw: string | null | undefined, treatDefaultAs return `${w}pt solid ${c}`; } +// A formula cell's number format: its style points at a data style, whose shape is +// all we keep — decimals, grouping, and whether it is a percentage. +function cellNumberFormat(cellEl: Element, ctx: Ctx): CellFormat | null { + const dataName = ctx.resolver.cellDataStyle(cellEl.getAttributeNS(NS.table, 'style-name')); + const styleEl = ctx.resolver.numberStyle(dataName); + const kind = styleEl?.localName; + if (!styleEl || (kind !== 'number-style' && kind !== 'percentage-style')) return null; + const num = styleEl.getElementsByTagNameNS(NS.number, 'number')[0]; + return cellFormatFromSpec({ + decimals: parseInt(num?.getAttributeNS(NS.number, 'decimal-places') ?? '0', 10) || 0, + grouping: num?.getAttributeNS(NS.number, 'grouping') === 'true', + percent: kind === 'percentage-style', + }); +} + function convertTable(el: Element, ctx: Ctx): Node | null { const weights = columnWeights(el, ctx.resolver); @@ -2441,9 +2457,12 @@ function convertTable(el: Element, ctx: Ctx): Node | null { // the cached result is the cell's text and needs nothing here. const rawFormula = cellEl.getAttributeNS(NS.table, 'formula'); const formula = rawFormula ? fromWriterFormula(rawFormula) : ''; + // The number format is on the cell's style, as a reference to a data style. + const cellFormat = formula ? cellNumberFormat(cellEl, ctx) : null; for (let r = 0; r < repeated; r++) { const attrs: Record = { colspan, rowspan, ...borders }; if (formula) attrs.formula = formula; + if (cellFormat) attrs.cellFormat = cellFormat; if (ownPad) attrs.cellPadding = ownPad; if (backgroundColor) attrs.backgroundColor = backgroundColor; if (verticalAlign) attrs.verticalAlign = verticalAlign; diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index b0f5f59..a30735b 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -145,6 +145,10 @@ function entryFromStyleElement(el: Element): StyleEntry { collectProps(el, 'table-row-properties', entry.misc); collectProps(el, 'table-cell-properties', entry.misc); collectProps(el, 'graphic-properties', entry.misc); + // Not a property element but an attribute of the style itself, and it inherits along + // the parent chain like one — a cell's number format is a reference to a data style. + const dataStyle = el.getAttributeNS(NS.style, 'data-style-name'); + if (dataStyle) entry.misc['style:data-style-name'] = dataStyle; return entry; } @@ -272,7 +276,8 @@ export class StyleResolver { } else if (el.namespaceURI === NS.text && el.localName === 'list-style') { const name = el.getAttributeNS(NS.style, 'name'); if (name) this.listStyles.set(name, el); - } else if (el.namespaceURI === NS.number && (el.localName === 'date-style' || el.localName === 'time-style')) { + } else if (el.namespaceURI === NS.number + && ['date-style', 'time-style', 'number-style', 'percentage-style'].includes(el.localName)) { const name = el.getAttributeNS(NS.style, 'name'); if (name) this.numberStyles.set(name, el); } @@ -407,6 +412,13 @@ export class StyleResolver { return c && c !== 'transparent' && c !== 'none' ? c : null; } + // The data style a cell style points at (style:data-style-name), which is where a + // cell's number format lives — the cell element itself carries none. + cellDataStyle(styleName: string | null): string | null { + if (!styleName) return null; + return this.merged('table-cell', styleName).misc['style:data-style-name'] ?? null; + } + // A cell style's style:vertical-align, where it is not the ODF default (top). cellVerticalAlign(styleName: string | null): 'middle' | 'bottom' | null { if (!styleName) return null; diff --git a/src/lib/utils/cellFormat.ts b/src/lib/utils/cellFormat.ts new file mode 100644 index 0000000..41f6d0d --- /dev/null +++ b/src/lib/utils/cellFormat.ts @@ -0,0 +1,60 @@ +// The number format a table cell prints its value in (LibreOffice's Table ▸ Number +// Format, Word's `\#` field switch). The set is the common head of both dialogs' +// lists — plain numbers and percentages; currency and date formats are not offered. + +import { formatCellNumber, type NumberLocale } from './tableFormula'; + +export type CellFormat = 'general' | 'int' | 'dec2' | 'group2' | 'percent' | 'percent2'; + +export const CELL_FORMATS: CellFormat[] = ['general', 'int', 'dec2', 'group2', 'percent', 'percent2']; + +// Word's picture for each, which is also how the format reads in the two dialogs. +export const CELL_FORMAT_CODES: Record = { + general: '', int: '0', dec2: '0.00', group2: '#,##0.00', percent: '0%', percent2: '0.00%', +}; + +export function cellFormatFromCode(code: string): CellFormat | null { + const key = code.trim(); + return CELL_FORMATS.find((f) => CELL_FORMAT_CODES[f] === key) ?? null; +} + +export function isCellFormat(v: unknown): v is CellFormat { + return typeof v === 'string' && (CELL_FORMATS as string[]).includes(v); +} + +/** The shape of one format, which is all both file formats record about it. */ +export type FormatSpec = { decimals: number; grouping: boolean; percent: boolean }; + +export const CELL_FORMAT_SPECS: Record = { + general: { decimals: 0, grouping: false, percent: false }, + int: { decimals: 0, grouping: false, percent: false }, + dec2: { decimals: 2, grouping: false, percent: false }, + group2: { decimals: 2, grouping: true, percent: false }, + percent: { decimals: 0, grouping: false, percent: true }, + percent2: { decimals: 2, grouping: false, percent: true }, +}; + +/** A file's number style read back as the nearest format we offer. */ +export function cellFormatFromSpec(spec: FormatSpec): CellFormat { + if (spec.percent) return spec.decimals >= 2 ? 'percent2' : 'percent'; + if (spec.grouping) return 'group2'; + if (spec.decimals >= 2) return 'dec2'; + return 'int'; +} + +/** + * A value printed in its cell's format. `general` is the editor's own rendering — + * no grouping, the locale's decimal separator, the float noise rounded off. + */ +export function formatCellValue(n: number, format: CellFormat, loc: NumberLocale, lang: string): string { + if (!Number.isFinite(n)) return ''; + if (format === 'general') return formatCellNumber(n, loc); + const spec = CELL_FORMAT_SPECS[format]; + const value = spec.percent ? n * 100 : n; + const body = new Intl.NumberFormat(lang || 'en', { + minimumFractionDigits: spec.decimals, + maximumFractionDigits: spec.decimals, + useGrouping: spec.grouping, + }).format(value); + return spec.percent ? `${body}%` : body; +} diff --git a/tests/unit/cell-number-format.test.ts b/tests/unit/cell-number-format.test.ts new file mode 100644 index 0000000..f9b7b61 --- /dev/null +++ b/tests/unit/cell-number-format.test.ts @@ -0,0 +1,79 @@ +// A formula cell's own number format. ODF keeps it on the cell's style as a reference +// to a data style; Word on the field, as its `\#` switch. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; +import { importOdt } from '../../src/lib/import/odt'; +import { importDocx } from '../../src/lib/import/docx'; +import { formatCellValue } from '../../src/lib/utils/cellFormat'; + +type N = any; + +const EN = { decimal: '.', group: ',' }; +const cell = (text: string, attrs: N = {}): N => ({ + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: null, ...attrs }, + content: [{ type: 'paragraph', content: [{ type: 'text', text }] }], +}); +const doc: N = { + type: 'doc', + content: [{ + type: 'table', + content: [ + { type: 'tableRow', content: [cell('1234.5')] }, + { type: 'tableRow', content: [cell('1,234.50', { formula: 'SUM(ABOVE)', cellFormat: 'group2' })] }, + ], + }], +}; +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + +const formulaCell = (r: N): N => { + const table = (r.content as N).content.find((n: N) => n.type === 'table'); + return table.content[1].content[0]; +}; + +describe('a cell number format', () => { + it('prints the value the way the format says', () => { + expect(formatCellValue(1234.5, 'general', EN, 'en')).toBe('1234.5'); + expect(formatCellValue(1234.5, 'int', EN, 'en')).toBe('1235'); + expect(formatCellValue(1234.5, 'dec2', EN, 'en')).toBe('1234.50'); + expect(formatCellValue(1234.5, 'group2', EN, 'en')).toBe('1,234.50'); + expect(formatCellValue(0.125, 'percent', EN, 'en')).toBe('13%'); + expect(formatCellValue(0.125, 'percent2', EN, 'en')).toBe('12.50%'); + }); + + it('round-trips through ODF', async () => { + const bytes = await buildOdt(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['content.xml']); + expect(xml).toContain(''); + expect(xml).toContain('number:decimal-places="2"'); + expect(xml).toContain('number:grouping="true"'); + expect(xml).toContain('style:data-style-name="Ncell1"'); + const back = formulaCell(importOdt(bytes)); + // ODF has no ABOVE: the export resolves it to the range it covers. + expect(back.attrs.formula).toBe('SUM(A1)'); + expect(back.attrs.cellFormat).toBe('group2'); + }); + + it('round-trips through DOCX', async () => { + const bytes = await buildDocx(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + expect(xml).toContain('=SUM(ABOVE) \\# "#,##0.00"'); + const back = formulaCell(importDocx(bytes)); + expect(back.attrs.formula).toBe('SUM(ABOVE)'); + expect(back.attrs.cellFormat).toBe('group2'); + }); + + it('leaves a cell in the general format alone', async () => { + const plain: N = { ...doc, content: [{ ...doc.content[0], content: [ + doc.content[0].content[0], + { type: 'tableRow', content: [cell('1234.5', { formula: 'SUM(ABOVE)' })] }, + ] }] }; + const xml = strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']); + expect(xml).not.toContain('style:data-style-name'); + const docxXml = strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']); + expect(docxXml).toContain('=SUM(ABOVE)'); + expect(docxXml).not.toContain('\\#'); + }); +}); diff --git a/tests/zz-dump.test.ts b/tests/zz-dump.test.ts new file mode 100644 index 0000000..b0c50ed --- /dev/null +++ b/tests/zz-dump.test.ts @@ -0,0 +1,17 @@ +import { it } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../src/lib/export/odt'; +type N = any; +const cell = (t: string, formula?: string): N => ({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: null, ...(formula ? { formula } : {}) }, content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }] }); +const doc: N = { type: 'doc', content: [ + { type: 'table', content: [ + { type: 'tableRow', content: [cell('2'), cell('3')] }, + { type: 'tableRow', content: [cell('5', 'SUM(ABOVE)'), cell('x')] }, + ] }, +] }; +it('dumps', async () => { + const bytes = await buildOdt(doc, { top: 2, bottom: 2, left: 2, right: 2 }, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['content.xml']); + console.log(xml.match(/]*family="table-cell"[\s\S]*?<\/style:style>/g)?.join('\n')); + console.log(xml.slice(xml.indexOf('') + 14)); +});