diff --git a/CLAUDE.md b/CLAUDE.md index d97ea8a..f261c19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ npm run test:lo # LibreOffice legs: round trip, fuzz re-read, ODT/DOCX rend 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, 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:monkey # random editing, body + header/footer, under invariants: schema, undo/redo, the saved file reads back 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) node scripts/make-thesaurus.mjs; node scripts/collect-licenses.mjs # re-vendor public/thesaurus/ (MyThes); regenerate public/licenses.txt diff --git a/docs/headless-testing.md b/docs/headless-testing.md index fac25ab..a0fa51e 100644 --- a/docs/headless-testing.md +++ b/docs/headless-testing.md @@ -40,7 +40,61 @@ text box in a note breaks the ODT's XML, and a table in a cell is dropped on sav 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. +repeat came back as ordinary cells. Then: setting one of a heading's two vertical margins +zeroed the other in the `.odt` (LibreOffice pairs them); a page break before a list item +reached neither file; a list item's further blocks lost their own alignment, spacing and +line height in the `.odt`; a run could be subscript and superscript at once; and typing +over a selection that spans two blocks let the contenteditable's own span land a whole CSS +font stack, and a weight already saying `bold`, in the document. A list in a table cell +shifted every body list item's own style by as many items as the cell held; and a heading +inside a list item came back from the `.odt` as a paragraph. In a note's own text the +`.docx` lost a formula, a ruby and a leading tab, and a heading in a table cell lost the +margin it did not set; a list toggle could pull a note anchor into a text box, where the +`.docx` dropped anchor and note without a word. A running-head or date field in a zone +lost its own formatting in the `.docx`: the library's simple field writes a bare run. +Typing over a selection spanning two header cells left a half-header first row, which no +file keeps. One finding was the checker's own: libxml2's RelaxNG cannot match an element +the ODF schema declares by name class inside a `` (the statistic fields), so a +formatted page count in a zone read as invalid — LibreOffice writes the same span. +The run's own `focus(pos)` is snapped to the next text position: TipTap makes the selection +wherever it is told, and Enter on a caret between two list items throws out of prosemirror — +a place the view itself never puts one. +In Firefox: a page break set inside a text box, which neither format keeps, so the same +plugin now sweeps it from a table cell and a note body too; and the run's 1x1 PNG had a +truncated IDAT, which only Firefox reports — the same broken bytes were in eleven fixtures. +WebKit raises `ResizeObserver loop completed with undelivered notifications` as a page +error — the text box refits the wrapper it observes, so the browser defers the rest of the +round. That converging loop is the one message `openApp` drops. +A comment over a line break rides the `hardBreak` too, which no file carries and the +comment's own text runs keep — `normalize` skips the marks of an inline atom, the picture +and the formula with it. +Then the table run: promoting a cell to a header dropped every attr it had; a replace +reaching into a table left the cell it landed in without its column weight, so redo (which +replays the recorded step) restored one the edit itself had lost; a list toggle wrapped a +table into a list item, where the `.odt` drops it; and a heading in a cell came back from +the `.docx` carrying its size as direct formatting. A list's `listStyleName` is no longer +compared: direct formatting on a marker makes the file keep the resolved automatic clone +instead, and `corpus.test.ts` holds the plain round trip. Its marker char goes the same +way — a list that lost its name carries the char the style drew, so both sides compare the +drawn one, as the kind already did. +Still open: a run set to the body's own font, in a columns section, kept its family on the +held side once in four runs of the same seed chain — the flow rewrites such a document +between frames, which is why that leg leaves the history unchecked too. +Both header/footer zones go first, before the body ops can cut the document to one page. +The run draws the two variant flags and the zone distances from the ribbon's Insert tab +(Options) — with them on, a double-click on pages 1 to 3 in turn is one session per +variant — and edits each with `MONKEY_HF_OPS` ops of the zone's own schema: runs, breaks, +the insert bar's three fields, inline images, left by Done. `16-hf-variants` brings the +same six zones in through the import path instead. Then one flag is flipped **while a +zone is open**, which ends the edit (`App.svelte` drops `hfActive` with it): the text +typed before the flip has to be in the zones all the same. A zone is no part of the +document, so the saved file is the only thing that reports on it: the round trip holds +the zones the app's storage keeps against the ones the importer gives back, per section +and per variant in play. Undo is not among the zone's keys — a pass falls into one +history group, so a single Mod+Z would empty the zone and the round trip would compare +nothing. Every run opens its document through `input.file-input[accept*=".odt"]`: the +Insert tab carries a second `.file-input` for pictures, and a bare class selector picks +whichever the ribbon happens to be showing. `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/browser.mjs b/tests/browser.mjs index e47d651..8f01941 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -58,7 +58,10 @@ export async function openApp(port) { console.log(`engine: ${name} ${browser.version()}`); const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, locale: 'en-US' }); const pageErrors = []; - page.on('pageerror', (err) => pageErrors.push(String(err))); + // The text box's ResizeObserver refits the wrapper it observes, so the browser defers + // the rest of the round to the next frame and reports it — a converging loop, not an error. + const benign = /ResizeObserver loop/; + page.on('pageerror', (err) => { if (!benign.test(String(err))) pageErrors.push(String(err)); }); page.on('dialog', (d) => d.accept()); return { browser, page, pageErrors }; } diff --git a/tests/corpus/16-hf-variants.docx b/tests/corpus/16-hf-variants.docx new file mode 100644 index 0000000..422b286 Binary files /dev/null and b/tests/corpus/16-hf-variants.docx differ diff --git a/tests/corpus/16-hf-variants.odt b/tests/corpus/16-hf-variants.odt new file mode 100644 index 0000000..2645b09 Binary files /dev/null and b/tests/corpus/16-hf-variants.odt differ diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index 84e95f9..227f22d 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -31,7 +31,7 @@ try { await page.waitForSelector('.tiptap', { timeout: 15_000 }); // A multi-page corpus document, through the file input (no picker in headless). - await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/05-breaks.odt')); + await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/05-breaks.odt')); await page.waitForFunction(() => (document.querySelector('.tiptap')?.textContent ?? '').length > 200, null, { timeout: 30_000 }); const opened = await settled(); @@ -95,7 +95,7 @@ try { const marked = await dot(true); await page.keyboard.press(`${MOD}+z`); const backToSaved = await dot(false); - await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt')); + await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/04-table.odt')); await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(), null, { timeout: 30_000 }); const opened2 = await dot(false); @@ -111,7 +111,7 @@ try { // And so does the name the file is saved under. The file comes back in first, so the // rename is the only thing standing between the document and its clean state. - await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt')); + await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/04-table.odt')); await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(), null, { timeout: 30_000 }); const reopened = await dot(false); @@ -131,6 +131,33 @@ try { const saved = await readFile(await download.path()); const isDocx = saved[0] === 0x50 && saved[1] === 0x4b && saved.includes('word/document.xml'); check(isDocx, `Save As (.docx) without a picker downloads a DOCX (${download.suggestedFilename()}, ${saved.length} bytes)`); + + // The header/footer switches live in the ribbon's Insert tab. A ticked "different + // first page" makes page 1 a second zone, so what is typed there lands beside the + // running one — and the distance travels to the app's own storage. + await page.keyboard.press('Escape'); + await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click(); + const hfOptions = page.locator('button.rb', { hasText: 'Options' }).first(); + await hfOptions.click(); + await page.locator('.ribbon-menu .check-row input').first().check(); + const dist = page.locator('.ribbon-menu .num-row input').first(); + await dist.fill('1.8'); + await dist.dispatchEvent('change'); + await hfOptions.click(); + const zone = page.locator('.hf-zone.hf-header').first(); + await zone.dblclick({ timeout: 5000 }).catch(() => zone.dispatchEvent('dblclick')); + await page.waitForSelector('.hf-active .tiptap', { timeout: 5000 }); + await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 5000 }); + await page.keyboard.type('Titelseite'); + await page.locator('.hf-bar-done').click(); + const hf = await page.evaluate(() => ({ + first: localStorage.getItem('edentext-hf-different-first'), + running: localStorage.getItem('edentext-header'), + firstPage: localStorage.getItem('edentext-header-first') ?? '', + dist: localStorage.getItem('edentext-hf-distances') ?? '', + })); + check(hf.first === 'true' && !hf.running && hf.firstPage.includes('Titelseite') && /"header":1.8/.test(hf.dist), + `the ribbon's header/footer switches reach the document (first page: ${hf.first}, running zone: ${hf.running}, distances: ${hf.dist})`); } catch (err) { check(false, `dom run threw: ${err.message ?? err}`); } finally { diff --git a/tests/layout/baseline.json b/tests/layout/baseline.json index 17d5e84..7a73403 100644 --- a/tests/layout/baseline.json +++ b/tests/layout/baseline.json @@ -107,105 +107,114 @@ ] }, "docs/showcase/book.odt": { - "pages": 95, - "lines": 3739, - "ms": 800, + "pages": 104, + "lines": 3757, + "ms": 1300, "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", + "ice to say it over) “—yes, that’s about ", + "Alice was not a bit hurt, and she jumped", + "things had happened lately, that Alice h", + "And so it was indeed: she was now only t", + "it,” said Alice, “and if it makes me gro", "", "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", + "As she said this she looked down at her ", + "Just then she heard something splashing ", + "pool, “and she sits purring so nicely by", + "Dodo, a", + "", "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", + "‘William the Conqueror, whose cause was", + "either!” And the Eaglet bent down its he", + "not got into it), and handed them round ", + "“Fury said to a mouse, That he met in th", + "“And who is Dinah, if I might venture to", "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? ", + "this, she came upon a neat little house,", + "there was not even room for this, and sh", + "“Mary Ann! Mary Ann!” said the voice. “F", + "were", + "Last came a little feeble, squeaking voi", + "“The first thing I’ve got to do,” said A", + "This seemed to Alice a good opportunity ", "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", + "“Well, I’ve tried to say “How doth the l", + "“Not", + "“Of the mushroom,” said the Caterpillar,", + "you?” She was moving them about as she s", + "“But I’m", + "It was so long since she had been anythi", "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", + "Fish-Footman was gone, and the other was", + "It was, no doubt: only Alice did not lik", + "“They all can,” said the Duchess; “and m", + "of lullaby to it as she did so, and givi", + "grunted in reply (it had left off sneezi", + "“Cheshire Puss,” she began, rather timid", + "“Call it what you like,” said the Cat. “", + "ause the chimneys were shaped like ears ", + "", "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", + "Alice considered a little, and then said", + "“Nor I,” said the March Hare.", + "“It goes on, you know,” the Hatter conti", + "“Tell us a story!” said the March Hare.", + "The Dormouse again took a minute or two ", + "This answer so confused poor Alice, that", + "Once more she found herself in the long ", "", "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 ", + "watching them, and he checked himself su", + "upon their faces, so that they couldn’t ", + "“Leave off that!” screamed the Queen. “Y", + "“What for?” said Alice.", + "short time the Queen was in a furious pa", + "“How do you like the Queen?” said the Ca", + "The hedgehog was engaged in a fight with", + "Alice could think of nothing else to say", + "", "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", + "“Perhaps it hasn’t one,” Alice ventured ", + "“Right, as usual,” said the Duchess: “wh", + "“Just about as much right,” said the Duc", + "“It’s the thing Mock Turtle Soup is made", + "So they went up to the Mock Turtle, who ", + "“Yes, we went to school in the sea, thou", + "“Yes,” said Alice doubtfully: “it means—", + "This was quite a new idea to Alice, and ", + "", "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", - "", + "“The reason is,” said the", + "“Of course not,” said the Mock Turtle: “", + "of the Lobster Quadrille, that she hardl", + "[later editions continued as follows The", + "“Chorus again!” cried the", "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 ", + "On this the White Rabbit blew three blas", + "Here the Queen put on her spectacles, an", + "“I’m a poor man, your Majesty,” the Hatt", + "which tied up at the mouth with strings:", + "“Well, if I must, I must,” the King said", "", "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" + "said to herself; “I should think it woul", + "“You are,” said the King.", + "“If you didn’t sign it,” said the King, ", + "him,) “I’ll give him sixpence.", + "“It’s a pun!” the King added in an offen", + "First, she dreamed of little Alice herse", + "her childhood: and how she would gather " ] }, "docs/showcase/newsletter.docx": { @@ -640,6 +649,26 @@ "Appendix" ] }, + "tests/corpus/16-hf-variants.docx": { + "pages": 3, + "lines": 1528, + "ms": 300, + "starts": [ + "1.", + "18.", + "35." + ] + }, + "tests/corpus/16-hf-variants.odt": { + "pages": 3, + "lines": 1688, + "ms": 100, + "starts": [ + "1.", + "18.", + "35." + ] + }, "tests/corpus/word/01-flow.docx": { "pages": 2, "lines": 1008, diff --git a/tests/layout/run.mjs b/tests/layout/run.mjs index da49bdc..c346f63 100644 --- a/tests/layout/run.mjs +++ b/tests/layout/run.mjs @@ -179,7 +179,7 @@ try { 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); + await page.setInputFiles('input.file-input[accept*=".odt"]', 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); diff --git a/tests/monkey/run.mjs b/tests/monkey/run.mjs index 395494a..721fe16 100644 --- a/tests/monkey/run.mjs +++ b/tests/monkey/run.mjs @@ -16,8 +16,9 @@ const PORT = +(process.env.MONKEY_PORT ?? 4189); const SEED = +(process.env.MONKEY_SEED ?? 1); const RUNS = +(process.env.MONKEY_RUNS ?? 3); const OPS = +(process.env.MONKEY_OPS ?? 60); -const DOCS = process.env.MONKEY_DOC ? [process.env.MONKEY_DOC] : ['02-blocks.odt', '04-table.odt', '08-lists.odt', '12-notes.odt', '15-chapters.odt']; -const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; +const HF_OPS = +(process.env.MONKEY_HF_OPS ?? 8); +const DOCS = process.env.MONKEY_DOC ? [process.env.MONKEY_DOC] : ['02-blocks.odt', '16-hf-variants.odt', '04-table.odt', '08-lists.odt', '12-notes.odt', '15-chapters.odt']; +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNwaDgAAAKEAYEml6crAAAAAElFTkSuQmCC'; const { check, failures } = checker(); if (!hasXmllint) console.log('xmllint missing: the saved files are not validated against the schemas'); @@ -86,6 +87,30 @@ const drawOp = (r, size) => { return { key: 'ArrowRight' }; }; +// The zone's own schema — one paragraph of runs, breaks, fields and inline images — so it +// draws from a table of its own. No Escape (that leaves), no undo: a pass falls into one +// history group, so one Mod+Z would empty the zone before the round trip ever sees it. +const HF_KEYS = ['Enter', 'Backspace', 'Backspace', 'Delete', 'Tab', 'Home', 'End', + 'ArrowLeft', 'ArrowRight', 'Shift+ArrowLeft', 'Shift+ArrowRight', `${MOD}+b`, `${MOD}+i`, `${MOD}+u`]; +const HF_OPS_TABLE = [ + [14, (r) => ({ type: pick(r, WORDS) })], + [12, (r) => ({ key: pick(r, HF_KEYS) })], + // The insert bar's three field buttons, in its order: page number, count, chapter. + [5, (r) => ({ field: int(r, 0, 2) })], + [9, (r) => pick(r, [ + { cmd: 'toggleBold' }, { cmd: 'toggleItalic' }, { cmd: 'toggleUnderline' }, { cmd: 'toggleStrike' }, + { cmd: 'unsetAllMarks' }, { cmd: 'setTextAlign', args: [pick(r, ['left', 'center', 'right'])] }, + { cmd: 'setFontSize', args: ['10pt'] }, { cmd: 'setColor', args: ['#c00000'] }, + { cmd: 'setImage', args: [{ src: PNG, width: 24, height: 18 }] }, + ])], +]; +const HF_TOTAL = HF_OPS_TABLE.reduce((n, [w]) => n + w, 0); +const drawHfOp = (r) => { + let x = r() * HF_TOTAL; + for (const [w, draw] of HF_OPS_TABLE) { if ((x -= w) < 0) return draw(r); } + return { key: 'ArrowRight' }; +}; + const server = await devServer(PORT); const { browser, page, pageErrors } = await openApp(PORT); const consoleErrors = []; @@ -93,20 +118,29 @@ page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text( const ed = (fn, arg) => page.evaluate(fn, arg); const focused = async () => { - const err = await ed(() => { try { document.querySelector('.tiptap').editor.commands.focus(); return null; } catch (e) { return String(e); } }); - await page.waitForFunction(() => document.activeElement === document.querySelector('.tiptap'), null, { timeout: 2000 }).catch(() => {}); + const err = await ed(() => { try { document.querySelector('.tiptap-host .tiptap').editor.commands.focus(); return null; } catch (e) { return String(e); } }); + await page.waitForFunction(() => document.activeElement === document.querySelector('.tiptap-host .tiptap'), null, { timeout: 2000 }).catch(() => {}); return err; }; const runCmd = (op) => ed(({ cmd, args }) => { - const editor = document.querySelector('.tiptap').editor; + const editor = document.querySelector('.tiptap-host .tiptap').editor; + // A caret only ever sits in a text block — the view puts it nowhere else — but + // focus(pos) makes the selection wherever it is told, and Enter on one between two + // list items throws out of prosemirror. Snap to the next text position instead. + if (cmd === 'focus' && typeof args?.[0] === 'number') { + const doc = editor.state.doc; + let at = Math.max(0, Math.min(args[0], doc.content.size)); + for (let n = at; n <= doc.content.size; n++) if (doc.resolve(n).parent.isTextblock) { at = n; break; } + args = [at]; + } try { return { ok: editor.commands[cmd](...(args ?? [])) }; } catch (e) { return { threw: String(e?.stack ?? e) }; } }, op); // The schema's own check of the document after every op — a command may leave a node // with content its spec rejects, which nothing else reports. const docCheck = () => ed(() => { - try { document.querySelector('.tiptap').editor.state.doc.check(); return null; } catch (e) { return String(e); } + try { document.querySelector('.tiptap-host .tiptap').editor.state.doc.check(); return null; } catch (e) { return String(e); } }); -const docJson = () => ed(() => JSON.stringify(document.querySelector('.tiptap').editor.getJSON())); +const docJson = () => ed(() => JSON.stringify(document.querySelector('.tiptap-host .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. @@ -123,8 +157,8 @@ const watchFlow = () => ed(async () => { return n; }; window.__docKey = () => JSON.stringify( - mergeJoinedParagraphsJson(strip(document.querySelector('.tiptap').editor.getJSON()).content ?? [])); - const view = document.querySelector('.tiptap').editor.view; + mergeJoinedParagraphsJson(strip(document.querySelector('.tiptap-host .tiptap').editor.getJSON()).content ?? [])); + const view = document.querySelector('.tiptap-host .tiptap').editor.view; window.__flowTx = 0; if (view.__flowWatched) return; view.__flowWatched = true; @@ -135,25 +169,132 @@ const watchFlow = () => ed(async () => { }; }); const docKey = () => ed(() => window.__docKey()); -const docSize = () => ed(() => document.querySelector('.tiptap').editor.state.doc.content.size); +const docSize = () => ed(() => document.querySelector('.tiptap-host .tiptap').editor.state.doc.content.size); -const saveAs = async (ext) => { +const HF_LIVE = '.hf-active .tiptap'; +const hfCmd = (op) => ed(({ cmd, args }) => { + const editor = document.querySelector('.hf-active .tiptap')?.editor; + if (!editor) return { threw: 'the zone editor is gone' }; + try { return { ok: editor.commands[cmd](...(args ?? [])) }; } catch (e) { return { threw: String(e?.stack ?? e) }; } +}, op); +const hfCheck = () => ed(() => { + const editor = document.querySelector('.hf-active .tiptap')?.editor; + if (!editor) return 'the zone editor is gone'; + try { editor.state.doc.check(); return null; } catch (e) { return String(e); } +}); + +// A floating toolbar of the body's — a selected frame's, say — can sit over the zone +// and its bar; the handler behind the element is what this run is after, not hit-testing. +const clickish = (at, how = 'click') => at[how]({ timeout: 4000 }) + .catch(() => at.dispatchEvent(how, {}, { timeout: 4000 })) + .catch(() => false); // gone already (an op closed the zone): the checks after it still speak + +// A checkbox in the ribbon's own menu: the menu re-renders under the click (the flag +// ends an open zone edit), so a real click can find the element detached. +const setBox = (at, on) => at.setChecked(on, { timeout: 4000 }) + .catch(() => at.evaluate((el, v) => { if (el.checked !== v) el.click(); }, on)); + +// The two variant flags and the zone distances, from the ribbon's Insert tab. Setting +// them here is what puts the first-page and even-page zones in play on a document that +// carries none of its own. +async function setHfOptions(r) { + await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click(); + const options = page.locator('button.rb', { hasText: 'Options' }).first(); + await clickish(options); + await page.waitForSelector('.ribbon-menu .check-row input', { timeout: 5000 }); + const boxes = page.locator('.ribbon-menu .check-row input'); + for (const i of [0, 1]) await setBox(boxes.nth(i), r() < 0.6); + if (r() < 0.4) { + const dist = page.locator('.ribbon-menu .num-row input').nth(int(r, 0, 1)); + await dist.fill(String(pick(r, [0.6, 1, 1.8]))); + await dist.dispatchEvent('change'); + } + await clickish(options); +} + +// Flipping a flag ends an open zone edit (App.svelte drops hfActive with it), which +// tears the live editor down mid-session — what was typed must be in the zones anyway. +async function flipUnderEdit(mark) { + const zone = page.locator('.hf-zone.hf-footer').first(); + if (!(await zone.count())) return null; + await clickish(zone, 'dblclick'); + await page.waitForSelector(HF_LIVE, { timeout: 5000 }); + await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 2000 }).catch(() => {}); + await page.keyboard.type(mark); + await page.locator('.ribbon-tab', { hasText: 'Insert' }).first().click(); + const options = page.locator('button.rb', { hasText: 'Options' }).first(); + await clickish(options); + const box = page.locator('.ribbon-menu .check-row input').nth(1); + await setBox(box, !(await box.isChecked())); + await clickish(options); + const gone = await page.waitForSelector('.hf-active', { state: 'detached', timeout: 5000 }).then(() => true, () => false); + const kept = await ed((m) => ['edentext-header', 'edentext-footer', 'edentext-header-first', + 'edentext-footer-first', 'edentext-header-even', 'edentext-footer-even', 'edentext-hf-sections'] + .some((k) => (localStorage.getItem(k) ?? '').includes(m)), mark); + if (!gone) return 'the flag left the zone editor open'; + return kept ? null : 'the text typed before the flag flipped is in no zone'; +} + +// A zone edited the way a user reaches it: double-click in, edit, leave by Done. Pages +// 1 to 3 in turn — with the two variant flags on that is one session per variant, the +// title page's, an even page's and a running one's. Only the round trip reports on it. +async function editZone(r, zone) { + const zones = page.locator(`.hf-zone.hf-${zone}`); + const n = await zones.count(); + if (!n) return { ops: [], broken: `no ${zone} zone on the page` }; + const ops = []; + let broken = null; + for (let p = 0; p < Math.min(3, n) && !broken; p++) { + await clickish(zones.nth(p), 'dblclick'); + await page.waitForSelector(HF_LIVE, { timeout: 5000 }); + // The mount focuses itself a tick later; typing before that loses its first keys. + await page.waitForFunction(() => document.activeElement?.closest?.('.hf-active'), null, { timeout: 2000 }).catch(() => {}); + for (let i = 0; i < HF_OPS && !broken; i++) { + const op = { page: p + 1, ...drawHfOp(r) }; + ops.push(op); + if (op.type) await page.keyboard.type(op.type); + else if (op.key) await page.keyboard.press(op.key); + else if (op.field !== undefined) await clickish(page.locator('.hf-bar-btn').nth(op.field)); + else { + const res = await hfCmd(op); + if (res.threw) broken = `${op.cmd} threw: ${res.threw}`; + } + const invalid = broken ? null : await hfCheck(); + if (invalid) broken = `schema-invalid ${zone} on page ${p + 1}: ${invalid}`; + if (pageErrors.length) broken = `uncaught: ${pageErrors.join(' | ')}`; + } + await clickish(page.locator('.hf-bar-done')); + await page.waitForSelector('.hf-active', { state: 'detached', timeout: 5000 }).catch(() => {}); + } + return { ops, broken }; +} + +const saveAs = async (ext, retry = true) => { await page.click('.ribbon-tab-file'); - const [dl] = await Promise.all([ - page.waitForEvent('download', { timeout: 60_000 }), - page.locator('.ribbon-menu button', { hasText: `(.${ext})` }).first().click(), - ]); - await page.keyboard.press('Escape'); - return new Uint8Array(await readFile(await dl.path())); + try { + const [dl] = await Promise.all([ + page.waitForEvent('download', { timeout: 30_000 }), + page.locator('.ribbon-menu button', { hasText: `(.${ext})` }).first().click(), + ]); + await page.keyboard.press('Escape'); + return new Uint8Array(await readFile(await dl.path())); + } catch (err) { + // The File menu can miss a click while the app is still laying the document out, and + // no download follows. One more go; a second miss is a finding. + if (!retry) throw err; + await page.keyboard.press('Escape'); + return saveAs(ext, false); + } }; // The file the app saved, read back by the app's importer against the editor's document // (its paragraphs joined across a column or page merged, as the export merges them). const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { - const [{ importOdt }, { importDocx }, { mergeJoinedParagraphsJson }, { normalize, firstDiff, stripFontHoist }] = await Promise.all([ - import('/src/lib/import/odt.ts'), import('/src/lib/import/docx.ts'), import('/src/lib/export/odt.ts'), import('/tests/normalize.ts')]); + const [{ importOdt }, { importDocx }, { mergeJoinedParagraphsJson }, { normalize, firstDiff, stripFontHoist }, hfStore, { effectiveListLevel, defaultLevelBullet }] = await Promise.all([ + import('/src/lib/import/odt.ts'), import('/src/lib/import/docx.ts'), import('/src/lib/export/odt.ts'), import('/tests/normalize.ts'), + import('/src/lib/storage/headerFooter.ts'), import('/src/lib/styles/listStyles.ts')]); const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); const res = ext === 'odt' ? importOdt(bin) : importDocx(bin); - const editor = document.querySelector('.tiptap').editor; + const editor = document.querySelector('.tiptap-host .tiptap').editor; const json = editor.getJSON(); // What the importer suppresses as the style's own: a block attribute equal to what its // named style resolves to, and bold in a header cell. @@ -169,8 +310,9 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { 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 }; + // A formula alone in its block is a display formula to both files — a note holds its + // inline content without a paragraph, so it is a block here too. + if ((n.type === 'paragraph' || n.type === 'note') && n.content?.length === 1 && n.content[0].type === 'formula') n.content[0].attrs = { ...n.content[0].attrs, display: true }; // 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 }; @@ -178,6 +320,10 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { // 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; + // Same for bold and italic: a heading is already both where its style says so, and + // a mark repeating that is what the importers drop. + if (n.type === 'text' && n.marks) n.marks = n.marks.filter((m) => + !((m.type === 'bold' && text.bold) || (m.type === 'italic' && text.italic))); // 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 ?? [] : []) { @@ -194,7 +340,40 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { for (const c of n.content ?? []) styled(c, header || n.type === 'tableHeader', inFrame, text); return n; }; - const held = styled({ ...json, content: mergeJoinedParagraphsJson(json.content ?? []) }); + // The exporter folds a columns section's page-split chain back into one node and merges + // the flow-split paragraphs inside it (replaceColumns, odt.ts); so must the held side. + const foldColumns = (blocks) => { + const out = []; + for (const b of mergeJoinedParagraphsJson(blocks)) { + const prev = out[out.length - 1]; + if (b.type !== 'columns') { out.push(b); continue; } + if (prev?.type === 'columns' && JSON.stringify(prev.attrs) === JSON.stringify(b.attrs)) { + prev.content = mergeJoinedParagraphsJson([...(prev.content ?? []), ...(b.content ?? [])]); + } else out.push({ ...b, content: mergeJoinedParagraphsJson(b.content ?? []) }); + } + return out; + }; + // A list's type is only what its marker draws: where the document's own list style + // governs the depth, that level's kind decides it (listStyles.ts) and neither file + // carries a node type beside it. Both sides compare the drawn kind. + const effListKinds = (n, style = null, depth = 0) => { + const list = n.type === 'bulletList' || n.type === 'orderedList'; + const own = list && depth === 0 ? sheet?.list?.[n.attrs?.listStyleName] ?? null : style; + if (list) { + const eff = effectiveListLevel(n.attrs ?? {}, n.type === 'orderedList', own, depth + 1); + n.type = eff.kind === 'number' ? 'orderedList' : 'bulletList'; + // The name is not always written: direct formatting on a marker makes the list keep + // its resolved automatic clone instead (odt.ts), and a nested list's own name is only + // the memory for a later lift out. corpus.test.ts holds the plain round trip. + // Its marker then reads back as the direct char the style drew, so both sides carry + // the drawn one — the same model the kind above compares by. + n.attrs = { ...(n.attrs ?? {}), listStyleName: null, + bulletChar: eff.kind === 'bullet' ? eff.bulletChar ?? defaultLevelBullet(depth + 1) : null }; + } + for (const c of n.content ?? []) effListKinds(c, own, depth + (list ? 1 : 0)); + return n; + }; + const held = effListKinds(styled({ ...json, content: foldColumns(json.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. @@ -209,22 +388,43 @@ const roundTrip = (bytes, ext) => ed(async ({ b64, ext }) => { 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'; + // A block 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. + const cont = kid.type === 'paragraph' || kid.type === 'heading'; + out.push(inList && cont ? bare(kid) : kid); + inList = inList && cont; } 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) }; + // The zones the app holds (its own storage is what it exports from) against the ones + // the file gives back — a zone is no part of the document compared above. + const { loadHfDoc, loadExtraHfSections, loadDifferentFirstPage, loadDifferentOddEven, hfIsEmpty, EMPTY_HF_SET } = hfStore; + const heldSets = [{ ...EMPTY_HF_SET, + header: loadHfDoc('header'), footer: loadHfDoc('footer'), + headerFirst: loadHfDoc('header', 'first'), footerFirst: loadHfDoc('footer', 'first'), + headerEven: loadHfDoc('header', 'even'), footerEven: loadHfDoc('footer', 'even'), + differentFirstPage: loadDifferentFirstPage(), differentOddEven: loadDifferentOddEven() }, ...loadExtraHfSections()]; + const zoneOf = (d) => (hfIsEmpty(d) ? null : stripFontHoist(normalize(structuredClone(d)))); + // Only the variants a set's flags put in play: the file carries them nowhere else. + const zonesOf = (s) => { + const out = { header: zoneOf(s.header), footer: zoneOf(s.footer), + differentFirstPage: !!s.differentFirstPage, differentOddEven: !!s.differentOddEven }; + if (out.differentFirstPage) { out.headerFirst = zoneOf(s.headerFirst); out.footerFirst = zoneOf(s.footerFirst); } + if (out.differentOddEven) { out.headerEven = zoneOf(s.headerEven); out.footerEven = zoneOf(s.footerEven); } + return out; + }; + const hfDiff = firstDiff(heldSets.map(zonesOf), (res.hfSections ?? []).map(zonesOf)); + return { diff: firstDiff(flat(held), flat(effListKinds(styled(res.content)))), hfDiff, warnings: res.warnings, + held: JSON.stringify(held), read: JSON.stringify(res.content), + hfHeld: JSON.stringify(heldSets.map(zonesOf)), hfRead: JSON.stringify((res.hfSections ?? []).map(zonesOf)) }; }, { b64: Buffer.from(bytes).toString('base64'), ext }); try { 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 }); + await page.waitForSelector('.tiptap-host .tiptap', { timeout: 30_000 }); await page.evaluate(() => { window.showSaveFilePicker = undefined; }); const retried = new Set(); @@ -234,13 +434,27 @@ try { 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.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus', doc)); + await page.waitForFunction((b) => JSON.stringify(document.querySelector('.tiptap-host .tiptap').editor.getJSON()) !== b, before, { timeout: 30_000 }); await page.waitForTimeout(1500); - await watchFlow(); - const initial = await docKey(); const log = []; let broken = null; + + // Both zones, before the body ops: they can cut the document to a single page, + // and pages 1 to 3 are what the two variant flags spread the six zones over. + await setHfOptions(r); + for (const zone of ['header', 'footer']) { + const pass = await editZone(r, zone); + await writeFile(join(tmpdir(), `monkey-${seed}-${zone}.json`), JSON.stringify(pass.ops)); + check(!pass.broken, `seed ${seed}: ${pass.ops.length} ops in the ${zone}${pass.broken ? ` — ${pass.broken}` : ''}`); + if (pass.broken) { broken = pass.broken; break; } + } + if (broken) break; + const flipped = await flipUnderEdit(`zap${seed}`); + check(!flipped, `seed ${seed}: a flag flipped under an open zone${flipped ? ` — ${flipped}` : ''}`); + if (flipped) { broken = flipped; break; } + await watchFlow(); + const initial = await docKey(); for (let i = 0; i < OPS && !broken; i++) { const op = drawOp(r, await docSize()); log.push(op); @@ -262,7 +476,7 @@ try { // 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; + const editor = document.querySelector('.tiptap-host .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()) })}`; @@ -271,7 +485,7 @@ try { return -1; }, initial); const redone = typeof undone !== 'number' || undone < 0 ? null : await ed((n) => { - const editor = document.querySelector('.tiptap').editor; + const editor = document.querySelector('.tiptap-host .tiptap').editor; for (let i = 0; i < n; i++) editor.commands.redo(); return window.__docKey(); }, undone); @@ -289,25 +503,28 @@ try { 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; + : rt.diff ? `round trip: ${rt.diff}` : rt.hfDiff ? `header/footer: ${rt.hfDiff}` + : 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)]); + if (rt.hfDiff) await Promise.all([writeFile(`${stem}-${ext}-hf-held.json`, rt.hfHeld), writeFile(`${stem}-${ext}-hf-read.json`, rt.hfRead)]); 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; + // tears the page context down mid-op — either message says so; one more go, then + // it is a finding. + if (!/Execution context was destroyed|promise was garbage collected/.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 }); + await page.waitForSelector('.tiptap-host .tiptap', { timeout: 30_000 }); run--; } } diff --git a/tests/normalize.ts b/tests/normalize.ts index 7bb8df4..f43933e 100644 --- a/tests/normalize.ts +++ b/tests/normalize.ts @@ -17,6 +17,9 @@ const ORDERED_DEFAULTS: Record = { maxLevel: MAX_HEADING_LEVEL, leader: '.', citationStyle: 'key', }; +// Inline atoms that carry no mark into either file (see normalize below). +const MARKLESS_ATOMS = new Set(['formula', 'image', 'hardBreak']); + // Mark-attr defaults dropped like ORDERED_DEFAULTS is for node attrs. const MARK_DEFAULTS: Record = { plain: false, // link.ts default; the DOCX importer writes it out explicitly @@ -59,14 +62,16 @@ export function normalize(node: N): N { if (node.type === 'doc') canonNoteIds(node); const out: N = { type: node.type }; 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') { + // An inline atom wears the marks of the run around it — a formula's font, a picture's + // or a line break's inherited comment. None of them paints, and no file carries them. + if (node.marks?.length && !MARKLESS_ATOMS.has(node.type)) { 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. + // An empty string is the picker's "unset" (a font family, say) — no value at all, + // and an empty list (a comment with no replies) is the same nothing. const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}) - .filter(([k, v]) => v != null && v !== '' && MARK_DEFAULTS[k] !== v) + .filter(([k, v]) => v != null && v !== '' && !(Array.isArray(v) && !v.length) && 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. diff --git a/tests/render-parity/make-fixtures.mjs b/tests/render-parity/make-fixtures.mjs index 6af9ef1..b6fdd92 100644 --- a/tests/render-parity/make-fixtures.mjs +++ b/tests/render-parity/make-fixtures.mjs @@ -339,6 +339,25 @@ await write('15-chapters.docx', [{ }, }, 'chapter-numbering'); +// 16. All six header/footer zones at once: a title page of its own and separate even +// pages, both riding on a running zone. Four pages, so each variant renders at least once. +await write('16-hf-variants.docx', [{ + properties: { page, titlePage: true }, + headers: { + default: new Header({ children: [new Paragraph({ alignment: AlignmentType.RIGHT, children: [new TextRun('Odd Header')] })] }), + first: new Header({ children: [new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun('Title Page Header')] })] }), + even: new Header({ children: [new Paragraph({ alignment: AlignmentType.LEFT, children: [new TextRun('Even Header')] })] }), + }, + footers: { + default: new Footer({ children: [new Paragraph({ alignment: AlignmentType.RIGHT, + children: [new TextRun({ children: ['Odd page ', PageNumber.CURRENT] })] })] }), + first: new Footer({ children: [new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun('Title Page Footer')] })] }), + even: new Footer({ children: [new Paragraph({ alignment: AlignmentType.LEFT, + children: [new TextRun({ children: ['Even page ', PageNumber.CURRENT] })] })] }), + }, + children: Array.from({ length: 40 }, (_, i) => para(`${i + 1}. ${LOREM}`)), +}], undefined, { evenAndOddHeaderAndFooters: true }); + // ODT twins, written by LibreOffice itself — the dominant ODT producer, so they carry // its own conventions (percentage font sizes, Text Body, list styles) and exercise the // foreign-document path our own exporter never produces. diff --git a/tests/render-parity/run.mjs b/tests/render-parity/run.mjs index b4c45c0..6fa553f 100644 --- a/tests/render-parity/run.mjs +++ b/tests/render-parity/run.mjs @@ -84,7 +84,7 @@ async function editorRender(browser, file) { }); await page.reload({ waitUntil: 'load' }); await page.waitForSelector('.tiptap'); - await page.setInputFiles('input.file-input', file); + await page.setInputFiles('input.file-input[accept*=".odt"]', file); await settle(page); const result = await page.evaluate(extractLayout); await page.close(); diff --git a/tests/schemaValidate.ts b/tests/schemaValidate.ts index f21725f..4ff0882 100644 --- a/tests/schemaValidate.ts +++ b/tests/schemaValidate.ts @@ -114,12 +114,19 @@ function validate(schema: string, kind: 'xsd' | 'rng', parts: Part[]): string[] // Every XML part of an unzipped .odt against the ODF schema (foreign namespaces // stripped, as extended conformance allows). Formula sub-documents root math:math, // which the schema's start pattern excludes; mimetype and Pictures/ are not XML. +// libxml2's RelaxNG cannot match an element the schema declares by name *class* inside a +// — the ODF statistic fields share one , and probed, +// text:creator (its own name=) in the same span validates. LibreOffice writes the span +// too, so the field is unwrapped for the check rather than avoided in the export. +const STAT_FIELD_SPAN = + /]*>(]*(?:\/>|>[^<]*<\/text:[a-z-]+>))<\/text:span>/g; + export function validateOdt(files: Record): string[] { const keepOdf = (ns: string | null) => ns == null || ODF_NS.has(ns); const docs: Part[] = []; const manifests: Part[] = []; for (const [name, bytes] of Object.entries(files)) { - const part = { label: `odt ${name}`, xml: stripForeign(strFromU8(bytes), keepOdf) }; + const part = { label: `odt ${name}`, xml: stripForeign(strFromU8(bytes), keepOdf).replace(STAT_FIELD_SPAN, '$1') }; if (/^(content|styles|meta|settings)\.xml$/.test(name)) docs.push(part); else if (name === 'META-INF/manifest.xml') manifests.push(part); } diff --git a/tests/smoke/run.mjs b/tests/smoke/run.mjs index 2e7b52a..7090dcb 100644 --- a/tests/smoke/run.mjs +++ b/tests/smoke/run.mjs @@ -40,7 +40,7 @@ try { check(true, 'autosave survives a reload'); // A corpus document imports, renders its table and reports a page count. - await page.setInputFiles('input.file-input', join(ROOT, 'tests/corpus/04-table.odt')); + await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/04-table.odt')); await page.waitForFunction(() => document.querySelector('.tiptap table td')?.textContent.trim(), null, { timeout: 30_000 }); const pages = await page.evaluate(() => document.querySelector('.statusbar')?.textContent ?? '');