Skip to content

Commit 5e306d0

Browse files
chaxusclaude
andcommitted
test(v9): docx tracked changes and header/footer round trips; buildDocx grows header/footer parts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a179986 commit 5e306d0

3 files changed

Lines changed: 125 additions & 4 deletions

File tree

‎docs/changelogs/2026-08-15-v9-regression-campaign.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,7 @@ gh workflow run nightly-corpus.yml -f limit=300 # 手动触发夜间
260260
- **ranui IIFE 对齐**列入 roadmap 方向九:审计结论=入库 IIFE 与 npm latest 一致,落后的是
261261
源仓库未发版;L1 防漂移哨兵 `test/unit/ranui-vendor-sync.test.ts` 已落,L2 发版对齐等
262262
用户在 ran 仓库发 0.5.0-alpha.3。
263+
- PR 制上线后的合并列车:`e2e-pages` 因 wrangler workerd 并行下载中止崩溃连红 12 次堵住
264+
所有 PR → PR #121(串行 + 重试 + 监督重启);`required_status_checks.strict` 改 false
265+
避免 PR 互相打成 BEHIND。对方会话迁到独立 worktree(CLAUDE.md 已记)。
266+
- docx-features.spec:修订(w:ins/w:del)与页眉页脚往返;`buildDocx` 支持 header/footer 部件。

‎test/e2e/docx-features.spec.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { buildDocx, ooxmlText, toBase64, zipEntryNames, zipEntryText } from './lib/ooxml';
2+
import { expect, test } from './lib/l0';
3+
4+
declare function post(type: string, payload?: Record<string, unknown>): Promise<any>;
5+
6+
/**
7+
* Word features a real document carries and the minimal fixture never did
8+
* (matrix section B "修订 / 页眉页脚"): tracked changes and header/footer
9+
* parts must survive open + save.
10+
*/
11+
test.describe('docx feature round trips (real editor)', () => {
12+
test.describe.configure({ timeout: 120_000 });
13+
14+
const roundTrip = (page: import('@playwright/test').Page, name: string, b64: string) =>
15+
page.evaluate(
16+
async ({ name, b64 }) => {
17+
const bin = atob(b64);
18+
const bytes = new Uint8Array(bin.length);
19+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
20+
await post('document:open-buffer', { fileName: name, buffer: bytes.buffer, readonly: false });
21+
const saved = await post('document:save', {});
22+
const out = new Uint8Array(await saved.file.arrayBuffer());
23+
let s = '';
24+
for (let i = 0; i < out.length; i += 0x8000)
25+
s += String.fromCharCode.apply(null, Array.from(out.subarray(i, i + 0x8000)));
26+
return btoa(s);
27+
},
28+
{ name, b64 },
29+
);
30+
31+
test.beforeEach(async ({ page }) => {
32+
await page.goto('/embed-demo.html');
33+
await expect(page.locator('#status')).toHaveText('ready', { timeout: 60_000 });
34+
});
35+
36+
test('tracked changes (insertion + deletion) survive a save', async ({ page }) => {
37+
const body =
38+
'<w:p><w:r><w:t xml:space="preserve">Kept text </w:t></w:r>' +
39+
'<w:ins w:id="1" w:author="Reviewer" w:date="2026-08-16T00:00:00Z"><w:r><w:t>inserted 插入</w:t></w:r></w:ins>' +
40+
'<w:del w:id="2" w:author="Reviewer" w:date="2026-08-16T00:00:00Z"><w:r><w:delText>deleted 删除</w:delText></w:r></w:del>' +
41+
'</w:p>';
42+
const out = new Uint8Array(
43+
Buffer.from(await roundTrip(page, 'revisions.docx', toBase64(buildDocx('', body))), 'base64'),
44+
);
45+
const xml = zipEntryText(out, 'word/document.xml') || '';
46+
expect(xml).toMatch(/<w:ins\b[^>]*w:author="Reviewer"/);
47+
expect(xml).toMatch(/<w:del\b[^>]*w:author="Reviewer"/);
48+
expect(xml).toContain('<w:delText');
49+
expect(ooxmlText(xml)).toContain('inserted 插入');
50+
expect(xml).toContain('deleted 删除');
51+
});
52+
53+
test('header and footer parts survive a save', async ({ page }) => {
54+
const out = new Uint8Array(
55+
Buffer.from(
56+
await roundTrip(
57+
page,
58+
'hf.docx',
59+
toBase64(buildDocx('body text', undefined, { headerText: 'Header 页眉', footerText: 'Footer 页脚' })),
60+
),
61+
'base64',
62+
),
63+
);
64+
const names = zipEntryNames(out);
65+
const header = names.find((n) => /^word\/header\d+\.xml$/.test(n));
66+
const footer = names.find((n) => /^word\/footer\d+\.xml$/.test(n));
67+
expect(header, `no header part in ${names.join(',')}`).toBeTruthy();
68+
expect(footer, `no footer part in ${names.join(',')}`).toBeTruthy();
69+
expect(ooxmlText(zipEntryText(out, header!) || '')).toContain('Header 页眉');
70+
expect(ooxmlText(zipEntryText(out, footer!) || '')).toContain('Footer 页脚');
71+
expect(ooxmlText(zipEntryText(out, 'word/document.xml') || '')).toContain('body text');
72+
});
73+
});

