test: the cross-format leg compares the look, not only the words
The corpus leg out of one format and into the other compared block types and text, so anything the trip dropped below that passed. It now compares every attribute each node carries, with the values a format states in its own units excluded: proportional vs absolute column widths, and a formula's inline flag, which ODF has no place for. Two the stricter leg caught: a note lost the paragraph style it names and fell to the stock 10pt, and a link stored an attribute equal to its default.
This commit is contained in:
1 parent
7e037c03a8
commit
759759a997
6 files changed
+45
-6
No files matched your search
@@ -37,7 +37,9 @@ LibreOffice's 10pt only for a note that names none. Its **indent** is the except
|
||||
note style declares a hanging pair (`fo:margin-left` with a negative `fo:text-indent`) and
|
||||
`ParaProps` has no first-line half, so applying the margin alone would move every note
|
||||
right by the indent. The hanging pair therefore stays the editor's — see
|
||||
`tests/render-parity/FINDINGS.md`.
|
||||
`tests/render-parity/FINDINGS.md`. The **DOCX** export writes that style as the note
|
||||
paragraph's `w:pStyle` and defines it; only a note naming none falls to the stock
|
||||
`FootnoteText`/`EndnoteText`, whose 10pt the importer suppresses again.
|
||||
|
||||
**The sync plugin** (`appendTransaction`) keeps one note per anchor, in anchor order, and
|
||||
renumbers each class. It runs in two rounds by design — structure first, numbering on the
|
||||
|
||||
@@ -67,6 +67,10 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
|
||||
text alike; only a side wrap has its side dictated by the wrap itself.
|
||||
- **An index row's tab stop is written even with no page number running to it** — it is
|
||||
where the row's leader is kept, and a stop no tab reaches draws nothing.
|
||||
- **A note writes the paragraph style it names** (`noteStyleName`, and `usedStyleNames`
|
||||
defines it): the stock `FootnoteText` is 10pt, so a note whose file gives it the body
|
||||
size would shrink. See `docs/architecture/notes.md`.
|
||||
|
||||
- **A heading writes its own `w:outlineLvl`** beside its `w:pStyle`: the style carries the
|
||||
look, the level says it is a heading at all, and a document setting its chapters in a
|
||||
style of its own leaves the importer nothing else to go by.
|
||||
|
||||
+10
-1
@@ -2539,6 +2539,12 @@ function styleOf(node: TiptapNode): string {
|
||||
return node.type === 'heading' ? `Heading ${(node.attrs?.level as number) ?? 1}` : DEFAULT_STYLE;
|
||||
}
|
||||
|
||||
// The paragraph style a note names, if it is not the stock note look.
|
||||
function noteStyleName(node: TiptapNode): string | null {
|
||||
const own = node.attrs?.styleName;
|
||||
return typeof own === 'string' && own ? own : null;
|
||||
}
|
||||
|
||||
// Styles to define in the file: the built-ins (Word defines its standard styles too)
|
||||
// plus the user styles the document references, each with its parent chain.
|
||||
function usedStyleNames(doc: TiptapNode, sheet: StyleSheet): Set<string> {
|
||||
@@ -2553,6 +2559,7 @@ function usedStyleNames(doc: TiptapNode, sheet: StyleSheet): Set<string> {
|
||||
for (const style of Object.values(sheet.paragraph)) if (style.builtin) addChain(style.name);
|
||||
const walk = (node: TiptapNode) => {
|
||||
if (node.type === 'paragraph' || node.type === 'heading') addChain(styleOf(node));
|
||||
if (node.type === 'note') { const n = noteStyleName(node); if (n) addChain(n); }
|
||||
// An index's rows are generated, but the styles their levels name are the file's own.
|
||||
if (node.type === 'tableOfContents' && Array.isArray(node.attrs?.levelStyles)) {
|
||||
for (const name of node.attrs.levelStyles as unknown[]) if (typeof name === 'string') addChain(name);
|
||||
@@ -2768,7 +2775,9 @@ export async function buildDocx(
|
||||
// The tab after the marker jumps to the note style's hanging indent (the gap the
|
||||
// editor draws itself); the importer strips it back off.
|
||||
children: [new Paragraph({
|
||||
style: kind === 'endnote' ? 'EndnoteText' : 'FootnoteText',
|
||||
// The note keeps the paragraph style it names; without one it falls to the
|
||||
// stock note style, which is the 10pt look a styleName-less note renders.
|
||||
style: noteStyleName(note) ? docxStyleId(noteStyleName(note)!) : kind === 'endnote' ? 'EndnoteText' : 'FootnoteText',
|
||||
children: [new TextRun({ children: [new Tab()] }), ...inlineToRuns(note.content ?? [])],
|
||||
})],
|
||||
};
|
||||
|
||||
@@ -1487,7 +1487,7 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
// style, which arrives as an ordinary run mark, so the editor must add nothing of
|
||||
// its own. Only our exporter's blue is an editor link — it is stripped above.
|
||||
const ours = hexColor(props.color)?.toUpperCase() === LINK_BLUE;
|
||||
marks.push({ type: 'link', attrs: { href: linkHref, plain: !ours } });
|
||||
marks.push({ type: 'link', attrs: { href: linkHref, ...(ours ? {} : { plain: true }) } });
|
||||
}
|
||||
return marks;
|
||||
};
|
||||
|
||||
+25
-2
@@ -39,6 +39,29 @@ function outline(node: N, out: string[] = []): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Every attribute a node carries, key order made stable. Dropped: note ids, minted per
|
||||
// export; a table's widths, proportional in ODF and absolute in OOXML; a formula's
|
||||
// `display`, which ODF has no flag for and reads back off the paragraph.
|
||||
function attrs(o: Record<string, N>, skip: (k: string) => boolean = () => false): string {
|
||||
const keep = Object.keys(o).filter((k) => k !== 'colwidth' && k !== 'display' && !skip(k)).sort();
|
||||
return JSON.stringify(Object.fromEntries(keep.map((k) =>
|
||||
[k, /^(ftn|edn|footnote|endnote)\d+$/.test(String(o[k])) ? 'ID' : o[k]])));
|
||||
}
|
||||
|
||||
// What outline() cannot see: the look each block carries. A round trip that keeps every
|
||||
// word can still drop a header row, a box's ring or a note's own style. A run value the
|
||||
// paragraph already states is redundant, and each importer is free to suppress it.
|
||||
function look(node: N, out: string[] = [], d = 0, from: Record<string, N> = {}): string[] {
|
||||
for (const c of node.content ?? []) {
|
||||
const same = (k: string) => from[k] !== undefined && from[k] === c.attrs?.[k];
|
||||
const marks = (c.marks ?? []).map((m: N) => `${m.type}${attrs(m.attrs ?? {}, (k) => from[k] === m.attrs?.[k])}`)
|
||||
.filter((m: string) => !m.endsWith(':{}') && !m.endsWith('{}')).join(',');
|
||||
out.push(`${' '.repeat(d)}${c.type}:${c.type === 'text' ? marks : attrs(c.attrs ?? {}, same)}`);
|
||||
look(c, out, d + 1, c.type === 'text' ? from : { ...from, ...(c.attrs ?? {}) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function textOf(node: N): string {
|
||||
if (node.type === 'text') return node.text ?? '';
|
||||
return (node.content ?? []).map(textOf).join('');
|
||||
@@ -111,9 +134,9 @@ describe.skipIf(!files.length)('the authored corpus', () => {
|
||||
expect(outline(odt.content)).toEqual(outline(docx.content));
|
||||
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
|
||||
const asOdt = await buildOdt(docx.content, margins, 'portrait', undefined, undefined, undefined, docx.styles);
|
||||
expect(outline(importOdt(asOdt).content)).toEqual(outline(docx.content));
|
||||
expect(look(importOdt(asOdt).content)).toEqual(look(docx.content));
|
||||
const asDocx = await buildDocx(odt.content, margins, 'portrait', undefined, undefined, undefined, odt.styles);
|
||||
expect(outline(importDocx(asDocx).content)).toEqual(outline(odt.content));
|
||||
expect(look(importDocx(asDocx).content)).toEqual(look(odt.content));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -22,6 +22,7 @@ describe('an editor link round-trips as an editor link', () => {
|
||||
const back = importDocx(bytes).content as unknown as N;
|
||||
const link = texts(back)[0]?.marks?.find((m: any) => m.type === 'link');
|
||||
expect(link?.attrs.href).toBe('https://example.com');
|
||||
expect(link?.attrs.plain).toBe(false);
|
||||
// Absent is the attribute's default: not plain, the same as an explicit false.
|
||||
expect(link?.attrs.plain).toBeFalsy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user