feat: insert date/time

This commit is contained in:
Steffen Becker committed 2026-07-08 23:21:07 +02:00
1 parent 2cfdd7a44f
commit 1fb81c389f
15 files changed
+882 -16

No files matched your search

+1
View File
@@ -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`
+213
View File
@@ -0,0 +1,213 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import {
DATE_FORMATS, TIME_FORMATS, renderFormat, localeTag, type DtFormat,
} from '../utils/dateTime';
import { t, locale } from '../i18n/i18n.svelte';
let {
editor,
open = $bindable(false),
onOpen,
onInsert,
}: {
editor: Editor | null;
open?: boolean;
onOpen?: () => void;
// Mirrors SpecialCharPicker: the parent runs the command against the saved range.
onInsert: (
opts: { kind: 'date' | 'time'; format: string; fixed: boolean },
range: { from: number; to: number },
) => void;
} = $props();
let fixed = $state(true);
let savedFrom: number | null = null;
let savedTo: number | null = null;
// A live clock so the samples show the real current date/time; ticks while open.
let now = $state(new Date());
$effect(() => {
if (!open) return;
now = new Date();
const id = setInterval(() => (now = new Date()), 1000);
return () => clearInterval(id);
});
function sample(fmt: DtFormat): string {
return renderFormat(fmt, now, localeTag(locale()));
}
function openPicker() {
if (!editor) return;
onOpen?.();
if (open) {
open = false;
return;
}
savedFrom = editor.state.selection.from;
savedTo = editor.state.selection.to;
now = new Date();
open = true;
}
function pick(fmt: DtFormat) {
if (!editor) return;
const from = savedFrom ?? editor.state.selection.from;
const to = savedTo ?? editor.state.selection.to;
onInsert({ kind: fmt.kind, format: fmt.key, fixed }, { from, to });
open = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
(e.currentTarget as HTMLElement).blur();
}
}
function dtPickerClickOutside(node: HTMLElement) {
function handler(e: MouseEvent) {
if (!node.contains(e.target as Node)) open = false;
}
window.addEventListener('mousedown', handler);
return { destroy() { window.removeEventListener('mousedown', handler); } };
}
</script>
<div class="dt-picker" use:dtPickerClickOutside>
<button class="dt-trigger" onclick={openPicker} title={t().dateTime.insert} aria-pressed={open} aria-label={t().dateTime.insert}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1.75" y="2.75" width="12.5" height="11.5" rx="1.5" stroke="currentColor" />
<path d="M1.75 5.5h12.5M4.5 1.5v2.5M11.5 1.5v2.5" stroke="currentColor" stroke-linecap="round" />
<circle cx="8" cy="10" r="2.4" stroke="currentColor" />
<path d="M8 8.8V10l0.9 0.6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
{#if open}
<div class="dt-dropdown" role="dialog" aria-label={t().dateTime.insert} tabindex="-1" onkeydown={onKeydown}>
<div class="dt-columns">
<div class="dt-col">
<div class="dt-section-label">{t().dateTime.dateHeading}</div>
{#each DATE_FORMATS as fmt (fmt.key)}
<button class="dt-cell" onclick={() => pick(fmt)}>{sample(fmt)}</button>
{/each}
</div>
<div class="dt-col">
<div class="dt-section-label">{t().dateTime.timeHeading}</div>
{#each TIME_FORMATS as fmt (fmt.key)}
<button class="dt-cell" onclick={() => pick(fmt)}>{sample(fmt)}</button>
{/each}
</div>
</div>
<label class="dt-auto" title={t().dateTime.updateHint}>
<input type="checkbox" checked={!fixed} onchange={(e) => (fixed = !(e.currentTarget as HTMLInputElement).checked)} />
{t().dateTime.updateAutomatically}
</label>
</div>
{/if}
</div>
<style>
.dt-picker {
position: relative;
}
.dt-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--toolbar-btn-size);
height: var(--toolbar-btn-size);
padding: 0 0.3rem;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.dt-trigger:hover {
background: var(--color-btn-hover);
border-color: var(--color-primary);
}
.dt-trigger[aria-pressed='true'] {
background: var(--color-primary);
color: white;
}
.dt-dropdown {
position: absolute;
top: calc(100% + 3px);
right: 0;
width: 340px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
z-index: 200;
padding: 8px;
}
.dt-columns {
display: grid;
grid-template-columns: 1.7fr 1fr;
gap: 10px;
}
.dt-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.dt-section-label {
padding: 0.1rem 0.1rem 0.25rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
font-family: var(--font-sans);
user-select: none;
}
.dt-cell {
text-align: left;
padding: 0.32rem 0.45rem;
border: 1px solid var(--color-border);
border-radius: 3px;
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-serif, serif);
font-size: 0.85rem;
line-height: 1.2;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.1s, border-color 0.1s;
}
.dt-cell:hover {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.dt-auto {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--color-border);
font-size: 0.78rem;
font-family: var(--font-sans);
color: var(--color-text);
cursor: pointer;
user-select: none;
}
</style>
+43 -3
View File
@@ -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<typeof effectiveSize>[0], parent as Parameters<typeof effectiveSize>[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<HTMLInputElement | null>(null);
@@ -1302,6 +1336,12 @@
onOpen={onSpecialCharPickerOpen}
onInsert={insertSpecialChar}
/>
<DateTimePicker
{editor}
bind:open={dateTimeOpen}
onOpen={onDateTimePickerOpen}
onInsert={insertDateTime}
/>
<button
onclick={() => imageInput?.click()}
disabled={!!hfActive}
+4
View File
@@ -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 <draw:frame>/<draw:image>
// (export/odt.ts replaceImages/applyImages, import/odt.ts).
Image,
// Inline date/time field (fixed or auto-updating); round-trips to ODF
// <text:date>/<text:time> 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,
@@ -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 <text:date>/<text:time> 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<ReturnType> {
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 } : {}),
});
},
};
},
});
+26 -2
View File
@@ -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<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
const num = new Numbering();
const landscape = orientation === 'landscape';
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
+92 -2
View File
@@ -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 <style:columns>. 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 <text:date>/<text:time> + a minted number style. U+E00A.
const DTF = '';
const EXT_BY_MIME: Record<string, string> = {
'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
// <number:date-style>/<number:time-style> and their references need the prefix.
function ensureNumberNamespace(content: string): string {
if (content.includes('xmlns:number=')) return content;
return content.replace(
/<office:document-content\b/,
'<office:document-content xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"',
);
}
// Resolve date/time field sentinels: swap each DTF{i}DTF for a <text:date>/<text:time>
// 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<string, string>();
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 `<text:time text:time-value="${toTimeValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:time>`;
}
return `<text:date text:date-value="${toDateValue(when)}"${fixedAttr} style:data-style-name="${styleName}">${display}</text:date>`;
});
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);
+7
View File
@@ -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',
+7
View File
@@ -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',
+50 -7
View File
@@ -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) {
+79 -2
View File
@@ -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 <number:date-style>/<number:time-style> 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 <text:date>/<text:time> 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;
}
+10
View File
@@ -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<string, StyleEntry>(); // family → style:default-style
private fontFaces = new Map<string, string>(); // style:name → first font-family
private listStyles = new Map<string, Element>(); // style:name → text:list-style
private numberStyles = new Map<string, Element>(); // style:name → number:date/time-style
// Section styles kept as raw elements: <style:columns> is a child element of
// <style:section-properties>, which the attribute-only StyleEntry drops.
private sectionStyleEls = new Map<string, Element>();
@@ -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 <number:date-style>/<number:time-style> 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 {
+180
View File
@@ -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
// <number:date/time-style>, 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<string, DtFormat>([...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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// One <number:date-style>/<number:time-style> 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(`<number:text>${escNumberText(tok.s)}</number:text>`); break;
case 'year': parts.push(`<number:year number:style="${tok.long ? 'long' : 'short'}"/>`); break;
case 'month':
if (tok.style === 'num') parts.push('<number:month number:style="short"/>');
else if (tok.style === 'num2') parts.push('<number:month number:style="long"/>');
else parts.push(`<number:month number:style="${tok.style === 'longText' ? 'long' : 'short'}" number:textual="true"/>`);
break;
case 'day': parts.push(`<number:day number:style="${tok.pad ? 'long' : 'short'}"/>`); break;
case 'weekday': parts.push(`<number:day-of-week number:style="${tok.long ? 'long' : 'short'}"/>`); break;
case 'hour24': case 'hour12': parts.push(`<number:hours number:style="${tok.pad ? 'long' : 'short'}"/>`); break;
case 'minute': parts.push('<number:minutes number:style="long"/>'); break;
case 'second': parts.push('<number:seconds number:style="long"/>'); break;
case 'ampm': parts.push('<number:am-pm/>'); 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<string, string> = { 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;
}
+29
View File
@@ -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:fldSimple[^>]*w:instr="[^"]*DATE[^"]*dd\.MM\.yyyy/);
expect(xml).toMatch(/<w:fldSimple[^>]*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(/<w:fldSimple[^>]*>[^<]*<w:r><w:t[^>]*>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 = `<?xml version="1.0"?><w:document ${W}><w:body>
+47
View File
@@ -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 <text:date>/<text:time> 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 <text:date>', content.includes('<text:date '), content.slice(0, 120));
check('emits <text:time>', content.includes('<text:time '));
check('fixed date carries text:fixed=true', /<text:date[^>]*text:fixed="true"/.test(content));
check('auto date carries text:fixed=false', /<text:date[^>]*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('<number:date-style '));
check('mints a number:time-style', content.includes('<number:time-style '));
check('declares number namespace', content.includes('xmlns:number='));
check('no leftover sentinel', !content.includes(''));
const res = importOdt(bytes);
const fields: N[] = [];
(function walk(n: N) { if (n.type === 'dateTimeField') fields.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
check('3 date/time fields imported', fields.length === 3, fields.map((f) => 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 ');
});
});