test: the layout run holds a baseline, and the monkey edits frames and columns

tests/layout/baseline.json records where each page starts and what the load cost, per
engine and platform; a page starting elsewhere is a layout change the run prints page by
page, a load over three times its recorded time a regression, and LAYOUT_UPDATE=1 records
it again. The monkey draws frame sizes, wraps, alignment and column counts too, holds the
history against the edits rather than the caches the editor recomputes, and leaves it
unchecked where the columns flow rewrote the document outside it. The differences both
importers make by design are named in the comparison; unhoist moved to normalize.ts.
This commit is contained in:
Steffen Becker committed 2026-09-06 19:07:17 +02:00
1 parent 76c9564028
commit c4239a6e72
7 files changed
+1003 -97

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ npm test # Vitest suite once (tests/**/*.test.ts)
npm run test:lo # LibreOffice legs: round trip, fuzz re-read, ODT/DOCX render consistency (needs `soffice`)
npm run test:smoke # boots the dist/ build headless (tests/smoke/run.mjs); BROWSER=firefox|webkit
npm run test:dom # pagination + editing in the real browser (tests/dom/run.mjs); same BROWSER
npm run test:layout # page counts vs LibreOffice + layout invariants over corpus, showcase, fuzz seeds (tests/layout/)
npm run test:layout # page counts vs LibreOffice, layout invariants, page starts vs baseline.json (tests/layout/)
npm run test:monkey # random editing under invariants: schema, undo/redo, the saved file reads back (tests/monkey/)
npm run test:coverage # vitest + v8 coverage over src/ → coverage/index.html
npm run test:parity # render parity vs LibreOffice (tests/render-parity/README.md)
+11 -2
View File
@@ -24,14 +24,23 @@ page count against LibreOffice's PDF (`soffice` + `pdfinfo`, a page or a tenth o
and checks the rendered lines: none overlap, none sit in the page gap or past the sheet,
none cross a single-section document's margins, no heading ends a page or column, nothing
is wider than the page. A tab's box, a frame, a formula and the decor are not lines.
It then holds the document against `tests/layout/baseline.json`, which records where each
page starts and what the load cost: a page starting elsewhere is a layout change the run
prints page by page, and a load over three times its recorded time is a regression. The
baseline is keyed by engine and platform (line breaking is theirs) and `LAYOUT_UPDATE=1`
records it again — the answer to a deliberate change, never to a surprise.
The **monkey run** replays `MONKEY_OPS` random keys and commands per seed
(`MONKEY_SEED`, `MONKEY_RUNS`, `MONKEY_DOC`) on a corpus document and checks after each:
(`MONKEY_SEED`, `MONKEY_RUNS`, `MONKEY_DOC`) on a corpus document — typing, formatting,
lists, tables, notes, frames and columns — and checks after each:
no uncaught error, a document its own schema accepts; at the end undo back to the opened
file and redo forward, then Save As both formats, xmllint each against the schemas and read
it back through the app's importer. A failing seed prints its last ops and keeps the file
and both documents under the OS temp dir. Found so far: a note inserted in a text box or a
text box in a note breaks the ODT's XML, and a table in a cell is dropped on save — both
commands now refuse there.
commands now refuse there; a page break inside a columns section, which the editor never
shows and only DOCX writes, is now cleared as the blocks are wrapped; an `.odt` dropped
every block of a list item after the first, and a header row that the table did not ask to
repeat came back as ordinary cells.
`BROWSER=chromium|firefox|webkit` picks the engine (Chromium by default; the others via
`npx playwright-core install firefox webkit`). CI runs both on all three, one per matrix
+761
View File
@@ -0,0 +1,761 @@
{
"chromium/darwin": {
"docs/showcase/book.docx": {
"pages": 99,
"lines": 3744,
"ms": 800,
"starts": [
"Alice’s Adventures in Wonderland",
"Contents",
"Chapter I",
"to think about stopping herself before s",
"walk with their heads downward! The Anti",
"found herself in a long, low hall, which",
"ore,” said Alice,) and round the neck of",
"of a candle is like after the candle is ",
"cake, but Alice had got so much into the",
"",
"Chapter II",
"“You ought to be ashamed of yourself,” s",
"n is—oh dear! I shall never get to twent",
"soon found out that the cause of this wa",
"very likely it can talk: at any rate, th",
"“We indeed!” cried the Mouse, who was tr",
"Chapter III",
"“I beg your pardon!” said the Mouse, fro",
"“What",
"“Only a thimble,” said Alice sadly.",
"‘I’ll be judge, I’ll be jury,’ Said cunn",
"you can’t think! And oh, I wish you coul",
"Chapter IV",
"“How queer it seems,” Alice said to hers",
"herself “Now I can do no more, whatever ",
"Presently the Rabbit came up to the door",
"She waited for some time without hearing",
"“So you did, old fellow!” said the other",
"It sounded an excellent plan, no doubt, ",
"only been the right size to do it! Oh de",
"Chapter V",
"Which brought them back again to the beg",
"“You are old, Father William,” the young",
"“What size do you want to be?” it asked.",
"at last she stretched her arms round it ",
"but the tops of the trees under which sh",
"one",
"an open place, with a little house in it",
"Chapter VI",
"raordinary noise going on within—a const",
"“Oh, there’s no use in talking to him,” ",
"reach at the Duchess and the baby—the fi",
"While the Duchess sang the second verse ",
"not like the look of the thing at all. “",
"“Then it doesn’t matter which way you go",
"“By-the-bye, what became of the baby?” s",
"Chapter VII",
"“Come, we shall have some fun now!” thou",
"The March Hare took the watch and looked",
"“I don’t know what you mean,” said Alice",
"“Well, I’d hardly finished the first ver",
"“Once upon a time there were three littl",
"remarked, “If you can’t be civil, you’d ",
"“They were learning to draw,” the Dormou",
"and began by taking the little golden ke",
"",
"Chapter VIII",
"Queen was to find it out, we should all ",
"“My name is Alice, so please your Majest",
"the soldiers remaining behind to execute",
"ned tone. “The Queen will hear you! You ",
"dreadfully fond of beheading people here",
"“Who",
"By the time she had caught the flamingo ",
"eared; so the King and the executioner r",
"",
"Chapter IX",
"right height to rest her chin upon Alice",
"“Oh, I know!” exclaimed Alice, who had n",
"“A fine day, your Majesty!” the Duchess ",
"that’s",
"So they sat down, and nobody spoke for s",
"“We had the best of educations—in fact, ",
"Alice did not feel encouraged to ask any",
"“That’s enough about lessons,” the",
"",
"Chapter X",
"“—as far out to sea as you can—”",
"not, could not, would not, could not, wo",
"“I can tell you more than that, if you l",
"“I could tell you my adventures—beginnin",
"[later editions continued as follows Whe",
"“What",
"“What trial is it?” Alice panted as she ",
"Chapter XI",
"girls of her age knew the meaning of it ",
"“Not yet, not yet!” the Rabbit hastily i",
"confusion he bit a large piece out of hi",
"“Of course twinkling begins with a T!” s",
"“If that’s all you know about it, you ma",
"“Collar that Dormouse,” the Queen shriek",
"",
"Chapter XII",
"anything but sit with its mouth open, ga",
"The King turned pale, and shut his note-",
"“Read them,” said the King.",
"at them with one eye; “I seem to see som",
"“Off with her head!” the Queen shouted a",
"The long grass rustled at her feet as th"
]
},
"docs/showcase/book.odt": {
"pages": 95,
"lines": 3739,
"ms": 800,
"starts": [
"Alice’s Adventures in Wonderland",
"Contents",
"Chapter I",
"fortunately was just in time to see it p",
"wonder what Latitude or Longitude I’ve g",
"down it. There was not a moment to be lo",
"at any rate a book of rules for shutting",
"said Alice to herself, “in my going out ",
"ained the same size: to be sure, this ge",
"",
"Chapter II",
"Just then her head struck against the ro",
"“I’m sure I’m not Ada,” she said, “for h",
"while she was talking. “How",
"“Would it be of any use, now,” thought A",
"“We indeed!” cried the Mouse, who was tr",
"Chapter III",
"soon submitted to by the English, who wa",
"“What I was going to say,” said the Dodo",
"“Of course,” the Dodo replied very grave",
"‘I’ll be judge, I’ll be jury,’ Said cunn",
"This speech caused a remarkable sensatio",
"Chapter IV",
"bright brass plate with the name “W. RAB",
"her head. Still she went on growing, and",
"Presently the Rabbit came up to the door",
"voices all talking together: she made ou",
"“We must burn the house down!” said the ",
"how to set about it; and while she was p",
"The great question certainly was, what? ",
"Chapter V",
"then after that into a butterfly, I shou",
"“Repeat, ‘",
"The Caterpillar was the first to speak.",
"at last she stretched her arms round it ",
"but the tops of the trees under which sh",
"use denying it. I suppose you’ll be tell",
"why, I should frighten them out of their",
"Chapter VI",
"Alice went timidly up to the door, and k",
"The Footman seemed to think this a good ",
"Alice did not at all like the tone of th",
"“Wow! wow! wow!”",
"not like the look of the thing at all. “",
"“Oh, you’re sure to do that,” said the C",
"“It turned into a pig,” Alice quietly sa",
"Chapter VII",
"“Your hair wants cutting,” said the Hatt",
"“Two days wrong!” sighed the Hatter. “I ",
"Alice sighed wearily. “I think you might",
"‘Up above the world you fly, Like a tea-",
"“And be quick about it,” added the Hatte",
"“There’s no such thing!” Alice was begin",
"“They were learning to draw,” the Dormou",
"led into the garden. Then she went to wo",
"",
"Chapter VIII",
"“Would you tell me,” said Alice, a littl",
"When the procession came opposite to Ali",
"“May it please your Majesty,” said Two, ",
"“She boxed the Queen’s ears—” the Rabbit",
"minute, “and then,” thought she, “what w",
"“It’s a friend of mine—a Cheshire Cat,” ",
"this side of the ground.” So she tucked ",
"Chapter IX",
"“Tut, tut, child!” said the Duchess. “Ev",
"“Of course it is,” said the Duchess, who",
"Queen in front of them, with her arms fo",
"that’s",
"So they sat down, and nobody spoke for s",
"“",
"Seaography",
"Chapter X",
"“Of course,” the Mock Turtle said: “adva",
"you, won’t you, will you join the dance?",
"a long way. So they got their tails fast",
"“I could tell you my adventures—beginnin",
"[later editions continued as follows Whe",
"“Yes, I think you’d better leave off,” s",
"",
"Chapter XI",
"“And that’s the jury-box,” thought Alice",
"“The Queen of Hearts, she made some tart",
"This did not seem to encourage the witne",
"“Of course twinkling begins with a T!” s",
"“I can’t go no lower,” said the Hatter: ",
"For some minutes the whole court was in ",
"",
"Chapter XII",
"As soon as the jury had a little recover",
"“Well, I shan’t go, at any rate,” said A",
"“That",
"“If there’s no meaning in it,” said the ",
"“I won’t!” said Alice.",
"The long grass rustled at her feet as th"
]
},
"docs/showcase/newsletter.docx": {
"pages": 2,
"lines": 153,
"ms": 200,
"starts": [
"Eden Astronomy Society",
"For the"
]
},
"docs/showcase/newsletter.odt": {
"pages": 2,
"lines": 153,
"ms": 100,
"starts": [
"Eden Astronomy Society",
"For the"
]
},
"docs/showcase/thesis-review.docx": {
"pages": 13,
"lines": 663,
"ms": 700,
"starts": [
"Widow and Orphan Control in Browser-Base",
"Abstract",
"Contents",
"Introduction",
"three research questions, Chapter 5 disc",
"Background",
"the editing surface intact and is the on",
"Method",
"Measurement",
"Results",
"are spent measuring line boxes. A full r",
"Discussion",
"Conclusion and Future Work"
]
},
"docs/showcase/thesis-review.odt": {
"pages": 13,
"lines": 663,
"ms": 600,
"starts": [
"Widow and Orphan Control in Browser-Base",
"Abstract",
"Contents",
"Introduction",
"describes the corpus and the measurement",
"Background",
"of the affected pages; or it can render ",
"Method",
"Measurement",
"Results",
"Running Time",
"Discussion",
"Conclusion and Future Work"
]
},
"docs/showcase/thesis.docx": {
"pages": 13,
"lines": 659,
"ms": 700,
"starts": [
"Widow and Orphan Control in Browser-Base",
"Abstract",
"Contents",
"Introduction",
"three research questions, Chapter 5 disc",
"Background",
"the editing surface intact and is the on",
"Method",
"Measurement",
"Results",
"are spent measuring line boxes. A full r",
"Discussion",
"Conclusion and Future Work"
]
},
"docs/showcase/thesis.odt": {
"pages": 13,
"lines": 659,
"ms": 600,
"starts": [
"Widow and Orphan Control in Browser-Base",
"Abstract",
"Contents",
"Introduction",
"three research questions, Chapter 5 disc",
"Background",
"of the affected pages; or it can render ",
"Method",
"Measurement",
"Results",
"Running Time",
"Discussion",
"Conclusion and Future Work"
]
},
"fuzz/seed-1.odt": {
"pages": 1,
"lines": 19,
"ms": 100,
"starts": [
"Lorem"
]
},
"fuzz/seed-10.odt": {
"pages": 1,
"lines": 9,
"ms": 100,
"starts": [
"🙂"
]
},
"fuzz/seed-2.odt": {
"pages": 1,
"lines": 9,
"ms": 100,
"starts": [
"ipsum"
]
},
"fuzz/seed-3.odt": {
"pages": 1,
"lines": 5,
"ms": 0,
"starts": [
""
]
},
"fuzz/seed-4.odt": {
"pages": 1,
"lines": 19,
"ms": 0,
"starts": [
"€42"
]
},
"fuzz/seed-5.odt": {
"pages": 1,
"lines": 15,
"ms": 100,
"starts": [
"sit"
]
},
"fuzz/seed-6.odt": {
"pages": 1,
"lines": 66,
"ms": 100,
"starts": [
"Lorem"
]
},
"fuzz/seed-7.odt": {
"pages": 1,
"lines": 49,
"ms": 100,
"starts": [
"🙂"
]
},
"fuzz/seed-8.odt": {
"pages": 1,
"lines": 32,
"ms": 100,
"starts": [
"ipsum"
]
},
"fuzz/seed-9.odt": {
"pages": 1,
"lines": 13,
"ms": 100,
"starts": [
"<tag> dolor"
]
},
"tests/corpus/01-flow.docx": {
"pages": 2,
"lines": 912,
"ms": 300,
"starts": [
"1.",
"18."
]
},
"tests/corpus/01-flow.odt": {
"pages": 2,
"lines": 1008,
"ms": 100,
"starts": [
"1.",
"18."
]
},
"tests/corpus/02-blocks.docx": {
"pages": 1,
"lines": 153,
"ms": 100,
"starts": [
"Document Title"
]
},
"tests/corpus/02-blocks.odt": {
"pages": 1,
"lines": 174,
"ms": 100,
"starts": [
"Document"
]
},
"tests/corpus/03-fonts.docx": {
"pages": 1,
"lines": 261,
"ms": 200,
"starts": [
"Lorem"
]
},
"tests/corpus/03-fonts.odt": {
"pages": 1,
"lines": 290,
"ms": 100,
"starts": [
"Lorem"
]
},
"tests/corpus/04-table.docx": {
"pages": 1,
"lines": 47,
"ms": 100,
"starts": [
"Before the table"
]
},
"tests/corpus/04-table.odt": {
"pages": 1,
"lines": 55,
"ms": 100,
"starts": [
"Before"
]
},
"tests/corpus/05-breaks.docx": {
"pages": 2,
"lines": 1212,
"ms": 100,
"starts": [
"1.",
"minim"
]
},
"tests/corpus/05-breaks.odt": {
"pages": 2,
"lines": 1335,
"ms": 100,
"starts": [
"1.",
"minim"
]
},
"tests/corpus/06-calibri.docx": {
"pages": 2,
"lines": 800,
"ms": 100,
"starts": [
"1.",
"19."
]
},
"tests/corpus/06-calibri.odt": {
"pages": 2,
"lines": 840,
"ms": 100,
"starts": [
"1.",
"19."
]
},
"tests/corpus/07-nowidow.docx": {
"pages": 2,
"lines": 912,
"ms": 100,
"starts": [
"1.",
"labore"
]
},
"tests/corpus/07-nowidow.odt": {
"pages": 2,
"lines": 1008,
"ms": 100,
"starts": [
"1.",
"labore"
]
},
"tests/corpus/08-lists.docx": {
"pages": 1,
"lines": 50,
"ms": 100,
"starts": [
"Bullets"
]
},
"tests/corpus/08-lists.odt": {
"pages": 1,
"lines": 78,
"ms": 100,
"starts": [
"Bullets"
]
},
"tests/corpus/09-headerfooter.docx": {
"pages": 2,
"lines": 1150,
"ms": 100,
"starts": [
"1.",
"18."
]
},
"tests/corpus/09-headerfooter.odt": {
"pages": 2,
"lines": 1270,
"ms": 100,
"starts": [
"1.",
"18."
]
},
"tests/corpus/10-tabs.docx": {
"pages": 1,
"lines": 47,
"ms": 0,
"starts": [
"Default tabs follow:"
]
},
"tests/corpus/10-tabs.odt": {
"pages": 1,
"lines": 54,
"ms": 100,
"starts": [
"Default"
]
},
"tests/corpus/11-marks.docx": {
"pages": 1,
"lines": 47,
"ms": 100,
"starts": [
"super"
]
},
"tests/corpus/11-marks.odt": {
"pages": 1,
"lines": 47,
"ms": 100,
"starts": [
"super"
]
},
"tests/corpus/12-notes.docx": {
"pages": 1,
"lines": 46,
"ms": 0,
"starts": [
"1"
]
},
"tests/corpus/12-notes.odt": {
"pages": 1,
"lines": 46,
"ms": 0,
"starts": [
"1"
]
},
"tests/corpus/13-images.docx": {
"pages": 1,
"lines": 39,
"ms": 0,
"starts": [
"Before"
]
},
"tests/corpus/13-images.odt": {
"pages": 1,
"lines": 39,
"ms": 0,
"starts": [
"Before"
]
},
"tests/corpus/14-formulas.docx": {
"pages": 1,
"lines": 44,
"ms": 100,
"starts": [
"Inline"
]
},
"tests/corpus/14-formulas.odt": {
"pages": 1,
"lines": 45,
"ms": 100,
"starts": [
"Inline"
]
},
"tests/corpus/15-chapters.docx": {
"pages": 3,
"lines": 228,
"ms": 100,
"starts": [
"Introduction",
"Results",
"Appendix"
]
},
"tests/corpus/15-chapters.odt": {
"pages": 3,
"lines": 260,
"ms": 100,
"starts": [
"Introduction",
"Results",
"Appendix"
]
},
"tests/corpus/word/01-flow.docx": {
"pages": 2,
"lines": 1008,
"ms": 100,
"starts": [
"1.",
"18."
]
},
"tests/corpus/word/02-blocks.docx": {
"pages": 1,
"lines": 174,
"ms": 100,
"starts": [
"Document"
]
},
"tests/corpus/word/03-fonts.docx": {
"pages": 1,
"lines": 290,
"ms": 100,
"starts": [
"Lorem"
]
},
"tests/corpus/word/04-table.docx": {
"pages": 1,
"lines": 59,
"ms": 0,
"starts": [
"Before"
]
},
"tests/corpus/word/05-breaks.docx": {
"pages": 2,
"lines": 1335,
"ms": 100,
"starts": [
"1.",
"minim"
]
},
"tests/corpus/word/06-calibri.docx": {
"pages": 2,
"lines": 840,
"ms": 100,
"starts": [
"1.",
"19."
]
},
"tests/corpus/word/07-nowidow.docx": {
"pages": 2,
"lines": 1008,
"ms": 100,
"starts": [
"1.",
"labore"
]
},
"tests/corpus/word/08-lists.docx": {
"pages": 1,
"lines": 78,
"ms": 100,
"starts": [
"Bullets"
]
},
"tests/corpus/word/09-headerfooter.docx": {
"pages": 2,
"lines": 1270,
"ms": 100,
"starts": [
"1.",
"18."
]
},
"tests/corpus/word/10-tabs.docx": {
"pages": 1,
"lines": 54,
"ms": 100,
"starts": [
"Default"
]
},
"tests/corpus/word/11-marks.docx": {
"pages": 1,
"lines": 59,
"ms": 0,
"starts": [
"super"
]
},
"tests/corpus/word/12-notes.docx": {
"pages": 1,
"lines": 64,
"ms": 100,
"starts": [
"1"
]
},
"tests/corpus/word/13-images.docx": {
"pages": 1,
"lines": 45,
"ms": 100,
"starts": [
"Before"
]
},
"tests/corpus/word/14-formulas.docx": {
"pages": 1,
"lines": 52,
"ms": 100,
"starts": [
"Inline"
]
}
}
}
+38 -8
View File
@@ -1,19 +1,27 @@
// Layout run: the documents the repo carries — corpus, showcase and fuzz seeds — opened in
// the editor, the page count held against LibreOffice's PDF, and the rendered lines checked
// for what no layout may show: overlapping lines, text off the page or past the margins, a
// heading alone at the foot of a page or column. `[regex]` limits the run by file name.
// Layout run: corpus, showcase and fuzz seeds opened in the editor, the page count held
// against LibreOffice's PDF, the lines checked for overlap, margin escapes and stranded
// headings, and the page starts and load time held against tests/layout/baseline.json.
import { execFileSync, execSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, existsSync, copyFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, basename, extname, relative } from 'node:path';
import { ROOT, checker, devServer, openApp, settle } from '../browser.mjs';
// `[regex]` limits the run by file name; LAYOUT_UPDATE=1 records the baseline for this
// engine and platform instead of holding the run against it.
const PORT = +(process.env.LAYOUT_PORT ?? 4188);
const SEEDS = +(process.env.LAYOUT_SEEDS ?? 10);
const UPDATE = process.env.LAYOUT_UPDATE === '1';
const only = process.argv[2] ? new RegExp(process.argv[2]) : null;
const has = (tool) => { try { execSync(`command -v ${tool}`, { stdio: 'ignore' }); return true; } catch { return false; } };
const LO = has('soffice') && has('pdfinfo');
const { check, failures } = checker();
// Line breaking is the engine's and the platform's, so each keeps its own baseline.
const BASE = join(ROOT, 'tests/layout/baseline.json');
const KEY = `${process.env.BROWSER ?? 'chromium'}/${process.platform}`;
const baseline = existsSync(BASE) ? JSON.parse(readFileSync(BASE, 'utf8')) : {};
const known = baseline[KEY] ?? {};
const fresh = {};
const files = (dir) => (existsSync(dir)
? readdirSync(dir).filter((f) => /\.(odt|docx)$/.test(f) && !f.startsWith('~$')).sort().map((f) => join(dir, f))
@@ -107,6 +115,7 @@ function lintLayout() {
if (dy > 0.5 * Math.min(a.bottom - a.top, b.bottom - b.top) && dx > 2) issue('overlap', a, { other: b.text, otherKind: b.kind, otherAt: box(b) });
}
}
const starts = sheets.map((_, i) => lines.find((l) => l.page === i && (l.kind === 'body' || l.kind === 'note'))?.text ?? '');
// Body text stays on a sheet and, in a single-section document, inside the margins.
for (const l of lines) {
if (l.kind !== 'body' && l.kind !== 'note') continue;
@@ -137,7 +146,7 @@ function lintLayout() {
}
const tiptap = document.querySelector('.tiptap');
if (tiptap.scrollWidth > tiptap.clientWidth + 1) issues.push({ kind: 'content wider than the page', page: 0, text: `${tiptap.scrollWidth}px in ${tiptap.clientWidth}px` });
return { pages: sheets.length, lines: lines.length, issues };
return { pages: sheets.length, lines: lines.length, starts, issues };
}
const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode(...u8.subarray(i, i + 0x8000)); return btoa(s); };
@@ -169,17 +178,31 @@ try {
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 30_000 });
await page.evaluate(() => { window.__loadAt = performance.now(); });
await page.setInputFiles('input.file-input', file);
// The document's name says the file is in; a document may hold nothing to wait for.
await page.waitForFunction((n) => document.querySelector('.doc-name-input')?.value.startsWith(n), basename(file, extname(file)), { timeout: 60_000 });
await settle(page, true);
// The layout's own time: from the file going in to the last change settle saw, so the
// quiet period it waits out afterwards is not part of the measurement.
const ms = await page.evaluate(() => Math.round((window.__paritySince - window.__loadAt) / 100) * 100);
const r = await page.evaluate(lintLayout);
const ref = lo.get(file);
const off = ref == null ? 0 : r.pages - ref;
// A page or a tenth either way is the slack two layout engines take.
const pagesOk = Math.abs(off) <= Math.max(1, Math.round((ref ?? 0) / 10));
const label = `${name} editor ${r.pages}p${ref != null ? ` / LO ${ref}p` : ''}${off && pagesOk ? ' (~)' : ''} ${r.issues.length} issue(s)`;
check(pagesOk && !r.issues.length, label);
// What the baseline holds: where each page starts, and what the load cost.
const prev = known[name];
fresh[name] = { pages: r.pages, lines: r.lines, ms, starts: r.starts };
const moved = prev ? r.starts.map((t, i) => [i + 1, t, prev.starts[i] ?? '—']).filter(([, a, b]) => a !== b) : [];
// ponytail: wall clock read through a 500ms poll, so only three times the recorded
// load plus a second of slack counts as a regression.
const slow = prev && ms > prev.ms * 3 + 1000 ? `${ms}ms against ${prev.ms}ms` : null;
const label = `${name} editor ${r.pages}p${ref != null ? ` / LO ${ref}p` : ''}${off && pagesOk ? ' (~)' : ''} ${r.issues.length} issue(s)${prev ? '' : ' (new)'}`;
check(pagesOk && !r.issues.length && (UPDATE || (!moved.length && !slow)), label);
if (slow) console.log(` slower than the baseline: ${slow}`);
for (const [pg, now, was] of moved.slice(0, 5)) console.log(` p${pg} starts "${now}", the baseline has "${was}"`);
if (moved.length > 5) console.log(` … ${moved.length - 5} page start(s) more`);
for (const i of r.issues.slice(0, 8)) {
const where = i.other ? ` × ${i.otherKind} "${i.other}" @${i.otherAt}` : i.body ? ` (body ${i.body})` : i.sheet ? ` (sheet ${i.sheet})` : '';
console.log(` p${i.page} ${i.kind}: "${i.text}"${i.at ? ` @${i.at}` : ''}${where}`);
@@ -192,6 +215,13 @@ try {
check(false, `${name}: ${err.message.split('\n')[0]}`);
}
}
if (UPDATE) {
const merged = Object.fromEntries(Object.entries({ ...known, ...fresh }).sort());
writeFileSync(BASE, JSON.stringify({ ...baseline, [KEY]: merged }, null, 1) + '\n');
console.log(`baseline for ${KEY}: ${Object.keys(fresh).length} document(s) recorded`);
} else if (!Object.keys(known).length) {
console.log(`no baseline for ${KEY} — record one with LAYOUT_UPDATE=1`);
}
} catch (err) {
check(false, `layout run threw: ${err.stack ?? err}`);
} finally {
+1 -16
View File
@@ -9,7 +9,7 @@ import { join } from 'node:path';
import { buildOdt } from '../src/lib/export/odt';
import { buildDocx } from '../src/lib/export/docx';
import { importOdt } from '../src/lib/import/odt';
import { normalize, stripFontHoist } from './normalize';
import { normalize, stripFontHoist, unhoist } from './normalize';
import { mulberry32 } from './fuzzDoc';
import { genCase, exportArgs, expectedOptions, importedOptions, diffLoose, omit, DOCX_LOSSY, docWideOddEven, type FuzzOptions } from './fuzzOptions';
@@ -32,21 +32,6 @@ function convertAll(files: string[], outDir: string): void {
'--convert-to', 'odt', '--outdir', outDir, ...files], { stdio: 'pipe', timeout: 900_000 });
}
// A font a paragraph carries for all its runs is written on the paragraph alone by
// LibreOffice; read it back onto the runs, where the authored document keeps it.
function unhoist(node: N): N {
const { fontSize, fontFamily } = node.attrs ?? {};
if ((fontSize || fontFamily) && node.content) {
for (const c of node.content) {
if (c.type !== 'text') continue;
const ts = (c.marks ??= []).find((m: N) => m.type === 'textStyle') ?? (c.marks.push({ type: 'textStyle', attrs: {} }), c.marks[c.marks.length - 1]);
ts.attrs = { ...(fontSize ? { fontSize } : {}), ...(fontFamily ? { fontFamily } : {}), ...ts.attrs };
}
}
for (const c of node.content ?? []) unhoist(c);
return node;
}
// LibreOffice's own reading: pictures re-encoded, sizes rounded through cm, a width on
// every column, a hyperlink in its Internet Link style, a comment dated by its own clock;
// from DOCX an index mark is dropped, a list style "WWNum", a lone operator quoted text.
+168 -68
View File
@@ -1,7 +1,6 @@
// Monkey run: random editing in the real browser under the invariants any sequence of
// commands must keep — no uncaught error, a document its schema accepts, undo back to the
// opened file and redo forward again, and a saved file the schema validates and the
// importer reads back as the editor holds it. A failure names the seed and the last ops.
// opened file and redo forward, and a saved file that validates and reads back as held.
import { readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -41,6 +40,7 @@ const KEYS = ['Enter', 'Enter', 'Backspace', 'Backspace', 'Delete', 'Tab', 'Shif
'Shift+ArrowLeft', 'Shift+ArrowRight', 'Shift+ArrowDown', 'Home', 'End', `${MOD}+b`, `${MOD}+i`, `${MOD}+u`, `${MOD}+Shift+7`, `${MOD}+Shift+8`,
`${MOD}+Enter`, `${MOD}+z`, `${MOD}+Shift+z`];
const STYLES = ['Standard', 'Heading 1', 'Heading 2', 'Heading 3', 'Title', 'Quotations'];
const WRAPS = ['inline', 'left', 'right', 'topBottom', 'through'];
// Each entry: a weight and a draw of the op — a key, typed text, or an editor command.
const OPS_TABLE = [
[24, (r) => ({ type: pick(r, WORDS) + (r() < 0.3 ? pick(r, WORDS) : '') })],
@@ -69,6 +69,14 @@ const OPS_TABLE = [
{ cmd: 'setImage', args: [{ src: PNG, width: 80, height: 60 }] }, { cmd: 'setHardBreak' },
{ cmd: 'addComment', args: [{ author: 'Monkey', text: 'hm' }] }, { cmd: 'insertContent', args: ['—'] },
])],
[8, (r) => pick(r, [
{ cmd: 'setImage', args: [{ src: PNG, width: int(r, 40, 220), height: int(r, 30, 160), wrap: pick(r, WRAPS) }] },
{ cmd: 'setImageWrap', args: [pick(r, WRAPS)] }, { cmd: 'insertTextBox' },
{ cmd: 'setTextBoxAttrs', args: [{ width: int(r, 60, 280), height: int(r, 40, 200) }] },
{ cmd: 'setTextBoxAttrs', args: [{ wrap: pick(r, WRAPS), wrapDist: pick(r, [0, 0.2]) }] },
{ cmd: 'setTextBoxAlign', args: [pick(r, ['left', 'center', 'right', null])] },
{ cmd: 'setColumns', args: [int(r, 2, 3)] }, { cmd: 'setColumnGap', args: [pick(r, [0.5, 1.5])] },
])],
[3, (r) => ({ cmd: pick(r, ['undo', 'redo']) })],
];
const TOTAL = OPS_TABLE.reduce((n, [w]) => n + w, 0);
@@ -99,6 +107,34 @@ const docCheck = () => ed(() => {
try { document.querySelector('.tiptap').editor.state.doc.check(); return null; } catch (e) { return String(e); }
});
const docJson = () => ed(() => JSON.stringify(document.querySelector('.tiptap').editor.getJSON()));
// The key the history is held against, and the count of the columns flow's own rewrites:
// the flow changes the document outside the history (`FLOW_TX`, columnsFlow.ts), so undo
// rebases over it — ponytail: such a run has its history left unchecked.
const watchFlow = () => ed(async () => {
const { mergeJoinedParagraphsJson } = await import('/src/lib/export/odt.ts');
// The caches the editor recomputes off the history — a note's label, an index's rows, a
// caption's number, a reference's text — plus a paragraph columnsFlow split at a page
// boundary: layout and derived values, not the edits undo has to reverse.
const DERIVED = { noteRef: 'text', note: 'text', chapterField: 'text', crossReference: 'text',
bibliographyEntry: 'text', sequenceField: 'number', tableOfContents: 'entries' };
const strip = (n) => {
if (n.attrs && DERIVED[n.type]) n.attrs = { ...n.attrs, [DERIVED[n.type]]: null };
for (const c of n.content ?? []) strip(c);
return n;
};
window.__docKey = () => JSON.stringify(
mergeJoinedParagraphsJson(strip(document.querySelector('.tiptap').editor.getJSON()).content ?? []));
const view = document.querySelector('.tiptap').editor.view;
window.__flowTx = 0;
if (view.__flowWatched) return;
view.__flowWatched = true;
const dispatch = view.dispatch.bind(view);
view.dispatch = (tr) => {
if (tr.docChanged && tr.getMeta('columnsFlowTx')) window.__flowTx++;
return dispatch(tr);
};
});
const docKey = () => ed(() => window.__docKey());
const docSize = () => ed(() => document.querySelector('.tiptap').editor.state.doc.content.size);
const saveAs = async (ext) => {
@@ -123,19 +159,65 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => {
// named style resolves to, and bold in a header cell.
const { resolveStyle } = await import('/src/lib/styles/styleSheet.ts');
const sheet = editor.extensionManager.extensions.find((e) => e.name === 'paragraphStyle')?.options.sheet();
const styled = (n, header = false) => {
if (n.attrs?.styleName && sheet) {
const own = resolveStyle(sheet, n.attrs.styleName, 'paragraph').para ?? {};
for (const [k, v] of Object.entries(n.attrs)) if (k !== 'styleName' && own[k] === v) delete n.attrs[k];
const styled = (n, header = false, framed = false, text = {}) => {
// A heading's level already names its style, so neither importer writes one out —
// and the style it resolves to is the one whose values are suppressed.
const style = n.attrs?.styleName ?? (n.type === 'heading' ? `Heading ${n.attrs?.level ?? 1}` : null);
if (style && sheet) {
const own = resolveStyle(sheet, style, 'paragraph');
for (const [k, v] of Object.entries(n.attrs)) if (k !== 'styleName' && own.para?.[k] === v) delete n.attrs[k];
text = own.text ?? {};
}
if (header && n.type === 'text' && n.marks) n.marks = n.marks.filter((m) => m.type !== 'bold');
// A formula alone in its paragraph is a display formula to both files.
if (n.type === 'paragraph' && n.content?.length === 1 && n.content[0].type === 'formula') n.content[0].attrs = { ...n.content[0].attrs, display: true };
for (const c of n.content ?? []) styled(c, header || n.type === 'tableHeader');
// A box with no size of its own is saved at the size the exporter falls back to, and
// Word's table grid always carries widths, so a merged cell reads back with them.
if (n.type === 'textBox') n.attrs = { ...n.attrs, width: n.attrs?.width ?? 280, height: n.attrs?.height ?? 96 };
if (n.attrs?.colwidth) delete n.attrs.colwidth;
// Both formats spell a header row as the one that repeats, so it reads back repeating.
if (n.type === 'table' && n.content?.[0]?.content?.[0]?.type === 'tableHeader') delete n.attrs?.repeatHeader;
if (n.attrs?.styleName === style || n.attrs?.styleName === 'Standard') delete n.attrs.styleName;
// A run repeating what its own style says is direct formatting both importers
// suppress — they cannot tell it from a style that failed to resolve.
for (const m of n.type === 'text' ? n.marks ?? [] : []) {
if (m.type !== 'textStyle' || !m.attrs) continue;
if (m.attrs.fontFamily === (text.fontFamily ?? 'Liberation Serif')) delete m.attrs.fontFamily;
if (parseFloat(m.attrs.fontSize) === (text.fontSizePt ?? 12)) delete m.attrs.fontSize;
}
// Word has no anchored drawing inside a text box, so a frame in one goes out inline.
if (framed && ext === 'docx' && (n.type === 'image' || n.type === 'textBox') && n.attrs) n.attrs = { ...n.attrs, wrap: null, inFront: null };
// A block in a box or a cell carries no style name (import/CLAUDE.md), so its chain
// is baked in and a heading there reads back bold instead of styled.
if (framed && n.type === 'heading') for (const c of n.content ?? []) if (c.marks) c.marks = c.marks.filter((m) => m.type !== 'bold');
const inFrame = framed || n.type === 'textBox' || n.type === 'tableCell' || n.type === 'tableHeader';
for (const c of n.content ?? []) styled(c, header || n.type === 'tableHeader', inFrame, text);
return n;
};
const held = styled({ ...json, content: mergeJoinedParagraphsJson(json.content ?? []) });
return { diff: firstDiff(stripFontHoist(normalize(held)), stripFontHoist(normalize(styled(res.content)))), warnings: res.warnings, held: JSON.stringify(held), read: JSON.stringify(res.content) };
// Word's list model is flat — an item's further blocks are unnumbered paragraphs at its
// indent, and nesting is the numbering's — so the DOCX leg compares a list's content
// flat, without its structure or the indent Word writes on a block it does not number.
const flatLists = (n) => {
if (!n.content) return n;
const out = [];
let inList = false;
const bare = (b) => (b.attrs ? { ...b, attrs: { ...b.attrs, indent: null } } : b);
for (const kid of n.content.map(flatLists)) {
if (kid.type === 'bulletList' || kid.type === 'orderedList') {
for (const item of kid.content ?? []) for (const b of item.content ?? []) out.push(bare(b));
inList = true;
continue;
}
// A paragraph following the list carries the same indent: it is a continuation of
// the item, which is the one shape Word has for a block it does not number.
out.push(inList && kid.type === 'paragraph' ? bare(kid) : kid);
inList = inList && kid.type === 'paragraph';
}
return { ...n, content: out };
};
const flat = (n) => stripFontHoist(normalize(ext === 'docx' ? flatLists(n) : n));
return { diff: firstDiff(flat(held), flat(styled(res.content))), warnings: res.warnings, held: JSON.stringify(held), read: JSON.stringify(res.content) };
}, { b64: Buffer.from(bytes).toString('base64'), ext });
try {
@@ -145,70 +227,88 @@ try {
await page.waitForSelector('.tiptap', { timeout: 30_000 });
await page.evaluate(() => { window.showSaveFilePicker = undefined; });
const retried = new Set();
for (let run = 0; run < RUNS; run++) {
const seed = SEED + run;
const doc = DOCS[run % DOCS.length];
const r = mulberry32(seed);
const before = await docJson();
await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus', doc));
await page.waitForFunction((b) => JSON.stringify(document.querySelector('.tiptap').editor.getJSON()) !== b, before, { timeout: 30_000 });
await page.waitForTimeout(1500);
const initial = await docJson();
const log = [];
let broken = null;
for (let i = 0; i < OPS && !broken; i++) {
const op = drawOp(r, await docSize());
log.push(op);
if (op.type || op.key) {
const err = await focused();
if (err) broken = `focus threw: ${err}`;
else if (op.type) await page.keyboard.type(op.type);
else await page.keyboard.press(op.key);
} else {
const res = await runCmd(op);
if (res.threw) broken = `${op.cmd} threw: ${res.threw}`;
try {
const seed = SEED + run;
const doc = DOCS[run % DOCS.length];
const r = mulberry32(seed);
const before = await docJson();
await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus', doc));
await page.waitForFunction((b) => JSON.stringify(document.querySelector('.tiptap').editor.getJSON()) !== b, before, { timeout: 30_000 });
await page.waitForTimeout(1500);
await watchFlow();
const initial = await docKey();
const log = [];
let broken = null;
for (let i = 0; i < OPS && !broken; i++) {
const op = drawOp(r, await docSize());
log.push(op);
if (op.type || op.key) {
const err = await focused();
if (err) broken = `focus threw: ${err}`;
else if (op.type) await page.keyboard.type(op.type);
else await page.keyboard.press(op.key);
} else {
const res = await runCmd(op);
if (res.threw) broken = `${op.cmd} threw: ${res.threw}`;
}
const invalid = await docCheck();
if (invalid) broken = `schema-invalid document: ${invalid}`;
if (pageErrors.length) broken = `uncaught: ${pageErrors.join(' | ')}`;
}
const invalid = await docCheck();
if (invalid) broken = `schema-invalid document: ${invalid}`;
if (pageErrors.length) broken = `uncaught: ${pageErrors.join(' | ')}`;
}
const final = await docJson();
await writeFile(join(tmpdir(), `monkey-${seed}-ops.json`), JSON.stringify(log));
// Undo until the opened document is back, redo as often, and the edited one is back.
const undone = broken ? -1 : await ed((initial) => {
const editor = document.querySelector('.tiptap').editor;
for (let n = 0; n < 500; n++) {
if (JSON.stringify(editor.getJSON()) === initial) return n;
if (!editor.can().undo()) return -1;
try { editor.commands.undo(); } catch (e) { return `undo threw: ${String(e).slice(0, 300)}`; }
}
return -1;
}, initial);
const redone = typeof undone !== 'number' || undone < 0 ? null : await ed((n) => {
const editor = document.querySelector('.tiptap').editor;
for (let i = 0; i < n; i++) editor.commands.redo();
return JSON.stringify(editor.getJSON());
}, undone);
const history = broken ?? (typeof undone === 'string' ? undone : undone < 0 ? 'undo does not reach the opened document' : redone !== final ? 'redo does not restore the edited document' : null);
const label = `seed ${seed} on ${doc}: ${log.length} ops, ${undone} undo steps`;
check(!history, history ? `${label} — ${history}` : label);
if (history) for (const op of log.slice(-12)) console.log(' ', JSON.stringify(op));
if (broken) break;
const final = await docKey();
await writeFile(join(tmpdir(), `monkey-${seed}-ops.json`), JSON.stringify(log));
// Undo until the opened document is back, redo as often, and the edited one is back.
const undone = broken ? -1 : await ed(async (initial) => {
const { firstDiff } = await import('/tests/normalize.ts');
const editor = document.querySelector('.tiptap').editor;
for (let n = 0; n < 500; n++) {
if (window.__docKey() === initial) return n;
if (!editor.can().undo()) return `undo stops at ${firstDiff({ type: 'doc', content: JSON.parse(initial) }, { type: 'doc', content: JSON.parse(window.__docKey()) })}`;
try { editor.commands.undo(); } catch (e) { return `undo threw: ${String(e).slice(0, 300)}`; }
}
return -1;
}, initial);
const redone = typeof undone !== 'number' || undone < 0 ? null : await ed((n) => {
const editor = document.querySelector('.tiptap').editor;
for (let i = 0; i < n; i++) editor.commands.redo();
return window.__docKey();
}, undone);
const flowed = await ed(() => window.__flowTx ?? 0);
const history = broken ?? (flowed ? null : typeof undone === 'string' ? undone : undone < 0 ? 'undo does not reach the opened document'
: redone !== final ? `redo does not restore the edited document: ${await ed(async ([a, b]) => (await import('/tests/normalize.ts')).firstDiff({ type: 'doc', content: JSON.parse(a) }, { type: 'doc', content: JSON.parse(b) }), [final, redone])}` : null);
const label = `seed ${seed} on ${doc}: ${log.length} ops, `
+ (flowed ? `the columns flow rewrote the document ${flowed}× — history not checked` : `${undone} undo steps`);
check(!history, history ? `${label} — ${history}` : label);
if (history) for (const op of log.slice(-12)) console.log(' ', JSON.stringify(op));
if (broken) break;
for (const ext of ['odt', 'docx']) {
const bytes = await saveAs(ext);
const schema = hasXmllint ? (ext === 'odt' ? validateOdt : validateDocx)(unzipSync(bytes)) : [];
const rt = await roundTrip(bytes, ext).catch((err) => ({ diff: null, warnings: [], threw: String(err.message ?? err).split('\n')[0] }));
const bad = rt.threw ? `import threw: ${rt.threw}` : schema.length ? `schema: ${schema[0].slice(0, 300)}`
: rt.diff ? `round trip: ${rt.diff}` : rt.warnings.length ? `warnings: ${rt.warnings.join(' | ')}` : null;
check(!bad, `seed ${seed}: the saved .${ext} validates and reads back${bad ? ` — ${bad}` : ''}`);
if (bad) {
// The file and both documents, for the repro.
const stem = join(tmpdir(), `monkey-${seed}`);
await writeFile(`${stem}.${ext}`, bytes);
if (rt.held) await Promise.all([writeFile(`${stem}-${ext}-held.json`, rt.held), writeFile(`${stem}-${ext}-read.json`, rt.read)]);
console.log(` kept: ${stem}.${ext}`);
for (const ext of ['odt', 'docx']) {
const bytes = await saveAs(ext);
const schema = hasXmllint ? (ext === 'odt' ? validateOdt : validateDocx)(unzipSync(bytes)) : [];
const rt = await roundTrip(bytes, ext).catch((err) => ({ diff: null, warnings: [], threw: String(err.message ?? err).split('\n')[0] }));
const bad = rt.threw ? `import threw: ${rt.threw}` : schema.length ? `schema: ${schema[0].slice(0, 300)}`
: rt.diff ? `round trip: ${rt.diff}` : rt.warnings.length ? `warnings: ${rt.warnings.join(' | ')}` : null;
check(!bad, `seed ${seed}: the saved .${ext} validates and reads back${bad ? ` — ${bad}` : ''}`);
if (bad) {
// The file and both documents, for the repro.
const stem = join(tmpdir(), `monkey-${seed}`);
await writeFile(`${stem}.${ext}`, bytes);
if (rt.held) await Promise.all([writeFile(`${stem}-${ext}-held.json`, rt.held), writeFile(`${stem}-${ext}-read.json`, rt.read)]);
console.log(` kept: ${stem}.${ext}`);
}
}
} catch (err) {
// The app reloading under the run (its own recovery prompt, a settings write)
// tears the page context down mid-op; one more go, then it is a finding.
if (!/Execution context was destroyed/.test(err.message) || retried.has(run)) throw err;
retried.add(run);
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
await page.evaluate(() => { localStorage.clear(); window.showSaveFilePicker = undefined; });
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 30_000 });
run--;
}
}
} catch (err) {
+23 -2
View File
@@ -61,11 +61,12 @@ export function normalize(node: N): N {
if (node.text != null) out.text = node.text;
// A formula adopts the caret's marks for its font; neither file carries them.
if (node.marks?.length && node.type !== 'formula') {
out.marks = node.marks
const marks = node.marks
.map((m: N) => {
const mm: N = { type: m.type };
// An empty string is the picker's "unset" (a font family, say) — no value at all.
const attrs = Object.fromEntries(Object.entries(m.attrs ?? {})
.filter(([k, v]) => v != null && MARK_DEFAULTS[k] !== v)
.filter(([k, v]) => v != null && v !== '' && MARK_DEFAULTS[k] !== v)
.map(([k, v]) => [k, typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v) ? v.toUpperCase() : v]));
// ODF keeps the authored (naive local) comment/revision date, DOCX re-serializes
// the same instant as UTC — compare the instant.
@@ -77,7 +78,9 @@ export function normalize(node: N): N {
if (Object.keys(attrs).length) mm.attrs = attrs;
return mm;
})
.filter((m: N) => m.type !== 'textStyle' || m.attrs) // a textStyle with nothing left says nothing
.sort((a: N, b: N) => a.type.localeCompare(b.type));
if (marks.length) out.marks = marks;
}
const attrs: N = {};
// An auto date/time field re-evaluates on load (the DOCX importer stamps "now"), so
@@ -90,6 +93,8 @@ export function normalize(node: N): N {
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 gap beside a frame is drawn on a side wrap only, and zero is no gap at all.
if (k === 'wrapDist' && !(Number(v) > 0 && (node.attrs?.wrap === 'left' || node.attrs?.wrap === 'right'))) continue;
// 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.
if (k === 'latex') { attrs.latex = String(v).trim(); continue; }
@@ -125,6 +130,22 @@ export function firstDiff(a: N, b: N, path = '$'): string | null {
return null;
}
// A block's own font pushed down onto its runs, so a hoisted font and a run-level one
// compare as the same document.
export function unhoist(node: any): any {
const { fontSize, fontFamily } = node.attrs ?? {};
if ((fontSize || fontFamily) && node.content) {
for (const c of node.content) {
if (c.type !== 'text') continue;
const ts = (c.marks ??= []).find((m: any) => m.type === 'textStyle')
?? (c.marks.push({ type: 'textStyle', attrs: {} }), c.marks[c.marks.length - 1]);
ts.attrs = { ...(fontSize ? { fontSize } : {}), ...(fontFamily ? { fontFamily } : {}), ...ts.attrs };
}
}
for (const c of node.content ?? []) unhoist(c);
return node;
}
// A paragraph whose runs share one font legitimately comes back with that font also
// on its attrs (the empty-line-height feature hoists it); ignore it on both sides.
export function stripFontHoist(node: any): any {