test: fuzz round 5 — revisions, fields, citations, char styles, floats

Generator gains insertion/deletion marks, sequence/index/date-time fields,
citations (one record per identifier), builtin character styles (redundant
direct marks dropped — import suppresses them like heading defaults) and
floating images (wrapDist only beside a side wrap). normalize canonicalizes
revision ids/dates like comments, recounts sequence numbers per category,
and drops an auto date field's value and a citation's derived label.
This commit is contained in:
Steffen Becker committed 2026-08-16 12:07:22 +02:00
1 parent 205ae72ecf
commit 5bf470f4b7
2 files changed
+79 -5

No files matched your search

+53
View File
@@ -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<string, number> = {};
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 & <this> "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 & <term>', 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 & <of> "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" <c>';
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 = '';
+26 -5
View File
@@ -7,6 +7,7 @@ const ORDERED_DEFAULTS: Record<string, unknown> = {
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<string, unknown> = {
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<string, string>();
const comments = new Map<string, string>();
const revs = new Map<string, string>();
const seq: Record<string, number> = {};
(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.