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:
1 parent
dca3c21e2c
commit
825af46ba4
4 files changed
+114
-5
No files matched your search
@@ -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()) } };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user