diff --git a/tests/fuzzDoc.ts b/tests/fuzzDoc.ts index 5baf510..9265d52 100644 --- a/tests/fuzzDoc.ts +++ b/tests/fuzzDoc.ts @@ -55,6 +55,15 @@ function marks(r: Rng, heading = false): N[] | undefined { if (!shifted && maybe(r, 0.15)) attrs.textPosition = pick(r, [3, -2]); if (Object.keys(attrs).length) out.push({ type: 'textStyle', attrs }); } + if (!heading && maybe(r, 0.06)) { + const name = pick(r, ['Emphasis', 'Strong Emphasis', 'Source Text']); + // a direct mark equal to what the style supplies is suppressed on import (same + // rule as the heading defaults), so it can't ride beside the style + const redundant = name === 'Emphasis' ? 'italic' : name === 'Strong Emphasis' ? 'bold' : ''; + const i = out.findIndex((m) => m.type === redundant); + if (i >= 0) out.splice(i, 1); + out.push({ type: 'charStyle', attrs: { name } }); + } return out.length ? out : undefined; } @@ -102,6 +111,8 @@ function paraAttrs(r: Rng, indents: boolean, top: boolean): N | null { // Doc-level state for cross-linked features (bookmark names, comment ids); genDoc resets it. let bmNames: string[] = []; let commentSeq = 0; +let revSeq = 0; +let seqCounters: Record = {}; const LATEX = ['x^{2}+1', '\\frac{a}{b}', '\\sqrt{x+1}', '\\alpha \\cdot \\beta'] as const; @@ -133,6 +144,38 @@ function paragraph(r: Rng, indents = true, top = false): N { { type: 'comment', attrs: { id: `c${++commentSeq}`, author: 'Fuzz Author', date: '2026-03-04T05:06:07', text: 'Check & "here"', resolved: maybe(r, 0.3) } }]; } + if (maybe(r, 0.06)) { + const run = content[int(r, 0, content.length - 1)]; + run.marks = [...(run.marks ?? []), { type: pick(r, ['insertion', 'deletion']), + attrs: { id: `rv${++revSeq}`, author: 'Rev Author', date: '2026-05-06T07:08:09' } }]; + } + if (maybe(r, 0.04)) { + const category = pick(r, ['figure', 'table'] as const); + seqCounters[category] = (seqCounters[category] ?? 0) + 1; + body.push({ type: 'sequenceField', + attrs: { category, format: pick(r, ['1', 'a', 'A', 'i', 'I']), number: seqCounters[category] } }); + } + if (maybe(r, 0.04)) { + body.push({ type: 'indexEntry', + attrs: { term: 'Größe & ', key1: maybe(r, 0.4) ? 'Key "1"' : '' } }); + } + // fields limited to the ones Word has a b:Source slot for (DOCX_BIB_FIELD); the rest + // survive only the ODF leg. text is derived on load — normalize drops it. The type is + // keyed to the identifier: one identifier is one source record (Word stores it once). + if (maybe(r, 0.04)) { + const src = int(r, 1, 3); + body.push({ type: 'bibliographyEntry', attrs: { + identifier: `src${src}`, type: ['book', 'article', 'www'][src - 1], + fields: { author: 'Knuth, Donald', title: 'Art & "CS"', year: '1986' }, text: '' } }); + } + // fixed=false only: Word has no fixed-date field, the DOCX exporter writes plain text. + if (maybe(r, 0.04)) { + const kind = pick(r, ['date', 'time'] as const); + body.push({ type: 'dateTimeField', attrs: { kind, + format: kind === 'date' ? pick(r, ['iso', 'dmy_dots', 'mdy_long', 'weekday_dmy']) + : pick(r, ['hm24', 'hms12']), + fixed: false, value: '2026-08-16T10:30:00' } }); + } if (maybe(r, 0.04)) body.push({ type: 'ruby', attrs: { base: '漢字', text: 'かんじ' } }); // display=false only: ODF has no display flag, the importer derives it from the formula // owning its line (aloneInParagraph) — a display formula is its own paragraph (genDoc). @@ -143,6 +186,14 @@ function paragraph(r: Rng, indents = true, top = false): N { const img: N = { src: PNG, width: int(r, 40, 200), height: int(r, 30, 120) }; if (maybe(r, 0.3)) img.alt = 'a & "b" '; if (maybe(r, 0.2)) img.rotation = pick(r, [90, 180]); + // floating at top level only; offsets in whole hundredth-cm (import rounds to 2) + if (top && maybe(r, 0.25)) { + img.wrap = pick(r, ['left', 'right', 'topBottom']); + if (maybe(r, 0.5)) img.wrapOffset = pick(r, [1.5, 2.25]); + // dist only beside a side wrap: with text only above/below there is no side + // gap to keep, and the ODT graphic style carries none + if (img.wrap !== 'topBottom' && maybe(r, 0.3)) img.wrapDist = 0.5; + } body.push({ type: 'image', attrs: img }); } return { type: 'paragraph', ...(attrs ? { attrs } : {}), content: body }; @@ -219,6 +270,8 @@ const ROMAN = ['i', 'ii', 'iii', 'iv', 'v'] as const; export function genDoc(r: Rng): N { bmNames = []; commentSeq = 0; + revSeq = 0; + seqCounters = {}; const blocks: N[] = []; const notes: N[] = []; let prev = ''; diff --git a/tests/normalize.ts b/tests/normalize.ts index 69b89ed..b908ccd 100644 --- a/tests/normalize.ts +++ b/tests/normalize.ts @@ -7,6 +7,7 @@ const ORDERED_DEFAULTS: Record = { listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1, type: null, level: undefined, rotation: 0, wrap: 'inline', shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1, + fixed: false, key1: '', }; // Mark-attr defaults dropped like ORDERED_DEFAULTS is for node attrs. @@ -14,21 +15,34 @@ const MARK_DEFAULTS: Record = { plain: false, // link.ts default; the DOCX importer writes it out explicitly }; -// Note and comment ids are importer-generated (ftn1/footnote1/…, c1 vs w0); remap them -// to their order of appearance so only the pairing is compared, not the naming scheme. +// Note, comment and revision ids are importer-generated (ftn1/footnote1/…, c1 vs w0, +// Word's numeric w:id); remap them to their order of appearance so only the pairing is +// compared, not the naming scheme. Sequence numbers are recounted the way the editor +// does on load (per category, document order) — the DOCX field carries no cached rank. function canonNoteIds(doc: N): void { const map = new Map(); const comments = new Map(); + const revs = new Map(); + const seq: Record = {}; (function walk(n: N) { if ((n.type === 'noteRef' || n.type === 'note') && n.attrs?.id != null) { if (!map.has(n.attrs.id)) map.set(n.attrs.id, `n${map.size + 1}`); n.attrs = { ...n.attrs, id: map.get(n.attrs.id) }; } + if (n.type === 'sequenceField' && n.attrs) { + const c = String(n.attrs.category ?? 'figure'); + seq[c] = (seq[c] ?? 0) + 1; + n.attrs = { ...n.attrs, number: seq[c] }; + } for (const m of n.marks ?? []) { if (m.type === 'comment' && m.attrs?.id != null) { if (!comments.has(m.attrs.id)) comments.set(m.attrs.id, `c${comments.size + 1}`); m.attrs = { ...m.attrs, id: comments.get(m.attrs.id) }; } + if ((m.type === 'insertion' || m.type === 'deletion') && m.attrs?.id != null) { + if (!revs.has(m.attrs.id)) revs.set(m.attrs.id, `rv${revs.size + 1}`); + m.attrs = { ...m.attrs, id: revs.get(m.attrs.id) }; + } } for (const c of n.content ?? []) walk(c); })(doc); @@ -44,9 +58,10 @@ export function normalize(node: N): N { const mm: N = { type: m.type }; const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}) .filter(([k, v]) => v != null && MARK_DEFAULTS[k] !== v)); - // ODF keeps the authored (naive local) comment date, DOCX re-serializes the same - // instant as UTC — compare the instant. - if (m.type === 'comment' && typeof attrs.date === 'string') { + // ODF keeps the authored (naive local) comment/revision date, DOCX re-serializes + // the same instant as UTC — compare the instant. + if ((m.type === 'comment' || m.type === 'insertion' || m.type === 'deletion') + && typeof attrs.date === 'string') { const t = Date.parse(attrs.date); if (!Number.isNaN(t)) attrs.date = t; } @@ -56,9 +71,15 @@ export function normalize(node: N): N { .sort((a: N, b: N) => a.type.localeCompare(b.type)); } const attrs: N = {}; + // An auto date/time field re-evaluates on load (the DOCX importer stamps "now"), so + // its cached value is presentational; a citation's text label is derived the same way. + const volatileKey = (k: string) => + (node.type === 'dateTimeField' && k === 'value' && node.attrs?.fixed !== true) + || (node.type === 'bibliographyEntry' && k === 'text'); for (const [k, v] of Object.entries(node.attrs ?? {})) { if (v == null) continue; if (k in ORDERED_DEFAULTS && ORDERED_DEFAULTS[k] === v) continue; + if (volatileKey(k)) continue; if (k === 'colwidth') { attrs.colwidth = 'CW'; continue; } // ratios compared separately // The DOCX importer keeps the OMML serializer's trailing space on purpose // (re-serialize-to-itself); the ODT annotation is the authored string. Same formula.