Skip to content

Commit 2b4e9a1

Browse files
authored
Merge pull request #3356 from superdoc-dev/caio-pizzol/SD-3171-roundtrip-coverage
test(behavior): cover style-cascade bidiVisual round-trip preservation (SD-3171)
2 parents 57a3891 + 8a156ed commit 2b4e9a1

1 file changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import fs from 'node:fs';
2+
import { readFile, writeFile } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import JSZip from 'jszip';
6+
import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js';
7+
8+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
9+
10+
test.use({ config: { toolbar: 'full', showSelection: true } });
11+
12+
// SD-3171 round-trip guard: the production fix narrows what visualDirection
13+
// consults to inline-only, but says nothing about whether the importer/
14+
// exporter still preserves a style-cascade `w:bidiVisual` in the exported XML.
15+
// A future commit that "cleans up" the unused property could silently strip it
16+
// from the style definition; renders would still pass (because we no longer
17+
// read it), but round-trip integrity would be gone and a Word user opening the
18+
// re-exported file would lose the property.
19+
//
20+
// This test pins both halves of the contract:
21+
// 1. Re-importing the exported file still renders A B C (the fix holds).
22+
// 2. The exported XML still contains a `w:bidiVisual` element somewhere in
23+
// the style or document parts (the property survives export).
24+
25+
async function readCellLayout(superdoc: SuperDocFixture): Promise<Array<{ text: string; relLeft: number }> | null> {
26+
return superdoc.page.evaluate(() => {
27+
const fragment = document.querySelector('.superdoc-table-fragment');
28+
if (!fragment) return null;
29+
const fragRect = fragment.getBoundingClientRect();
30+
const cells = Array.from(fragment.children).filter((el) => (el as HTMLElement).style?.position === 'absolute');
31+
if (cells.length === 0) return null;
32+
return cells
33+
.map((cell) => {
34+
const rect = (cell as HTMLElement).getBoundingClientRect();
35+
return { text: (cell.textContent ?? '').trim(), relLeft: rect.left - fragRect.left };
36+
})
37+
.filter((c) => c.text === 'A' || c.text === 'B' || c.text === 'C')
38+
.sort((a, b) => a.relLeft - b.relLeft);
39+
});
40+
}
41+
42+
async function exportCurrentDocument(superdoc: SuperDocFixture, outputPath: string): Promise<void> {
43+
const exportedBytes = await superdoc.page.evaluate(async () => {
44+
const exported = await (window as any).editor.exportDocx({ isFinalDoc: false });
45+
if (exported instanceof Blob) {
46+
return Array.from(new Uint8Array(await exported.arrayBuffer()));
47+
}
48+
if (exported instanceof ArrayBuffer) {
49+
return Array.from(new Uint8Array(exported));
50+
}
51+
if (ArrayBuffer.isView(exported)) {
52+
return Array.from(new Uint8Array(exported.buffer, exported.byteOffset, exported.byteLength));
53+
}
54+
throw new Error(`Unexpected exportDocx() result: ${Object.prototype.toString.call(exported)}`);
55+
});
56+
await writeFile(outputPath, Buffer.from(exportedBytes));
57+
}
58+
59+
/**
60+
* Return the `w:bidiVisual` element (if any) inside the `<w:tblPr>` of the
61+
* named style in `word/styles.xml`, plus whether it resolves to a truthy
62+
* OOXML boolean per §17.17.4 (missing `w:val` attribute, or `w:val` in
63+
* {"1","true","on"} case-insensitive).
64+
*
65+
* Scoping to a specific styleId is what distinguishes "style cascade
66+
* preserved" from "any bidiVisual present anywhere" — the latter would also
67+
* pass if export stripped the style value and emitted an explicit-false
68+
* inline element instead.
69+
*/
70+
async function readStyleBidiVisual(
71+
docxPath: string,
72+
styleId: string,
73+
): Promise<{ found: boolean; truthy: boolean; raw: string | null }> {
74+
const zip = await JSZip.loadAsync(await readFile(docxPath));
75+
const stylesXml = (await zip.file('word/styles.xml')?.async('string')) ?? '';
76+
const styleStart = stylesXml.indexOf(`w:styleId="${styleId}"`);
77+
if (styleStart < 0) return { found: false, truthy: false, raw: null };
78+
const styleEnd = stylesXml.indexOf('</w:style>', styleStart);
79+
const block = stylesXml.slice(styleStart, styleEnd >= 0 ? styleEnd : undefined);
80+
const match = block.match(/<w:bidiVisual\b([^/>]*)\/?>/);
81+
if (!match) return { found: false, truthy: false, raw: null };
82+
const attrs = match[1] ?? '';
83+
const valMatch = attrs.match(/\bw:val="([^"]*)"/);
84+
const val = valMatch ? valMatch[1].toLowerCase() : null;
85+
const truthy = val === null || val === '1' || val === 'true' || val === 'on';
86+
return { found: true, truthy, raw: match[0] };
87+
}
88+
89+
test('style-cascade bidiVisual survives DOCX export and continues to render in logical order', async ({
90+
superdoc,
91+
}, testInfo) => {
92+
const fixturePath = path.resolve(__dirname, 'fixtures/rtl-style-derived-bidivisual.docx');
93+
expect(fs.existsSync(fixturePath)).toBe(true);
94+
95+
await superdoc.loadDocument(fixturePath);
96+
await superdoc.waitForStable();
97+
98+
const originalLayout = await readCellLayout(superdoc);
99+
expect(originalLayout).not.toBeNull();
100+
expect(originalLayout!.map((c) => c.text)).toEqual(['A', 'B', 'C']);
101+
102+
const exportedPath = testInfo.outputPath('rtl-style-derived-bidivisual-roundtrip.docx');
103+
await exportCurrentDocument(superdoc, exportedPath);
104+
105+
// The exported file must still carry `w:bidiVisual` as a truthy property on
106+
// the same style entry it came in on (`RtlStyleTable`). Scoping to the
107+
// specific style — and validating the boolean per §17.17.4 — prevents a
108+
// regression where export strips the style value and emits an explicit
109+
// `<w:bidiVisual w:val="0"/>` inline; the re-import would still render
110+
// A B C (explicit-false produces no flip) and a substring check would still
111+
// pass, but the property would no longer be preserved for a Word consumer.
112+
const styleBidi = await readStyleBidiVisual(exportedPath, 'RtlStyleTable');
113+
expect(styleBidi.found).toBe(true);
114+
expect(styleBidi.truthy).toBe(true);
115+
116+
await superdoc.loadDocument(exportedPath);
117+
await superdoc.waitForStable();
118+
119+
const roundTrippedLayout = await readCellLayout(superdoc);
120+
expect(roundTrippedLayout).not.toBeNull();
121+
// The Word-parity contract must hold on re-import: style-cascade bidiVisual
122+
// does NOT visually flip cells. A regression here would mean the fix
123+
// accidentally promoted the style cascade through export (e.g., baked it
124+
// into the table's inline tblPr) and the flip came back.
125+
expect(roundTrippedLayout!.map((c) => c.text)).toEqual(['A', 'B', 'C']);
126+
});

0 commit comments

Comments
 (0)