‎test/e2e/lib/ooxml.ts‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,50 @@ export function makeStoredZip(entries: Array<{ name: string; data: Uint8Array |
8080

8181
const XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
8282

83-
/** A one-paragraph .docx (or, with `bodyXml`, an arbitrary <w:body> content). */
84-
export function buildDocx(text: string, bodyXml?: string): Uint8Array {
83+
/**
84+
* A one-paragraph .docx (or, with `bodyXml`, an arbitrary <w:body> content).
85+
* `opts.headerText` / `opts.footerText` add a default header/footer part
86+
* wired through sectPr.
87+
*/
88+
export function buildDocx(
89+
text: string,
90+
bodyXml?: string,
91+
opts: { headerText?: string; footerText?: string } = {},
92+
): Uint8Array {
93+
const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
94+
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
95+
const hf = (tag: 'hdr' | 'ftr', t: string) =>
96+
XML_HEAD + `<w:${tag} xmlns:w="${W}"><w:p><w:r><w:t>${t}</w:t></w:r></w:p></w:${tag}>`;
97+
const extraOverrides =
98+
(opts.headerText
99+
? '<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/>'
100+
: '') +
101+
(opts.footerText
102+
? '<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/>'
103+
: '');
104+
const docRels =
105+
(opts.headerText ? `<Relationship Id="rIdH1" Type="${REL}/header" Target="header1.xml"/>` : '') +
106+
(opts.footerText ? `<Relationship Id="rIdF1" Type="${REL}/footer" Target="footer1.xml"/>` : '');
107+
const sectPr =
108+
opts.headerText || opts.footerText
109+
? '<w:sectPr>' +
110+
(opts.headerText ? '<w:headerReference w:type="default" r:id="rIdH1"/>' : '') +
111+
(opts.footerText ? '<w:footerReference w:type="default" r:id="rIdF1"/>' : '') +
112+
'<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr>'
113+
: '';
114+
const extraParts: Array<{ name: string; data: string }> = [];
115+
if (opts.headerText) extraParts.push({ name: 'word/header1.xml', data: hf('hdr', opts.headerText) });
116+
if (opts.footerText) extraParts.push({ name: 'word/footer1.xml', data: hf('ftr', opts.footerText) });
117+
if (docRels) {
118+
extraParts.push({
119+
name: 'word/_rels/document.xml.rels',
120+
data:
121+
XML_HEAD +
122+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
123+
docRels +
124+
'</Relationships>',
125+
});
126+
}
85127
return makeStoredZip([
86128
{
87129
name: '[Content_Types].xml',
@@ -91,6 +133,7 @@ export function buildDocx(text: string, bodyXml?: string): Uint8Array {
91133
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
92134
'<Default Extension="xml" ContentType="application/xml"/>' +
93135
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
136+
extraOverrides +
94137
'</Types>',
95138
},
96139
{
@@ -105,9 +148,10 @@ export function buildDocx(text: string, bodyXml?: string): Uint8Array {
105148
name: 'word/document.xml',
106149
data:
107150
XML_HEAD +
108-
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
109-
`<w:body>${bodyXml ?? `<w:p><w:r><w:t>${text}</w:t></w:r></w:p>`}</w:body></w:document>`,
151+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
152+
`<w:body>${bodyXml ?? `<w:p><w:r><w:t>${text}</w:t></w:r></w:p>`}${sectPr}</w:body></w:document>`,
110153
},
154+
...extraParts,
111155
]);
112156
}
113157

0 commit comments

Comments
 (0)