test: the monkey edits both zones, and the runs compare what is drawn

Header and footer go first, each with its own ops, and a variant flag is
flipped while a zone is open. The round trip compares a list's drawn kind and
marker rather than its node type and style name — a list that loses its name to
a formatted marker carries the style's own char — and skips the marks of an
inline atom, which no file keeps. A caret is snapped to a text position, a
swallowed File-menu click is retried, and WebKit's converging ResizeObserver
round is no page error. A new corpus pair brings all six zones through the
import path; the baseline holds it and a re-recorded showcase entry.
This commit is contained in:
Steffen Becker committed 2026-09-06 19:07:23 +02:00
1 parent 6d4a7e08df
commit 055839655e
14 files changed
+487 -126

No files matched your search

+1 -1
View File
@@ -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
+55 -1
View File
@@ -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 `<text:span>` (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
+4 -1
View File
@@ -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 };
}
Binary file not shown.
Binary file not shown.
+30 -3
View File
@@ -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 {
+102 -73
View File
@@ -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,
+1 -1
View File
@@ -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);
+256 -39
View File
@@ -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--;
}
}
+9 -4
View File
@@ -17,6 +17,9 @@ const ORDERED_DEFAULTS: Record<string, unknown> = {
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<string, unknown> = {
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.
+19
View File
@@ -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.
+1 -1
View File
@@ -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();
+8 -1
View File
@@ -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
// <text:span> — the ODF statistic fields share one <rng:element><rng:choice>, 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:span\b[^>]*>(<text:(?:page|paragraph|word|character|table|image|object)-count\b[^>]*(?:\/>|>[^<]*<\/text:[a-z-]+>))<\/text:span>/g;
export function validateOdt(files: Record<string, Uint8Array>): 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);
}
+1 -1
View File
@@ -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 ?? '');