feat: a formula cell prints a currency and a date too

Both come from the document's language, read off Intl: the symbol's side
and spacing, the short date's order and padding. Each minted data style
names that language — without it LibreOffice prints its own locale's
separators (probed: a German document's 1.234,00 € came out $1,234.00).
A date is a serial day from LibreOffice's day 0, 1899-12-30. Word's
formula dialog offers no date, so the DOCX leg uses the field's \@
picture. Also drops a document's name from three older comments.
This commit is contained in:
Steffen Becker committed 2026-08-15 11:44:43 +02:00
1 parent 9892377ee3
commit 9be1578b97
15 files changed
+239 -50

No files matched your search

+1 -1
View File
@@ -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 → `<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.)
- **Ruby** (`replaceRuby`/`applyRuby`, `applyRubyDocx`): an `RBY` sentinel becomes a `<text:ruby>` 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 `<w:ruby>` 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 `<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` — and the cell's **number format** rides that field's `\#` switch, where ODF puts it on the cell style (a minted data style plus a copy of the shared cell style that references it). See `docs/architecture/tables.md`.
- **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` — and the cell's **number format** rides that field's `\#` switch (`\@` for a date), where ODF puts it on the cell style (a minted data style plus a copy of the shared cell style that references it). The data style names the document's language, or LibreOffice prints its own locale's separators and currency symbol. 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.
+6 -3
View File
@@ -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 : '';
+38 -10
View File
@@ -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(`<style:style style:name="${base}" style:family="table-cell"[^>]*>([\\s\\S]*?)</style:style>`)
.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 = `<number:number number:decimal-places="${spec.decimals}"`
+ ` number:min-decimal-places="${spec.decimals}" number:min-integer-digits="1"`
+ `${spec.grouping ? ' number:grouping="true"' : ''}/>`;
return spec.percent
? `<number:percentage-style style:name="${name}">${number}<number:text>%</number:text></number:percentage-style>`
: `<number:number-style style:name="${name}">${number}</number:number-style>`;
if (spec.kind === 'percent') {
return `<number:percentage-style ${at}>${number}`
+ `<number:text>%</number:text></number:percentage-style>`;
}
if (spec.kind === 'currency') {
const { symbol, before, space } = currencyParts(lang);
const sym = `<number:currency-symbol number:language="${code}"`
+ `${country ? ` number:country="${country}"` : ''}>${escapeXml(symbol)}</number:currency-symbol>`;
const gap = space ? `<number:text>${escapeXml(space)}</number:text>` : '';
return `<number:currency-style ${at}>`
+ (before ? `${sym}${gap}${number}` : `${number}${gap}${sym}`)
+ `</number:currency-style>`;
}
return `<number:number-style ${at}>${number}</number:number-style>`;
}
// 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 `<number:text>${escapeXml(f.text)}</number:text>`;
const long = f.kind === 'year' ? f.digits >= 4 : f.digits >= 2;
return `<number:${f.kind}${long ? ' number:style="long"' : ''}/>`;
}).join('');
return `<number:date-style ${at}>${body}</number:date-style>`;
}
// 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);
+1
View File
@@ -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<string, string>,
formula: 'Formel…',
formulaAria: 'Formel',
+1
View File
@@ -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<string, string>,
formula: 'Formula…',
formulaAria: 'Formula',
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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,
};
}
+11 -5
View File
@@ -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<string, FormatKind | undefined> = {
'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);
+1 -1
View File
@@ -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);
}
+95 -16
View File
@@ -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<CellFormat, string> = {
// 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<string, string> = {
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<CellFormat, FormatSpec> = {
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<string, string> = {
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;
}