Files
Steffen Becker bd38c955ae test: every fixture picture is a PNG a browser accepts
The 1x1 fixture carried an IDAT from a different image: its CRC fails and the
zlib stream is truncated. Chromium and WebKit render it anyway, Firefox reports
"Image corrupt or truncated", and the same bytes sat in eleven fixtures.
2026-09-06 19:07:24 +02:00

488 lines
25 KiB
TypeScript

// 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;
export type Rng = () => number;
export function mulberry32(seed: number): Rng {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const pick = <T,>(r: Rng, arr: readonly T[]): T => arr[Math.floor(r() * arr.length)];
export const int = (r: Rng, min: number, max: number) => min + Math.floor(r() * (max - min + 1));
export const maybe = (r: Rng, p: number) => r() < p;
// XML-hostile characters, umlauts, an astral-plane glyph and a tab on purpose.
const WORDS = ['Lorem', 'ipsum', 'Größe', 'fünf', 'a&b', '<tag>', '"quote"', "it's",
'€42', '🙂ok', 'x\ty', 'dolor', 'sit'] as const;
// A heading run must not carry marks equal to the heading defaults (bold, italic on
// levels 4/6, Arial, the level's size) — both importers suppress default-equal values,
// so those are lossy by design. Headings get the marks with no heading default.
function marks(r: Rng, heading = false): N[] | undefined {
const out: N[] = [];
if (!heading && maybe(r, 0.25)) out.push({ type: 'bold' });
if (!heading && maybe(r, 0.2)) out.push({ type: 'italic' });
// A link excludes color/underline effects: the DOCX importer tells an editor link from
// a styled foreign one by exactly that styling (link.ts `plain`), so it can't survive.
if (maybe(r, 0.08)) {
out.push({ type: 'link', attrs: { href: 'https://example.com/x?a=1&b=2' } });
return out;
}
if (maybe(r, 0.1)) {
const attrs: N = {};
if (maybe(r, 0.3)) attrs.lineStyle = pick(r, ['dotted', 'double', 'dashed']);
if (maybe(r, 0.2)) attrs.lineColor = '#FF0000';
// A user style may underline plainly, and a run repeating exactly that is suppressed
// on import — under a named style a run underlines only in its own shape or colour.
const line = Object.keys(attrs).length ? { attrs } : null;
if (line || !heading) out.push({ type: 'underline', ...(line ?? {}) });
}
if (maybe(r, 0.1)) {
out.push(maybe(r, 0.25) ? { type: 'strike', attrs: { lineStyle: 'double' } } : { type: 'strike' });
}
const shifted = maybe(r, 0.08);
if (shifted) out.push({ type: pick(r, ['superscript', 'subscript']) });
if (maybe(r, 0.1)) out.push({ type: 'highlight', attrs: { color: pick(r, ['#FFFF00', '#00FF00']) } });
if (maybe(r, 0.25)) {
const attrs: N = {};
if (maybe(r, 0.5)) attrs.color = pick(r, ['#C00000', '#0070C0']);
if (!heading && maybe(r, 0.4)) attrs.fontFamily = 'Arial';
if (!heading && maybe(r, 0.4)) attrs.fontSize = pick(r, ['10pt', '14pt', '18pt']);
if (maybe(r, 0.2)) attrs.caps = pick(r, ['uppercase', 'smallCaps']);
// sub/superscript and a free raise share ODF's one text-position attribute
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;
}
function runs(r: Rng, heading = false): N[] {
const n = int(r, 1, 4);
return Array.from({ length: n }, (_, i) => {
const text = Array.from({ length: int(r, 1, 5) }, () => pick(r, WORDS)).join(' ')
+ (i < n - 1 ? ' ' : '');
const m = marks(r, heading);
return { type: 'text', text, ...(m ? { marks: m } : {}) };
});
}
// indents=false inside list items: a list paragraph's indent lives in the list style
// and does not round-trip as direct formatting (import/odt.ts skips it there).
// top=false in cells/lists: the page-break sentinel rides top-level blocks only.
function paraAttrs(r: Rng, indents: boolean, top: boolean): N | null {
const attrs: N = {};
// The base direction first: the alignment pick must avoid the direction's default
// edge (rtl right), which both importers suppress as unset.
if (maybe(r, 0.05)) attrs.dir = 'rtl';
if (maybe(r, 0.3)) {
attrs.textAlign = pick(r, attrs.dir ? ['left', 'center', 'justify'] : ['left', 'center', 'right', 'justify']);
}
if (maybe(r, 0.2)) attrs.lineHeight = pick(r, ['1.5', '2']);
if (maybe(r, 0.15)) attrs.spaceBefore = pick(r, [6, 12]);
if (maybe(r, 0.15)) attrs.spaceAfter = pick(r, [12, 18]);
if (indents && maybe(r, 0.15)) attrs.indent = pick(r, [1.25, 2.5]);
if (indents && maybe(r, 0.1)) attrs.indentFirst = pick(r, [0.75, -0.75]);
if (indents && maybe(r, 0.1)) attrs.indentRight = 1.5;
if (top && maybe(r, 0.05)) attrs.breakBefore = 'page';
// A section break is a page break as well: naming a master page breaks the page in
// ODF, and the DOCX sectPr is a nextPage one.
if (top && maybe(r, 0.06)) { attrs.sectionBreak = true; attrs.breakBefore = 'page'; }
// Paragraph box: shading and/or rule lines (ParaStyle carries them into cells/lists).
if (maybe(r, 0.08)) {
if (maybe(r, 0.6)) attrs.backgroundColor = pick(r, ['#CCFFFF', '#FFE0E0']);
if (maybe(r, 0.5)) attrs.borderBottom = '2pt solid #FF0000';
if (maybe(r, 0.3)) attrs.borderTop = '1pt solid #00B050';
if (maybe(r, 0.25)) { attrs.borderLeft = '1pt solid #00B050'; attrs.borderRight = '1pt solid #00B050'; }
}
// The flow/pagination flags and tab stops ride the top-level PBX spec only.
if (top && maybe(r, 0.05)) attrs.keepNext = true;
if (top && maybe(r, 0.05)) attrs.keepLines = true;
if (top && maybe(r, 0.04)) attrs.widowControl = false;
// no noHyphenation: without the document-wide hyphenate flag (a buildOdt parameter
// the round trip doesn't pass) the importers suppress it as meaningless.
if (top && maybe(r, 0.06)) attrs.tabStops = pick(r, ['6l;12r', '3c.;9r_', '5d']);
return Object.keys(attrs).length ? attrs : 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> = {};
let bibIndex = false;
const LATEX = ['x^{2}+1', '\\frac{a}{b}', '\\sqrt{x+1}', '\\alpha \\cdot \\beta'] as const;
function paragraph(r: Rng, indents = true, top = false): N {
if (maybe(r, 0.08)) return { type: 'paragraph' }; // empty line
if (maybe(r, 0.03)) { // a display formula owns its line — that is what makes it display
return { type: 'paragraph',
content: [{ type: 'formula', attrs: { latex: pick(r, LATEX), display: true } }] };
}
const attrs = paraAttrs(r, indents, top);
const content = runs(r);
const body: N[] = [];
for (const run of content) {
body.push(run);
if (maybe(r, 0.1)) body.push({ type: 'hardBreak' });
}
if (maybe(r, 0.05)) {
const name = `bm${bmNames.length + 1}`;
bmNames.push(name);
content[0].marks = [...(content[0].marks ?? []), { type: 'bookmark', attrs: { name } }];
}
if (bmNames.length && maybe(r, 0.05)) {
const format = pick(r, ['text', 'page'] as const);
body.push({ type: 'crossRef', attrs: {
name: pick(r, bmNames), format, text: format === 'page' ? '1' : 'Lorem ipsum' } });
}
if (maybe(r, 0.05)) {
content[content.length - 1].marks = [...(content[content.length - 1].marks ?? []),
{ 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: 'かんじ' } });
// label without leading/trailing space: both importers trim the shown text.
if (maybe(r, 0.04)) {
body.push({ type: 'placeholderField', attrs: { text: pick(r, ['Empfängername', 'Straße & <Nr>', 'Datum "heute"']) } });
}
// 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).
if (maybe(r, 0.05)) {
body.push({ type: 'formula', attrs: { latex: pick(r, LATEX), display: false } });
}
if (maybe(r, 0.06)) {
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;
if (img.wrap === 'topBottom' && maybe(r, 0.3)) img.wrapOffsetY = 1.5;
}
body.push({ type: 'image', attrs: img });
}
return { type: 'paragraph', ...(attrs ? { attrs } : {}), content: body };
}
// px multiples of 48 map to whole-hundredth cm and back without rounding drift.
function textBox(r: Rng): N {
const attrs: N = { width: 48 * int(r, 3, 7), height: 48 * int(r, 2, 4) };
if (maybe(r, 0.3)) attrs.shapeKind = pick(r, ['roundRect', 'ellipse', 'diamond']);
if (maybe(r, 0.3)) attrs.fillColor = pick(r, ['#FFE0A0', '#DDEEFF']);
// width only beside a stroke color: with no stroke drawn the width is meaningless
if (maybe(r, 0.25)) {
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']);
if (maybe(r, 0.5)) attrs.wrapOffset = pick(r, [1.5, 2.25]);
if (attrs.wrap !== 'topBottom' && maybe(r, 0.3)) attrs.wrapDist = 0.5;
if (attrs.wrap === 'topBottom' && maybe(r, 0.3)) attrs.wrapOffsetY = 1.5;
}
const kids = Array.from({ length: int(r, 1, 2) }, () => paragraph(r));
// A box is inline, so it rides a paragraph of its own.
return { type: 'paragraph', content: [{ 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 {
const attrs: N = { count: int(r, 2, 3), gapCm: pick(r, [0.5, 1]) };
const kids = Array.from({ length: int(r, 1, 3) }, () => {
const roll = r();
if (roll < 0.15) return { type: 'heading', attrs: { level: int(r, 1, 8) }, content: runs(r, true) };
if (roll < 0.3) return list(r, pick(r, ['bulletList', 'orderedList']), 0);
return paragraph(r); // top=false: breaks, flow flags and tab stops are top-level-only
});
return { type: 'columns', attrs, content: kids };
}
// Built-ins the generator can follow: the node species per depth mirrors the style's
// level kinds, so the import (which derives the species from the style) agrees.
const LIST_STYLE_KINDS: Record<string, readonly ('bulletList' | 'orderedList')[]> = {
'Outline I.A.1': ['orderedList', 'orderedList', 'orderedList'],
'Outline A.I.1': ['orderedList', 'orderedList', 'orderedList'],
'Numbering 1.a.i': ['orderedList', 'orderedList', 'orderedList'],
'Numbering with Bullets': ['orderedList', 'bulletList', 'bulletList'],
'Diamond Bullets': ['bulletList', 'bulletList', 'bulletList'],
'Checklist': ['bulletList', 'bulletList', 'bulletList'],
};
function list(r: Rng, kind: 'bulletList' | 'orderedList', depth: number,
styleKinds?: readonly ('bulletList' | 'orderedList')[]): N {
// A named style may govern the tree. It then stays override- and start-free: an
// override drops the name on export, a start keeps a private DOCX clone without
// the name (export/CLAUDE.md) — either would diff against the ODT reading.
let styleName: string | undefined;
if (depth === 0 && maybe(r, 0.25)) {
const names = Object.keys(LIST_STYLE_KINDS).filter((n) => LIST_STYLE_KINDS[n][0] === kind);
styleName = pick(r, names);
styleKinds = LIST_STYLE_KINDS[styleName];
}
const items = Array.from({ length: int(r, 1, 3) }, () => {
const kids: N[] = [paragraph(r, false)];
if (depth < 2 && maybe(r, 0.25)) {
const nested = styleKinds ? styleKinds[depth + 1] : pick(r, ['bulletList', 'orderedList'] as const);
kids.push(list(r, nested, depth + 1, styleKinds));
}
return { type: 'listItem', content: kids };
});
// A common marker format also drops the name (the one <text:list> reference carries
// either it or the named style) — one unformatted lead run breaks the commonality.
if (styleKinds) {
const p = items[0].content[0];
if (p.content?.[0]?.type === 'text') delete p.content[0].marks;
if (p.attrs) delete p.attrs.fontSize;
}
const attrs: N = {};
if (styleName) attrs.listStyleName = styleName;
if (!styleKinds) {
// start only at the top level: applyListStartValues writes text:start-value for
// top-level lists only, a nested list's start does not round-trip.
if (kind === 'orderedList' && depth === 0 && maybe(r, 0.2)) attrs.start = 3;
// Nested lists get only upper-* types: those are never the depth default, which the
// importers suppress to null (defaultOrderedTypeAt yields lower/decimal forms only).
if (kind === 'orderedList' && maybe(r, 0.25)) {
attrs.listStyleType = depth === 0
? pick(r, ['lower-alpha', 'upper-roman', 'lower-alpha-paren'])
: pick(r, ['upper-roman', 'upper-alpha-paren']);
}
// Never a depth-default bullet (•/◦/▪), which the importers suppress to null.
if (kind === 'bulletList' && maybe(r, 0.2)) attrs.bulletChar = pick(r, ['❖', '✓', '➢']);
}
return { type: kind, ...(Object.keys(attrs).length ? { attrs } : {}), content: items };
}
// A tiny valid PNG; only its bytes matter for the round-trip (no image decoding).
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNwaDgAAAKEAYEml6crAAAAAElFTkSuQmCC';
// A styled table as the editor authors it: name + look on the table, the style's fill,
// borders and `region` materialized onto the cells (paintTable). Both importers
// re-derive the regions and suppress the baked region text against them.
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;
if (paint.regions.length) attrs.region = paint.regions.join(' ');
// a bold region's runs read like a heading's: bold is the style's, not a mark
const content = [{ type: 'paragraph', content: runs(r, paint.text.bold === true) }];
// direct overrides over the paint (cell shading / border picker); never #F2F2F2,
// that exact shade is HEADER_SHADE, whose bold is presentational
if (maybe(r, 0.12)) attrs.backgroundColor = pick(r, ['#FFD700', '#E0F0E0']);
else if (attrs.backgroundColor && maybe(r, 0.08)) delete attrs.backgroundColor; // cleared shading
if (maybe(r, 0.08)) attrs.borderBottom = pick(r, ['2pt solid #FF0000', 'none']);
return { type: 'tableCell', attrs, content };
}),
}));
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).
const vMerge = cols >= 2 && nRows >= 2 && maybe(r, 0.15);
const rows = Array.from({ length: nRows }, (_, ri) => {
// one two-column merge per row at most (never beside the vertical merge),
// so the grid stays consistent
const merge = cols >= 2 && maybe(r, 0.15) && !(vMerge && ri <= 1);
const skip = vMerge && ri === 1 ? 1 : 0;
const cells = Array.from({ length: (merge ? cols - 1 : cols) - skip }, (_, i) => {
const attrs: N = { colspan: merge && i === 0 ? 2 : 1,
rowspan: vMerge && ri === 0 && i === 0 ? 2 : 1, colwidth: null };
// not #F2F2F2: that exact shade is HEADER_SHADE, whose bold is presentational
if (maybe(r, 0.15)) attrs.backgroundColor = pick(r, ['#FFFF00', '#DDEEFF']);
if (maybe(r, 0.1)) attrs.verticalAlign = pick(r, ['middle', 'bottom']);
if (maybe(r, 0.1)) attrs.borderBottom = pick(r, ['2pt solid #FF0000', 'none']);
if (maybe(r, 0.06)) attrs.borderTop = '0.5pt solid #000080';
// never the table's first cell: ODF has no table-level cell margin, so the
// importer reads the first cell's padding as the table's
if ((ri > 0 || i > 0) && maybe(r, 0.06)) attrs.cellPadding = [0, 0.5, 0, 0.5];
// the DOCX leg swaps the first paragraph for the field and caches its flat
// text, so a formula cell holds one plain run that reads as a number
if (maybe(r, 0.06)) {
attrs.formula = pick(r, ['SUM(A1:A2)', 'A1*2', '2+3']);
if (maybe(r, 0.4)) attrs.cellFormat = pick(r, ['int', 'dec2', 'percent']);
return { type: 'tableCell', attrs,
content: [{ type: 'paragraph', content: [{ type: 'text', text: String(int(r, 1, 99)) }] }] };
}
return { type: 'tableCell', attrs, content: [paragraph(r)] };
});
const rowAttrs: N | null = maybe(r, 0.1) ? { rowHeight: pick(r, [40, 64]) } : null;
return { type: 'tableRow', ...(rowAttrs ? { attrs: rowAttrs } : {}), content: cells };
});
const attrs: N = {};
if (maybe(r, 0.12)) attrs.marginLeft = pick(r, [1.5, 2.25]);
if (maybe(r, 0.12)) attrs.marginRight = 1.5;
if (maybe(r, 0.1)) attrs.cellPadding = [0.1, 0.3, 0.1, 0.3];
return { type: 'table', ...(Object.keys(attrs).length ? { attrs } : {}), content: rows };
}
// Roman labels for endnotes match the importer's default numbering.
const ROMAN = ['i', 'ii', 'iii', 'iv', 'v'] as const;
// `styleNames` are the sheet's user styles; a styled block may name one of them.
export function genDoc(r: Rng, styleNames: string[] = []): N {
bmNames = [];
commentSeq = 0;
revSeq = 0;
seqCounters = {};
bibIndex = false;
const blocks: N[] = [];
const notes: N[] = [];
let prev = '';
const noteRef = (kind: 'footnote' | 'endnote'): N => {
const n = notes.filter(x => x.attrs.kind === kind).length + 1;
const label = maybe(r, 0.15) ? '*' : null;
const text = label ?? (kind === 'footnote' ? String(n) : ROMAN[n - 1]);
const id = `${kind[0]}${n}`;
notes.push({ type: 'note', attrs: { id, kind, label, text },
content: [{ type: 'text', text: `Note body ${id}` }] });
return { type: 'noteRef', attrs: { id, kind, text } };
};
for (let i = int(r, 1, 6); i > 0; i--) {
const roll = r();
let block: N;
if (roll < 0.45) {
// Title/Subtitle centre + size and Quotations' indent live in the style, and a
// 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, styleNames.length ? 0.15 : 0.07)
? { type: 'paragraph', attrs: { styleName: pick(r, ['Title', 'Subtitle', 'Quotations', 'Caption', ...styleNames]) },
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)) {
// 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
if ((block.type.endsWith('List') || block.type === 'columns') && block.type === prev) {
blocks.push({ type: 'paragraph' });
}
blocks.push(block);
prev = block.type;
}
// The first block opens section 1 as it is; only a later one can start another.
if (blocks[0]?.attrs?.sectionBreak) delete blocks[0].attrs.sectionBreak;
if (notes.length) blocks.push({ type: 'noteSection', content: notes });
return { type: 'doc', content: blocks };
}