feat: sort a table, sum a column, recognise a number
The formula rides the cell, as ODF writes it; Word's field syntax is what is stored, since it is the wider language. `ooow:` in the ODF attribute value is a namespace prefix — undeclared, LibreOffice reads it as part of the formula and prints "Expression is faulty". Sort takes one key and detects the type per cell where both dialogs make you pick. Number recognition is off by default, as it is in LibreOffice, and a formula parses a cell's text either way — exactly as there.
This commit is contained in:
1 parent
0428cb674f
commit
147342655d
25 files changed
+1595
-28
No files matched your search
+4
-1
@@ -62,6 +62,9 @@
|
||||
**Insert**
|
||||
- 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): by any column, ascending or descending, optionally keeping the first row as a header. The type is detected per cell rather than chosen — a column of numbers sorts numerically, anything else by the document language's collation — where both dialogs make you pick; and only the first of their three sort keys is offered
|
||||
- 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 <A1:A3>`, 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`
|
||||
- Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste. A floating frame is placed by the file's own offsets, and two top-and-bottom frames set against opposite ends of the text share one band, side by side
|
||||
- A CMYK JPEG's embedded ICC profile is dropped on import: Chromium colour-manages through it where LibreOffice and Word convert naively, which turned a contract's logo from vivid blue to dull teal and its black to grey. It also removes what is often most of the file (a 757 KB logo → 102 KB, easing the localStorage ceiling below)
|
||||
@@ -148,7 +151,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
|
||||
- Heading levels 7–10 (Word and LibreOffice go that far; HTML stops at `h6`)
|
||||
- A bibliography's citation style: the list is set the one way ("key: author, title, year"), where both word processors offer APA, MLA, Chicago and the rest, and numbered citations (`[1]`) instead of the source's short name
|
||||
- A section's own **page format** is set only by the file it came from — the ribbon offers the orientation per section, the paper size document-wide. Pages of different widths are left-aligned in the scroll, where both word processors centre each
|
||||
- Table extras: sort, sum/formula in a cell, number recognition
|
||||
- A sort dialog's second and third key, and its choice of sort type over the detected one; 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 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 formatting or by style (the regular-expression half is implemented)
|
||||
|
||||
@@ -16,6 +16,9 @@ Covers the `table*.ts` extensions in `src/lib/editor/extensions/` and the table-
|
||||
- **`tableCellPadding.ts`** (`TableCellPadding`) — **cell margins**, a global `cellPadding` attr `[top, right, bottom, left]` in cm on `table` *and* on `tableCell`/`tableHeader`. `null` = inherit: a table's from the default `[0, 0.19, 0, 0.19]` (Word's own, and `editor.css`'s fallback), a cell's from its table — so `cellPaddingAttr(sides, base)` takes the baseline it is measured against, and only a real disagreement carries an attr. A side within `SAME_CM` (0.005cm) of that baseline *is* the baseline, or Word's 108 twips (0.1905cm) and LibreOffice's 0.191cm would tag every table. Rendered as one `--cell-pad` custom property (the attr's `renderHTML` for the static paths, `TableView` for the node view, since it bypasses `renderHTML`) that `td/th { padding: var(--cell-pad, 0 0.19cm) }` reads — the property inherits, so a cell's own declaration wins for its own `td`. **DOCX**: `w:tblPr/w:tblCellMar` direct, else the table style's (`DocxStyles.tableCellMar` walks `w:basedOn`), and `w:tcPr/w:tcMar` per cell (`cellMarginsCm` reads either, `w:type="nil"` being an explicit zero); export writes `Table.margins` + `TableCell.margins`. **ODF** has no table-level cell margin — it sits on each cell's style, so import takes the first cell's `fo:padding*` for the table and measures the rest against it. `fo:padding` defaults to **0** there, so an undeclared side is imported as a real zero rather than left open to the Word default (probed: LibreOffice puts an unstyled cell's text flush against the column edge, and the Math Guide's figure tables carry no cell style at all). Export rides each cell's value on it as a CSS-style TRBL shorthand that `expandCellPadding` splits per side (odf-kit only writes the `fo:padding` shorthand, and ODF allows it only one length). Getting this wrong is not cosmetic: a 0.71cm bibliography marker column lost 0.38cm to the old constant and wrapped `"[2] "` onto three lines.
|
||||
- **`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 <A1:A3>"` (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.
|
||||
- **`tableSort.ts`** (`TableSort`) — LibreOffice's Table ▸ Sort / Word's Layout ▸ Sort on the one key both put first: `sortedRows` is pure (rows in, rows out), stable, orders numerically where both cells parse as numbers and by `Intl.Collator` otherwise, and keeps a header row in place. `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`.
|
||||
- **`tableStyle.ts`** (`TableStyle`) — the **table style** family's editor half (model in `styles/tableStyles.ts`, see below): global attrs `tableStyle` on `table` (⇄ `data-table-style`) and `region` on `tableCell`/`tableHeader` (⇄ `data-region`, a space-separated region list). `setTableStyle(name|null)` writes the attr and **materializes** the style into the cells in one transaction — `paintTable` walks the `TableMap`, resolves each cell via `resolveTableCell` (merged cells by their top-left `findCell` box) and writes `backgroundColor`/the four border attrs/`region`, so **export, PDF and clipboard need no new code**. It only writes cells that differ, which makes it idempotent — the condition the re-band plugin depends on. That plugin (`appendTransaction`) repaints a styled table when its row/column **shape** changes (Word's re-banding); deliberately *not* on every transaction, or a manually shaded cell would be repainted away immediately. `refreshTableStyles()` repaints every styled table and is called from `Editor.svelte`'s document-stylesheet `$effect` — the choke point every registry change (edit, rename, reset, import) passes through. `activeTableStyle`/`cellRegionText` feed the gallery and the Bold button (a style's bold region behaves like a heading, see `tableHeaderRow.ts`). `TableView` sets `data-table-style` itself, since the node view bypasses `renderHTML`.
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
import { loadPageDecor, savePageDecor, EMPTY_PAGE_DECOR, type PageDecor } from './lib/storage/pageDecor';
|
||||
import { loadLineNumbering, saveLineNumbering, DEFAULT_LINE_NUMBERING, type LineNumbering } from './lib/storage/lineNumbering';
|
||||
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
|
||||
import { setTableLanguage } from './lib/storage/tableOptions.svelte';
|
||||
import { spellController } from './lib/spell/controller';
|
||||
import LanguagePicker from './lib/components/LanguagePicker.svelte';
|
||||
import UiLanguagePicker from './lib/components/UiLanguagePicker.svelte';
|
||||
@@ -242,6 +243,8 @@
|
||||
$effect(() => {
|
||||
saveDocumentLanguage(documentLanguage);
|
||||
void spellController.setLanguage(documentLanguage);
|
||||
// A table cell's number is read and written in the document's language.
|
||||
setTableLanguage(documentLanguage);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
import { buildContextMenu, type MenuEntry, type SpellSection } from '../editor/contextMenuItems';
|
||||
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 TableToolbar from './TableToolbar.svelte';
|
||||
import TableSplitDialog from './TableSplitDialog.svelte';
|
||||
import TableSortDialog from './TableSortDialog.svelte';
|
||||
import TableFormulaDialog from './TableFormulaDialog.svelte';
|
||||
import ImageToolbar from './ImageToolbar.svelte';
|
||||
import TextBoxToolbar from './TextBoxToolbar.svelte';
|
||||
import type { WrapMode } from '../editor/extensions/image';
|
||||
@@ -472,11 +476,26 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
// Shown when the selection is inside a table; positioned just above that table.
|
||||
let tableUi = $state<{ visible: boolean; top: number; left: number }>({ visible: false, top: 0, left: 0 });
|
||||
let tableUiRaf = 0;
|
||||
// "Split Cells…" popover, opened from the table toolbar.
|
||||
let splitDialogOpen = $state(false);
|
||||
// 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)' };
|
||||
let tableGrid = $derived.by(() => {
|
||||
const state = editor?.state;
|
||||
if (tick < 0 || !state || !isInTable(state)) return NO_TABLE;
|
||||
const rect = selectedRect(state);
|
||||
return {
|
||||
columns: rect.map.width,
|
||||
column: rect.left,
|
||||
headerRow: rect.table.firstChild?.firstChild?.type.name === 'tableHeader'
|
||||
|| rect.table.attrs.repeatHeader === true,
|
||||
cell: currentCellName(state),
|
||||
formula: currentCellFormula(state) || guessFormula(state),
|
||||
};
|
||||
});
|
||||
// Drop the dialog if the selection leaves the table (toolbar hidden).
|
||||
$effect(() => {
|
||||
if (!tableUi.visible && splitDialogOpen) splitDialogOpen = false;
|
||||
if (!tableUi.visible && tableDialog) tableDialog = null;
|
||||
});
|
||||
|
||||
// The DOM element of the table containing the current selection, or null.
|
||||
@@ -1081,25 +1100,50 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if tableUi.visible && !splitDialogOpen}
|
||||
{#if tableUi.visible && !tableDialog}
|
||||
<TableToolbar
|
||||
{editor}
|
||||
{tick}
|
||||
top={tableUi.top}
|
||||
left={tableUi.left}
|
||||
onSplit={() => (splitDialogOpen = true)}
|
||||
onDialog={(which) => (tableDialog = which)}
|
||||
/>
|
||||
{/if}
|
||||
{#if splitDialogOpen && tableUi.visible}
|
||||
{#if tableUi.visible}
|
||||
<TableSplitDialog
|
||||
open={splitDialogOpen}
|
||||
open={tableDialog === 'split'}
|
||||
top={tableUi.top}
|
||||
left={tableUi.left}
|
||||
onApply={(cols, rows) => {
|
||||
editor?.chain().focus().splitCellInto(cols, rows).run();
|
||||
splitDialogOpen = false;
|
||||
tableDialog = null;
|
||||
}}
|
||||
onClose={() => (splitDialogOpen = false)}
|
||||
onClose={() => (tableDialog = null)}
|
||||
/>
|
||||
<TableSortDialog
|
||||
open={tableDialog === 'sort'}
|
||||
top={tableUi.top}
|
||||
left={tableUi.left}
|
||||
columns={tableGrid.columns}
|
||||
column={tableGrid.column}
|
||||
headerRow={tableGrid.headerRow}
|
||||
onApply={(options) => {
|
||||
editor?.chain().focus().sortTable(options).run();
|
||||
tableDialog = null;
|
||||
}}
|
||||
onClose={() => (tableDialog = null)}
|
||||
/>
|
||||
<TableFormulaDialog
|
||||
open={tableDialog === 'formula'}
|
||||
top={tableUi.top}
|
||||
left={tableUi.left}
|
||||
cell={tableGrid.cell}
|
||||
initial={tableGrid.formula}
|
||||
onApply={(formula) => {
|
||||
editor?.chain().focus().setCellFormula(formula).run();
|
||||
tableDialog = null;
|
||||
}}
|
||||
onClose={() => (tableDialog = null)}
|
||||
/>
|
||||
{/if}
|
||||
{#if imageUi.visible}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import { FORMULA_FUNCTIONS } from '../utils/tableFormula';
|
||||
// Word's Table ▸ Formula: the guessed formula, editable, plus the function list to
|
||||
// paste from. The parent owns `open` and holds the cell the formula is set on.
|
||||
let {
|
||||
open,
|
||||
top,
|
||||
left,
|
||||
cell = null,
|
||||
initial = '=SUM(ABOVE)',
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
top: number;
|
||||
left: number;
|
||||
/** The cell's name (A1), which is how a formula refers to it. */
|
||||
cell?: string | null;
|
||||
initial?: string;
|
||||
onApply: (formula: string) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let text = $state('');
|
||||
let field = $state<HTMLInputElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
text = initial;
|
||||
queueMicrotask(() => field?.focus());
|
||||
queueMicrotask(() => field?.select());
|
||||
}
|
||||
});
|
||||
|
||||
// A formula the cell no longer has is an empty field, which clears it.
|
||||
function apply() {
|
||||
onApply(text.trim().replace(/^=/, ''));
|
||||
}
|
||||
|
||||
function paste(fn: string) {
|
||||
text = text.trim() ? `${text}${fn}()` : `=${fn}()`;
|
||||
field?.focus();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); apply(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="formula-dialog"
|
||||
style="top: {top}px; left: {left}px;"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-label={t().table.formulaDialogLabel}
|
||||
>
|
||||
<label class="formula-row">
|
||||
<span>{cell ? t().table.formulaCell(cell) : t().table.formulaField}</span>
|
||||
<input bind:this={field} bind:value={text} type="text" spellcheck="false" onkeydown={onKeydown} />
|
||||
</label>
|
||||
<div class="formula-funcs">
|
||||
{#each FORMULA_FUNCTIONS as fn}
|
||||
<button class="formula-fn" onclick={() => paste(fn)}>{fn}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="formula-actions">
|
||||
<span class="formula-spacer"></span>
|
||||
<button class="formula-cancel" onclick={onClose}>{t().common.cancel}</button>
|
||||
<button class="formula-apply" onclick={apply}>{t().common.ok}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.formula-dialog {
|
||||
position: absolute;
|
||||
/* Sit just above the table's top-left corner, like TableToolbar. */
|
||||
transform: translateY(calc(-100% - 6px));
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 17rem;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-toolbar-bg, #fff);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.formula-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.formula-funcs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.formula-fn {
|
||||
padding: 0.15rem 0.35rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.formula-fn:hover { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
|
||||
|
||||
.formula-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.formula-spacer { flex: 1; }
|
||||
|
||||
.formula-actions button {
|
||||
height: 1.8rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.formula-actions button:hover { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
|
||||
.formula-apply { border-color: var(--color-accent, #1a56db) !important; color: var(--color-accent, #1a56db); }
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import { colName } from '../utils/tableFormula';
|
||||
import type { TableSortOptions } from '../editor/extensions/tableSort';
|
||||
// LibreOffice's Table ▸ Sort / Word's Layout ▸ Sort, on the one key both put
|
||||
// first. The parent owns `open` and holds the cell selection the sort runs against.
|
||||
let {
|
||||
open,
|
||||
top,
|
||||
left,
|
||||
columns,
|
||||
column = 0,
|
||||
headerRow = false,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
top: number;
|
||||
left: number;
|
||||
columns: number;
|
||||
/** The cursor's column, which is what the dialog offers first. */
|
||||
column?: number;
|
||||
headerRow?: boolean;
|
||||
onApply: (options: TableSortOptions) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let pick = $state(0);
|
||||
let header = $state(false);
|
||||
let descending = $state(false);
|
||||
let firstField = $state<HTMLSelectElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
pick = Math.min(Math.max(0, column), Math.max(0, columns - 1));
|
||||
header = headerRow;
|
||||
descending = false;
|
||||
queueMicrotask(() => firstField?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); onApply({ column: pick, descending, headerRow: header }); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="sort-dialog"
|
||||
style="top: {top}px; left: {left}px;"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-label={t().table.sortDialogLabel}
|
||||
onkeydown={onKeydown}
|
||||
>
|
||||
<label class="sort-row">
|
||||
<span>{t().table.sortBy}</span>
|
||||
<select bind:this={firstField} bind:value={pick}>
|
||||
{#each Array.from({ length: columns }, (_, i) => i) as i}
|
||||
<option value={i}>{colName(i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="sort-dirs" role="radiogroup" aria-label={t().table.sortDialogLabel}>
|
||||
{#each [{ v: false, label: t().table.sortAscending }, { v: true, label: t().table.sortDescending }] as d}
|
||||
<label class="sort-radio">
|
||||
<input type="radio" value={d.v} bind:group={descending} />
|
||||
<span>{d.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="sort-radio">
|
||||
<input type="checkbox" bind:checked={header} />
|
||||
<span>{t().table.sortHeaderRow}</span>
|
||||
</label>
|
||||
<div class="sort-actions">
|
||||
<span class="sort-spacer"></span>
|
||||
<button class="sort-cancel" onclick={onClose}>{t().common.cancel}</button>
|
||||
<button class="sort-apply" onclick={() => onApply({ column: pick, descending, headerRow: header })}>
|
||||
{t().common.ok}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sort-dialog {
|
||||
position: absolute;
|
||||
/* Sit just above the table's top-left corner, like TableToolbar. */
|
||||
transform: translateY(calc(-100% - 6px));
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-toolbar-bg, #fff);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.sort-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
select {
|
||||
min-width: 5rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.sort-dirs {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.sort-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sort-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.sort-spacer { flex: 1; }
|
||||
|
||||
.sort-actions button {
|
||||
height: 1.8rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-actions button:hover { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
|
||||
.sort-apply { border-color: var(--color-accent, #1a56db) !important; color: var(--color-accent, #1a56db); }
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import TableBorderPicker from './TableBorderPicker.svelte';
|
||||
import TableStylePicker from './TableStylePicker.svelte';
|
||||
import { numberRecognition, setNumberRecognition } from '../storage/tableOptions.svelte';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
|
||||
let {
|
||||
@@ -13,13 +14,13 @@
|
||||
top,
|
||||
left,
|
||||
tick,
|
||||
onSplit,
|
||||
onDialog,
|
||||
}: {
|
||||
editor: Editor | null;
|
||||
top: number;
|
||||
left: number;
|
||||
tick: number;
|
||||
onSplit: () => void;
|
||||
onDialog: (which: 'split' | 'sort' | 'formula') => void;
|
||||
} = $props();
|
||||
|
||||
// Run a table command without disturbing the cell selection: preventDefault on
|
||||
@@ -196,7 +197,7 @@
|
||||
class="tt-btn"
|
||||
title={t().table.splitCells}
|
||||
aria-label={t().table.splitCellsAria}
|
||||
onclick={() => onSplit()}
|
||||
onclick={() => onDialog('split')}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<rect x="1.5" y="3.5" width="15" height="11" rx="1" stroke="currentColor" stroke-width="1.3"/>
|
||||
@@ -207,6 +208,45 @@
|
||||
|
||||
<span class="tt-sep"></span>
|
||||
|
||||
<button
|
||||
class="tt-btn"
|
||||
title={t().table.sort}
|
||||
aria-label={t().table.sortAria}
|
||||
onclick={() => onDialog('sort')}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path d="M3 4.5h11M3 9h7.5M3 13.5h4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<path d="M15 6v8M12.6 11.6L15 14l2.4-2.4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tt-btn"
|
||||
title={t().table.formula}
|
||||
aria-label={t().table.formulaAria}
|
||||
onclick={() => onDialog('formula')}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path d="M13.5 3H5l4.2 6L5 15h8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tt-btn"
|
||||
class:active={numberRecognition()}
|
||||
title={t().table.numberRecognition}
|
||||
aria-label={t().table.numberRecognition}
|
||||
aria-pressed={numberRecognition()}
|
||||
onclick={() => setNumberRecognition(!numberRecognition())}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<rect x="1.5" y="1.5" width="15" height="15" rx="1" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M7.5 5.4A1.9 1.9 0 0 1 10.6 6.9c0 1.5-3.1 2.6-3.1 5.7h3.9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span class="tt-sep"></span>
|
||||
|
||||
{#each [
|
||||
{ v: null, label: t().table.cellAlignTop, d: 'M5 5h8M5 8h5' },
|
||||
{ v: 'middle', label: t().table.cellAlignMiddle, d: 'M5 7.5h8M5 10.5h5' },
|
||||
|
||||
@@ -77,6 +77,9 @@ export const STROKED = {
|
||||
caption: ['M2 2.5h12v7.5H2z', 'M2 13h7'],
|
||||
// A citation as it is set: a short reference between square brackets.
|
||||
citation: ['M6 3.5H4v9h2', 'M10 3.5h2v9h-2', 'M6.5 8h3'],
|
||||
// Lines shortening under a downward arrow, and a sigma for the formula.
|
||||
sortRows: ['M2 3.5h8M2 8h5.5M2 12.5h3', 'M12.5 4v9M10.4 10.9 12.5 13l2.1-2.1'],
|
||||
formula: ['M12 3H5l3.7 5.2L5 13h7'],
|
||||
watermark: ['M2.5 2h11v12h-11z', 'M4.5 11.5 11.5 4.5'],
|
||||
trackChanges: ['M2 4h12M2 8h7', 'M9.5 13.5 14 9l-1.6-1.6L7.9 12z', 'M7.9 12 7.4 14.1l2.1-.6'],
|
||||
close: ['M3.5 3.5 12.5 12.5M12.5 3.5 3.5 12.5'],
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
import TableBorderPicker from '../../TableBorderPicker.svelte';
|
||||
import TableStylePicker from '../../TableStylePicker.svelte';
|
||||
import TableSplitDialog from '../../TableSplitDialog.svelte';
|
||||
import TableSortDialog from '../../TableSortDialog.svelte';
|
||||
import TableFormulaDialog from '../../TableFormulaDialog.svelte';
|
||||
import { captionClicks, anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
|
||||
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 { numberRecognition, setNumberRecognition } from '../../../storage/tableOptions.svelte';
|
||||
import { t } from '../../../i18n/i18n.svelte';
|
||||
|
||||
// Word splits the table's contextual tabs in two: Design paints it, Layout
|
||||
@@ -23,13 +27,14 @@
|
||||
which: 'design' | 'layout';
|
||||
} = $props();
|
||||
|
||||
let splitOpen = $state(false);
|
||||
let splitAt = $state({ top: 0, left: 0 });
|
||||
// One popover at a time, opened under the button that owns it.
|
||||
let dialog = $state<'split' | 'sort' | 'formula' | null>(null);
|
||||
let dialogAt = $state({ top: 0, left: 0 });
|
||||
|
||||
function openSplit(e: MouseEvent) {
|
||||
function openDialog(e: MouseEvent, which: 'split' | 'sort' | 'formula') {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
splitAt = { top: r.bottom + 4, left: r.left };
|
||||
splitOpen = true;
|
||||
dialogAt = { top: r.bottom + 4, left: r.left };
|
||||
dialog = which;
|
||||
}
|
||||
|
||||
function run(cmd: (chain: ChainedCommands) => ChainedCommands) {
|
||||
@@ -38,6 +43,21 @@
|
||||
}
|
||||
|
||||
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 grid = $derived.by(() => {
|
||||
if (!inTable || !editor) return NO_TABLE;
|
||||
const rect = selectedRect(editor.state);
|
||||
return {
|
||||
columns: rect.map.width,
|
||||
column: rect.left,
|
||||
headerRow: rect.table.firstChild?.firstChild?.type.name === 'tableHeader'
|
||||
|| rect.table.attrs.repeatHeader === true,
|
||||
cell: currentCellName(editor.state),
|
||||
formula: currentCellFormula(editor.state) || guessFormula(editor.state),
|
||||
};
|
||||
});
|
||||
const isHeaderRow = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'row'));
|
||||
const isHeaderCol = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'column'));
|
||||
// The structural header: the first row repeated on every page the table continues on.
|
||||
@@ -181,15 +201,15 @@
|
||||
<RibbonGroup label={t().ribbon.groups.merge}>
|
||||
<RibbonButton variant="big" icon="merge" label={t().table.mergeCells} disabled={!canMerge} onclick={() => run((c) => c.mergeCells())} />
|
||||
<span class="split-anchor">
|
||||
<button class="split-trigger" onclick={openSplit} title={t().table.splitCells}>
|
||||
<button class="split-trigger" onclick={(e) => openDialog(e, 'split')} title={t().table.splitCells}>
|
||||
<RibbonButton variant="big" icon="split" label={t().table.splitCellsAria} />
|
||||
</button>
|
||||
<TableSplitDialog
|
||||
open={splitOpen}
|
||||
top={splitAt.top}
|
||||
left={splitAt.left}
|
||||
onApply={(cols, rows) => { splitOpen = false; editor?.chain().focus().splitCellInto(cols, rows).run(); }}
|
||||
onClose={() => (splitOpen = false)}
|
||||
open={dialog === 'split'}
|
||||
top={dialogAt.top}
|
||||
left={dialogAt.left}
|
||||
onApply={(cols, rows) => { dialog = null; editor?.chain().focus().splitCellInto(cols, rows).run(); }}
|
||||
onClose={() => (dialog = null)}
|
||||
/>
|
||||
</span>
|
||||
</RibbonGroup>
|
||||
@@ -244,6 +264,48 @@
|
||||
{/if}
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.tableData}>
|
||||
<span class="split-anchor">
|
||||
<button class="split-trigger" onclick={(e) => openDialog(e, 'sort')} title={t().table.sort}>
|
||||
<RibbonButton variant="big" icon="sortRows" label={t().table.sortAria} disabled={!inTable} />
|
||||
</button>
|
||||
<TableSortDialog
|
||||
open={dialog === 'sort'}
|
||||
top={dialogAt.top}
|
||||
left={dialogAt.left}
|
||||
columns={grid.columns}
|
||||
column={grid.column}
|
||||
headerRow={grid.headerRow}
|
||||
onApply={(options) => { dialog = null; editor?.chain().focus().sortTable(options).run(); }}
|
||||
onClose={() => (dialog = null)}
|
||||
/>
|
||||
</span>
|
||||
<span class="split-anchor">
|
||||
<button class="split-trigger" onclick={(e) => openDialog(e, 'formula')} title={t().table.formula}>
|
||||
<RibbonButton variant="big" icon="formula" label={t().table.formulaAria} disabled={!inTable} />
|
||||
</button>
|
||||
<TableFormulaDialog
|
||||
open={dialog === 'formula'}
|
||||
top={dialogAt.top}
|
||||
left={dialogAt.left}
|
||||
cell={grid.cell}
|
||||
initial={grid.formula}
|
||||
onApply={(formula) => { dialog = null; editor?.chain().focus().setCellFormula(formula).run(); }}
|
||||
onClose={() => (dialog = null)}
|
||||
/>
|
||||
</span>
|
||||
<RibbonButton
|
||||
variant="small"
|
||||
icon="wordCount"
|
||||
label={t().table.numberRecognition}
|
||||
title={t().table.numberRecognitionHint}
|
||||
active={numberRecognition()}
|
||||
onclick={() => setNumberRecognition(!numberRecognition())}
|
||||
/>
|
||||
</RibbonGroup>
|
||||
{/if}
|
||||
|
||||
{#snippet shadeIcon()}<span class="shade-glyph"></span>{/snippet}
|
||||
|
||||
@@ -42,6 +42,8 @@ import { TableView } from './extensions/tableView';
|
||||
import { TableColumnResize } from './extensions/tableColumnResize';
|
||||
import { TableRowResize } from './extensions/tableRowResize';
|
||||
import { TableSplit } from './extensions/tableSplit';
|
||||
import { TableFormula } from './extensions/tableFormula';
|
||||
import { TableSort } from './extensions/tableSort';
|
||||
import { TableCellBackground } from './extensions/tableCellBackground';
|
||||
import { TableCellAlign } from './extensions/tableCellAlign';
|
||||
import { TableCellBorders } from './extensions/tableCellBorders';
|
||||
@@ -181,6 +183,8 @@ export const extensions = [
|
||||
TableRowResize,
|
||||
// "Split Cells…" (N×M); merge uses extension-table's built-in mergeCells.
|
||||
TableSplit,
|
||||
TableFormula,
|
||||
TableSort,
|
||||
// Cell shading: backgroundColor attr on tableCell/tableHeader (→ fo:background-color).
|
||||
TableCellBackground,
|
||||
// Cell content set against the middle/bottom of its box (→ style:vertical-align).
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import type { CommandProps } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import type { EditorState, Transaction } from '@tiptap/pm/state';
|
||||
import type { Node as PMNode, ResolvedPos } from '@tiptap/pm/model';
|
||||
import { TableMap, selectedRect, isInTable } from '@tiptap/pm/tables';
|
||||
import {
|
||||
evalFormula, parseCellNumber, formatCellNumber, refName,
|
||||
type CellRef, type NumberLocale,
|
||||
} from '../../utils/tableFormula';
|
||||
import { numberRecognition, 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
|
||||
// (`table:formula`), Word keeping it in a field inside the cell instead. The
|
||||
// result is the cell's own text, recomputed here whenever the table changes.
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
tableFormula: {
|
||||
setCellFormula: (formula: string | null) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const key = new PluginKey('tableFormula');
|
||||
|
||||
export type CellGrid = {
|
||||
map: TableMap;
|
||||
nodeAt: (ref: CellRef) => PMNode | null;
|
||||
offsetAt: (ref: CellRef) => number | null;
|
||||
};
|
||||
|
||||
export function cellGrid(table: PMNode): CellGrid {
|
||||
const map = TableMap.get(table);
|
||||
const offsetAt = (ref: CellRef): number | null => {
|
||||
if (ref.row < 0 || ref.col < 0 || ref.row >= map.height || ref.col >= map.width) return null;
|
||||
return map.map[ref.row * map.width + ref.col];
|
||||
};
|
||||
return {
|
||||
map,
|
||||
offsetAt,
|
||||
nodeAt: (ref) => {
|
||||
const off = offsetAt(ref);
|
||||
return off == null ? null : table.nodeAt(off);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 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<number, string> {
|
||||
const grid = cellGrid(table);
|
||||
const results = new Map<number, string>();
|
||||
const cache = new Map<number, number | null>();
|
||||
const busy = new Set<number>();
|
||||
|
||||
const valueOf = (ref: CellRef): number | null => {
|
||||
const off = grid.offsetAt(ref);
|
||||
const node = off == null ? null : table.nodeAt(off);
|
||||
if (off == null || !node) return null;
|
||||
if (cache.has(off)) return cache.get(off) ?? null;
|
||||
const formula = node.attrs.formula as string | null;
|
||||
if (!formula) return parseCellNumber(node.textContent, loc);
|
||||
if (busy.has(off)) return null;
|
||||
busy.add(off);
|
||||
const value = evalFormula(formula, {
|
||||
rows: grid.map.height, cols: grid.map.width, self: ref, valueAt: valueOf,
|
||||
});
|
||||
busy.delete(off);
|
||||
cache.set(off, value);
|
||||
results.set(off, value == null ? error : formatCellNumber(value, loc));
|
||||
return value;
|
||||
};
|
||||
|
||||
for (let row = 0; row < grid.map.height; row++) {
|
||||
for (let col = 0; col < grid.map.width; col++) {
|
||||
if (grid.nodeAt({ row, col })?.attrs.formula) valueOf({ row, col });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// The cell the cursor sits in, as a document position.
|
||||
function cellPosOf($pos: ResolvedPos): number | null {
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
const role = $pos.node(d).type.spec.tableRole;
|
||||
if (role === 'cell' || role === 'header_cell') return $pos.before(d);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Replace a cell's text with `text`, keeping the first run's marks — a formula cell
|
||||
// carries the formatting of the cell it was set in, as a field would.
|
||||
function setCellText(tr: Transaction, cellPos: number, cell: PMNode, text: string): void {
|
||||
const first = cell.firstChild;
|
||||
if (!first || !first.isTextblock) return;
|
||||
const from = cellPos + 2;
|
||||
const to = from + first.content.size;
|
||||
if (text) tr.replaceWith(from, to, tr.doc.type.schema.text(text, first.firstChild?.marks));
|
||||
else if (to > from) tr.delete(from, to);
|
||||
}
|
||||
|
||||
type Edit = { pos: number; cell: PMNode; text: string };
|
||||
|
||||
function formulaEdits(doc: PMNode, loc: NumberLocale): Edit[] {
|
||||
const edits: Edit[] = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'table') return;
|
||||
for (const [off, text] of evaluateTable(node, loc, t().table.formulaError)) {
|
||||
const cell = node.nodeAt(off);
|
||||
if (cell && cell.textContent !== text) edits.push({ pos: pos + 1 + off, cell, text });
|
||||
}
|
||||
return false; // a table holds no table
|
||||
});
|
||||
return edits;
|
||||
}
|
||||
|
||||
// LibreOffice's number recognition, applied where it applies it: on leaving the cell,
|
||||
// a text that reads as a number is rewritten in the document's number format.
|
||||
function recognitionEdit(doc: PMNode, cellPos: number, loc: NumberLocale): Edit | null {
|
||||
const cell = doc.nodeAt(cellPos);
|
||||
const role = cell?.type.spec.tableRole;
|
||||
if (!cell || (role !== 'cell' && role !== 'header_cell') || cell.attrs.formula) return null;
|
||||
const value = parseCellNumber(cell.textContent, loc);
|
||||
if (value == null) return null;
|
||||
const text = formatCellNumber(value, loc);
|
||||
return text === cell.textContent ? null : { pos: cellPos, cell, text };
|
||||
}
|
||||
|
||||
export const TableFormula = Extension.create({
|
||||
name: 'tableFormula',
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ['tableCell', 'tableHeader'],
|
||||
attributes: {
|
||||
formula: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('data-formula'),
|
||||
renderHTML: (attributes: Record<string, unknown>) =>
|
||||
attributes.formula ? { 'data-formula': attributes.formula } : {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setCellFormula:
|
||||
(formula: string | null) =>
|
||||
({ state, dispatch }: CommandProps) => {
|
||||
if (!isInTable(state)) return false;
|
||||
const rect = selectedRect(state);
|
||||
const off = cellGrid(rect.table).offsetAt({ row: rect.top, col: rect.left });
|
||||
if (off == null) return false;
|
||||
const pos = rect.tableStart + off;
|
||||
const cell = state.doc.nodeAt(pos);
|
||||
if (!cell) return false;
|
||||
if (dispatch) {
|
||||
dispatch(state.tr.setNodeMarkup(pos, undefined, { ...cell.attrs, formula: formula || null }));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key,
|
||||
appendTransaction(trs, oldState, newState) {
|
||||
const loc = tableNumberLocale();
|
||||
const tr = newState.tr;
|
||||
// Recognition first: it feeds the formulas that read the cell.
|
||||
let recognized = false;
|
||||
if (numberRecognition()) {
|
||||
const left = cellPosOf(oldState.selection.$from);
|
||||
const now = cellPosOf(newState.selection.$from);
|
||||
const moved = left == null ? null : trs.reduce((p, t) => t.mapping.map(p), left);
|
||||
if (moved != null && moved !== now) {
|
||||
const edit = recognitionEdit(newState.doc, moved, loc);
|
||||
if (edit) {
|
||||
setCellText(tr, edit.pos, edit.cell, edit.text);
|
||||
recognized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
setCellText(tr, edit.pos, edit.cell, edit.text);
|
||||
}
|
||||
return tr.steps.length ? tr.setMeta('addToHistory', false) : null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// What the Formula dialog offers: Word guesses the numbers above the cell, and the
|
||||
// ones to its left where there are none.
|
||||
export function guessFormula(state: EditorState): string {
|
||||
if (!isInTable(state)) return '=SUM(ABOVE)';
|
||||
const rect = selectedRect(state);
|
||||
const grid = cellGrid(rect.table);
|
||||
const loc = tableNumberLocale();
|
||||
const numeric = (ref: CellRef): boolean => {
|
||||
const node = grid.nodeAt(ref);
|
||||
return !!node && !node.attrs.formula && parseCellNumber(node.textContent, loc) != null;
|
||||
};
|
||||
if (rect.top > 0 && numeric({ row: rect.top - 1, col: rect.left })) return '=SUM(ABOVE)';
|
||||
if (rect.left > 0 && numeric({ row: rect.top, col: rect.left - 1 })) return '=SUM(LEFT)';
|
||||
return '=SUM(ABOVE)';
|
||||
}
|
||||
|
||||
// The cell the cursor is in, named the way a formula refers to it (LibreOffice shows
|
||||
// this in its status bar).
|
||||
export function currentCellName(state: EditorState): string | null {
|
||||
if (!isInTable(state)) return null;
|
||||
const rect = selectedRect(state);
|
||||
return refName({ row: rect.top, col: rect.left });
|
||||
}
|
||||
|
||||
// The formula of the cell the cursor is in, '' when it has none.
|
||||
export function currentCellFormula(state: EditorState): string {
|
||||
if (!isInTable(state)) return '';
|
||||
const rect = selectedRect(state);
|
||||
const node = cellGrid(rect.table).nodeAt({ row: rect.top, col: rect.left });
|
||||
const formula = node?.attrs.formula;
|
||||
return typeof formula === 'string' && formula ? `=${formula}` : '';
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import type { CommandProps } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { selectedRect, isInTable } from '@tiptap/pm/tables';
|
||||
import { cellGrid } from './tableFormula';
|
||||
import { parseCellNumber, type NumberLocale } from '../../utils/tableFormula';
|
||||
import { tableLanguage, tableNumberLocale } from '../../storage/tableOptions.svelte';
|
||||
|
||||
// LibreOffice's Table ▸ Sort and Word's Table Layout ▸ Sort, reduced to the one
|
||||
// sort key both offer first. The type is detected per cell rather than chosen:
|
||||
// a column of numbers sorts numerically, anything else by the document's collation.
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
tableSort: {
|
||||
sortTable: (options: TableSortOptions) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type TableSortOptions = {
|
||||
/** 0-based grid column the rows are ordered by. */
|
||||
column: number;
|
||||
descending: boolean;
|
||||
/** Keep the first row where it is, as both sort dialogs offer. */
|
||||
headerRow: boolean;
|
||||
};
|
||||
|
||||
// A vertically merged cell belongs to more than one row, so there are no rows to
|
||||
// reorder; both word processors refuse to sort such a table too.
|
||||
export function canSortTable(table: PMNode): boolean {
|
||||
if (table.childCount < 2) return false;
|
||||
let ok = true;
|
||||
table.forEach((row) => row.forEach((cell) => {
|
||||
if (((cell.attrs.rowspan as number) ?? 1) > 1) ok = false;
|
||||
}));
|
||||
return ok;
|
||||
}
|
||||
|
||||
export function sortedRows(
|
||||
table: PMNode,
|
||||
opts: TableSortOptions,
|
||||
loc: NumberLocale,
|
||||
collator: Intl.Collator,
|
||||
): PMNode[] {
|
||||
const grid = cellGrid(table);
|
||||
const rows: PMNode[] = [];
|
||||
table.forEach((row) => rows.push(row));
|
||||
const first = opts.headerRow ? 1 : 0;
|
||||
const keyed = rows.slice(first).map((row, i) => {
|
||||
const text = grid.nodeAt({ row: first + i, col: opts.column })?.textContent.trim() ?? '';
|
||||
return { row, text, num: parseCellNumber(text, loc) };
|
||||
});
|
||||
const dir = opts.descending ? -1 : 1;
|
||||
// Stable, so rows sharing a key keep the order they were typed in.
|
||||
keyed.sort((a, b) => {
|
||||
if (a.num != null && b.num != null) return dir * (a.num - b.num);
|
||||
// A number ahead of text: the order a mixed column sorts in has to be decided,
|
||||
// and this is the one every spreadsheet takes.
|
||||
if (a.num != null) return -dir;
|
||||
if (b.num != null) return dir;
|
||||
return dir * collator.compare(a.text, b.text);
|
||||
});
|
||||
return [...rows.slice(0, first), ...keyed.map((k) => k.row)];
|
||||
}
|
||||
|
||||
export const TableSort = Extension.create({
|
||||
name: 'tableSort',
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
sortTable:
|
||||
(options: TableSortOptions) =>
|
||||
({ state, dispatch }: CommandProps) => {
|
||||
if (!isInTable(state)) return false;
|
||||
const rect = selectedRect(state);
|
||||
if (!canSortTable(rect.table)) return false;
|
||||
if (dispatch) {
|
||||
const collator = new Intl.Collator(tableLanguage(), { numeric: true, sensitivity: 'variant' });
|
||||
const rows = sortedRows(rect.table, options, tableNumberLocale(), collator);
|
||||
const table = rect.table.type.create(rect.table.attrs, rows, rect.table.marks);
|
||||
const from = rect.tableStart - 1;
|
||||
dispatch(state.tr.replaceWith(from, from + rect.table.nodeSize, table));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -32,6 +32,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
|
||||
- **Captions** (`replaceSequenceFields`/`applySequenceFields`): a `SEQ` sentinel becomes a `<text:sequence>` carrying LibreOffice's own `text:formula="ooow:<name>+1"` (what makes its reader recount on load) plus a `<text:sequence-decl>` per counter used; DOCX gets a `SimpleField` `SEQ Figure \* ARABIC`. Both keep the editor's resolved rank as the field's cached result.
|
||||
- **Tracked changes** (`replaceRevisions`/`applyRevisions`): ODF splits the pair. An insertion keeps its text inline and is bracketed by `TCI` sentinels → `<text:change-start/>`/`<text:change-end/>`; a deletion's text is **cut out of the run** and left as one `TCD` → `<text:change/>`, the text itself going into the `<text:tracked-changes>` registry `applyRevisions` builds right after `<office:text>` (a `<text:deletion>` holds the deleted paragraphs, a `<text:insertion>` only its `<office:change-info>`). The registry's `<dc:creator>`/`<dc:date>` 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 `<text:bibliography-mark>` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `<text:bibliography>` 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.)
|
||||
- **Cell formulas** (`applyCellFormulas`, `formulaCellContent`): ODF puts the formula on the **cell**, so the pass walks `<table:table-cell` in the same document order `applyCellBlocks` does and writes `table:formula="ooow:…"` + the cached `office:value`. The `ooow:` is a namespace prefix, so `ensureOoowNamespace` declares it — undeclared, LibreOffice reads it as part of the formula. Word instead keeps a field *inside* the cell, so the DOCX side swaps the cell's first paragraph for one `SimpleField`. See `docs/architecture/tables.md`.
|
||||
- **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
|
||||
- **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base).
|
||||
- **`applyNestedListTypes`** — odf-kit emits nested lists as bare `<text:list>` 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.
|
||||
|
||||
+23
-1
@@ -534,6 +534,10 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
|
||||
const shown = String(node.attrs?.text || citationText(identifier));
|
||||
out.push(new SimpleField(`CITATION "${docxTag(identifier)}"`, shown));
|
||||
}
|
||||
} 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 ?? '')));
|
||||
} else if (node.type === 'formula') {
|
||||
const latex = typeof node.attrs?.latex === 'string' ? node.attrs.latex : '';
|
||||
if (latex) {
|
||||
@@ -1401,6 +1405,23 @@ function docxCellBorder(attrs: Record<string, unknown> | undefined, side: Border
|
||||
return cellBorder;
|
||||
}
|
||||
|
||||
// A synthetic inline node standing for the whole content of a formula cell; the
|
||||
// inline walker turns it into the field Word reads.
|
||||
const FORMULA_CELL = '__formula_cell__';
|
||||
|
||||
// The cell's blocks with its first paragraph replaced by that field, or null when the
|
||||
// cell carries no formula. Everything the paragraph itself sets is kept.
|
||||
function formulaCellContent(cell: TiptapNode): TiptapNode[] | null {
|
||||
const formula = typeof cell.attrs?.formula === 'string' ? cell.attrs.formula : '';
|
||||
const first = (cell.content ?? [])[0];
|
||||
if (!formula || !first || (first.type !== 'paragraph' && first.type !== 'heading')) return 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 } };
|
||||
return [{ ...first, content: [field] }, ...(cell.content ?? []).slice(1)];
|
||||
}
|
||||
|
||||
function cellBlocksToDocx(content: TiptapNode[] = [], force: TextProps, num: Numbering, contentWidthCm: number): (Paragraph | Table)[] {
|
||||
const out: (Paragraph | Table)[] = [];
|
||||
for (const child of content) {
|
||||
@@ -1448,6 +1469,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
|
||||
col += colspan;
|
||||
// The cell's own margins (w:tcMar); absent, Word falls back to the table's.
|
||||
const ownPad = parseCellPadding(cell.attrs?.cellPadding);
|
||||
const content = formulaCellContent(cell) ?? cell.content;
|
||||
cells.push(new TableCell({
|
||||
columnSpan: colspan > 1 ? colspan : undefined,
|
||||
rowSpan: rowspan > 1 ? rowspan : undefined,
|
||||
@@ -1465,7 +1487,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering):
|
||||
left: docxCellBorder(cell.attrs, 'borderLeft'),
|
||||
right: docxCellBorder(cell.attrs, 'borderRight'),
|
||||
},
|
||||
children: cellBlocksToDocx(cell.content, cellForce(tableStyle, cell, bg), num, contentWidthCm),
|
||||
children: cellBlocksToDocx(content, cellForce(tableStyle, cell, bg), num, contentWidthCm),
|
||||
}));
|
||||
}
|
||||
return new TableRow({
|
||||
|
||||
+98
-3
@@ -23,6 +23,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
|
||||
import { parseCellPadding, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
|
||||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
import { numberLocale, parseCellNumber, toWriterFormula, type CellRef, type NumberLocale } from '../utils/tableFormula';
|
||||
import { isShapeKind, odfEnhancedGeometry, type ShapeKind } from '../utils/shapes';
|
||||
import { normalizeLeader, parseTabStops } from '../editor/extensions/tabStops';
|
||||
import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
|
||||
@@ -3233,12 +3234,101 @@ function tablePropsOf(node: TiptapNode, contentWidthCm: number): TableProps | nu
|
||||
return { ml: round3(ml), mr: round3(mr), mt: round3(mt), mb: round3(mb), keepRows, repeatHeader };
|
||||
}
|
||||
|
||||
// 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 };
|
||||
|
||||
function ensureOoowNamespace(content: string): string {
|
||||
if (content.includes('xmlns:ooow=')) return content;
|
||||
return content.replace(/<office:document-content\b/, '<office:document-content xmlns:ooow="http://openoffice.org/2004/writer"');
|
||||
}
|
||||
|
||||
// One entry per real cell in document order (covered cells carry no formula and never
|
||||
// match the regex), the same walk applyCellBlocks makes.
|
||||
function applyCellFormulas(odtBytes: Uint8Array, cells: (CellFormulaExport | null)[]): Uint8Array {
|
||||
if (!cells.some(Boolean)) return odtBytes;
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
|
||||
let idx = 0;
|
||||
const content = strFromU8(contentBytes).replace(
|
||||
/<table:table-cell\b([^>]*?)(\/?)>/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 `<table:table-cell${attrs} table:formula="ooow:${escapeXml(cell.formula)}"${value}${selfClose}>`;
|
||||
},
|
||||
);
|
||||
// `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).
|
||||
files['content.xml'] = strToU8(ensureOoowNamespace(content));
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
at: Map<TiptapNode, CellRef>;
|
||||
rows: number;
|
||||
cols: number;
|
||||
text: (ref: CellRef) => string;
|
||||
};
|
||||
|
||||
function cellText(node: TiptapNode): string {
|
||||
if (node.type === 'text') return node.text ?? '';
|
||||
return (node.content ?? []).map(cellText).join('');
|
||||
}
|
||||
|
||||
function tableGridJson(rows: TiptapNode[]): CellGridJson {
|
||||
const slots: (string | undefined)[][] = [];
|
||||
const at = new Map<TiptapNode, CellRef>();
|
||||
rows.forEach((row, r) => {
|
||||
slots[r] ??= [];
|
||||
let c = 0;
|
||||
for (const cell of row.content ?? []) {
|
||||
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
|
||||
while (slots[r][c] !== undefined) c++;
|
||||
at.set(cell, { row: r, col: c });
|
||||
const text = cellText(cell);
|
||||
const colspan = (cell.attrs?.colspan as number) ?? 1;
|
||||
const rowspan = (cell.attrs?.rowspan as number) ?? 1;
|
||||
for (let dr = 0; dr < rowspan; dr++) {
|
||||
slots[r + dr] ??= [];
|
||||
for (let dc = 0; dc < colspan; dc++) slots[r + dr][c + dc] = text;
|
||||
}
|
||||
c += colspan;
|
||||
}
|
||||
});
|
||||
return {
|
||||
at,
|
||||
rows: rows.length,
|
||||
cols: Math.max(0, ...slots.map((s) => s.length)),
|
||||
text: (ref) => slots[ref.row]?.[ref.col] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
// The cell's formula in LibreOffice's own language, plus the result it caches.
|
||||
function cellFormulaOf(cell: TiptapNode, grid: CellGridJson, loc: NumberLocale): CellFormulaExport | null {
|
||||
const formula = typeof cell.attrs?.formula === 'string' ? cell.attrs.formula : '';
|
||||
const self = grid.at.get(cell);
|
||||
if (!formula || !self) return null;
|
||||
const ctx = {
|
||||
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) };
|
||||
}
|
||||
|
||||
// Build an ODF table from a CUST_TABLE node, bypassing odf-kit's native walkTable to
|
||||
// pass an explicit cell border (the native path emits none → invisible). Column widths
|
||||
// come from tableColumnWidthsCm; when absent odf-kit distributes columns evenly.
|
||||
function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][], tableMargins: (TableProps | null)[], tableStyleNames: (TableStyleRef | null)[]): void {
|
||||
function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][], tableMargins: (TableProps | null)[], tableStyleNames: (TableStyleRef | null)[], cellFormulas: (CellFormulaExport | null)[], numberLoc: NumberLocale): void {
|
||||
const rows = (node.content ?? []).filter(r => r.type === 'tableRow');
|
||||
if (rows.length === 0) return;
|
||||
const grid = tableGridJson(rows);
|
||||
const margins = tablePropsOf(node, contentWidthCm);
|
||||
tableMargins.push(margins);
|
||||
// The named table style, if the registry still knows it: its name goes on the table's
|
||||
@@ -3285,6 +3375,7 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number,
|
||||
// (presentational), so bake that onto the runs for Word/LibreOffice.
|
||||
const force = regionText(tableStyle, cell.attrs?.region);
|
||||
if (bg === HEADER_SHADE) force.bold = true;
|
||||
cellFormulas.push(cellFormulaOf(cell, grid, numberLoc));
|
||||
r.addCell((c: CellBuilder) => {
|
||||
cellBlocks.push(buildCellContent(cell, c, force));
|
||||
}, opts);
|
||||
@@ -4166,6 +4257,10 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const tableMargins: (TableProps | null)[] = [];
|
||||
// The named table style + its options per table, same order — applyTableStyleNames.
|
||||
const tableStyleNames: (TableStyleRef | null)[] = [];
|
||||
// One entry per cell, same order as cellBlocks — applyCellFormulas. A cell's number
|
||||
// is read in the document's language, as LibreOffice reads it.
|
||||
const cellFormulas: (CellFormulaExport | null)[] = [];
|
||||
const numberLoc = numberLocale(language ? `${language.language}-${language.country}` : 'en');
|
||||
|
||||
const odt = await tiptapToOdt(json, {
|
||||
// Orientation sets style:print-orientation; rewriteStylesXml overrides the exact
|
||||
@@ -4185,7 +4280,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
return;
|
||||
}
|
||||
if (node.type === CUST_TABLE) {
|
||||
exportTable(node, doc, contentWidthCm, cellBlocks, tableMargins, tableStyleNames);
|
||||
exportTable(node, doc, contentWidthCm, cellBlocks, tableMargins, tableStyleNames, cellFormulas, numberLoc);
|
||||
return;
|
||||
}
|
||||
const opts: {
|
||||
@@ -4296,7 +4391,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
|
||||
// Rebuild real headings/lists/paragraphs inside table cells. Must run after
|
||||
// applyListItemStyles (cell lists don't exist yet) and before collapseRunWhitespace.
|
||||
const styledCells = applyCellBlocks(indentedLists, cellBlocks);
|
||||
const styledCells = applyCellFormulas(applyCellBlocks(indentedLists, cellBlocks), cellFormulas);
|
||||
|
||||
const rowHeights: (string | null)[] = [];
|
||||
collectTableRowHeights(raw, rowHeights);
|
||||
|
||||
@@ -375,6 +375,22 @@ const de: Messages = {
|
||||
columns: 'Spalten',
|
||||
rowsField: 'Zeilen',
|
||||
split: 'Teilen',
|
||||
sort: 'Sortieren…',
|
||||
sortAria: 'Sortieren',
|
||||
sortDialogLabel: 'Tabelle sortieren',
|
||||
sortBy: 'Sortieren nach',
|
||||
sortAscending: 'Aufsteigend',
|
||||
sortDescending: 'Absteigend',
|
||||
sortHeaderRow: 'Erste Zeile ist eine Kopfzeile',
|
||||
formula: 'Formel…',
|
||||
formulaAria: 'Formel',
|
||||
formulaDialogLabel: 'Tabellenformel',
|
||||
formulaField: 'Formel',
|
||||
formulaCell: (name: string) => `Zelle ${name}`,
|
||||
// LibreOffice' eigener Wortlaut für eine Formel, die es nicht lesen kann.
|
||||
formulaError: '** Ausdruck ist fehlerhaft **',
|
||||
numberRecognition: 'Zahlenerkennung',
|
||||
numberRecognitionHint: 'Eine Zelle, die eine Zahl enthält, als Zahl lesen',
|
||||
// No trailing ellipsis here (unlike the other dialog-opening entries): the
|
||||
// row truncates, so CSS adds the dots only when the label doesn't fit.
|
||||
manageStyles: 'Tabellenvorlagen verwalten',
|
||||
@@ -672,6 +688,7 @@ const de: Messages = {
|
||||
rowsColumns: 'Zeilen & Spalten',
|
||||
merge: 'Zusammenführen',
|
||||
cellSize: 'Zellengröße',
|
||||
tableData: 'Daten',
|
||||
arrange: 'Anordnen',
|
||||
shapeStyles: 'Formenarten',
|
||||
accessibility: 'Barrierefreiheit',
|
||||
|
||||
@@ -379,6 +379,22 @@ const en = {
|
||||
columns: 'Columns',
|
||||
rowsField: 'Rows',
|
||||
split: 'Split',
|
||||
sort: 'Sort…',
|
||||
sortAria: 'Sort',
|
||||
sortDialogLabel: 'Sort table',
|
||||
sortBy: 'Sort by',
|
||||
sortAscending: 'Ascending',
|
||||
sortDescending: 'Descending',
|
||||
sortHeaderRow: 'First row is a header',
|
||||
formula: 'Formula…',
|
||||
formulaAria: 'Formula',
|
||||
formulaDialogLabel: 'Table formula',
|
||||
formulaField: 'Formula',
|
||||
formulaCell: (name: string) => `Cell ${name}`,
|
||||
// LibreOffice's own wording for a formula it cannot parse.
|
||||
formulaError: '** Expression is faulty **',
|
||||
numberRecognition: 'Number recognition',
|
||||
numberRecognitionHint: 'Read a cell that holds a number as a number',
|
||||
// No trailing ellipsis here (unlike the other dialog-opening entries): the
|
||||
// row truncates, so CSS adds the dots only when the label doesn't fit.
|
||||
manageStyles: 'Manage table styles',
|
||||
@@ -676,6 +692,7 @@ const en = {
|
||||
rowsColumns: 'Rows & Columns',
|
||||
merge: 'Merge',
|
||||
cellSize: 'Cell Size',
|
||||
tableData: 'Data',
|
||||
arrange: 'Arrange',
|
||||
shapeStyles: 'Shape Styles',
|
||||
accessibility: 'Accessibility',
|
||||
|
||||
@@ -26,6 +26,7 @@ import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImpor
|
||||
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 { 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';
|
||||
@@ -354,6 +355,16 @@ 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 {
|
||||
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) : '';
|
||||
}
|
||||
|
||||
function scanTocField(p: Element, st: TocFieldState): { emit: boolean } {
|
||||
let emit = false;
|
||||
for (const r of Array.from(p.getElementsByTagNameNS(W, 'r'))) {
|
||||
@@ -2179,6 +2190,10 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
|
||||
// undefined (nothing declared) and null (= editor default) both leave the attr off.
|
||||
for (const [attr, v] of sides) if (typeof v === 'string') attrs[attr] = v;
|
||||
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);
|
||||
if (formula) attrs.formula = formula;
|
||||
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;
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { PageDecor } from '../storage/pageDecor';
|
||||
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 { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import type { Schema } from '@tiptap/pm/model';
|
||||
@@ -2382,8 +2383,13 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
|
||||
// Header-shaded cells render bold by default (CSS); convert their runs like headings
|
||||
// so a baked-bold run needs no mark and only an explicitly normal run gets one.
|
||||
const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell', backgroundColor === HEADER_SHADE);
|
||||
// LibreOffice writes the formula in its own language behind an `ooow:` prefix;
|
||||
// the cached result is the cell's text and needs nothing here.
|
||||
const rawFormula = cellEl.getAttributeNS(NS.table, 'formula');
|
||||
const formula = rawFormula ? fromWriterFormula(rawFormula) : '';
|
||||
for (let r = 0; r < repeated; r++) {
|
||||
const attrs: Record<string, unknown> = { colspan, rowspan, ...borders };
|
||||
if (formula) attrs.formula = formula;
|
||||
if (ownPad) attrs.cellPadding = ownPad;
|
||||
if (backgroundColor) attrs.backgroundColor = backgroundColor;
|
||||
if (verticalAlign) attrs.verticalAlign = verticalAlign;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- **`writingMode.ts`** — the page's text direction (`loadPageRtl`/`savePageRtl`, `edentext-page-rtl`), from ODF `style:writing-mode="rl-tb"` or Word's section-level `w:bidi`. `Editor.svelte` puts it on the body host as `dir`, which is what makes a multi-column page fill from the right; the header/footer band is left alone (LibreOffice lays those out unchanged — measured). The vertical modes are not read.
|
||||
- **`pageNumbering.ts`** — the page-number field's format and start value (`edentext-page-numbering`, absent at the 1/1 default). `HeaderFooterLayer.svelte` formats each page through `formatOrdinal`; the page **count** stays decimal, as the field both word processors write does. ODF keeps the format on the page layout and the start on the first paragraph's own style (`style:page-number` — probed, ODF has no document-level start); Word keeps both in the first section's `w:pgNumType`.
|
||||
- **`hyphenation.ts`** — automatic hyphenation for the whole document (`edentext-hyphenation`, off by default as in both word processors). `Editor.svelte` puts `hyphens: auto` and the document `lang` on the body host — the browser needs the language to pick its patterns. ODF keeps it as `fo:hyphenate` in the **text** properties of the base paragraph style (probed: in paragraph-properties LibreOffice ignores it and drops it on the next save), Word as `w:autoHyphenation` in settings.xml.
|
||||
- **`tableOptions.svelte.ts`** — table number recognition (`edentext-number-recognition`, the key removed when off, as it is by default in LibreOffice). A reactive singleton like `trackChanges.svelte.ts`: the extension reads it on every transaction, both table surfaces flip it. It also holds the **number locale** — the document's language, which is what LibreOffice reads and writes a cell's number in — pushed by `App.svelte`'s language effect; the exporters take that locale from their own `language` argument instead, so they stay store-free. See `docs/architecture/tables.md`.
|
||||
- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `<draw:custom-shape>` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`.
|
||||
- **`trackChanges.svelte.ts`** — whether the editor records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks.
|
||||
- **`lineNumbering.ts`** — numbers in the left margin (`edentext-line-numbering`, the key removed when off, as it is by default in both word processors). ODF keeps one `<text:linenumbering-configuration>` in `office:styles`, Word a `<w:lnNumType>` per `w:sectPr` (probed). `LineNumberLayer.svelte` measures the lines: CSS exposes no line boxes, so a Range per block is taken and its distinct client-rect tops are the lines.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// LibreOffice's Table ▸ Number Recognition, **off** by default there (its
|
||||
// Writer/Table/Input/NumberRecognition schema default) and so here. The locale a
|
||||
// cell's number is read and written in is the document's language, as it is there.
|
||||
|
||||
import { loadDocumentLanguage, NO_LANGUAGE } from './documentLanguage';
|
||||
import { numberLocale, type NumberLocale } from '../utils/tableFormula';
|
||||
|
||||
const KEY = 'edentext-number-recognition';
|
||||
|
||||
let recognize = $state(localStorage.getItem(KEY) === 'true');
|
||||
let lang = $state(loadDocumentLanguage());
|
||||
const loc = $derived(numberLocale(lang === NO_LANGUAGE ? 'en' : lang));
|
||||
|
||||
export function numberRecognition(): boolean {
|
||||
return recognize;
|
||||
}
|
||||
|
||||
export function setNumberRecognition(on: boolean): void {
|
||||
recognize = on;
|
||||
if (on) localStorage.setItem(KEY, 'true');
|
||||
else localStorage.removeItem(KEY);
|
||||
}
|
||||
|
||||
export function setTableLanguage(code: string): void {
|
||||
lang = code;
|
||||
}
|
||||
|
||||
export function tableLanguage(): string {
|
||||
return lang === NO_LANGUAGE ? 'en' : lang;
|
||||
}
|
||||
|
||||
export function tableNumberLocale(): NumberLocale {
|
||||
return loc;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Table cell formulas and number recognition. The stored language is Word's
|
||||
// (`SUM(ABOVE)`, `A1*2`, no leading `=`), the wider of the two; the two translations
|
||||
// below cover the ODF leg, where LibreOffice writes `ooow:sum <A1:A3>`.
|
||||
|
||||
export type NumberLocale = { decimal: string; group: string };
|
||||
|
||||
// The separators the document's language uses, from Intl rather than a table.
|
||||
export function numberLocale(lang: string): NumberLocale {
|
||||
const parts = new Intl.NumberFormat(lang || 'en').formatToParts(1234.5);
|
||||
return {
|
||||
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
||||
group: parts.find((p) => p.type === 'group')?.value ?? ',',
|
||||
};
|
||||
}
|
||||
|
||||
// A cell's text as a number, or null when it is text. Spaces (incl. NBSP and the
|
||||
// narrow one Intl groups with) and the group separator are noise; anything else
|
||||
// beyond one sign and one decimal separator makes it text, as in LibreOffice.
|
||||
export function parseCellNumber(text: string, loc: NumberLocale): number | null {
|
||||
let s = text.replace(/[\s ]/g, '');
|
||||
if (loc.group) s = s.split(loc.group).join('');
|
||||
if (!s) return null;
|
||||
if (loc.decimal !== '.') s = s.split(loc.decimal).join('.');
|
||||
if (!/^[+-]?(\d+(\.\d*)?|\.\d+)$/.test(s)) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// General number format: no grouping, the locale's decimal separator, and the
|
||||
// float noise of a sum rounded off.
|
||||
export function formatCellNumber(n: number, loc: NumberLocale): string {
|
||||
if (!Number.isFinite(n)) return '';
|
||||
const s = String(Math.round(n * 1e10) / 1e10);
|
||||
return loc.decimal === '.' ? s : s.replace('.', loc.decimal);
|
||||
}
|
||||
|
||||
export type CellRef = { row: number; col: number };
|
||||
|
||||
export type FormulaCtx = {
|
||||
rows: number;
|
||||
cols: number;
|
||||
self: CellRef;
|
||||
// null = the cell holds no number, which stops a directional walk.
|
||||
valueAt: (ref: CellRef) => number | null;
|
||||
};
|
||||
|
||||
export function colName(col: number): string {
|
||||
let s = '';
|
||||
for (let n = col; n >= 0; n = Math.floor(n / 26) - 1) s = String.fromCharCode(65 + (n % 26)) + s;
|
||||
return s;
|
||||
}
|
||||
|
||||
export function parseRef(s: string): CellRef | null {
|
||||
const m = /^([A-Z]+)(\d+)$/.exec(s.toUpperCase());
|
||||
if (!m) return null;
|
||||
let col = 0;
|
||||
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||
return { row: Number(m[2]) - 1, col: col - 1 };
|
||||
}
|
||||
|
||||
export const refName = (r: CellRef): string => `${colName(r.col)}${r.row + 1}`;
|
||||
|
||||
const DIRECTIONS = ['ABOVE', 'BELOW', 'LEFT', 'RIGHT'] as const;
|
||||
type Direction = (typeof DIRECTIONS)[number];
|
||||
|
||||
const FUNCS: Record<string, (v: number[]) => number> = {
|
||||
SUM: (v) => v.reduce((a, b) => a + b, 0),
|
||||
PRODUCT: (v) => v.reduce((a, b) => a * b, 1),
|
||||
AVERAGE: (v) => (v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0),
|
||||
MIN: (v) => (v.length ? Math.min(...v) : 0),
|
||||
MAX: (v) => (v.length ? Math.max(...v) : 0),
|
||||
COUNT: (v) => v.length,
|
||||
ABS: (v) => Math.abs(v[0] ?? 0),
|
||||
INT: (v) => Math.trunc(v[0] ?? 0),
|
||||
SIGN: (v) => Math.sign(v[0] ?? 0),
|
||||
MOD: (v) => (v[1] ? (v[0] ?? 0) % v[1] : 0),
|
||||
ROUND: (v) => {
|
||||
const f = 10 ** Math.trunc(v[1] ?? 0);
|
||||
return Math.round((v[0] ?? 0) * f) / f;
|
||||
},
|
||||
};
|
||||
|
||||
export const FORMULA_FUNCTIONS = Object.keys(FUNCS);
|
||||
|
||||
// LibreOffice's own names for the ones it spells differently.
|
||||
const WRITER_FUNC: Record<string, string> = { AVERAGE: 'mean' };
|
||||
const FROM_WRITER_FUNC: Record<string, string> = { MEAN: 'AVERAGE' };
|
||||
|
||||
type Token = { kind: 'num'; value: number } | { kind: 'name'; value: string } | { kind: 'ref'; value: string } | { kind: 'op'; value: string };
|
||||
|
||||
// One tokenizer for both evaluation and the two translations, so a formula the
|
||||
// evaluator accepts is exactly one an export can rewrite.
|
||||
function tokenize(src: string): Token[] | null {
|
||||
const out: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (/\s/.test(c)) { i++; continue; }
|
||||
if (/[0-9.]/.test(c)) {
|
||||
const m = /^\d*\.?\d+/.exec(src.slice(i));
|
||||
if (!m) return null;
|
||||
out.push({ kind: 'num', value: Number(m[0]) });
|
||||
i += m[0].length;
|
||||
} else if (/[A-Za-z]/.test(c)) {
|
||||
const m = /^[A-Za-z]+\d*/.exec(src.slice(i))!;
|
||||
const word = m[0].toUpperCase();
|
||||
out.push(/\d/.test(word) ? { kind: 'ref', value: word } : { kind: 'name', value: word });
|
||||
i += m[0].length;
|
||||
} else if ('+-*/(),;:%|'.includes(c)) {
|
||||
out.push({ kind: 'op', value: c === ';' || c === '|' ? ',' : c });
|
||||
i++;
|
||||
} else return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
constructor(private readonly toks: Token[], private readonly ctx: FormulaCtx) {}
|
||||
|
||||
parse(): number {
|
||||
const v = this.expr();
|
||||
if (this.i < this.toks.length) throw new Error('trailing');
|
||||
return v;
|
||||
}
|
||||
|
||||
private at(value: string): boolean {
|
||||
const t = this.toks[this.i];
|
||||
return !!t && t.kind === 'op' && t.value === value;
|
||||
}
|
||||
|
||||
private expr(): number {
|
||||
let v = this.term();
|
||||
for (;;) {
|
||||
if (this.at('+')) { this.i++; v += this.term(); }
|
||||
else if (this.at('-')) { this.i++; v -= this.term(); }
|
||||
else return v;
|
||||
}
|
||||
}
|
||||
|
||||
private term(): number {
|
||||
let v = this.unary();
|
||||
for (;;) {
|
||||
if (this.at('*')) { this.i++; v *= this.unary(); }
|
||||
else if (this.at('/')) {
|
||||
this.i++;
|
||||
const d = this.unary();
|
||||
if (d === 0) throw new Error('div0');
|
||||
v /= d;
|
||||
} else return v;
|
||||
}
|
||||
}
|
||||
|
||||
private unary(): number {
|
||||
if (this.at('-')) { this.i++; return -this.unary(); }
|
||||
if (this.at('+')) { this.i++; return this.unary(); }
|
||||
let v = this.primary();
|
||||
while (this.at('%')) { this.i++; v /= 100; }
|
||||
return v;
|
||||
}
|
||||
|
||||
private primary(): number {
|
||||
const t = this.toks[this.i];
|
||||
if (!t) throw new Error('eof');
|
||||
if (t.kind === 'num') { this.i++; return t.value; }
|
||||
if (this.at('(')) {
|
||||
this.i++;
|
||||
const v = this.expr();
|
||||
if (!this.at(')')) throw new Error('paren');
|
||||
this.i++;
|
||||
return v;
|
||||
}
|
||||
if (t.kind === 'ref') { this.i++; return this.cells(t.value).reduce((a, b) => a + b, 0); }
|
||||
if (t.kind === 'name') {
|
||||
this.i++;
|
||||
const fn = FUNCS[t.value];
|
||||
if (!fn) return this.direction(t.value).reduce((a, b) => a + b, 0);
|
||||
// Both `SUM(A1:A3)` and LibreOffice's parenthesis-free `sum <A1:A3>` arrive
|
||||
// here as the same tokens, so the brackets are optional.
|
||||
const braced = this.at('(');
|
||||
if (braced) this.i++;
|
||||
const args: number[] = [];
|
||||
for (;;) {
|
||||
const a = this.toks[this.i];
|
||||
if (!a || (a.kind === 'op' && a.value === ')')) break;
|
||||
if (a.kind === 'ref' || (a.kind === 'name' && !FUNCS[a.value])) {
|
||||
this.i++;
|
||||
args.push(...(a.kind === 'ref' ? this.cells(a.value) : this.direction(a.value)));
|
||||
} else args.push(this.expr());
|
||||
if (this.at(',')) this.i++;
|
||||
else break;
|
||||
}
|
||||
if (braced) {
|
||||
if (!this.at(')')) throw new Error('paren');
|
||||
this.i++;
|
||||
}
|
||||
return fn(args);
|
||||
}
|
||||
throw new Error('token');
|
||||
}
|
||||
|
||||
// A reference or, with a `:` behind it, a rectangular range. A cell holding no
|
||||
// number counts as nothing, which is what makes AVERAGE ignore empty cells.
|
||||
private cells(name: string): number[] {
|
||||
const from = parseRef(name);
|
||||
if (!from) throw new Error('ref');
|
||||
let to = from;
|
||||
if (this.at(':')) {
|
||||
this.i++;
|
||||
const t = this.toks[this.i];
|
||||
if (!t || t.kind !== 'ref') throw new Error('range');
|
||||
this.i++;
|
||||
const end = parseRef(t.value);
|
||||
if (!end) throw new Error('range');
|
||||
to = end;
|
||||
}
|
||||
const out: number[] = [];
|
||||
for (let r = Math.min(from.row, to.row); r <= Math.max(from.row, to.row); r++) {
|
||||
for (let c = Math.min(from.col, to.col); c <= Math.max(from.col, to.col); c++) {
|
||||
const v = this.ctx.valueAt({ row: r, col: c });
|
||||
if (v != null) out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Word's ABOVE/LEFT/BELOW/RIGHT: the run of numbers next to the formula cell,
|
||||
// stopping at the first cell that holds none.
|
||||
private direction(name: string): number[] {
|
||||
if (!(DIRECTIONS as readonly string[]).includes(name)) throw new Error('name');
|
||||
const out: number[] = [];
|
||||
for (const ref of walk(name as Direction, this.ctx)) {
|
||||
const v = this.ctx.valueAt(ref);
|
||||
if (v == null) break;
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir: Direction, ctx: FormulaCtx): CellRef[] {
|
||||
const { row, col } = ctx.self;
|
||||
const out: CellRef[] = [];
|
||||
if (dir === 'ABOVE') for (let r = row - 1; r >= 0; r--) out.push({ row: r, col });
|
||||
else if (dir === 'BELOW') for (let r = row + 1; r < ctx.rows; r++) out.push({ row: r, col });
|
||||
else if (dir === 'LEFT') for (let c = col - 1; c >= 0; c--) out.push({ row, col: c });
|
||||
else for (let c = col + 1; c < ctx.cols; c++) out.push({ row, col: c });
|
||||
return out;
|
||||
}
|
||||
|
||||
// null = the formula does not parse, which both word processors print as an error.
|
||||
export function evalFormula(formula: string, ctx: FormulaCtx): number | null {
|
||||
const toks = tokenize(formula.replace(/^=/, '').replace(/[<>]/g, ''));
|
||||
if (!toks || toks.length === 0) return null;
|
||||
try {
|
||||
return new Parser(toks, ctx).parse();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The range a direction stands for, so the ODF leg can name cells: LibreOffice's
|
||||
// formula language has no ABOVE. An empty run collapses to the cell itself.
|
||||
function resolveDirection(name: string, ctx: FormulaCtx): string {
|
||||
if (!(DIRECTIONS as readonly string[]).includes(name)) return name;
|
||||
const run: CellRef[] = [];
|
||||
for (const ref of walk(name as Direction, ctx)) {
|
||||
if (ctx.valueAt(ref) == null) break;
|
||||
run.push(ref);
|
||||
}
|
||||
if (run.length === 0) return refName(ctx.self);
|
||||
const [a, b] = [refName(run[run.length - 1]), refName(run[0])];
|
||||
return a === b ? a : `${a}:${b}`;
|
||||
}
|
||||
|
||||
const opAt = (toks: Token[], i: number, value: string): boolean => {
|
||||
const t = toks[i];
|
||||
return !!t && t.kind === 'op' && t.value === value;
|
||||
};
|
||||
|
||||
// `SUM(ABOVE)` → `sum <A1:A3>`: LibreOffice brackets every reference, separates a
|
||||
// list with `|` and writes a function's argument without parentheses.
|
||||
export function toWriterFormula(formula: string, ctx: FormulaCtx): string {
|
||||
const toks = tokenize(formula.replace(/^=/, '').replace(/[<>]/g, ''));
|
||||
if (!toks) return formula;
|
||||
// One entry per open bracket: true = a call's, which LibreOffice does not write.
|
||||
const swallowed: boolean[] = [];
|
||||
let out = '';
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i];
|
||||
if (t.kind === 'num') out += String(t.value);
|
||||
else if (t.kind === 'ref') {
|
||||
const range = opAt(toks, i + 1, ':') && toks[i + 2]?.kind === 'ref';
|
||||
out += range ? `<${t.value}:${toks[i + 2].value}>` : `<${t.value}>`;
|
||||
if (range) i += 2;
|
||||
} else if (t.kind === 'name') {
|
||||
if (!FUNCS[t.value]) { out += `<${resolveDirection(t.value, ctx)}>`; continue; }
|
||||
out += `${WRITER_FUNC[t.value] ?? t.value.toLowerCase()} `;
|
||||
if (opAt(toks, i + 1, '(')) { i++; swallowed.push(true); }
|
||||
} else if (t.value === '(') { swallowed.push(false); out += '('; }
|
||||
else if (t.value === ')') { if (!swallowed.pop()) out += ')'; }
|
||||
else out += t.value === ',' ? '|' : t.value;
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
// The reverse, for both foreign legs: LibreOffice's `ooow:sum <A1:A3>` and Word's
|
||||
// `=SUM(A1:A3)` both land back on the stored form.
|
||||
export function fromWriterFormula(raw: string): string {
|
||||
const body = raw.replace(/^\s*(ooow:|of:)+/, '').replace(/^\s*=/, '').trim();
|
||||
const toks = tokenize(body.replace(/[<>]/g, ''));
|
||||
if (!toks) return body;
|
||||
let open = 0;
|
||||
let out = '';
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i];
|
||||
if (t.kind === 'num') out += String(t.value);
|
||||
else if (t.kind === 'ref') out += t.value;
|
||||
else if (t.kind === 'name') {
|
||||
const name = FROM_WRITER_FUNC[t.value] ?? t.value;
|
||||
if (!FUNCS[name]) { out += name; continue; }
|
||||
out += `${name}(`;
|
||||
// Only a call LibreOffice wrote without brackets still needs one closed.
|
||||
if (opAt(toks, i + 1, '(')) i++;
|
||||
else open++;
|
||||
} else out += t.value;
|
||||
}
|
||||
return out + ')'.repeat(open);
|
||||
}
|
||||
@@ -988,6 +988,12 @@
|
||||
background: var(--color-field-shade, rgba(0, 0, 0, 0.18));
|
||||
}
|
||||
|
||||
/* A cell holding a formula shows its result on the field shade, since that result is
|
||||
recomputed and typing over it does not last. Word draws the field the same way. */
|
||||
.tiptap :is(td, th)[data-formula] {
|
||||
background-image: linear-gradient(var(--color-field-shade, rgba(0, 0, 0, 0.08)) 0 100%);
|
||||
}
|
||||
|
||||
/* A citation is a field: it shows its text on the field shade both word processors use. */
|
||||
.tiptap .pm-citation {
|
||||
background: var(--color-field-shade, rgba(0, 0, 0, 0.08));
|
||||
|
||||
@@ -2412,3 +2412,45 @@ describe('Leg 27: per-section page size and orientation (ODT + DOCX)', () => {
|
||||
check('the A5 section round-trips', paperOf(res, 2) === 'A5/-', paperOf(res, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 28: a cell formula (ODT + DOCX)', () => {
|
||||
// A1..A3 hold the numbers, A4 sums them the way Word names the range.
|
||||
const num = (text: string): N => ({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [100] }, content: [P(null, T(text))] });
|
||||
const sum: N = {
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: [100], formula: 'SUM(ABOVE)' },
|
||||
content: [P(null, T('19.5'))],
|
||||
};
|
||||
const doc: N = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
P(null, T('before')),
|
||||
{ type: 'table', content: [ROW(num('10')), ROW(num('2.5')), ROW(num('7')), ROW(sum)] },
|
||||
P(null, T('after')),
|
||||
],
|
||||
};
|
||||
const formulaOf = (res: N): unknown =>
|
||||
(res.content.content ?? []).find((n: N) => n.type === 'table')?.content?.[3]?.content?.[0]?.attrs?.formula;
|
||||
|
||||
it('ODT: the formula rides the cell, in LibreOffice\'s own language', async () => {
|
||||
const bytes = await buildOdt(doc, margins);
|
||||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
const cell = /<table:table-cell[^>]*table:formula="[^"]*"[^>]*>/.exec(xml)?.[0] ?? '';
|
||||
check('ABOVE is resolved to the range it stands for', /table:formula="ooow:sum <A1:A3>"/.test(cell), cell);
|
||||
check('and the result is cached as the cell value', /office:value-type="float" office:value="19.5"/.test(cell), cell);
|
||||
|
||||
const res = importOdt(bytes);
|
||||
check('it comes back as the stored formula', formulaOf(res) === 'SUM(A1:A3)', formulaOf(res));
|
||||
});
|
||||
|
||||
it('DOCX: the cell holds a formula field', async () => {
|
||||
const bytes = await buildDocx(doc, margins);
|
||||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||||
const field = /<w:fldSimple[^>]*w:instr="[^"]*=[^"]*"[^>]*>[\s\S]*?<\/w:fldSimple>/.exec(xml)?.[0] ?? '';
|
||||
check('the field is the formula Word reads', /w:instr="\s*=SUM\(ABOVE\)\s*"/.test(field), field);
|
||||
check('with the result cached in it', />19\.5</.test(field), field);
|
||||
|
||||
const res = importDocx(bytes);
|
||||
check('it comes back as the stored formula', formulaOf(res) === 'SUM(ABOVE)', formulaOf(res));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// The cell-formula language and number recognition: the pure half (parse, evaluate,
|
||||
// translate to and from LibreOffice's own syntax) plus the two commands that read a
|
||||
// real table — evaluation over a ProseMirror doc, and the row sort.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import Document from '@tiptap/extension-document';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
|
||||
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
|
||||
import { TableFormula, evaluateTable } from '../../src/lib/editor/extensions/tableFormula';
|
||||
import { sortedRows, canSortTable } from '../../src/lib/editor/extensions/tableSort';
|
||||
import {
|
||||
numberLocale, parseCellNumber, formatCellNumber, evalFormula,
|
||||
toWriterFormula, fromWriterFormula, colName, parseRef,
|
||||
} from '../../src/lib/utils/tableFormula';
|
||||
|
||||
type N = any;
|
||||
|
||||
const EN = numberLocale('en');
|
||||
const DE = numberLocale('de');
|
||||
|
||||
const schema = getSchema([Document, Paragraph, Text, Table, ResizableTableRow, TableCell, TableHeader, TableFormula]);
|
||||
|
||||
const cell = (text: string, formula: string | null = null): N => ({
|
||||
type: 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null, formula },
|
||||
content: [{ type: 'paragraph', content: text ? [{ type: 'text', text }] : [] }],
|
||||
});
|
||||
const table = (...rows: N[][]): N =>
|
||||
schema.nodeFromJSON({ type: 'table', content: rows.map((cells) => ({ type: 'tableRow', content: cells })) });
|
||||
|
||||
// The grid of a one-column table, as a formula reads it.
|
||||
const column = (...texts: string[]): N[][] => texts.map((t) => [cell(t)]);
|
||||
|
||||
describe('number recognition', () => {
|
||||
it('reads a number in the document\'s own language', () => {
|
||||
expect(parseCellNumber('1234.5', EN)).toBe(1234.5);
|
||||
expect(parseCellNumber('1,234.5', EN)).toBe(1234.5);
|
||||
expect(parseCellNumber('1.234,5', DE)).toBe(1234.5);
|
||||
expect(parseCellNumber('-7', EN)).toBe(-7);
|
||||
expect(parseCellNumber(' 42 ', EN)).toBe(42);
|
||||
});
|
||||
|
||||
it('leaves text alone', () => {
|
||||
for (const text of ['', 'alpha', '12 apples', '1.2.3', '5%', '€5']) {
|
||||
expect(parseCellNumber(text, EN), text).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('writes it back in the general number format', () => {
|
||||
expect(formatCellNumber(19.5, EN)).toBe('19.5');
|
||||
expect(formatCellNumber(19.5, DE)).toBe('19,5');
|
||||
// The float noise of a sum, rounded off rather than printed.
|
||||
expect(formatCellNumber(0.1 + 0.2, EN)).toBe('0.3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evalFormula', () => {
|
||||
const grid = [[10, null], [2.5, null], [7, null], [null, null]];
|
||||
const ctx = {
|
||||
rows: 4, cols: 2, self: { row: 3, col: 0 },
|
||||
valueAt: ({ row, col }: { row: number; col: number }) => grid[row]?.[col] ?? null,
|
||||
};
|
||||
|
||||
it('sums the run above the cell', () => {
|
||||
expect(evalFormula('SUM(ABOVE)', ctx)).toBe(19.5);
|
||||
expect(evalFormula('AVERAGE(A1:A3)', ctx)).toBe(6.5);
|
||||
expect(evalFormula('MAX(A1:A3)', ctx)).toBe(10);
|
||||
expect(evalFormula('COUNT(A1:A3)', ctx)).toBe(3);
|
||||
});
|
||||
|
||||
it('stops a direction at the first cell holding no number', () => {
|
||||
const gap = { ...ctx, self: { row: 3, col: 0 }, valueAt: (r: { row: number; col: number }) => (r.row === 1 ? null : grid[r.row]?.[r.col] ?? null) };
|
||||
expect(evalFormula('SUM(ABOVE)', gap)).toBe(7);
|
||||
// A range spans the gap; only a directional walk stops at it.
|
||||
expect(evalFormula('SUM(A1:A3)', gap)).toBe(17);
|
||||
});
|
||||
|
||||
it('does arithmetic, with the usual precedence', () => {
|
||||
expect(evalFormula('A1*2', ctx)).toBe(20);
|
||||
expect(evalFormula('A1+A2*2', ctx)).toBe(15);
|
||||
expect(evalFormula('(A1+A2)*2', ctx)).toBe(25);
|
||||
expect(evalFormula('=SUM(A1:A3)/3', ctx)).toBe(6.5);
|
||||
});
|
||||
|
||||
it('reports a formula it cannot read', () => {
|
||||
for (const bad of ['', 'SUM(', 'A1+', 'NOPE(A1)', 'A1/0', '#$%']) {
|
||||
expect(evalFormula(bad, ctx), bad).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('translating to and from LibreOffice', () => {
|
||||
const ctx = {
|
||||
rows: 4, cols: 1, self: { row: 3, col: 0 },
|
||||
valueAt: ({ row }: { row: number; col: number }) => (row < 3 ? 1 : null),
|
||||
};
|
||||
|
||||
it('resolves a direction, which LibreOffice has no word for', () => {
|
||||
expect(toWriterFormula('SUM(ABOVE)', ctx)).toBe('sum <A1:A3>');
|
||||
expect(toWriterFormula('AVERAGE(A1:A3)', ctx)).toBe('mean <A1:A3>');
|
||||
expect(toWriterFormula('A1*2', ctx)).toBe('<A1>*2');
|
||||
expect(toWriterFormula('SUM(A1,A2)', ctx)).toBe('sum <A1>|<A2>');
|
||||
expect(toWriterFormula('SUM((A1+A2)*2)', ctx)).toBe('sum (<A1>+<A2>)*2');
|
||||
});
|
||||
|
||||
it('reads back what either word processor wrote', () => {
|
||||
expect(fromWriterFormula('ooow:sum <A1:A3>')).toBe('SUM(A1:A3)');
|
||||
expect(fromWriterFormula('ooow:mean <A1:A3>')).toBe('AVERAGE(A1:A3)');
|
||||
expect(fromWriterFormula('ooow:<A1>*2')).toBe('A1*2');
|
||||
// Word's own field, and the one LibreOffice's DOCX export writes.
|
||||
expect(fromWriterFormula(' =SUM(ABOVE) ')).toBe('SUM(ABOVE)');
|
||||
expect(fromWriterFormula(' =sum (A1:A3)')).toBe('SUM(A1:A3)');
|
||||
});
|
||||
|
||||
it('names a cell the way a formula refers to it', () => {
|
||||
expect(colName(0)).toBe('A');
|
||||
expect(colName(26)).toBe('AA');
|
||||
expect(parseRef('B3')).toEqual({ row: 2, col: 1 });
|
||||
expect(parseRef('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateTable', () => {
|
||||
const ERR = '!';
|
||||
|
||||
it('fills every formula cell from the cells it reads', () => {
|
||||
const t = table(...column('10', '2.5', '7'), [cell('', 'SUM(ABOVE)')]);
|
||||
expect([...evaluateTable(t, EN, ERR).values()]).toEqual(['19.5']);
|
||||
});
|
||||
|
||||
it('resolves a formula that reads another formula', () => {
|
||||
const t = table([cell('4')], [cell('', 'SUM(ABOVE)')], [cell('', 'A2*3')]);
|
||||
expect([...evaluateTable(t, EN, ERR).values()]).toEqual(['4', '12']);
|
||||
});
|
||||
|
||||
it('does not loop on a formula that reads itself', () => {
|
||||
const t = table([cell('', 'A2')], [cell('', 'A1')]);
|
||||
expect([...evaluateTable(t, EN, ERR).values()]).toEqual(['0', '0']);
|
||||
});
|
||||
|
||||
it('reports a formula it cannot read', () => {
|
||||
const t = table([cell('', 'SUM(')]);
|
||||
expect([...evaluateTable(t, EN, ERR).values()]).toEqual([ERR]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortedRows', () => {
|
||||
const collator = new Intl.Collator('en');
|
||||
const texts = (rows: N[]): string[] => rows.map((r) => r.textContent);
|
||||
|
||||
it('orders a column of numbers numerically, not as text', () => {
|
||||
const t = table(...column('10', '2', '7'));
|
||||
expect(texts(sortedRows(t, { column: 0, descending: false, headerRow: false }, EN, collator))).toEqual(['2', '7', '10']);
|
||||
expect(texts(sortedRows(t, { column: 0, descending: true, headerRow: false }, EN, collator))).toEqual(['10', '7', '2']);
|
||||
});
|
||||
|
||||
it('orders text by the document\'s collation', () => {
|
||||
const t = table(...column('Zebra', 'ähnlich', 'Apfel'));
|
||||
expect(texts(sortedRows(t, { column: 0, descending: false, headerRow: false }, EN, collator))).toEqual(['ähnlich', 'Apfel', 'Zebra']);
|
||||
});
|
||||
|
||||
it('keeps a header row where it is', () => {
|
||||
const t = table(...column('Name', 'Zebra', 'Apfel'));
|
||||
expect(texts(sortedRows(t, { column: 0, descending: false, headerRow: true }, EN, collator))).toEqual(['Name', 'Apfel', 'Zebra']);
|
||||
});
|
||||
|
||||
it('sorts on the column it is given', () => {
|
||||
const t = table(
|
||||
[cell('b'), cell('2')],
|
||||
[cell('a'), cell('1')],
|
||||
);
|
||||
const bySecond = sortedRows(t, { column: 1, descending: false, headerRow: false }, EN, collator);
|
||||
expect(texts(bySecond)).toEqual(['a1', 'b2']);
|
||||
});
|
||||
|
||||
it('refuses a table whose cells span rows', () => {
|
||||
const spanned = table(
|
||||
[{ ...cell('a'), attrs: { colspan: 1, rowspan: 2, colwidth: null, formula: null } }, cell('b')],
|
||||
[cell('c')],
|
||||
);
|
||||
expect(canSortTable(spanned)).toBe(false);
|
||||
expect(canSortTable(table(...column('a', 'b')))).toBe(true);
|
||||
// A single row has nothing to reorder.
|
||||
expect(canSortTable(table(...column('a')))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user