Skip to content

Commit a30b475

Browse files
chaxusclaude
andcommitted
test(v9): idempotence checks compare docx/pptx text after two round trips (zip inflate + OOXML text)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 92236d9 commit a30b475

2 files changed

Lines changed: 50 additions & 12 deletions

File tree

‎test/e2e/lib/ooxml.ts‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* and no binary fixture lives in the repo.
77
*/
88

9+
import { inflateRawSync } from 'node:zlib';
10+
911
const CRC_TABLE = (() => {
1012
const table = new Uint32Array(256);
1113
for (let n = 0; n < 256; n++) {
@@ -223,8 +225,9 @@ export function buildPptx(title: string): Uint8Array {
223225
]);
224226
}
225227

226-
/** Read every stored/deflated entry name of a zip (central directory walk); enough for L1 checks. */
227-
export function zipEntryNames(bytes: Uint8Array): string[] {
228+
type ZipEntry = { name: string; method: number; compressedSize: number; localOffset: number };
229+
230+
function zipEntries(bytes: Uint8Array): ZipEntry[] {
228231
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
229232
let eocd = -1;
230233
for (let i = bytes.length - 22; i >= 0 && i >= bytes.length - 65557; i--) {
@@ -236,15 +239,48 @@ export function zipEntryNames(bytes: Uint8Array): string[] {
236239
if (eocd < 0) return [];
237240
const count = view.getUint16(eocd + 10, true);
238241
let off = view.getUint32(eocd + 16, true);
239-
const names: string[] = [];
242+
const entries: ZipEntry[] = [];
240243
const dec = new TextDecoder();
241244
for (let n = 0; n < count; n++) {
242245
if (view.getUint32(off, true) !== 0x02014b50) break;
246+
const method = view.getUint16(off + 10, true);
247+
const compressedSize = view.getUint32(off + 20, true);
243248
const nameLen = view.getUint16(off + 28, true);
244249
const extraLen = view.getUint16(off + 30, true);
245250
const commentLen = view.getUint16(off + 32, true);
246-
names.push(dec.decode(bytes.subarray(off + 46, off + 46 + nameLen)));
251+
const localOffset = view.getUint32(off + 42, true);
252+
entries.push({
253+
name: dec.decode(bytes.subarray(off + 46, off + 46 + nameLen)),
254+
method,
255+
compressedSize,
256+
localOffset,
257+
});
247258
off += 46 + nameLen + extraLen + commentLen;
248259
}
249-
return names;
260+
return entries;
261+
}
262+
263+
/** Entry names of a zip (central directory walk); enough for L1 checks. */
264+
export function zipEntryNames(bytes: Uint8Array): string[] {
265+
return zipEntries(bytes).map((e) => e.name);
266+
}
267+
268+
/** Decoded text of one zip entry (stored or deflated) -- for L2 content checks on OOXML parts. */
269+
export function zipEntryText(bytes: Uint8Array, name: string): string | null {
270+
const entry = zipEntries(bytes).find((e) => e.name === name);
271+
if (!entry) return null;
272+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
273+
const lo = entry.localOffset;
274+
if (view.getUint32(lo, true) !== 0x04034b50) return null;
275+
const nameLen = view.getUint16(lo + 26, true);
276+
const extraLen = view.getUint16(lo + 28, true);
277+
const start = lo + 30 + nameLen + extraLen;
278+
const raw = bytes.subarray(start, start + entry.compressedSize);
279+
const data = entry.method === 8 ? new Uint8Array(inflateRawSync(raw)) : raw;
280+
return new TextDecoder().decode(data);
281+
}
282+
283+
/** Concatenated text of all <w:t>/<a:t> runs in an OOXML part. */
284+
export function ooxmlText(xml: string): string {
285+
return Array.from(xml.matchAll(/<(?:w|a):t(?:\s[^>]*)?>([^<]*)<\/(?:w|a):t>/g), (m) => m[1]).join('');
250286
}

‎test/e2e/resave-idempotence.spec.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { buildDocx, buildPptx, toBase64, zipEntryNames } from './lib/ooxml';
1+
import { buildDocx, buildPptx, ooxmlText, toBase64, zipEntryNames, zipEntryText } from './lib/ooxml';
22
import { expect, test } from './lib/l0';
33

44
declare const XLSX: any;
@@ -83,12 +83,11 @@ test.describe('re-open / re-save idempotence (real editor)', () => {
8383
{ docxB64: toBase64(buildDocx('idempotent paragraph 往返')) },
8484
);
8585
expect(result.name).toBe('idem.docx');
86-
const bytes = Buffer.from(result.b64, 'base64');
87-
// Stored or deflated, the document part must be present; the text is
88-
// asserted through the editor's own reload (trip 2 opened trip 1's output).
89-
const names = zipEntryNames(new Uint8Array(bytes));
86+
const bytes = new Uint8Array(Buffer.from(result.b64, 'base64'));
87+
const names = zipEntryNames(bytes);
9088
expect(names).toContain('word/document.xml');
9189
expect(names).toContain('[Content_Types].xml');
90+
expect(ooxmlText(zipEntryText(bytes, 'word/document.xml') || '')).toContain('idempotent paragraph 往返');
9291
});
9392

9493
test('pptx: two round trips keep the slide and its title', async ({ page }) => {
@@ -102,8 +101,11 @@ test.describe('re-open / re-save idempotence (real editor)', () => {
102101
{ pptxB64: toBase64(buildPptx('Idempotent Title 标题')) },
103102
);
104103
expect(result.name).toBe('idem.pptx');
105-
const names = zipEntryNames(new Uint8Array(Buffer.from(result.b64, 'base64')));
104+
const bytes = new Uint8Array(Buffer.from(result.b64, 'base64'));
105+
const names = zipEntryNames(bytes);
106106
expect(names).toContain('ppt/presentation.xml');
107-
expect(names.filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))).toHaveLength(1);
107+
const slides = names.filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n));
108+
expect(slides).toHaveLength(1);
109+
expect(ooxmlText(zipEntryText(bytes, slides[0]) || '')).toContain('Idempotent Title 标题');
108110
});
109111
});

0 commit comments

Comments
 (0)