diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 4d73768..80c14a1 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -442,18 +442,35 @@ type Inline = TextRun | ImageRun | ExternalHyperlink | InternalHyperlink | Simpl // 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 { +function dateTimeFieldParts(node: TiptapNode): { fixed: boolean; instr: string; text: string } { 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); + return { + fixed: a.fixed === true, + instr: `${kind === 'time' ? 'TIME' : 'DATE'} \\@ "${docxPicture(fmt)}"`, + text: renderFormat(fmt, when, docLangTag), + }; +} + +function dateTimeRun(node: TiptapNode): Inline { + const { fixed, instr, text } = dateTimeFieldParts(node); // 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); + if (fixed) return new TextRun({ text, ...runPropsFromMarks(node.marks) }); + return new SimpleField(instr, text); +} + +// The XE field instruction; a literal quote is backslash-escaped, as Word writes it, +// and indexEntryFromInstr (import) unescapes the same way. +function xeInstr(attrs: Record | undefined): string | null { + const term = String(attrs?.term ?? '').trim(); + const key1 = String(attrs?.key1 ?? '').trim(); + if (!term) return null; + return `XE "${(key1 ? `${key1}:${term}` : term).replace(/([\\"])/g, '\\$1')}"`; } // Word's numeric-picture switch per ODF num-format — the SEQ field's own formatting. @@ -575,9 +592,8 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline } else if (node.type === 'indexEntry') { // Word's index entry: a hidden XE field, its term in the instruction. A key files // the term under it, "key:term", exactly as LibreOffice's text:key1 does. - const term = String(node.attrs?.term ?? '').trim(); - const key1 = String(node.attrs?.key1 ?? '').trim(); - if (term) out.push(new SimpleField(`XE "${(key1 ? `${key1}:${term}` : term).replace(/"/g, "'")}"`, '')); + const instr = xeInstr(node.attrs); + if (instr) out.push(new SimpleField(instr, '')); } else if (node.type === 'bibliographyEntry') { // Word's citation: a CITATION field naming the source's tag, its cached result the // text the reader sees. applyBibliographyDocx writes the source itself. @@ -757,6 +773,9 @@ function escapeXml(s: string): string { function txbxRunPropsXml(marks: TiptapNode['marks'] = []): string { const ts = marks.find((m) => m.type === 'textStyle'); const parts: string[] = []; + // w:rStyle leads w:rPr; the run's own properties below still win, as in the body. + const cs = marks.find((m) => m.type === 'charStyle')?.attrs?.name; + if (typeof cs === 'string' && cs) parts.push(``); const ff = ts?.attrs?.fontFamily; if (ff) { const f = escapeXml(String(ff) === SCREEN_FONT ? DOC_FONT : String(ff)); @@ -928,6 +947,31 @@ function txbxParagraphXml(node: TiptapNode, parts: TxbxParts, indentTwip = 0, nu const verb = child.attrs?.format === 'page' ? 'PAGEREF' : 'REF'; runs += `` + `${escapeXml(String(child.attrs?.text ?? ''))}`; + } else if (child.type === 'indexEntry') { + const instr = xeInstr(child.attrs); + if (instr) runs += ``; + } else if (child.type === 'bibliographyEntry') { + // Same CITATION field as the body; the source record joins docSources, which + // applyBibliographyDocx (running after the boxes are packed) writes out. + const identifier = String(child.attrs?.identifier ?? '').trim(); + if (identifier) { + if (!docSources.some(s => s.identifier === identifier)) { + docSources.push({ + identifier, + type: String(child.attrs?.type ?? 'misc'), + fields: (child.attrs?.fields ?? {}) as Record, + }); + } + const shown = String(child.attrs?.text || citationText(identifier)); + runs += `` + + `${txbxRunPropsXml(child.marks)}${escapeXml(shown)}`; + } + } else if (child.type === 'dateTimeField') { + const { fixed, instr, text } = dateTimeFieldParts(child); + runs += fixed + ? `${txbxRunPropsXml(child.marks)}${escapeXml(text)}` + : `` + + `${escapeXml(text)}`; } else if (child.type === 'formula') { // A sentinel run, resolved by applyFormulasDocx — it runs after the boxes are packed. const latex = typeof child.attrs?.latex === 'string' ? child.attrs.latex : ''; @@ -940,12 +984,20 @@ function txbxParagraphXml(node: TiptapNode, parts: TxbxParts, indentTwip = 0, nu runs += `${RBY}${docRubies.length - 1}${RBY}`; } else if (child.type === 'text' && child.text) { const rPr = txbxRunPropsXml(child.marks); + // A recorded revision wraps the run; a deletion's text sits in w:delText. + const rev = revisionOf(child); + const tag = rev?.kind === 'deletion' ? 'w:delText' : 'w:t'; let inner = ''; child.text.split('\t').forEach((seg, i) => { if (i > 0) inner += ''; - if (seg) inner += `${escapeXml(seg)}`; + if (seg) inner += `<${tag} xml:space="preserve">${escapeXml(seg)}`; }); - const run = `${rPr}${inner}`; + let run = `${rPr}${inner}`; + if (rev) { + const el = rev.kind === 'insertion' ? 'w:ins' : 'w:del'; + run = `<${el} w:id="${docRevisionId(rev.attrs.id)}" w:author="${escapeXml(rev.attrs.author)}"` + + ` w:date="${escapeXml(rev.attrs.date)}">${run}`; + } const href = child.marks?.find((m) => m.type === 'link')?.attrs?.href; const link = href ? String(href) : ''; if (link.startsWith('#')) { diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index eab5e9a..efd7341 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1276,6 +1276,25 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD return marks; }; + // A recorded revision (w:ins/w:del). Its runs keep their formatting and take the + // mark; a deletion's text sits in w:delText, which handleRun reads as ordinary text. + const handleRevision = (el: Element, linkHref?: string) => { + const kind = el.localName === 'del' ? 'deletion' : 'insertion'; + const attrs = { + id: el.getAttributeNS(W, 'id') ?? '', + author: el.getAttributeNS(W, 'author') ?? '', + date: el.getAttributeNS(W, 'date') ?? '', + }; + const before = out.length; + for (const r of fcAll(el, 'r')) handleRun(r, linkHref); + if (!hfFields) { + for (let i = before; i < out.length; i++) { + if (out[i].type !== 'text') continue; + out[i].marks = [...(out[i].marks ?? []), { type: kind, attrs }]; + } + } + }; + 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 @@ -1413,7 +1432,11 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD // No relationship id: an internal link to a bookmark in this document. const anchor = el.getAttributeNS(W, 'anchor'); const href = rid ? ctx.rels.get(rid)?.target : anchor ? `#${anchor}` : undefined; - for (const r of fcAll(el, 'r')) handleRun(r, href); + // A revision wraps its runs inside the link element, so walk both shapes. + for (const child of Array.from(el.children)) { + if (child.localName === 'r') handleRun(child, href); + else if (child.localName === 'ins' || child.localName === 'del') handleRevision(child, href); + } break; } case 'fldSimple': { @@ -1451,25 +1474,9 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD break; } case 'ins': - case 'del': { - // A recorded revision. Its runs keep their formatting and take the mark; a - // deletion's text sits in w:delText, which handleRun reads as ordinary text. - const kind = el.localName === 'del' ? 'deletion' : 'insertion'; - const attrs = { - id: el.getAttributeNS(W, 'id') ?? '', - author: el.getAttributeNS(W, 'author') ?? '', - date: el.getAttributeNS(W, 'date') ?? '', - }; - const before = out.length; - for (const r of fcAll(el, 'r')) handleRun(r); - if (!hfFields) { - for (let i = before; i < out.length; i++) { - if (out[i].type !== 'text') continue; - out[i].marks = [...(out[i].marks ?? []), { type: kind, attrs }]; - } - } + case 'del': + handleRevision(el); break; - } case 'smartTag': for (const r of fcAll(el, 'r')) handleRun(r); break;