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:
1 parent
9892377ee3
commit
9be1578b97
15 files changed
+239
-50
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user