diff --git a/CHANGELOG.md b/CHANGELOG.md index bd0d836..5813bbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,8 @@ - Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header row / first column toggles, named table styles (see Styles); a table splits cleanly across page boundaries - Repeat the header row: the first row is drawn again at the top of every page the table continues on, as in Word and LibreOffice. Round-trips to ODF `table:table-header-rows` and Word's `w:tblHeader`. A row that fits a page never splits anyway (the editor paginates a table between its rows), so Word's "don't allow row to break" needs nothing - Sort a table's rows (LibreOffice's Table ▸ Sort, Word's Layout ▸ Sort): the three keys both dialogs offer, each by any column, ascending or descending and with its own sort type — automatic (a cell that reads as a number sorts numerically, anything else by the document language's collation), numerically, or alphanumerically, which is the plain collation that puts "10" before "2". A later key decides only where the ones before it tie, rows alike in every key keep the order they were typed in, and the first row can be kept in place as a header -- A formula cell's own number format (in the Formula dialog, as Word has it): the general format, whole numbers, two decimals, grouped thousands, and the two percentages. ODF keeps it on the cell's style as a data style LibreOffice regenerates the value from, Word on the field as its `\#` switch — LibreOffice's own DOCX filter neither reads nor writes that switch, so the format survives the ODF leg and our own DOCX round trip +- A formula cell's own number format (in the Formula dialog, as Word has it): the general format, whole numbers, two decimals, grouped thousands, the two percentages, the document language's currency and its short date. ODF keeps it on the cell's style as a data style LibreOffice regenerates the value from, Word on the field as its `\#` switch — LibreOffice's own DOCX filter neither reads nor writes that switch, so the format survives the ODF leg and our own DOCX round trip +- The currency symbol and the date's order come from the document's language, not from a table of locales: LibreOffice renders our file exactly as the editor does (`$1,234.00` / `3/15/23` in English, `1.234,00 €` / `15.03.23` in German). A date is a serial day count from LibreOffice's own day 0, 1899-12-30. Word's formula dialog offers no date format, so the DOCX leg carries it in the field's `\@` date picture for us to read back - Formulas in a cell (LibreOffice's Table ▸ Formula, Word's Layout ▸ Formula): `=SUM(ABOVE)`, `=AVERAGE(A1:A3)`, `=A1*2` — SUM, PRODUCT, AVERAGE, MIN, MAX, COUNT, ABS, INT, SIGN, MOD and ROUND over cell references, ranges and Word's directions, with arithmetic and parentheses. A formula cell shows its result on the field shade both word processors use and recomputes as the cells it reads change, a formula reading another formula included. It rides the cell as ODF's `table:formula` in LibreOffice's own language (`ooow:sum `, a direction resolved to the range it stands for) with the result cached as `office:value`, and as Word's `=` field inside the cell — both re-read - Number recognition (LibreOffice's Table ▸ Number Recognition): with it on, a cell whose text reads as a number is rewritten in the document language's number format when the cursor leaves it — `007,50` becomes `7,5`. **Off** by default, as it is in LibreOffice, and the parsing a formula does is independent of it, exactly as there - Table border control (Word/LibreOffice-style): per-side cell borders with presets (all / outside / inside / single edges / none), line width and color; buttons show active states matching the current pen and toggle borders off; round-trips to ODF `fo:border-*` and DOCX `w:tcBorders` @@ -160,7 +161,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14. **Missing while writing** - Hyphenation's zone and ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither -- A formula's own currency and date formats (the number and percentage ones are implemented), and a formula reaching into another table — LibreOffice's `` has no counterpart in Word's field language, so it could not survive the DOCX leg +- A formula reaching into another table — LibreOffice's `` has no counterpart in Word's field language, so it could not survive the DOCX leg. A cell's number format is offered as the closed set both dialogs list, so a foreign document's own currency symbol or date order is re-spelled in the document's language rather than kept - 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 - Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare @@ -204,7 +205,7 @@ merely unimplemented belongs in the list above, not here. paragraph's text instead. Noted 2026-08-08, revised 2026-08-09. - A line takes one word more or fewer than LibreOffice, for two reasons neither of which CSS exposes. LibreOffice **compresses** inter-word spaces to fit a - line (measured on the thesis: 91 of 349 full-width justified lines, up to + line (measured on a fixture: 91 of 349 full-width justified lines, up to 0.83 px per space at 12 pt) where CSS justification only expands; and it quantizes every glyph advance where Chromium keeps it fractional, so the two drift apart along the line (identical word widths, 0.45 mm apart by the @@ -236,11 +237,11 @@ merely unimplemented belongs in the list above, not here. choose the other ascent. Noted 2026-08-13. - A text box anchored inside a paragraph loses the vertical offset it was anchored by: it is a block node here, so the importer lifts it out and it - simply follows that paragraph (measured 4.7 mm on the thesis' figure page). + simply follows that paragraph (measured 4.7 mm on a fixture's figure page). It also costs flow height a word processor does not spend: Word's picture caption is a text box declared 0.05 pt tall that overflows its own box, so LibreOffice reserves nothing for it and the empty paragraph after it holds the - caption. Here it is a block, ~55 px — enough to push two lines off the thesis' + caption. Here it is a block, ~55 px — enough to push two lines off a fixture's page 15 and keep it one page behind through page 45. Noted 2026-08-09. - Line height follows the paragraph, not the line: the block's CSS strut applies to every line, where a word processor takes each line's own runs. A paragraph diff --git a/docs/architecture/formulas.md b/docs/architecture/formulas.md index 1647deb..2ac9f4b 100644 --- a/docs/architecture/formulas.md +++ b/docs/architecture/formulas.md @@ -90,7 +90,7 @@ formula in a Word document was silently dropped before this feature. `m:oMathPar ## A pasted formula is a picture, not a formula -Real documents mix both: a thesis will carry OMML formulas *and* equations its author pasted +Real documents mix both: one will carry OMML formulas *and* equations its author pasted as raster images. Those import as `image` nodes and offer the image toolbar's wrap options on click — in Word and LibreOffice too. Nothing short of OCR changes that, so a click that offers wrap options instead of the formula dialog is the document's doing, not a broken import. diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index 64ef871..ecf2da2 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -17,7 +17,8 @@ Covers the `table*.ts` extensions in `src/lib/editor/extensions/` and the table- - **`tableHeaderRow.ts`** (`TableHeaderRow`) — "header row" as a **styling preset** (not the structural ODF header that repeats per page, by design): `toggleHeaderRowStyle` sets/clears a **light-grey fill (`HEADER_SHADE` `#F2F2F2`)** on the first row's cells; `isHeaderStyled(state, axis)` drives the toolbar toggle's active state. **A table carrying a table style owns these two areas through its Table Style Options instead**: there `toggleHeaderStyle`/`isHeaderStyled` read and write `headerRow`/`firstColumn` in the table's `tableLook` (delegating to `setTableLook`), so the toolbar button and the gallery's checkbox are one state and can't disagree. Both also read their label from the same key (`styles.regions.*`), so they can't drift apart in wording either — the button used to say "header column" where the checkbox said "first column". The fill is the *marker* and **round-trips natively** as `fo:background-color`, so the toggle state survives reopen with no `table:table-header-rows` (⇒ Word/LibreOffice don't repeat it). **Bold is presentational, exactly like a heading**: `tableCellBackground.ts` tags a header-shaded cell with `class="cell-header"` and `editor.css` renders `.cell-header` bold — so *all* its text is bold (existing, typed, pasted, IME) without storing marks, and it stays **editable** via the `fontWeight:'normal'` override (the `Toolbar.svelte` Bold button treats `isInHeaderCell` like a heading: toggles `setFontWeight('normal')`/`unsetFontWeight`). Export (`export/odt.ts`) **bakes** bold onto header-shaded cells' runs (`applyRuns(..., forceBold)`, skipping `fontWeight:'normal'` runs) so Word/LibreOffice match; import (`import/odt.ts`) converts those cells' runs with `boldByDefault` (bold run → no mark, normal run → `fontWeight:'normal'`), mirroring headings. `TableToolbar.svelte` exposes the toggle button. - **`tableFormula.ts`** (`TableFormula`) — a cell's **formula**, on the cell itself, which is where ODF puts it. The stored language is **Word's** (`SUM(ABOVE)`, `A1*2`, no leading `=`), the wider of the two; `utils/tableFormula.ts` holds the whole pure half — the tokenizer, the recursive-descent evaluator (`SUM PRODUCT AVERAGE MIN MAX COUNT ABS INT SIGN MOD ROUND`, ranges, Word's four directions, `+ - * / %` and parentheses) and the two translations. A **direction stops at the first cell holding no number**, a range does not. `evaluateTable` fills every formula cell in one pass, resolving a formula that reads another recursively behind a visited set, so a chain settles at once and a cycle yields 0 rather than looping. The `appendTransaction` recomputes on any doc change and writes the result as the cell's own text (keeping the first run's marks), **off the undo stack**; `editor.css` shades `td[data-formula]` the way both word processors shade a field, since typing over a result does not last. **ODF**: `applyCellFormulas` writes `table:formula="ooow:sum "` (ABOVE resolved to the range it stands for — LibreOffice has no word for it) plus `office:value-type="float"`/`office:value`. The `ooow:` in that value is a **namespace prefix**: undeclared, LibreOffice reads it as part of the formula and prints "Expression is faulty", so `ensureOoowNamespace` declares it (probed both ways). **DOCX**: the cell's first paragraph becomes one `SimpleField` `=SUM(ABOVE)` with the result cached; import reads the instruction back off the cell. LibreOffice's own formula engine parses a cell's **text** in its locale regardless of number recognition (probed), which is why the editor formats a result in the document language. - A formula cell can print its result in a **number format** of its own (`utils/cellFormat.ts`: the general one, whole numbers, two decimals, grouped, and the two percentages — LibreOffice's currency and date formats are not offered). ODF keeps it on the cell's **style**, as a `style:data-style-name` reference to a minted ``/``; odf-kit shares one cell style across every cell, so `applyCellFormulas` mints a copy of it per format used. LibreOffice writes both back out unchanged (probed) — including from our `.docx`, which it does *not* read the format from: Word's `\#` field switch is where the DOCX leg keeps it, and LibreOffice's filter neither reads nor writes that switch. + A formula cell can print its result in a **number format** of its own (`utils/cellFormat.ts`: the general one, whole numbers, two decimals, grouped, the two percentages, the currency and the short date). ODF keeps it on the cell's **style**, as a `style:data-style-name` reference to a minted ``/``/``/``; odf-kit shares one cell style across every cell, so `applyCellFormulas` mints a copy of it per format used. LibreOffice writes all four back out unchanged (probed) — including from our `.docx`, which it does *not* read the format from: Word's `\#` field switch is where the DOCX leg keeps it, and LibreOffice's filter neither reads nor writes that switch. + The **currency and date are the document language's own**, read off `Intl` rather than a table of locales: `currencyParts` says which side of the number the symbol sits and what separates them, `datePattern` gives the short date's order, padding and separators. Each minted data style carries `number:language`/`number:country` — without them LibreOffice prints its *own* locale's separators and symbol instead of the editor's (probed, and the reason a German document's `1.234,00 €` came out `$1,234.00`). A date is a serial day count from **1899-12-30**, LibreOffice's own day 0 (probed: 45000 is 15 March 2023); a number no calendar reaches stays a number. Word's formula dialog offers no date format at all, so the DOCX leg writes the field's date picture switch (`\@ "dd.MM.yy"`) for us to read back — Word keeps showing the cached result. A **foreign** document's currency symbol and date order are read as "currency"/"date" and re-spelled in the document's language, which is all this closed set of formats can carry. - **`tableSort.ts`** (`TableSort`) — LibreOffice's Table ▸ Sort / Word's Layout ▸ Sort with the three keys both offer, each with its own direction and sort type; a later key decides only where the ones before it tie, and column `-1` is the key that is off. `sortedRows` is pure (rows in, rows out) and stable, so rows alike in every key keep the order they were typed in. The type is `auto` (numeric where both cells parse as numbers, else the collation), `number` — where a cell that is no number sorts last either way, as an empty cell does in a spreadsheet — or `text`, which is the **plain** collation: "10" before "2" is the whole point of choosing it, so `sortCollators` builds one collator with `numeric: true` and one without. `canSortTable` refuses a table with a vertically merged cell — such a cell belongs to more than one row, so there are no rows to reorder. The command rebuilds the table node in one transaction. - **Number recognition** (`storage/tableOptions.svelte.ts`) — LibreOffice's Table ▸ Number Recognition, **off** by default there (its `Writer/Table/Input/NumberRecognition` schema default) and so here. On, a cell whose text reads as a number is rewritten in the document language's general number format when the **cursor leaves it**, which is where LibreOffice applies it. It is an app option, not part of the file — LibreOffice keeps it in its own registry too — so only its effect travels. `setTableLanguage` is pushed from `App.svelte`'s language effect; the exporters take the same locale from the `language` argument instead, staying store-free. - **`tableCellAlign.ts`** (`TableCellAlign`) — `verticalAlign` (`middle`/`bottom`, null = top, the ODF and Word default) on `tableCell`/`tableHeader`, rendered as the cell's CSS `vertical-align`. A callout table's label sits mid-cell with it, at the row's top without. Round-trips to ODF `style:vertical-align` (import via `StyleResolver.cellVerticalAlign`, export via odf-kit's `CellOptions.verticalAlign` — it mints one cell style per property set, so a differing cell gets its own) and DOCX `w:vAlign` (Word's `center` is our `middle`; `top`/`both` stay the default). Set from `TableToolbar.svelte`'s three buttons via `setCellAttribute`, which covers a whole `CellSelection`. diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index a82ad08..d56acaa 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -37,7 +37,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`. - **Tracked changes** (`replaceRevisions`/`applyRevisions`): ODF splits the pair. An insertion keeps its text inline and is bracketed by `TCI` sentinels → ``/``; a deletion's text is **cut out of the run** and left as one `TCD` → ``, the text itself going into the `` registry `applyRevisions` builds right after `` (a `` holds the deleted paragraphs, a `` only its ``). The registry's ``/`` need `ensureDcNamespace` — odf-kit declares no `dc:` prefix in content.xml. DOCX needs no string pass: `InsertedTextRun`/`DeletedTextRun` from the `docx` package emit `w:ins`/`w:del` + `w:delText`, with `docRevisionId` numbering them per document as it does comments. - **Bibliography** (`replaceBibEntries`/`applyBibEntries`, `applyBibliographyDocx`): a `BIB` sentinel becomes a `` whose attributes are the whole source record and which **wraps** the text the citation shows (unlike an index mark, which is a point). The `` index writes an entry template per type actually cited, spelling out the same "key: author, title, year" the node view prints — LibreOffice regenerates the rows from it, so a template that said anything else would rewrite them. DOCX cites by tag (`CITATION "key"`) and keeps the sources in a **custom-XML part** the `docx` package knows nothing about, so the post-pack pass mints all four pieces itself: `customXml/item1.xml` (`b:Sources`), its `itemProps1.xml`, both relationships and the properties part's content type. (Probed: LibreOffice's own DOCX export writes the `CITATION` field but no sources at all.) - **Ruby** (`replaceRuby`/`applyRuby`, `applyRubyDocx`): an `RBY` sentinel becomes a `` holding both halves plus one minted `style:family="ruby"` automatic style (centred, above — probed: LibreOffice writes that style straight back out, adding only its own `loext:` mirror of the position). Word's is a `` run the `docx` package cannot emit, so a post-pack pass swaps the sentinel run for it; the `w:rubyPr` sizes are half-points of the 12pt body default, which is what LibreOffice's own export writes. -- **Cell formulas** (`applyCellFormulas`, `formulaCellContent`): ODF puts the formula on the **cell**, so the pass walks `` sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 7dd49cf..beeab58 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -17,7 +17,7 @@ import type { import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; import { SHAPES, isShapeKind, type ShapeKind } from '../utils/shapes'; -import { CELL_FORMAT_CODES, isCellFormat } from '../utils/cellFormat'; +import { cellFormatCode, isCellFormat } from '../utils/cellFormat'; import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; import { pageDimsCm, PAGE_FORMAT_CM, type PageFormat } from '../storage/pageFormat'; @@ -572,8 +572,11 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline // the cell; `formulaCellContent` mints this node for a cell that carries one. // Word's number format is the field's own `\#` switch, its picture the same one // its dialog shows. - const picture = isCellFormat(node.attrs?.format) ? CELL_FORMAT_CODES[node.attrs.format] : ''; - const switches = picture ? ` \\# "${picture}"` : ''; + const format = isCellFormat(node.attrs?.format) ? node.attrs.format : null; + const picture = format ? cellFormatCode(format, docLangTag) : ''; + // A date is the field's date-time picture; Word's own formula dialog offers none, + // so that switch is ours to write and read back. + const switches = picture ? ` \\${format === 'date' ? '@' : '#'} "${picture}"` : ''; out.push(new SimpleField(`=${node.attrs?.formula ?? ''}${switches}`, String(node.attrs?.text ?? ''))); } else if (node.type === 'formula') { const latex = typeof node.attrs?.latex === 'string' ? node.attrs.latex : ''; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index f081998..13d0eb2 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -33,7 +33,7 @@ import { indexKindOf, INDEX_TITLES, type IndexKind } from '../editor/extensions/ import { citationText, isBibType } from '../editor/extensions/bibliographyEntry'; import { isCitationStyle, rowTemplate, type CitationStyle } from '../utils/citationStyle'; import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes'; -import { CELL_FORMAT_SPECS, isCellFormat, type CellFormat } from '../utils/cellFormat'; +import { CELL_FORMAT_SPECS, currencyParts, datePattern, isCellFormat, type CellFormat } from '../utils/cellFormat'; import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime'; import { parseLatex } from '../math/latex'; import { mathmlDocument } from '../math/mathml'; @@ -3311,7 +3311,7 @@ function ensureOoowNamespace(content: string): string { // 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 { +function applyCellFormulas(odtBytes: Uint8Array, cells: (CellFormulaExport | null)[], lang: string): Uint8Array { if (!cells.some(Boolean)) return odtBytes; const files = unzipSync(odtBytes); const contentBytes = files['content.xml']; @@ -3330,7 +3330,7 @@ function applyCellFormulas(odtBytes: Uint8Array, cells: (CellFormulaExport | nul const name = `Cf${derived.size + 1}`; const dataName = `Ncell${derived.size + 1}`; derived.set(key, name); - minted.push(odfCellNumberStyle(format, dataName)); + minted.push(odfCellNumberStyle(format, dataName, lang)); const props = new RegExp(`]*>([\\s\\S]*?)`) .exec(source)?.[1] ?? ''; minted.push( @@ -3362,15 +3362,42 @@ function applyCellFormulas(odtBytes: Uint8Array, cells: (CellFormulaExport | nul } // The data style a formatted cell points at: how many decimals, whether the thousands -// are grouped, and — for a percentage — the style family that appends the sign. -function odfCellNumberStyle(format: CellFormat, name: string): string { +// are grouped, and the style family — percentage, currency and date each have their own. +function odfCellNumberStyle(format: CellFormat, name: string, lang: string): string { const spec = CELL_FORMAT_SPECS[format]; + const [code, country] = lang.split('-'); + // The style names the document's language: without it LibreOffice prints the + // separators and the symbol of its own locale, not the ones the editor shows (probed). + const at = `style:name="${name}" number:language="${code}"${country ? ` number:country="${country}"` : ''}`; + if (spec.kind === 'date') return odfDateStyle(at, lang); const number = ``; - return spec.percent - ? `${number}%` - : `${number}`; + if (spec.kind === 'percent') { + return `${number}` + + `%`; + } + if (spec.kind === 'currency') { + const { symbol, before, space } = currencyParts(lang); + const sym = `${escapeXml(symbol)}`; + const gap = space ? `${escapeXml(space)}` : ''; + return `` + + (before ? `${sym}${gap}${number}` : `${number}${gap}${sym}`) + + ``; + } + return `${number}`; +} + +// The locale's short date as ODF spells it: a `long` field is the zero-padded one, a +// long year the four-digit one. +function odfDateStyle(at: string, lang: string): string { + const body = datePattern(lang).map((f) => { + if (f.kind === 'literal') return `${escapeXml(f.text)}`; + const long = f.kind === 'year' ? f.digits >= 4 : f.digits >= 2; + return ``; + }).join(''); + return `${body}`; } // A table's grid: where every real cell sits and what each slot reads, merged cells @@ -4408,7 +4435,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU // 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 langTag = language ? `${language.language}-${language.country}` : 'en'; + const numberLoc = numberLocale(langTag); const odt = await tiptapToOdt(json, { // Orientation sets style:print-orientation; rewriteStylesXml overrides the exact @@ -4539,7 +4567,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 = applyCellFormulas(applyCellBlocks(indentedLists, cellBlocks), cellFormulas); + const styledCells = applyCellFormulas(applyCellBlocks(indentedLists, cellBlocks), cellFormulas, langTag); const rowHeights: (string | null)[] = []; collectTableRowHeights(raw, rowHeights); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index c77c15d..9c0f4ef 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -421,6 +421,7 @@ const de: Messages = { numberFormats: { general: 'Allgemein', int: '1235', dec2: '1234,57', group2: '1.234,57', percent: '123457 %', percent2: '123456,79 %', + currency: '1.234,57 €', date: '15.03.23', } as Record, formula: 'Formel…', formulaAria: 'Formel', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 0dc595c..4e97f3c 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -425,6 +425,7 @@ const en = { numberFormats: { general: 'General', int: '1235', dec2: '1234.57', group2: '1,234.57', percent: '123457%', percent2: '123456.79%', + currency: '$1,234.57', date: '3/15/23', } as Record, formula: 'Formula…', formulaAria: 'Formula', diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index be97bd9..20b7bc5 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -31,7 +31,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame. - **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text. - **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default. -- **A cell's number format** is on its *style* in ODF (`style:data-style-name` → a `number:number-style`/`percentage-style`, read for its decimals, grouping and percent sign) and on the field in DOCX (the `\#` picture, which is not part of the formula and is cut out of it). Only a formula cell keeps one. +- **A cell's number format** is on its *style* in ODF (`style:data-style-name` → a `number:number-style`/`percentage-style`/`currency-style`/`date-style`, read for its family, decimals and grouping) and on the field in DOCX (the `\#` picture, or `\@` for a date, which is not part of the formula and is cut out of it). Only a formula cell keeps one, and the file's own currency symbol and date order give way to the document language's. - **Vertical text in a box** is the frame style's writing mode in ODF (its *paragraph* properties, both top-to-bottom modes) and `w:bodyPr vert` in DOCX (`vert`/`eaVert`/`mongolianVert`/`wordArtVert`; `vert270` reads bottom-to-top, which the browser cannot lay out, so it is not one). VML says the same as `layout-flow:vertical` on the textbox. - **Ruby**: `text:ruby` and `w:ruby` are one atom here (`ruby.ts`), so only the two texts are read — the base out of `text:ruby-base` / `w:rubyBase`, the reading out of `text:ruby-text` / `w:rt`. A file's own alignment and position are dropped; both products' defaults are what the exports write. - **Bibliography**: a `text:bibliography-mark`'s every `text:` attribute but the identifier and the type is a source field, whatever the file names it, so nothing is lost on a format we have no UI for. Word's `CITATION` names a tag only — the record comes from `customXml/item*.xml` (each is probed for a `b:Sources` root), and a tag no source declares still cites with the field's cached text. `BIBLIOGRAPHY` and `text:bibliography` both become the index node. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index c212dbd..1778e3c 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -362,7 +362,7 @@ 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. Its -// `\#` switch is the number format, and it is not part of the formula. +// picture switch is the number format (`\@` for a date), not part of the formula. function cellFormulaOf(tc: Element): { formula: string; format: CellFormat | null } { const instrs = [ instrTextOf(tc), @@ -370,9 +370,9 @@ function cellFormulaOf(tc: Element): { formula: string; format: CellFormat | nul ]; const found = instrs.find(i => /^\s*=\s*\S/.test(i)); if (!found) return { formula: '', format: null }; - const picture = /\\#\s*"([^"]*)"/.exec(found); + const picture = /\\[#@]\s*"([^"]*)"/.exec(found); return { - formula: fromWriterFormula(found.replace(/\\#\s*"[^"]*"/, '')), + formula: fromWriterFormula(found.replace(/\\[#@]\s*"[^"]*"/, '')), format: picture ? cellFormatFromCode(picture[1]) : null, }; } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 267bc98..3c80743 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -34,7 +34,7 @@ import type { LineNumbering } from '../storage/lineNumbering'; import type { EmbeddedFont } from '../fonts/embeddedFonts'; import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; import { fromWriterFormula } from '../utils/tableFormula'; -import { cellFormatFromSpec, type CellFormat } from '../utils/cellFormat'; +import { cellFormatFromSpec, type CellFormat, type FormatKind } from '../utils/cellFormat'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; import { getSchema } from '@tiptap/core'; import type { Schema } from '@tiptap/pm/model'; @@ -2401,20 +2401,26 @@ export function borderAttrFromOdf(raw: string | null | undefined, treatDefaultAs } // A formula cell's number format: its style points at a data style, whose shape is -// all we keep — decimals, grouping, and whether it is a percentage. +// all we keep — its family, the decimals and the grouping. A foreign document's own +// currency symbol and date order give way to the ones the document's language spells. function cellNumberFormat(cellEl: Element, ctx: Ctx): CellFormat | null { const dataName = ctx.resolver.cellDataStyle(cellEl.getAttributeNS(NS.table, 'style-name')); const styleEl = ctx.resolver.numberStyle(dataName); - const kind = styleEl?.localName; - if (!styleEl || (kind !== 'number-style' && kind !== 'percentage-style')) return null; + const kind = FORMAT_KINDS[styleEl?.localName ?? '']; + if (!styleEl || !kind) return null; const num = styleEl.getElementsByTagNameNS(NS.number, 'number')[0]; return cellFormatFromSpec({ decimals: parseInt(num?.getAttributeNS(NS.number, 'decimal-places') ?? '0', 10) || 0, grouping: num?.getAttributeNS(NS.number, 'grouping') === 'true', - percent: kind === 'percentage-style', + kind, }); } +const FORMAT_KINDS: Record = { + 'number-style': 'number', 'percentage-style': 'percent', + 'currency-style': 'currency', 'date-style': 'date', +}; + function convertTable(el: Element, ctx: Ctx): Node | null { const weights = columnWeights(el, ctx.resolver); diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index b059210..75a10ec 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -277,7 +277,7 @@ export class StyleResolver { const name = el.getAttributeNS(NS.style, 'name'); if (name) this.listStyles.set(name, el); } else if (el.namespaceURI === NS.number - && ['date-style', 'time-style', 'number-style', 'percentage-style'].includes(el.localName)) { + && ['date-style', 'time-style', 'number-style', 'percentage-style', 'currency-style'].includes(el.localName)) { const name = el.getAttributeNS(NS.style, 'name'); if (name) this.numberStyles.set(name, el); } diff --git a/src/lib/utils/cellFormat.ts b/src/lib/utils/cellFormat.ts index 41f6d0d..53796f7 100644 --- a/src/lib/utils/cellFormat.ts +++ b/src/lib/utils/cellFormat.ts @@ -1,21 +1,41 @@ // The number format a table cell prints its value in (LibreOffice's Table ▸ Number // Format, Word's `\#` field switch). The set is the common head of both dialogs' -// lists — plain numbers and percentages; currency and date formats are not offered. +// lists — numbers, percentages, the locale's currency and its short date. import { formatCellNumber, type NumberLocale } from './tableFormula'; -export type CellFormat = 'general' | 'int' | 'dec2' | 'group2' | 'percent' | 'percent2'; +export type CellFormat = + 'general' | 'int' | 'dec2' | 'group2' | 'percent' | 'percent2' | 'currency' | 'date'; -export const CELL_FORMATS: CellFormat[] = ['general', 'int', 'dec2', 'group2', 'percent', 'percent2']; +export const CELL_FORMATS: CellFormat[] = + ['general', 'int', 'dec2', 'group2', 'percent', 'percent2', 'currency', 'date']; -// Word's picture for each, which is also how the format reads in the two dialogs. -export const CELL_FORMAT_CODES: Record = { +// Word's picture for the formats whose code is the same everywhere; the currency and +// date ones are the locale's, built below. +const FIXED_CODES: Record = { general: '', int: '0', dec2: '0.00', group2: '#,##0.00', percent: '0%', percent2: '0.00%', }; +/** Word's picture switch for a format, as its own dialog writes it. */ +export function cellFormatCode(format: CellFormat, lang: string): string { + if (format === 'currency') { + const { symbol, before, space } = currencyParts(lang); + return before ? `${symbol}${space}#,##0.00` : `#,##0.00${space}${symbol}`; + } + if (format === 'date') { + return datePattern(lang) + .map((f) => (f.kind === 'literal' ? f.text : f.kind[0].replace('m', 'M').repeat(f.digits))) + .join(''); + } + return FIXED_CODES[format] ?? ''; +} + export function cellFormatFromCode(code: string): CellFormat | null { const key = code.trim(); - return CELL_FORMATS.find((f) => CELL_FORMAT_CODES[f] === key) ?? null; + const fixed = CELL_FORMATS.find((f) => FIXED_CODES[f] === key); + if (fixed) return fixed; + if (!/[#0]/.test(key) && /[yMd]/.test(key)) return 'date'; + return /[^#0.,;()%\s+-]/.test(key) ? 'currency' : null; } export function isCellFormat(v: unknown): v is CellFormat { @@ -23,25 +43,77 @@ export function isCellFormat(v: unknown): v is CellFormat { } /** The shape of one format, which is all both file formats record about it. */ -export type FormatSpec = { decimals: number; grouping: boolean; percent: boolean }; +export type FormatKind = 'number' | 'percent' | 'currency' | 'date'; +export type FormatSpec = { decimals: number; grouping: boolean; kind: FormatKind }; export const CELL_FORMAT_SPECS: Record = { - general: { decimals: 0, grouping: false, percent: false }, - int: { decimals: 0, grouping: false, percent: false }, - dec2: { decimals: 2, grouping: false, percent: false }, - group2: { decimals: 2, grouping: true, percent: false }, - percent: { decimals: 0, grouping: false, percent: true }, - percent2: { decimals: 2, grouping: false, percent: true }, + general: { decimals: 0, grouping: false, kind: 'number' }, + int: { decimals: 0, grouping: false, kind: 'number' }, + dec2: { decimals: 2, grouping: false, kind: 'number' }, + group2: { decimals: 2, grouping: true, kind: 'number' }, + percent: { decimals: 0, grouping: false, kind: 'percent' }, + percent2: { decimals: 2, grouping: false, kind: 'percent' }, + currency: { decimals: 2, grouping: true, kind: 'currency' }, + date: { decimals: 0, grouping: false, kind: 'date' }, }; /** A file's number style read back as the nearest format we offer. */ export function cellFormatFromSpec(spec: FormatSpec): CellFormat { - if (spec.percent) return spec.decimals >= 2 ? 'percent2' : 'percent'; + if (spec.kind === 'currency' || spec.kind === 'date') return spec.kind; + if (spec.kind === 'percent') return spec.decimals >= 2 ? 'percent2' : 'percent'; if (spec.grouping) return 'group2'; if (spec.decimals >= 2) return 'dec2'; return 'int'; } +// The currency a language spends, as LibreOffice takes it from the document's own +// language. The bundled dictionaries are en-US and de-DE; a neighbour keeps its own. +const CURRENCY_BY_REGION: Record = { + US: 'USD', DE: 'EUR', AT: 'EUR', FR: 'EUR', GB: 'GBP', CH: 'CHF', +}; + +export function localeCurrency(lang: string): string { + const region = new Intl.Locale(lang || 'en').maximize().region ?? 'US'; + return CURRENCY_BY_REGION[region] ?? 'USD'; +} + +/** Where the locale puts its currency symbol, read off Intl rather than a table. */ +export function currencyParts(lang: string): { symbol: string; before: boolean; space: string } { + const parts = new Intl.NumberFormat(lang || 'en', { style: 'currency', currency: localeCurrency(lang) }) + .formatToParts(1234.5); + const at = parts.findIndex((p) => p.type === 'currency'); + const num = parts.findIndex((p) => p.type === 'integer'); + const gap = parts[at < num ? at + 1 : at - 1]; + return { + symbol: parts[at]?.value ?? '¤', + before: at < num, + space: gap?.type === 'literal' ? gap.value : '', + }; +} + +export type DateField = + | { kind: 'literal'; text: string } + | { kind: 'day' | 'month' | 'year'; digits: number }; + +/** The locale's short date as its fields, from Intl: the order, padding and separators. */ +export function datePattern(lang: string): DateField[] { + const ref = new Date(Date.UTC(2023, 2, 5)); // single-digit day and month → is it padded? + return new Intl.DateTimeFormat(lang || 'en', { dateStyle: 'short', timeZone: 'UTC' }) + .formatToParts(ref) + .map((p): DateField => (p.type === 'day' || p.type === 'month' || p.type === 'year' + ? { kind: p.type, digits: p.value.length } + : { kind: 'literal', text: p.value })); +} + +// A cell's number as a date: LibreOffice's own day 0 is 1899-12-30 (probed — serial +// 45000 renders as 15.03.2023). Out of the calendar's range it stays a number. +const DATE_EPOCH = Date.UTC(1899, 11, 30); + +export function serialToDate(n: number): Date | null { + const d = new Date(DATE_EPOCH + Math.round(n) * 86400000); + return Number.isNaN(d.getTime()) ? null : d; +} + /** * A value printed in its cell's format. `general` is the editor's own rendering — * no grouping, the locale's decimal separator, the float noise rounded off. @@ -50,11 +122,18 @@ export function formatCellValue(n: number, format: CellFormat, loc: NumberLocale if (!Number.isFinite(n)) return ''; if (format === 'general') return formatCellNumber(n, loc); const spec = CELL_FORMAT_SPECS[format]; - const value = spec.percent ? n * 100 : n; + if (spec.kind === 'date') { + const date = serialToDate(n); + if (!date) return formatCellNumber(n, loc); + return new Intl.DateTimeFormat(lang || 'en', { dateStyle: 'short', timeZone: 'UTC' }).format(date); + } + const value = spec.kind === 'percent' ? n * 100 : n; const body = new Intl.NumberFormat(lang || 'en', { + style: spec.kind === 'currency' ? 'currency' : 'decimal', + currency: spec.kind === 'currency' ? localeCurrency(lang) : undefined, minimumFractionDigits: spec.decimals, maximumFractionDigits: spec.decimals, useGrouping: spec.grouping, }).format(value); - return spec.percent ? `${body}%` : body; + return spec.kind === 'percent' ? `${body}%` : body; } diff --git a/tests/unit/cell-number-format.test.ts b/tests/unit/cell-number-format.test.ts index f9b7b61..29fcc47 100644 --- a/tests/unit/cell-number-format.test.ts +++ b/tests/unit/cell-number-format.test.ts @@ -6,7 +6,9 @@ import { buildOdt } from '../../src/lib/export/odt'; import { buildDocx } from '../../src/lib/export/docx'; import { importOdt } from '../../src/lib/import/odt'; import { importDocx } from '../../src/lib/import/docx'; -import { formatCellValue } from '../../src/lib/utils/cellFormat'; +import { CELL_FORMATS, cellFormatCode, cellFormatFromCode, formatCellValue } from '../../src/lib/utils/cellFormat'; +import en from '../../src/lib/i18n/locales/en'; +import de from '../../src/lib/i18n/locales/de'; type N = any; @@ -46,7 +48,7 @@ describe('a cell number format', () => { it('round-trips through ODF', async () => { const bytes = await buildOdt(doc, margins, 'portrait'); const xml = strFromU8(unzipSync(bytes)['content.xml']); - expect(xml).toContain(''); + expect(xml).toContain(''); expect(xml).toContain('number:decimal-places="2"'); expect(xml).toContain('number:grouping="true"'); expect(xml).toContain('style:data-style-name="Ncell1"'); @@ -77,3 +79,70 @@ describe('a cell number format', () => { expect(docxXml).not.toContain('\\#'); }); }); + +// The currency and the date are the document language's own: the symbol where the +// locale puts it, the short date in the locale's order and padding. +describe('the currency and date formats', () => { + const DE = { language: 'de', country: 'DE' }; + const cellDoc = (format: string): N => ({ + type: 'doc', + content: [{ + type: 'table', + content: [ + { type: 'tableRow', content: [cell('1234.5')] }, + { type: 'tableRow', content: [cell('x', { formula: 'SUM(ABOVE)', cellFormat: format })] }, + ], + }], + }); + + it('prints the value in the locale that names the format', () => { + expect(formatCellValue(1234.5, 'currency', EN, 'en-US')).toBe('$1,234.50'); + expect(formatCellValue(1234.5, 'currency', EN, 'de-DE')).toMatch(/^1\.234,50.€$/); + // LibreOffice's day 0 is 1899-12-30, so 45000 is 15 March 2023 (probed). + expect(formatCellValue(45000, 'date', EN, 'en-US')).toBe('3/15/23'); + expect(formatCellValue(45000, 'date', EN, 'de-DE')).toBe('15.03.23'); + // A number no calendar reaches stays a number. + expect(formatCellValue(1e12, 'date', EN, 'en-US')).toBe('1000000000000'); + }); + + it('shows a sample of every format in both UI languages', () => { + for (const l of [en, de]) for (const f of CELL_FORMATS) expect(l.table.numberFormats[f]).toBeTruthy(); + }); + + it('writes the picture switch each locale spells', () => { + expect(cellFormatCode('currency', 'en-US')).toBe('$#,##0.00'); + expect(cellFormatCode('currency', 'de-DE')).toMatch(/^#,##0\.00.€$/); + expect(cellFormatCode('date', 'en-US')).toBe('M/d/yy'); + expect(cellFormatCode('date', 'de-DE')).toBe('dd.MM.yy'); + expect(cellFormatFromCode('$#,##0.00')).toBe('currency'); + expect(cellFormatFromCode('dd.MM.yy')).toBe('date'); + }); + + it('round-trips a currency through both formats', async () => { + const odt = await buildOdt(cellDoc('currency'), margins, 'portrait', undefined, { language: 'en', country: 'US' }); + const xml = strFromU8(unzipSync(odt)['content.xml']); + // The style names the document's language, so LibreOffice prints the same + // separators and symbol the editor does (probed). + expect(xml).toContain(''); + expect(xml).toContain('$'); + expect(formulaCell(importOdt(odt)).attrs.cellFormat).toBe('currency'); + + const docx = await buildDocx(cellDoc('currency'), margins, 'portrait'); + expect(strFromU8(unzipSync(docx)['word/document.xml'])).toContain('\\# "$#,##0.00"'); + expect(formulaCell(importDocx(docx)).attrs.cellFormat).toBe('currency'); + }); + + it('round-trips a date through both formats', async () => { + const odt = await buildOdt(cellDoc('date'), margins, 'portrait', undefined, DE); + const xml = strFromU8(unzipSync(odt)['content.xml']); + expect(xml).toContain('' + + '.' + + '.' + + ''); + expect(formulaCell(importOdt(odt)).attrs.cellFormat).toBe('date'); + + const docx = await buildDocx(cellDoc('date'), margins, 'portrait', undefined, DE); + expect(strFromU8(unzipSync(docx)['word/document.xml'])).toContain('\\@ "dd.MM.yy"'); + expect(formulaCell(importDocx(docx)).attrs.cellFormat).toBe('date'); + }); +}); diff --git a/tests/unit/docx-formula.test.ts b/tests/unit/docx-formula.test.ts index 33adebd..112915b 100644 --- a/tests/unit/docx-formula.test.ts +++ b/tests/unit/docx-formula.test.ts @@ -40,7 +40,7 @@ describe('DOCX formulas', () => { expect(found.map((f) => f.attrs.display)).toEqual([false, true]); }); - // Sources harvested from a real Word thesis (the fixtures directory is gitignored, so + // Sources harvested from a real Word document (the fixtures directory is gitignored, so // they live here as literals). Each one broke the parser once: the n-ary swallowing the // enclosing \right, a literal ^ from typed-in code, a run's trailing space. it('re-serializes messy real-world sources unchanged', () => {