test: fuzz round 7 — indexes, table styles, frame pairs, vertical boxes

The generator now emits tableOfContents nodes of all five families, styled
tables painted through the real resolveTableCell (name + look + materialized
fill/borders), side-by-side topBottom image pairs (wrapAlign), vertical-text
boxes and the Caption paragraph style. normalize learns the index attr
defaults the DOCX TOC field has no switch for.
This commit is contained in:
Steffen Becker committed 2026-08-16 13:17:47 +02:00
1 parent 6789377b48
commit c473fd5f0f
2 files changed
+78 -2

No files matched your search

+74 -2
View File
@@ -1,4 +1,8 @@
// Seeded random-document generator for the fuzz round-trip leg (fuzz-roundtrip.test.ts).
import {
DEFAULT_TABLE_LOOK, TABLE_REGIONS, builtinTableStyles, resolveTableCell, tableLookAttr,
} from '../src/lib/styles/tableStyles';
type N = any;
type Rng = () => number;
@@ -113,6 +117,7 @@ let bmNames: string[] = [];
let commentSeq = 0;
let revSeq = 0;
let seqCounters: Record<string, number> = {};
let bibIndex = false;
const LATEX = ['x^{2}+1', '\\frac{a}{b}', '\\sqrt{x+1}', '\\alpha \\cdot \\beta'] as const;
@@ -210,6 +215,7 @@ function textBox(r: Rng): N {
attrs.strokeColor = '#0070C0';
if (maybe(r, 0.5)) attrs.strokeWidthPt = 2;
}
if (maybe(r, 0.12)) attrs.textVertical = true;
// floats like an image; dist beside a side wrap only (as there)
if (maybe(r, 0.3)) {
attrs.wrap = pick(r, ['left', 'right', 'topBottom']);
@@ -221,6 +227,38 @@ function textBox(r: Rng): N {
return { type: 'textBox', attrs, content: kids };
}
// A generated index. Both importers return an empty entry cache (the node view refills
// it live), no own title and the default leader — so that is what an authored one holds.
function tocBlock(r: Rng): N {
let kind = pick(r, ['toc', 'toc', 'figures', 'tables', 'alphabetical', 'bibliography'] as const);
if (kind === 'bibliography' && bibIndex) kind = 'toc';
// maxLevel rides only the TOC family (ODF text:outline-level, Word's \o range).
const attrs: N = { entries: [], title: '', index: kind,
leader: '.', tabPosCm: null, maxLevel: kind === 'toc' ? pick(r, [3, 5, 10]) : 10 };
// One bibliography per doc with a Word-nameable style: DOCX keeps a single document
// citation style (b:Sources StyleName), and it has no name for LibreOffice's
// cite-by-key (the export writes APA for it), so 'key' survives only the ODT leg.
if (kind === 'bibliography') {
bibIndex = true;
attrs.citationStyle = pick(r, ['numbered', 'apa', 'mla', 'chicago']);
}
return { type: 'tableOfContents', attrs };
}
// Two topBottom images set against opposite band ends share the band side by side; only
// such a pair keeps its wrapAlign on import (pairAlignedFrames — a lone aligned frame
// reserves the whole band). No wrapOffset: the export writes the alignment as the x.
function framePair(r: Rng): N {
const img = (side: string): N => {
const a: N = { src: PNG, width: int(r, 40, 200), height: int(r, 30, 120),
wrap: 'topBottom', wrapAlign: side };
if (maybe(r, 0.3)) a.wrapOffsetY = 1.5;
return { type: 'image', attrs: a };
};
const flip = maybe(r, 0.5);
return { type: 'paragraph', content: [img(flip ? 'right' : 'left'), img(flip ? 'left' : 'right')] };
}
// Adjacent equal-attr sections merge on export (a columnsFlow page split looks the
// same), so genDoc keeps columns blocks apart like same-type lists.
function columnsBlock(r: Rng): N {
@@ -259,9 +297,34 @@ function list(r: Rng, kind: 'bulletList' | 'orderedList', depth: number): N {
// A tiny valid PNG; only its bytes matter for the round-trip (no image decoding).
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
// A styled table as the editor authors it: name + look on the table, the style's fill
// and borders materialized onto the cells (paintTable). `region` stays off — the app
// re-derives it from the registry on load, and its baked region text is not clean yet.
const TABLE_STYLE_PICK = ['Simple Grid', 'Simple List Shaded', 'Plain Table',
'Box List Blue', 'Grid Table Accent', 'Academic'] as const;
function styledTable(r: Rng, name: string, cols: number, nRows: number): N {
const style = builtinTableStyles()[name];
const look = { ...DEFAULT_TABLE_LOOK, ...(style.look ?? {}) };
if (maybe(r, 0.3)) look[pick(r, TABLE_REGIONS)] = maybe(r, 0.5);
const rows = Array.from({ length: nRows }, (_, ri) => ({
type: 'tableRow',
content: Array.from({ length: cols }, (_, ci) => {
const paint = resolveTableCell(style, { row: ri, col: ci, rows: nRows, cols }, look);
const attrs: N = { colspan: 1, rowspan: 1, colwidth: null };
if (paint.fill) attrs.backgroundColor = paint.fill;
for (const [k, v] of Object.entries(paint.borders)) if (v !== null) attrs[k] = v;
// heading-safe runs: a #F2F2F2 header cell reads bold as presentational
return { type: 'tableCell', attrs, content: [{ type: 'paragraph', content: runs(r, true) }] };
}),
}));
return { type: 'table', attrs: { tableStyle: name, tableLook: tableLookAttr(look) }, content: rows };
}
function table(r: Rng): N {
const cols = int(r, 1, 3);
const nRows = int(r, 1, 3);
if (maybe(r, 0.18)) return styledTable(r, pick(r, TABLE_STYLE_PICK), cols, nRows);
// vertical merge: cell (0,0) spans rows 0-1, so row 1 starts one cell short.
// cols >= 2 keeps row 1 non-empty — a row of only covered cells is not a document
// the editor can author (the importer clamps such a file's spans instead).
@@ -310,6 +373,7 @@ export function genDoc(r: Rng): N {
commentSeq = 0;
revSeq = 0;
seqCounters = {};
bibIndex = false;
const blocks: N[] = [];
const notes: N[] = [];
let prev = '';
@@ -330,18 +394,26 @@ export function genDoc(r: Rng): N {
// direct value equal to the style's own is suppressed on import — a styled
// block carries heading-safe runs and no paragraph attrs of its own.
block = maybe(r, 0.07)
? { type: 'paragraph', attrs: { styleName: pick(r, ['Title', 'Subtitle', 'Quotations']) },
? { type: 'paragraph', attrs: { styleName: pick(r, ['Title', 'Subtitle', 'Quotations', 'Caption']) },
content: runs(r, true) }
: paragraph(r, true, true);
// not beside a lone display formula: company would cost it its own line (= display)
if (block.content && block.content[0]?.type !== 'formula' && maybe(r, 0.15)) {
block.content.push(noteRef(pick(r, ['footnote', 'endnote'])));
// ahead of a trailing sunk frame: a topBottom image set below the paragraph
// top sinks behind the text on import (sinkOffsetFrames)
const c = block.content;
const last = c[c.length - 1];
const at = last?.type === 'image' && last.attrs?.wrap === 'topBottom'
&& last.attrs?.wrapOffsetY > 0 ? c.length - 1 : c.length;
c.splice(at, 0, noteRef(pick(r, ['footnote', 'endnote'])));
}
}
else if (roll < 0.55) block = { type: 'heading', attrs: { level: int(r, 1, 8) }, content: runs(r, true) };
else if (roll < 0.72) block = list(r, pick(r, ['bulletList', 'orderedList']), 0);
else if (roll < 0.77) block = textBox(r);
else if (roll < 0.84) block = columnsBlock(r);
else if (roll < 0.87) block = tocBlock(r);
else if (roll < 0.9) block = framePair(r);
else block = table(r);
// adjacent same-type lists merge on import (and columns fragments on export);
// keep them apart so identity holds
+4
View File
@@ -1,5 +1,7 @@
// Shared round-trip comparison: reduce editor JSON to a canonical shape (defaults
// dropped, marks sorted, adjacent identical runs merged) and report the first diff.
import { MAX_HEADING_LEVEL } from '../src/lib/export/odt';
type N = any;
const ORDERED_DEFAULTS: Record<string, unknown> = {
@@ -8,6 +10,8 @@ const ORDERED_DEFAULTS: Record<string, unknown> = {
type: null, level: undefined, rotation: 0, wrap: 'inline',
shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1,
fixed: false, key1: '',
// index attrs the DOCX TOC field has no switch for come back at their defaults
maxLevel: MAX_HEADING_LEVEL, leader: '.', citationStyle: 'key',
};
// Mark-attr defaults dropped like ORDERED_DEFAULTS is for node attrs.