fix: a date/time format outside the catalog stays a live field

Both importers matched a file's format against the seven date and four time
formats the picker offers and kept anything else as the text the producer had
cached — a Word template's `DATE \@ "MMMM d"` then froze at the day it was
written. An unlisted format now rides its own DOCX picture as the key:
`findFormat` parses it, so display, ODF number style and DOCX picture are
derived from it as they are for a catalog entry, and it re-exports unchanged.

Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G
This commit is contained in:
Steffen Becker committed 2026-09-10 23:34:55 +02:00
1 parent dca3c21e2c
commit 825af46ba4
4 files changed
+114 -5

No files matched your search

+4 -2
View File
@@ -17,7 +17,7 @@ import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
import { builtinTableStyles, parseTableLook, resolveTableCell, tableLookAttr } from '../styles/tableStyles';
import { formatOrdinal, orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime';
import { DATE_FORMATS, TIME_FORMATS, docxPicture, findFormat, toDateValue } from '../utils/dateTime';
import { shapeFromPrst, isLineKind, lineKindFor, parseSvgPath, parseVmlPath, fitPath } from '../utils/shapes';
import { imageDataUrl, placeholderImage, unzipArchive, type ConvertedImages } from './imageFormats';
import { PX_PER_CM, cmToPx, fitMargins, type PageMargins } from '../storage/pageMargins';
@@ -1880,7 +1880,9 @@ function dateTimeFieldFromInstr(instr: string): Node | null {
if (!/\b(DATE|TIME)\b/i.test(instr)) return null;
const m = /\\@\s*"([^"]*)"/.exec(instr);
if (!m) return null;
const fmt = [...DATE_FORMATS, ...TIME_FORMATS].find((f) => docxPicture(f) === m[1]);
// The catalog's key where one renders this picture, else the picture itself:
// `findFormat` reads it back, so a format only this file names stays live.
const fmt = [...DATE_FORMATS, ...TIME_FORMATS].find((f) => docxPicture(f) === m[1]) ?? findFormat(m[1]);
if (!fmt) return null;
return { type: 'dateTimeField', attrs: { kind: fmt.kind, format: fmt.key, fixed: false, value: toDateValue(new Date()) } };
}
+4 -2
View File
@@ -17,7 +17,7 @@ import {
} from '../styles/tableStyles';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
import { matchFormat, toDateValue, type Token } from '../utils/dateTime';
import { docxPicture, matchFormat, toDateValue, type Token } from '../utils/dateTime';
import {
shapeFromOdfType, lineKindFor, parseSvgPath, parseOdfPoints, fitPath, type ShapeKind,
} from '../utils/shapes';
@@ -2088,7 +2088,9 @@ function convertDateTimeField(e: Element, ctx: Ctx): Node | null {
// Kind follows the number style's actual tokens, not the element name: OpenOffice
// can wrap a date field in <text:time> yet reference a date-style, and vice versa.
const kind = numberStyleKind(tokens) ?? (e.localName === 'time' ? 'time' : 'date');
const format = matchFormat(tokens, kind);
// A style the catalog does not list rides its own DOCX picture as the key, which
// renders and re-exports the same (dateTime.ts findFormat).
const format = matchFormat(tokens, kind) ?? (tokens.length ? docxPicture({ key: '', kind, tokens }) : null);
if (!format) return null;
const fixed = e.getAttributeNS(NS.text, 'fixed') === 'true';
const raw = e.getAttributeNS(NS.text, 'date-value') ?? e.getAttributeNS(NS.text, 'time-value');
+50 -1
View File
@@ -57,8 +57,57 @@ export const TIME_FORMATS: DtFormat[] = [
const BY_KEY = new Map<string, DtFormat>([...DATE_FORMATS, ...TIME_FORMATS].map(f => [f.key, f]));
// A format the catalog does not list is stored (and round-tripped) as its own DOCX
// picture — the one notation all three consumers are derived from, so a file's
// "MMMM d" stays a live field instead of freezing into text.
export function findFormat(key: string): DtFormat | null {
return BY_KEY.get(key) ?? null;
const known = BY_KEY.get(key);
if (known) return known;
const tokens = parsePicture(key);
return tokens ? { key, kind: pictureKind(tokens), tokens } : null;
}
const PICTURE_TOKENS: Record<string, Token> = {
yyyy: { t: 'year', long: true }, yy: { t: 'year', long: false },
MMMM: { t: 'month', style: 'longText' }, MMM: { t: 'month', style: 'shortText' },
MM: { t: 'month', style: 'num2' }, M: { t: 'month', style: 'num' },
dddd: { t: 'weekday', long: true }, ddd: { t: 'weekday', long: false },
dd: { t: 'day', pad: true }, d: { t: 'day', pad: false },
HH: { t: 'hour24', pad: true }, H: { t: 'hour24', pad: false },
hh: { t: 'hour12', pad: true }, h: { t: 'hour12', pad: false },
mm: { t: 'minute' }, m: { t: 'minute' }, ss: { t: 'second' }, s: { t: 'second' },
'AM/PM': { t: 'ampm' }, 'am/pm': { t: 'ampm' }, 'A/P': { t: 'ampm' },
};
const PICTURE_RE = /yyyy|yy|MMMM|MMM|MM|M|dddd|ddd|dd|d|HH|H|hh|h|mm|m|ss|s|AM\/PM|am\/pm|A\/P|'[^']*'/g;
// A Word field picture ('dd.MM.yyyy', 'MMMM d') → tokens; null when it names no field
// at all. Quoted runs are literals, as they are in the field itself.
export function parsePicture(pic: string): Token[] | null {
const out: Token[] = [];
let lit = '';
let last = 0;
PICTURE_RE.lastIndex = 0;
for (let m = PICTURE_RE.exec(pic); m; m = PICTURE_RE.exec(pic)) {
lit += pic.slice(last, m.index);
last = m.index + m[0].length;
const tok = PICTURE_TOKENS[m[0]];
if (!tok) { lit += m[0].slice(1, -1); continue; }
if (lit) { out.push({ t: 'lit', s: lit }); lit = ''; }
out.push({ ...tok });
}
lit += pic.slice(last);
if (lit) out.push({ t: 'lit', s: lit });
return out.some((t) => t.t !== 'lit') ? out : null;
}
const TIME_TOKENS = ['hour24', 'hour12', 'minute', 'second', 'ampm'];
// What a token list is: a clock without a calendar in it is a time, everything else
// (a date, or a date carrying a time) a date — the element each format serializes to.
export function pictureKind(tokens: Token[]): FieldKind {
return tokens.some((t) => TIME_TOKENS.includes(t.t)) && !tokens.some((t) => ['year', 'month', 'day', 'weekday'].includes(t.t))
? 'time' : 'date';
}
export const DEFAULT_DATE_FORMAT = 'dmy_dots';
+56
View File
@@ -0,0 +1,56 @@
// A DATE/TIME field whose picture the catalog does not list: it stays a live field,
// keyed by the picture itself, and renders and re-exports the same.
import { describe, it, expect } from 'vitest';
import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate';
import { importDocx } from '../../src/lib/import/docx';
import { buildDocx } from '../../src/lib/export/docx';
import { findFormat, renderFormat, docxPicture } from '../../src/lib/utils/dateTime';
const CT = `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`;
const RELS = `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`;
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const DOCUMENT = `<?xml version="1.0"?><w:document ${W}><w:body><w:p>
<w:r><w:fldChar w:fldCharType="begin"/></w:r>
<w:r><w:instrText xml:space="preserve"> DATE \\@ "MMMM d" \\* MERGEFORMAT </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:r><w:t>July 10</w:t></w:r>
<w:r><w:fldChar w:fldCharType="end"/></w:r>
</w:p></w:body></w:document>`;
const file = zipSync({
'[Content_Types].xml': strToU8(CT),
'_rels/.rels': strToU8(RELS),
'word/document.xml': strToU8(DOCUMENT),
});
const walk = (n: any, type: string, out: any[] = []): any[] => {
for (const c of n.content ?? []) { if (c.type === type) out.push(c); walk(c, type, out); }
return out;
};
describe('a date field format the catalog does not list', () => {
it('imports as a live field keyed by its own picture', () => {
const fields = walk(importDocx(file).content, 'dateTimeField');
expect(fields).toHaveLength(1);
expect(fields[0].attrs).toMatchObject({ kind: 'date', format: 'MMMM d', fixed: false });
});
it('renders the current date in that format', () => {
const fmt = findFormat('MMMM d')!;
expect(renderFormat(fmt, new Date(2026, 8, 10), 'en-US')).toBe('September 10');
expect(docxPicture(fmt)).toBe('MMMM d');
});
it('exports the picture back, so the field stays live in Word', async () => {
const doc = importDocx(file).content;
const xml = strFromU8(unzipSync(await buildDocx(doc as any))['word/document.xml']);
expect(xml).toMatch(/<w:fldSimple[^>]*w:instr="[^"]*DATE[^"]*MMMM d/);
});
});