From 825af46ba49dbbf7b3591a9722281a879d0938ce Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 10 Sep 2026 23:34:55 +0200 Subject: [PATCH] fix: a date/time format outside the catalog stays a live field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/import/docx.ts | 6 ++- src/lib/import/odt.ts | 6 ++- src/lib/utils/dateTime.ts | 51 +++++++++++++++++++++++- tests/unit/date-field-picture.test.ts | 56 +++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 tests/unit/date-field-picture.test.ts diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index bf80be1..2a17a46 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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()) } }; } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 826c81c..6704ca9 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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 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'); diff --git a/src/lib/utils/dateTime.ts b/src/lib/utils/dateTime.ts index aa89822..1fb0441 100644 --- a/src/lib/utils/dateTime.ts +++ b/src/lib/utils/dateTime.ts @@ -57,8 +57,57 @@ export const TIME_FORMATS: DtFormat[] = [ const BY_KEY = new Map([...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 = { + 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'; diff --git a/tests/unit/date-field-picture.test.ts b/tests/unit/date-field-picture.test.ts new file mode 100644 index 0000000..68b7c1b --- /dev/null +++ b/tests/unit/date-field-picture.test.ts @@ -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 = ` + + +`; + +const RELS = ` + +`; + +const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'; +const DOCUMENT = ` + + DATE \\@ "MMMM d" \\* MERGEFORMAT + +July 10 + +`; + +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:instr="[^"]*DATE[^"]*MMMM d/); + }); +});