diff --git a/CLAUDE.md b/CLAUDE.md index f87e1b7..d97ea8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/docs/headless-testing.md b/docs/headless-testing.md index a4ff849..fac25ab 100644 --- a/docs/headless-testing.md +++ b/docs/headless-testing.md @@ -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 diff --git a/tests/layout/baseline.json b/tests/layout/baseline.json new file mode 100644 index 0000000..17d5e84 --- /dev/null +++ b/tests/layout/baseline.json @@ -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": [ + " 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" + ] + } + } +} diff --git a/tests/layout/run.mjs b/tests/layout/run.mjs index a650c67..da49bdc 100644 --- a/tests/layout/run.mjs +++ b/tests/layout/run.mjs @@ -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 { diff --git a/tests/lo-fuzz.test.ts b/tests/lo-fuzz.test.ts index 4e041ed..411e451 100644 --- a/tests/lo-fuzz.test.ts +++ b/tests/lo-fuzz.test.ts @@ -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. diff --git a/tests/monkey/run.mjs b/tests/monkey/run.mjs index 1d0e227..395494a 100644 --- a/tests/monkey/run.mjs +++ b/tests/monkey/run.mjs @@ -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) { diff --git a/tests/normalize.ts b/tests/normalize.ts index 97f44f2..7bb8df4 100644 --- a/tests/normalize.ts +++ b/tests/normalize.ts @@ -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 {