fix: write the cell-format picture with the document language's separators

Word reads a \# picture with the reader's regional separators, so a
German Word took "0.00"'s dot as grouping and printed 84 as "084".
The importer now reads a picture back by its shape, whichever locale
wrote it.
This commit is contained in:
Steffen Becker committed 2026-08-30 10:06:43 +00:00
1 parent 43828d0d70
commit d9496abe4f
2 files changed
+40 -12

No files matched your search

+27 -11
View File
@@ -10,32 +10,48 @@ export type CellFormat =
export const CELL_FORMATS: CellFormat[] =
['general', 'int', 'dec2', 'group2', 'percent', 'percent2', 'currency', 'date'];
// 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 reads a `\#` picture with the *reader's* regional separators (a German Word takes
// `.` as grouping, so "0.00" prints 84 as "084") — write the document language's own.
function numberSeparators(lang: string): { group: string; decimal: string } {
const parts = new Intl.NumberFormat(lang || 'en').formatToParts(1234.5);
const pick = (t: string, fb: string) => parts.find((p) => p.type === t)?.value ?? fb;
// Any spacing group char (a no-break space) becomes the plain space Word's dialog writes.
return { group: pick('group', ',').replace(/\s/gu, ' '), decimal: pick('decimal', '.') };
}
/** Word's picture switch for a format, as its own dialog writes it. */
/** Word's picture switch for a format, as its own dialog writes it in that locale. */
export function cellFormatCode(format: CellFormat, lang: string): string {
const { group, decimal } = numberSeparators(lang);
if (format === 'currency') {
const { symbol, before, space } = currencyParts(lang);
return before ? `${symbol}${space}#,##0.00` : `#,##0.00${space}${symbol}`;
const body = `#${group}##0${decimal}00`;
return before ? `${symbol}${space}${body}` : `${body}${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] ?? '';
const codes: Record<string, string> = {
general: '', int: '0', dec2: `0${decimal}00`, group2: `#${group}##0${decimal}00`,
percent: '0%', percent2: `0${decimal}00%`,
};
return codes[format] ?? '';
}
// Read by shape, not by string match: the producer's locale decides which of `.`/`,`
// is the decimal (it is the one closing the digit picture), the other one is grouping.
export function cellFormatFromCode(code: string): CellFormat | null {
const key = code.trim();
const fixed = CELL_FORMATS.find((f) => FIXED_CODES[f] === key);
if (fixed) return fixed;
if (key === '') return 'general';
if (!/[#0]/.test(key) && /[yMd]/.test(key)) return 'date';
return /[^#0.,;()%\s+-]/.test(key) ? 'currency' : null;
if (/[^#0.,;()%\s+-]/u.test(key)) return 'currency';
const tail = /([.,])([0#]+)%?\s*$/.exec(key);
const decimals = tail ? tail[2].length : 0;
const grouping = /[0#][\s.,][0#]/u.test(tail ? key.slice(0, tail.index) : key);
if (key.includes('%')) return decimals >= 2 ? 'percent2' : 'percent';
if (grouping) return 'group2';
return decimals >= 2 ? 'dec2' : 'int';
}
export function isCellFormat(v: unknown): v is CellFormat {
+13 -1
View File
@@ -111,13 +111,25 @@ describe('the currency and date formats', () => {
it('writes the picture switch each locale spells', () => {
expect(cellFormatCode('currency', 'en-US')).toBe('$#,##0.00');
expect(cellFormatCode('currency', 'de-DE')).toMatch(/^#,##0\.00.€$/);
// Word reads the picture with the regional separators, so German swaps them.
expect(cellFormatCode('currency', 'de-DE')).toMatch(/^#\.##0,00.€$/);
expect(cellFormatCode('dec2', 'de-DE')).toBe('0,00');
expect(cellFormatCode('group2', 'de-DE')).toBe('#.##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('reads a picture by its shape, whichever locale wrote it', () => {
expect(cellFormatFromCode('0,00')).toBe('dec2');
expect(cellFormatFromCode('#.##0,00')).toBe('group2');
expect(cellFormatFromCode('# ##0,00')).toBe('group2');
expect(cellFormatFromCode('0,00%')).toBe('percent2');
expect(cellFormatFromCode('#.##0,00 €')).toBe('currency');
expect(cellFormatFromCode('0')).toBe('int');
});
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']);