diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78433af..bb59b9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -44,6 +44,7 @@
- Table border control (Word/LibreOffice-style): per-side cell borders with presets (all / outside / inside / single edges / none), line width and color; buttons show active states matching the current pen and toggle borders off; round-trips to ODF `fo:border-*` and DOCX `w:tcBorders`
- Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste
- Special characters picker
+- Date and time fields: picker with 7 date and 4 time formats (live samples) and an "update automatically" toggle — fixed fields keep the inserted moment, auto fields refresh on open. Round-trips to ODF `text:date`/`text:time` (minted `number:date/time-style`) and DOCX `DATE`/`TIME` fields; the field carries the surrounding font
- Table of contents: generated from headings (H1–H3) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
- Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before`
diff --git a/src/lib/components/DateTimePicker.svelte b/src/lib/components/DateTimePicker.svelte
new file mode 100644
index 0000000..aa46036
--- /dev/null
+++ b/src/lib/components/DateTimePicker.svelte
@@ -0,0 +1,213 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if open}
+
+
+
+
{t().dateTime.dateHeading}
+ {#each DATE_FORMATS as fmt (fmt.key)}
+
pick(fmt)}>{sample(fmt)}
+ {/each}
+
+
+
{t().dateTime.timeHeading}
+ {#each TIME_FORMATS as fmt (fmt.key)}
+
pick(fmt)}>{sample(fmt)}
+ {/each}
+
+
+
+ (fixed = !(e.currentTarget as HTMLInputElement).checked)} />
+ {t().dateTime.updateAutomatically}
+
+
+ {/if}
+
+
+
diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte
index 2558821..1f92787 100644
--- a/src/lib/components/ToolbarExpanded.svelte
+++ b/src/lib/components/ToolbarExpanded.svelte
@@ -4,6 +4,7 @@
import ColorPicker from './ColorPicker.svelte';
import TablePicker from './TablePicker.svelte';
import SpecialCharPicker from './SpecialCharPicker.svelte';
+ import DateTimePicker from './DateTimePicker.svelte';
import LinkDialog from './LinkDialog.svelte';
import { OPEN_LINK_DIALOG_EVENT } from '../editor/extensions/link';
import {
@@ -100,6 +101,12 @@
if (list && list.length > 0) allInstalledFonts = list;
}
+ // A node that carries text formatting in the selection: a text node, or an inline
+ // atom (e.g. a date/time field) bearing the mark in question. Selecting such an atom
+ // makes a NodeSelection, so without this its font/size/color would read as default.
+ const bearsMark = (node: { isText: boolean; isInline: boolean; isAtom: boolean; marks: readonly { type: { name: string } }[] }, markName: string): boolean =>
+ node.isText || (node.isInline && node.isAtom && node.marks.some(m => m.type.name === markName));
+
// Returns the uniform font of the selection, or '' when fonts are mixed.
// Plain Text without an explicit mark falls back to DEFAULT_EDITOR_FONT.
let currentFont = $derived.by(() => {
@@ -112,7 +119,7 @@
let font: string | undefined;
let mixed = false;
editor.state.doc.nodesBetween(from, to, (node) => {
- if (mixed || !node.isText) return;
+ if (mixed || !bearsMark(node, 'textStyle')) return;
const f: string = node.marks.find(m => m.type.name === 'textStyle')?.attrs.fontFamily ?? DEFAULT_EDITOR_FONT;
if (font === undefined) font = f;
else if (font !== f) mixed = true;
@@ -147,7 +154,7 @@
let size: string | undefined;
let mixed = false;
editor.state.doc.nodesBetween(from, to, (node, _pos, parent) => {
- if (mixed || !node.isText) return;
+ if (mixed || !bearsMark(node, 'textStyle')) return;
const s = effectiveSize(node as Parameters[0], parent as Parameters[1]);
if (size === undefined) size = s;
else if (size !== s) mixed = true;
@@ -252,7 +259,7 @@
let color: string | null | undefined;
let mixed = false;
editor.state.doc.nodesBetween(from, to, (node) => {
- if (mixed || !node.isText) return;
+ if (mixed || !bearsMark(node, markName)) return;
const c = node.marks.find(m => m.type.name === markName)?.attrs.color ?? null;
if (color === undefined) color = c;
else if (color !== c) mixed = true;
@@ -766,6 +773,7 @@
layoutOpen = false;
tableOpen = false;
columnsOpen = false;
+ dateTimeOpen = false;
}
function insertSpecialChar(char: string, range: { from: number; to: number }) {
@@ -773,6 +781,32 @@
editor.chain().focus().setTextSelection(range).insertContent(char).run();
}
+ // --- Date/time field insertion (DateTimePicker.svelte) ---
+ let dateTimeOpen = $state(false);
+
+ // Close the sibling dropdowns (incl. the special-char picker) without touching
+ // dateTimeOpen, so the trigger's own open/close toggle keeps working.
+ function onDateTimePickerOpen() {
+ fontOpen = false;
+ sizeOpen = false;
+ sizeInputFocused = false;
+ lineHeightOpen = false;
+ fontColorOpen = false;
+ highlightColorOpen = false;
+ layoutOpen = false;
+ tableOpen = false;
+ columnsOpen = false;
+ specialCharOpen = false;
+ }
+
+ function insertDateTime(
+ opts: { kind: 'date' | 'time'; format: string; fixed: boolean },
+ range: { from: number; to: number },
+ ) {
+ if (!editor) return;
+ editor.chain().focus().setTextSelection(range).insertDateTimeField(opts).run();
+ }
+
// --- Image insertion (image.ts) ---
let imageInput = $state(null);
@@ -1302,6 +1336,12 @@
onOpen={onSpecialCharPickerOpen}
onInsert={insertSpecialChar}
/>
+
imageInput?.click()}
disabled={!!hfActive}
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts
index a044120..b1941ec 100644
--- a/src/lib/editor/extensions.ts
+++ b/src/lib/editor/extensions.ts
@@ -40,6 +40,7 @@ import { TableCellBorders } from './extensions/tableCellBorders';
import { TableHeaderRow } from './extensions/tableHeaderRow';
import { TrailingNode } from './extensions/trailingNode';
import { Image } from './extensions/image';
+import { DateTimeField } from './extensions/dateTimeField';
import { TextBox } from './extensions/textBox';
import { Columns } from './extensions/columns';
import { ColumnsFlow } from './extensions/columnsFlow';
@@ -73,6 +74,9 @@ export const extensions = [
// Inline, as-character image; round-trips to ODF /
// (export/odt.ts replaceImages/applyImages, import/odt.ts).
Image,
+ // Inline date/time field (fixed or auto-updating); round-trips to ODF
+ // / and DOCX DATE/TIME fields.
+ DateTimeField,
// Block-level text box / basic shape with editable content; round-trips to ODF
// draw:frame/draw:text-box + draw:custom-shape and DOCX wps:wsp/wps:txbx.
TextBox,
diff --git a/src/lib/editor/extensions/dateTimeField.ts b/src/lib/editor/extensions/dateTimeField.ts
new file mode 100644
index 0000000..6baabed
--- /dev/null
+++ b/src/lib/editor/extensions/dateTimeField.ts
@@ -0,0 +1,94 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+import { locale } from '../../i18n/i18n.svelte';
+import {
+ findFormat, renderFormat, toDateValue, localeTag,
+ DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type FieldKind,
+} from '../../utils/dateTime';
+
+// Inline atom for an inserted date/time field (Word's "Insert Date and Time"). A
+// `fixed` field shows its stored `value`; an auto field renders the current time on
+// load. Round-trips to ODF / and DOCX DATE/TIME fields.
+
+export interface DateTimeFieldAttrs {
+ kind: FieldKind;
+ format: string;
+ fixed: boolean;
+ /** ISO local datetime captured at insert; the source for a fixed field's display. */
+ value: string;
+}
+
+// The text a field shows: a fixed field uses its stored moment, an auto field "now".
+export function dateTimeFieldText(attrs: DateTimeFieldAttrs): string {
+ const fmt = findFormat(attrs.format)
+ ?? findFormat(attrs.kind === 'time' ? DEFAULT_TIME_FORMAT : DEFAULT_DATE_FORMAT)!;
+ const when = attrs.fixed && attrs.value ? new Date(attrs.value) : new Date();
+ return renderFormat(fmt, isNaN(when.getTime()) ? new Date() : when, localeTag(locale()));
+}
+
+declare module '@tiptap/core' {
+ interface Commands {
+ dateTimeField: {
+ insertDateTimeField: (opts: { kind: FieldKind; format: string; fixed: boolean }) => ReturnType;
+ };
+ }
+}
+
+export const DateTimeField = Node.create({
+ name: 'dateTimeField',
+ group: 'inline',
+ inline: true,
+ atom: true,
+
+ addAttributes() {
+ return {
+ kind: {
+ default: 'date' as FieldKind,
+ parseHTML: (el) => (el.getAttribute('data-datetime-field') === 'time' ? 'time' : 'date'),
+ renderHTML: (attrs) => ({ 'data-datetime-field': attrs.kind }),
+ },
+ format: {
+ default: DEFAULT_DATE_FORMAT,
+ parseHTML: (el) => el.getAttribute('data-format') || DEFAULT_DATE_FORMAT,
+ renderHTML: (attrs) => ({ 'data-format': attrs.format }),
+ },
+ fixed: {
+ default: false,
+ parseHTML: (el) => el.getAttribute('data-fixed') === 'true',
+ renderHTML: (attrs) => ({ 'data-fixed': attrs.fixed ? 'true' : 'false' }),
+ },
+ value: {
+ default: '',
+ parseHTML: (el) => el.getAttribute('data-value') || '',
+ renderHTML: (attrs) => ({ 'data-value': attrs.value }),
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'span[data-datetime-field]' }];
+ },
+
+ renderHTML({ node, HTMLAttributes }) {
+ return ['span', mergeAttributes(HTMLAttributes), dateTimeFieldText(node.attrs as DateTimeFieldAttrs)];
+ },
+
+ renderText({ node }) {
+ return dateTimeFieldText(node.attrs as DateTimeFieldAttrs);
+ },
+
+ addCommands() {
+ return {
+ insertDateTimeField: (opts) => ({ commands, state }) => {
+ // Adopt the cursor's marks (font, size, …) so the atom renders like the
+ // surrounding text instead of falling back to the editor's default font.
+ const marks = (state.storedMarks ?? state.selection.$to.marks())
+ .map((m) => ({ type: m.type.name, attrs: m.attrs }));
+ return commands.insertContent({
+ type: this.name,
+ attrs: { kind: opts.kind, format: opts.format, fixed: opts.fixed, value: toDateValue(new Date()) },
+ ...(marks.length ? { marks } : {}),
+ });
+ },
+ };
+ },
+});
diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts
index 24cb4ba..f44c6b7 100644
--- a/src/lib/export/docx.ts
+++ b/src/lib/export/docx.ts
@@ -1,7 +1,7 @@
import {
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, Tab,
TableOfContents,
- Table, TableRow, TableCell, Header, Footer, PageNumber,
+ Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
AlignmentType, HeadingLevel, LevelFormat, UnderlineType, BorderStyle, ShadingType,
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType,
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
@@ -23,6 +23,11 @@ import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCell
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, HEADING_STYLE_OVERRIDES, mergeJoinedParagraphsJson, type HfExport } from './odt';
+import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
+
+// BCP-47 tag for rendering a fixed field's cached text; set at buildDocx start from
+// the document language (Word recomputes an auto field on open using its own locale).
+let docLangTag = 'en-US';
// DOCX export. Mirrors export/odt.ts feature-for-feature, but builds OOXML via the
// `docx` library instead of odf-kit. Lazy-loaded from App.svelte so neither this
@@ -210,7 +215,23 @@ function textNodeToRuns(node: TiptapNode, forceBold: boolean): TextRun[] {
return [new TextRun({ children, ...props })];
}
-type Inline = TextRun | ImageRun | ExternalHyperlink;
+type Inline = TextRun | ImageRun | ExternalHyperlink | SimpleField;
+
+// A date/time field. A fixed field is plain text (Word has no fixed-date field); an
+// auto field is a DATE/TIME field with the picture switch and a cached value.
+function dateTimeRun(node: TiptapNode): Inline {
+ const a = node.attrs ?? {};
+ const kind = a.kind === 'time' ? 'time' : 'date';
+ const fmt = findFormat(String(a.format ?? ''))
+ ?? findFormat(kind === 'time' ? DEFAULT_TIME_FORMAT : DEFAULT_DATE_FORMAT)!;
+ const parsed = a.fixed && typeof a.value === 'string' && a.value ? new Date(a.value) : new Date();
+ const when = isNaN(parsed.getTime()) ? new Date() : parsed;
+ const text = renderFormat(fmt, when, docLangTag);
+ // A fixed field is a plain run, so carry its font/size/etc.; SimpleField takes no
+ // run props, so an auto field inherits the paragraph font (Word recomputes it).
+ if (a.fixed) return new TextRun({ text, ...runPropsFromMarks(node.marks) });
+ return new SimpleField(`${kind === 'time' ? 'TIME' : 'DATE'} \\@ "${docxPicture(fmt)}"`, text);
+}
function inlineToRuns(content: TiptapNode[] = [], forceBold = false): Inline[] {
const out: Inline[] = [];
@@ -229,6 +250,8 @@ function inlineToRuns(content: TiptapNode[] = [], forceBold = false): Inline[] {
out.push(new TextRun({ children: [PageNumber.CURRENT] }));
} else if (node.type === 'pageCount') {
out.push(new TextRun({ children: [PageNumber.TOTAL_PAGES] }));
+ } else if (node.type === 'dateTimeField') {
+ out.push(dateTimeRun(node));
}
}
return out;
@@ -813,6 +836,7 @@ export async function buildDocx(
language?: { language: string; country: string } | null,
pageFormat: PageFormat = 'A4',
): Promise {
+ docLangTag = localeTag(language ? language.language : 'en');
const num = new Numbering();
const landscape = orientation === 'landscape';
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts
index ffd60af..6437c5b 100644
--- a/src/lib/export/odt.ts
+++ b/src/lib/export/odt.ts
@@ -9,6 +9,7 @@ import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBor
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
+import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -89,6 +90,11 @@ const TBX = '';
// section style carrying . U+E009.
const COL = '';
+// Sentinel wrapping a date/time field's index (DTF{i}DTF), emitted as plain run text
+// by replaceDateTimeFields so it rides every odf-kit path; applyDateTimeFields
+// rewrites it to / + a minted number style. U+E00A.
+const DTF = '';
+
const EXT_BY_MIME: Record = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -267,6 +273,33 @@ function replaceImages(node: TiptapNode, images: ImageExport[]): TiptapNode {
return { ...node, content };
}
+// One inserted date/time field, collected by replaceDateTimeFields and emitted by
+// applyDateTimeFields. `value` is the field's stored moment (ISO local datetime).
+type DateTimeFieldExport = { kind: 'date' | 'time'; format: string; fixed: boolean; value: string };
+
+// Replace every inline `dateTimeField` node with a DTF-sentinel text run (carrying
+// the node's marks) and collect its attrs; mirrors replaceImages so the sentinel
+// rides every odf-kit path. applyDateTimeFields resolves it after serialization.
+function replaceDateTimeFields(node: TiptapNode, fields: DateTimeFieldExport[]): TiptapNode {
+ if (!node.content?.length) return node;
+ const content: TiptapNode[] = [];
+ for (const child of node.content) {
+ if (child.type === 'dateTimeField') {
+ const a = child.attrs ?? {};
+ fields.push({
+ kind: a.kind === 'time' ? 'time' : 'date',
+ format: typeof a.format === 'string' ? a.format : '',
+ fixed: a.fixed === true,
+ value: typeof a.value === 'string' ? a.value : '',
+ });
+ content.push({ type: 'text', text: `${DTF}${fields.length - 1}${DTF}`, marks: child.marks });
+ continue;
+ }
+ content.push(replaceDateTimeFields(child, fields));
+ }
+ return { ...node, content };
+}
+
// One text box / shape, collected by replaceTextBoxes and emitted by applyTextBoxes.
type ShapeKind = 'textbox' | 'roundRect' | 'ellipse';
type TextBoxExport = {
@@ -1694,6 +1727,61 @@ function applyImages(odtBytes: Uint8Array, images: ImageExport[]): Uint8Array {
return rezipOdt(files);
}
+// Ensure content.xml declares the number namespace (odf-kit may omit it); the minted
+// / and their references need the prefix.
+function ensureNumberNamespace(content: string): string {
+ if (content.includes('xmlns:number=')) return content;
+ return content.replace(
+ //
+// carrying the displayed value, the ISO date/time-value, text:fixed, and a minted
+// data style. Auto fields render (and store) the current moment; fixed ones use `value`.
+function applyDateTimeFields(odtBytes: Uint8Array, fields: DateTimeFieldExport[], lang: { language: string; country: string } | null): Uint8Array {
+ if (!fields.length) return odtBytes;
+ const files = unzipSync(odtBytes);
+ const contentBytes = files['content.xml'];
+ if (!contentBytes) return odtBytes;
+
+ const tag = localeTag(lang ? `${lang.language}` : 'en');
+ // One data style per distinct format actually used; reference it by minted name.
+ const styleNames = new Map();
+ const mintedStyles: string[] = [];
+ const styleFor = (fmt: DtFormat): string => {
+ const existing = styleNames.get(fmt.key);
+ if (existing) return existing;
+ const name = `Ndt${styleNames.size + 1}`;
+ styleNames.set(fmt.key, name);
+ mintedStyles.push(odfNumberStyle(fmt, name, lang));
+ return name;
+ };
+
+ let content = strFromU8(contentBytes);
+ content = content.replace(new RegExp(`${DTF}(\\d+)${DTF}`, 'g'), (_m, idx: string) => {
+ const field = fields[Number(idx)];
+ if (!field) return '';
+ const fmt = findFormat(field.format)
+ ?? findFormat(field.kind === 'time' ? DEFAULT_TIME_FORMAT : DEFAULT_DATE_FORMAT)!;
+ const parsed = field.fixed && field.value ? new Date(field.value) : new Date();
+ const when = isNaN(parsed.getTime()) ? new Date() : parsed;
+ const display = escapeXml(renderFormat(fmt, when, tag));
+ const styleName = styleFor(fmt);
+ const fixedAttr = ` text:fixed="${field.fixed ? 'true' : 'false'}"`;
+ if (fmt.kind === 'time') {
+ return `${display} `;
+ }
+ return `${display} `;
+ });
+
+ content = ensureNumberNamespace(content);
+ content = injectAutomaticStyles(content, mintedStyles.join(''));
+ files['content.xml'] = strToU8(content);
+ return rezipOdt(files);
+}
+
// Static enhanced geometry for the two non-rectangular shape kinds, matching what
// LibreOffice emits (round-rectangle path pre-evaluated for modifier 3600).
const ENHANCED_GEOMETRY: Record<'roundRect' | 'ellipse', string> = {
@@ -1927,7 +2015,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const tocs: TocExport[] = [];
const textBoxes: TextBoxExport[] = [];
const columns: ColumnsExport[] = [];
- const raw = replaceImages(replaceTabs(replaceHardBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns))), images);
+ const dateFields: DateTimeFieldExport[] = [];
+ const raw = replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns))), images), dateFields);
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
@@ -2049,7 +2138,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const cleaned = collapseRunWhitespace(styledRows);
const withBreaks = applyInlineSentinels(cleaned);
const withImages = applyImages(withBreaks, images);
- const withTextBoxes = applyTextBoxes(withImages, textBoxes);
+ const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null);
+ const withTextBoxes = applyTextBoxes(withDateFields, textBoxes);
const withColumns = applyColumns(withTextBoxes, columns);
const withToc = applyToc(withColumns, tocs, contentWidthCm);
const withPageBreaks = applyPageBreaks(withToc);
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index e32877d..f46e043 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -272,6 +272,13 @@ const de: Messages = {
Symbols: 'Symbole',
},
},
+ dateTime: {
+ insert: 'Datum und Uhrzeit einfügen',
+ dateHeading: 'Datum',
+ timeHeading: 'Uhrzeit',
+ updateAutomatically: 'Automatisch aktualisieren',
+ updateHint: 'Beim Öffnen des Dokuments auf das aktuelle Datum/die Uhrzeit aktualisieren',
+ },
hf: {
addHeaderHint: 'Zum Hinzufügen einer Kopfzeile doppelklicken',
addFooterHint: 'Zum Hinzufügen einer Fußzeile doppelklicken',
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index 3c5b1b2..291529d 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -275,6 +275,13 @@ const en = {
Symbols: 'Symbols',
},
},
+ dateTime: {
+ insert: 'Insert date and time',
+ dateHeading: 'Date',
+ timeHeading: 'Time',
+ updateAutomatically: 'Update automatically',
+ updateHint: 'Refresh to the current date/time each time the document is opened',
+ },
hf: {
addHeaderHint: 'Double-click to add a header',
addFooterHint: 'Double-click to add a footer',
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index 281e75c..fd240bf 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -5,6 +5,7 @@ import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { 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 { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
@@ -475,6 +476,9 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu
const out: Node[] = [];
let fieldMode: 'none' | 'instr' | 'result' = 'none';
let fieldInstr = '';
+ // A recognized body date/time field: its cached result runs are dropped and a live
+ // dateTimeField node is emitted in their place when the field ends.
+ let fieldDateTime: Node | null = null;
const pushText = (text: string, marks: Mark[]) => {
if (!text) return;
@@ -485,14 +489,22 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu
// A run may hold a whole field (begin/instrText/separate/end) or just part of one,
// so walk its children in order and keep the field state across runs.
- const handleRun = (r: Element, linkHref?: string) => {
+ // Resolved marks for a run element (font/size/color/etc.), including a link mark.
+ const runMarks = (r: Element, linkHref?: string): Mark[] => {
const rPr = fc(r, 'rPr');
const rStyle = fc(rPr, 'rStyle');
const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr));
if (!props.font && props.fontTheme) props.font = ctx.styles.themeFont(props.fontTheme);
const marks = marksFor(props, headingLevel, boldByDefault, !!linkHref);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
- const skipResult = () => fieldMode === 'result' && hfFields; // hide a hf field's cached value
+ return marks;
+ };
+
+ const handleRun = (r: Element, linkHref?: string) => {
+ const marks = runMarks(r, linkHref);
+ // Hide a field's cached result: always for a hf field, and for a recognized body
+ // date/time field (replaced by its live node).
+ const skipResult = () => fieldMode === 'result' && (hfFields || !!fieldDateTime);
// Route a drawing/pict result: an image is inline; a text box is a block node
// riding ctx.pendingBlocks (dropped in the one-paragraph header/footer schema).
@@ -523,9 +535,20 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu
switch (child.localName) {
case 'fldChar': {
const t = child.getAttributeNS(W, 'fldCharType');
- if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; }
- else if (t === 'separate') fieldMode = 'result';
- else if (t === 'end') { emitField(out, fieldInstr, hfFields); fieldMode = 'none'; }
+ if (t === 'begin') { fieldMode = 'instr'; fieldInstr = ''; fieldDateTime = null; }
+ else if (t === 'separate') {
+ fieldMode = 'result';
+ if (!hfFields) {
+ fieldDateTime = dateTimeFieldFromInstr(fieldInstr);
+ // Carry the field run's marks so the atom renders in the run's font.
+ if (fieldDateTime && marks.length) fieldDateTime.marks = marks;
+ }
+ } else if (t === 'end') {
+ if (fieldDateTime) out.push(fieldDateTime);
+ else emitField(out, fieldInstr, hfFields);
+ fieldMode = 'none';
+ fieldDateTime = null;
+ }
break;
}
case 'instrText': if (fieldMode === 'instr') fieldInstr += child.textContent ?? ''; break;
@@ -550,8 +573,16 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu
}
case 'fldSimple': {
const instr = el.getAttributeNS(W, 'instr') ?? '';
- if (hfFields) emitField(out, instr, true);
- else for (const r of fcAll(el, 'r')) handleRun(r); // body: keep the shown value
+ if (hfFields) { emitField(out, instr, true); break; }
+ const field = dateTimeFieldFromInstr(instr);
+ if (field) {
+ const first = fcAll(el, 'r')[0];
+ const m = first ? runMarks(first) : [];
+ if (m.length) field.marks = m;
+ out.push(field);
+ break;
+ }
+ for (const r of fcAll(el, 'r')) handleRun(r); // body: keep the shown value
break;
}
case 'ins': // accepted tracked-change insertion → keep its runs
@@ -571,6 +602,18 @@ function emitField(out: Node[], instr: string, hfFields: boolean): void {
else if (/\bPAGE\b/.test(instr)) out.push({ type: 'pageNumber' });
}
+// A DATE/TIME field instruction with a picture switch we recognize → a live
+// dateTimeField node (always auto: Word writes a fixed date as plain text). Returns
+// null for an unknown picture so the caller keeps the field's shown text.
+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]);
+ if (!fmt) return null;
+ return { type: 'dateTimeField', attrs: { kind: fmt.kind, format: fmt.key, fixed: false, value: toDateValue(new Date()) } };
+}
+
function mergeAdjacentText(nodes: Node[]): Node[] {
const out: Node[] = [];
for (const n of nodes) {
diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts
index bd6dae4..9fa65ae 100644
--- a/src/lib/import/odt.ts
+++ b/src/lib/import/odt.ts
@@ -4,6 +4,7 @@ import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
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 { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import type { PageFormat } from '../storage/pageFormat';
@@ -607,6 +608,66 @@ function snapPt(v: number): number {
return Math.abs(r - i) <= 0.03 ? i : r;
}
+// ---- date/time fields ---------------------------------------------------------
+
+// Parse a / body into our token model so it can
+// be matched to a catalog format. number:style="long" = padded/4-digit, and hours
+// become 12-hour when an am-pm token is present.
+function parseNumberStyleTokens(styleEl: Element): Token[] {
+ const toks: Token[] = [];
+ for (const c of Array.from(styleEl.children)) {
+ if (c.namespaceURI !== NS.number) continue;
+ const long = c.getAttributeNS(NS.number, 'style') === 'long';
+ switch (c.localName) {
+ case 'text': toks.push({ t: 'lit', s: c.textContent ?? '' }); break;
+ case 'year': toks.push({ t: 'year', long }); break;
+ case 'month': {
+ const textual = c.getAttributeNS(NS.number, 'textual') === 'true';
+ toks.push({ t: 'month', style: textual ? (long ? 'longText' : 'shortText') : (long ? 'num2' : 'num') });
+ break;
+ }
+ case 'day': toks.push({ t: 'day', pad: long }); break;
+ case 'day-of-week': toks.push({ t: 'weekday', long }); break;
+ case 'hours': toks.push({ t: 'hour24', pad: long }); break;
+ case 'minutes': toks.push({ t: 'minute' }); break;
+ case 'seconds': toks.push({ t: 'second' }); break;
+ case 'am-pm': toks.push({ t: 'ampm' }); break;
+ }
+ }
+ if (toks.some(t => t.t === 'ampm')) {
+ for (let i = 0; i < toks.length; i++) {
+ if (toks[i].t === 'hour24') toks[i] = { t: 'hour12', pad: (toks[i] as { pad: boolean }).pad };
+ }
+ }
+ return toks;
+}
+
+// ISO local datetime for the node's `value`: a date-value is kept; a time-value
+// (PThHmMsS) is placed on today's date (only its time part is rendered).
+function fieldValue(kind: 'date' | 'time', raw: string | null): string {
+ if (kind === 'date') {
+ const d = raw ? new Date(raw) : null;
+ return d && !isNaN(d.getTime()) ? raw! : '';
+ }
+ const m = raw ? /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/.exec(raw) : null;
+ if (!m) return '';
+ const now = new Date();
+ now.setHours(Number(m[1] ?? 0), Number(m[2] ?? 0), Number(m[3] ?? 0), 0);
+ return toDateValue(now);
+}
+
+// A / whose data style matches a known format → a live field
+// node; returns null (keep the shown text) for an unrecognised format.
+function convertDateTimeField(e: Element, ctx: Ctx): Node | null {
+ const kind: 'date' | 'time' = e.localName === 'time' ? 'time' : 'date';
+ const styleEl = ctx.resolver.numberStyle(e.getAttributeNS(NS.style, 'data-style-name'));
+ const format = styleEl ? matchFormat(parseNumberStyleTokens(styleEl), kind) : null;
+ if (!format) return null;
+ const fixed = e.getAttributeNS(NS.text, 'fixed') === 'true';
+ const raw = e.getAttributeNS(NS.text, kind === 'time' ? 'time-value' : 'date-value');
+ return { type: 'dateTimeField', attrs: { kind, format, fixed, value: fieldValue(kind, raw) } };
+}
+
// ---- inline conversion --------------------------------------------------------
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null, hfFields = false, boldByDefault = false): Node[] {
@@ -682,8 +743,24 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
out.push({ type: 'pageCount' });
continue;
}
- // Other text fields (date, title, …) store their evaluated value as
- // element text — keep what the source document showed.
+ // Date/time fields become live dateTimeField nodes in the body (a known
+ // format; falls through to the shown text otherwise). The one-paragraph
+ // header/footer schema has no such node, so there they stay text.
+ if (!hfFields && (e.localName === 'date' || e.localName === 'time')) {
+ const field = convertDateTimeField(e, ctx);
+ if (field) {
+ // The field is an atom without inline children, so it can't inherit
+ // the surrounding run's font from a sibling span — carry the run's
+ // marks on the node so it renders in the same font/size/etc.
+ const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
+ if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
+ if (marks.length) field.marks = marks;
+ out.push(field);
+ continue;
+ }
+ }
+ // Other text fields (title, …) store their evaluated value as element
+ // text — keep what the source document showed.
if (e.textContent) pushText(e.textContent, props, linkHref);
continue;
}
diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts
index 31ccf3d..45f87eb 100644
--- a/src/lib/import/styleResolver.ts
+++ b/src/lib/import/styleResolver.ts
@@ -15,6 +15,7 @@ export const NS = {
svg: 'urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0',
draw: 'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0',
xlink: 'http://www.w3.org/1999/xlink',
+ number: 'urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0',
} as const;
// Property keys are stored as "alias:localName" for the namespaces we care
@@ -102,6 +103,7 @@ export class StyleResolver {
private defaults = new Map(); // family → style:default-style
private fontFaces = new Map(); // style:name → first font-family
private listStyles = new Map(); // style:name → text:list-style
+ private numberStyles = new Map(); // style:name → number:date/time-style
// Section styles kept as raw elements: is a child element of
// , which the attribute-only StyleEntry drops.
private sectionStyleEls = new Map();
@@ -151,6 +153,9 @@ export class StyleResolver {
} else if (el.namespaceURI === NS.text && el.localName === 'list-style') {
const name = el.getAttributeNS(NS.style, 'name');
if (name) this.listStyles.set(name, el);
+ } else if (el.namespaceURI === NS.number && (el.localName === 'date-style' || el.localName === 'time-style')) {
+ const name = el.getAttributeNS(NS.style, 'name');
+ if (name) this.numberStyles.set(name, el);
}
}
}
@@ -272,6 +277,11 @@ export class StyleResolver {
return name ? this.listStyles.get(name) ?? null : null;
}
+ // The / element a date/time field references.
+ numberStyle(name: string | null): Element | null {
+ return name ? this.numberStyles.get(name) ?? null : null;
+ }
+
// A section style's column layout: { count, gapCm } when it declares more than one
// column, else null.
sectionColumns(name: string | null): { count: number; gapCm: number } | null {
diff --git a/src/lib/utils/dateTime.ts b/src/lib/utils/dateTime.ts
new file mode 100644
index 0000000..2bc5b5e
--- /dev/null
+++ b/src/lib/utils/dateTime.ts
@@ -0,0 +1,180 @@
+// Framework-free date/time field support: a catalog of formats built from field
+// tokens, with renderers for the three consumers (on-screen display, ODF
+// , DOCX field picture) all derived from one token list.
+
+export type FieldKind = 'date' | 'time';
+
+// One piece of a format: a literal string, or a field with a rendering variant.
+export type Token =
+ | { t: 'lit'; s: string }
+ | { t: 'year'; long: boolean }
+ | { t: 'month'; style: 'num' | 'num2' | 'longText' | 'shortText' }
+ | { t: 'day'; pad: boolean }
+ | { t: 'weekday'; long: boolean }
+ | { t: 'hour24'; pad: boolean }
+ | { t: 'hour12'; pad: boolean }
+ | { t: 'minute' }
+ | { t: 'second' }
+ | { t: 'ampm' };
+
+export interface DtFormat {
+ /** Stable key stored on the node and round-tripped. */
+ key: string;
+ /** ODF element / DOCX field this format serializes to. */
+ kind: FieldKind;
+ tokens: Token[];
+}
+
+// Word-style catalog. Samples in the picker render the current time, so the exact
+// ordering/separators are visible when choosing.
+export const DATE_FORMATS: DtFormat[] = [
+ { key: 'iso', kind: 'date', tokens: [
+ { t: 'year', long: true }, { t: 'lit', s: '-' }, { t: 'month', style: 'num2' }, { t: 'lit', s: '-' }, { t: 'day', pad: true } ] },
+ { key: 'dmy_dots', kind: 'date', tokens: [
+ { t: 'day', pad: true }, { t: 'lit', s: '.' }, { t: 'month', style: 'num2' }, { t: 'lit', s: '.' }, { t: 'year', long: true } ] },
+ { key: 'mdy_slash', kind: 'date', tokens: [
+ { t: 'month', style: 'num' }, { t: 'lit', s: '/' }, { t: 'day', pad: false }, { t: 'lit', s: '/' }, { t: 'year', long: true } ] },
+ { key: 'dmy_long', kind: 'date', tokens: [
+ { t: 'day', pad: false }, { t: 'lit', s: '. ' }, { t: 'month', style: 'longText' }, { t: 'lit', s: ' ' }, { t: 'year', long: true } ] },
+ { key: 'mdy_long', kind: 'date', tokens: [
+ { t: 'month', style: 'longText' }, { t: 'lit', s: ' ' }, { t: 'day', pad: false }, { t: 'lit', s: ', ' }, { t: 'year', long: true } ] },
+ { key: 'weekday_dmy', kind: 'date', tokens: [
+ { t: 'weekday', long: true }, { t: 'lit', s: ', ' }, { t: 'day', pad: false }, { t: 'lit', s: '. ' }, { t: 'month', style: 'longText' }, { t: 'lit', s: ' ' }, { t: 'year', long: true } ] },
+ { key: 'weekday_mdy', kind: 'date', tokens: [
+ { t: 'weekday', long: true }, { t: 'lit', s: ', ' }, { t: 'month', style: 'longText' }, { t: 'lit', s: ' ' }, { t: 'day', pad: false }, { t: 'lit', s: ', ' }, { t: 'year', long: true } ] },
+];
+
+export const TIME_FORMATS: DtFormat[] = [
+ { key: 'hm24', kind: 'time', tokens: [
+ { t: 'hour24', pad: true }, { t: 'lit', s: ':' }, { t: 'minute' } ] },
+ { key: 'hms24', kind: 'time', tokens: [
+ { t: 'hour24', pad: true }, { t: 'lit', s: ':' }, { t: 'minute' }, { t: 'lit', s: ':' }, { t: 'second' } ] },
+ { key: 'hm12', kind: 'time', tokens: [
+ { t: 'hour12', pad: false }, { t: 'lit', s: ':' }, { t: 'minute' }, { t: 'lit', s: ' ' }, { t: 'ampm' } ] },
+ { key: 'hms12', kind: 'time', tokens: [
+ { t: 'hour12', pad: false }, { t: 'lit', s: ':' }, { t: 'minute' }, { t: 'lit', s: ':' }, { t: 'second' }, { t: 'lit', s: ' ' }, { t: 'ampm' } ] },
+];
+
+const BY_KEY = new Map([...DATE_FORMATS, ...TIME_FORMATS].map(f => [f.key, f]));
+
+export function findFormat(key: string): DtFormat | null {
+ return BY_KEY.get(key) ?? null;
+}
+
+export const DEFAULT_DATE_FORMAT = 'dmy_dots';
+export const DEFAULT_TIME_FORMAT = 'hm24';
+
+// Match a parsed token sequence back to a catalog format key (for import). Returns
+// null when no format renders exactly these tokens — the caller then keeps the value
+// as plain text rather than a field.
+export function matchFormat(tokens: Token[], kind: FieldKind): string | null {
+ const cands = kind === 'time' ? TIME_FORMATS : DATE_FORMATS;
+ const key = JSON.stringify(tokens);
+ for (const f of cands) if (JSON.stringify(f.tokens) === key) return f.key;
+ return null;
+}
+
+function pad2(n: number): string {
+ return String(n).padStart(2, '0');
+}
+
+// --- display -----------------------------------------------------------------
+
+// Render the tokens for the given moment, using Intl for locale month/weekday names.
+export function renderFormat(fmt: DtFormat, d: Date, locale: string): string {
+ const monthName = (opts: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, opts).format(d);
+ let out = '';
+ for (const tok of fmt.tokens) {
+ switch (tok.t) {
+ case 'lit': out += tok.s; break;
+ case 'year': out += tok.long ? String(d.getFullYear()) : pad2(d.getFullYear() % 100); break;
+ case 'month':
+ if (tok.style === 'num') out += String(d.getMonth() + 1);
+ else if (tok.style === 'num2') out += pad2(d.getMonth() + 1);
+ else out += monthName({ month: tok.style === 'longText' ? 'long' : 'short' });
+ break;
+ case 'day': out += tok.pad ? pad2(d.getDate()) : String(d.getDate()); break;
+ case 'weekday': out += monthName({ weekday: tok.long ? 'long' : 'short' }); break;
+ case 'hour24': out += tok.pad ? pad2(d.getHours()) : String(d.getHours()); break;
+ case 'hour12': { const h = ((d.getHours() + 11) % 12) + 1; out += tok.pad ? pad2(h) : String(h); break; }
+ case 'minute': out += pad2(d.getMinutes()); break;
+ case 'second': out += pad2(d.getSeconds()); break;
+ case 'ampm': out += d.getHours() < 12 ? 'AM' : 'PM'; break;
+ }
+ }
+ return out;
+}
+
+// --- ODF values --------------------------------------------------------------
+
+// text:date-value = ISO local datetime (no offset); text:time-value = ISO 8601 duration.
+export function toDateValue(d: Date): string {
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
+}
+
+export function toTimeValue(d: Date): string {
+ return `PT${pad2(d.getHours())}H${pad2(d.getMinutes())}M${pad2(d.getSeconds())}S`;
+}
+
+// --- ODF number style --------------------------------------------------------
+
+function escNumberText(s: string): string {
+ return s.replace(/&/g, '&').replace(//g, '>');
+}
+
+// One / body from the tokens; the caller wraps
+// it with the style name and language. Times/months render per the style's language.
+export function odfNumberStyle(fmt: DtFormat, styleName: string, lang: { language: string; country: string } | null): string {
+ const parts: string[] = [];
+ for (const tok of fmt.tokens) {
+ switch (tok.t) {
+ case 'lit': parts.push(`${escNumberText(tok.s)} `); break;
+ case 'year': parts.push(` `); break;
+ case 'month':
+ if (tok.style === 'num') parts.push(' ');
+ else if (tok.style === 'num2') parts.push(' ');
+ else parts.push(` `);
+ break;
+ case 'day': parts.push(` `); break;
+ case 'weekday': parts.push(` `); break;
+ case 'hour24': case 'hour12': parts.push(` `); break;
+ case 'minute': parts.push(' '); break;
+ case 'second': parts.push(' '); break;
+ case 'ampm': parts.push(' '); break;
+ }
+ }
+ const el = fmt.kind === 'date' ? 'number:date-style' : 'number:time-style';
+ const langAttr = lang ? ` number:language="${lang.language}" number:country="${lang.country}"` : '';
+ return `<${el} style:name="${styleName}"${langAttr}>${parts.join('')}${el}>`;
+}
+
+// --- DOCX field picture ------------------------------------------------------
+
+// Word field switch picture (e.g. `dd.MM.yyyy`). Month = M/MM/MMM/MMMM, minute = mm.
+export function docxPicture(fmt: DtFormat): string {
+ let out = '';
+ for (const tok of fmt.tokens) {
+ switch (tok.t) {
+ case 'lit': out += tok.s; break;
+ case 'year': out += tok.long ? 'yyyy' : 'yy'; break;
+ case 'month':
+ out += tok.style === 'num' ? 'M' : tok.style === 'num2' ? 'MM' : tok.style === 'longText' ? 'MMMM' : 'MMM';
+ break;
+ case 'day': out += tok.pad ? 'dd' : 'd'; break;
+ case 'weekday': out += tok.long ? 'dddd' : 'ddd'; break;
+ case 'hour24': out += tok.pad ? 'HH' : 'H'; break;
+ case 'hour12': out += tok.pad ? 'hh' : 'h'; break;
+ case 'minute': out += 'mm'; break;
+ case 'second': out += 'ss'; break;
+ case 'ampm': out += 'AM/PM'; break;
+ }
+ }
+ return out;
+}
+
+const LOCALE_TAG: Record = { en: 'en-US', de: 'de-DE' };
+
+// Map a UI locale ('en'/'de') to a BCP-47 tag for Intl.
+export function localeTag(locale: string): string {
+ return LOCALE_TAG[locale] ?? locale;
+}
diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts
index 86b3701..23b6f59 100644
--- a/tests/docx-roundtrip.test.ts
+++ b/tests/docx-roundtrip.test.ts
@@ -268,6 +268,35 @@ describe('DOCX export → import round trip', () => {
});
});
+describe('DOCX date/time fields', () => {
+ const dtf = (kind: string, format: string, fixed: boolean, value: string): N =>
+ ({ type: 'dateTimeField', attrs: { kind, format, fixed, value } });
+ const doc: N = { type: 'doc', content: [
+ para([text('Auto '), dtf('date', 'dmy_dots', false, '2026-07-08T00:00:00')]),
+ para([text('Fixed '), dtf('date', 'mdy_long', true, '2026-07-08T14:30:00')]),
+ para([text('Time '), dtf('time', 'hm24', false, '2026-07-08T14:30:00')]),
+ ] };
+
+ it('exports an auto field as a DATE fldSimple and a fixed field as text', async () => {
+ const bytes = await buildDocx(doc, undefined, 'portrait', undefined, { language: 'de', country: 'DE' });
+ const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
+ expect(xml).toMatch(/]*w:instr="[^"]*DATE[^"]*dd\.MM\.yyyy/);
+ expect(xml).toMatch(/]*w:instr="[^"]*TIME[^"]*HH:mm/);
+ // The fixed field has no fldSimple wrapper — just a plain run rendered in the
+ // document language (German month name here).
+ expect(xml).toContain('Juli 8, 2026');
+ expect(xml).not.toMatch(/]*>[^<]*]*>Juli/);
+ });
+
+ it('re-imports the auto DATE/TIME fields as live dateTimeField nodes', async () => {
+ const bytes = await buildDocx(doc, undefined, 'portrait', undefined, { language: 'de', country: 'DE' });
+ const res = importDocx(bytes).content as N;
+ const fields = walk(res, 'dateTimeField');
+ expect(fields.map((f: N) => f.attrs.format).sort()).toEqual(['dmy_dots', 'hm24']);
+ expect(fields.every((f: N) => f.attrs.fixed === false)).toBe(true);
+ });
+});
+
describe('DOCX import of a foreign Word document', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const documentXml = `
diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts
index 3e2967b..f6e498b 100644
--- a/tests/roundtrip.test.ts
+++ b/tests/roundtrip.test.ts
@@ -1048,3 +1048,50 @@ describe('Leg 5: table of contents (text:table-of-content)', () => {
blocks.some((n: N) => n.type === 'heading' && n.content?.[0]?.text === 'Introduction'));
});
});
+
+describe('Leg 10: date/time fields (text:date / text:time)', () => {
+ const DTF = (kind: string, format: string, fixed: boolean, value: string, marks?: N[]): N =>
+ ({ type: 'dateTimeField', attrs: { kind, format, fixed, value }, ...(marks ? { marks } : {}) });
+ const FONT = { type: 'textStyle', attrs: { fontFamily: 'Calibri' } };
+ const dtDoc: N = {
+ type: 'doc',
+ content: [
+ P(null, T('Signed on '), DTF('date', 'dmy_dots', true, '2026-07-08T14:30:45', [FONT]), T(' at '),
+ DTF('time', 'hms24', true, '2026-07-08T14:30:45')),
+ P(null, T('Printed: '), DTF('date', 'weekday_mdy', false, '2026-07-08T09:00:00')),
+ ],
+ };
+
+ it('exports / with a minted number style and re-imports the fields', async () => {
+ const bytes = await buildOdt(dtDoc, margins, 'portrait', undefined, { language: 'de', country: 'DE' });
+ const content = strFromU8(unzipSync(bytes)['content.xml']);
+ check('emits ', content.includes('', content.includes(']*text:fixed="true"/.test(content));
+ check('auto date carries text:fixed=false', /]*text:fixed="false"/.test(content));
+ check('date-value present', content.includes('text:date-value="2026-07-08T14:30:45"'));
+ check('time-value present', content.includes('text:time-value="PT14H30M45S"'));
+ check('mints a number:date-style', content.includes(' f.attrs));
+ const fixedDate = fields.find((f) => f.attrs.kind === 'date' && f.attrs.fixed);
+ check('fixed date format round-trips', fixedDate?.attrs.format === 'dmy_dots', fixedDate?.attrs);
+ check('fixed date value round-trips', fixedDate?.attrs.value === '2026-07-08T14:30:45', fixedDate?.attrs);
+ // The field carries the surrounding font so the atom doesn't fall back to the
+ // editor default (regression: DOCX date field imported without its font).
+ const font = (fixedDate?.marks ?? []).find((m: N) => m.type === 'textStyle')?.attrs?.fontFamily;
+ check('fixed date carries its font mark', font === 'Calibri', fixedDate?.marks);
+ const fixedTime = fields.find((f) => f.attrs.kind === 'time');
+ check('fixed time format round-trips', fixedTime?.attrs.format === 'hms24', fixedTime?.attrs);
+ const autoDate = fields.find((f) => f.attrs.kind === 'date' && !f.attrs.fixed);
+ check('auto date format round-trips', autoDate?.attrs.format === 'weekday_mdy', autoDate?.attrs);
+ check('surrounding text preserved',
+ (res.content.content ?? [])[0]?.content?.[0]?.text === 'Signed on ');
+ });
+});