feat: insert date/time
This commit is contained in:
1 parent
2cfdd7a44f
commit
1fb81c389f
15 files changed
+882
-16
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user