Skip to content

Commit ccd1c5b

Browse files
iguangfeiclaude
andcommitted
fix(cloud): 云端 IR 补齐表格结构、标题语义,并停止把几何路径当资产
云端走的是通用 walker,它只按键名猜,三处因此与本地产物对不上。用报告里那份 99 页 文档打真实后端逐条实测: 1. **表格整张被静默丢掉。** walker 把单元格摊成 table 的一串 shape 子节点,而 Markdown 渲染器只认 table_row / table_cell,并且对 table 节点显式抑制 childBody —— 两边对不上, 一张两行两列的表渲染出来是空字符串。local/pptx、local/docx、result3 三个适配器都发 table → table_row → table_cell,现在云端也一样,合并跨度一并带到单元格上。 2. **几何路径被当成资产指针。** assetPath 的兜底链取到 `record.path`,而 pptx 里它是自选 图形的几何路径("M 0 0 L 10 10 Z"),任何 Freeform 都会被判成「指向不存在的资产」。 实测同一份文档凭空产出 cloud_asset_unavailable × 17(99 页那份是 131),并把 quality 顶成 degraded。去掉这一项后:告警 17 → 0,quality degraded → pass。 3. **一个标题都没有。** 节点类型直接取 record.type,而 pptx 一律是 "Shape",整篇落成 清一色 shape,MD 里 `grep -c '^#'` 是 0(本地 96)。云端 IR 其实带着 ph.type,实测 title 恰好 96 个,与本地一一对应。改用与 local/pptx/parser.ts 相同的判据后 96 条标题 逐条一致,其中 2 处云端更完整(本地截断成 "DNS name ",云端是 "DNS name resolution example")。只在有文字时改判 —— 空的标题占位符靠类型词才建得出节点。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9608f7d commit ccd1c5b

2 files changed

Lines changed: 185 additions & 3 deletions

File tree

‎src/ir/cloud-adapter.ts‎

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,103 @@ function walkCloud(value: unknown, nodes: DeckIrNode[], hash: string, locator: s
5757
if (Array.isArray(value)) { value.forEach((item, index) => walkCloud(item, nodes, hash, `${locator}/${index}`, page, parentId)); return; }
5858
const record = value as Record<string, unknown>;
5959
const text = cloudText(record);
60-
const kind = String(record.type ?? (record.txBody ? 'shape' : record.table ? 'table' : text ? 'text' : 'object')).toLowerCase();
60+
const kind = cloudKind(record, text);
6161
let nextParent = parentId;
62+
const skipKeys = new Set<string>(WALK_SKIP_KEYS);
6263
if (text || ['shape', 'picture', 'image', 'table', 'group', 'chart'].some((token) => kind.includes(token))) {
6364
const id = stableId(hash, `cloud:${locator}`);
6465
const xfrm = object(record.xfrm);
65-
const assetPath = string(record.assetPath ?? record.suggestedPath ?? record.path);
66+
// 只认解析器明确给出的资产指针。`path` 曾经也在这条兜底链上,但在 pptx 里它是自选
67+
// 图形的几何路径(`"M 0 0 L 10 10 Z"`),任何 Freeform 都会因此被判成「指向不存在的
68+
// 资产」,凭空产出一条 cloud_asset_unavailable 并把 quality 顶成 degraded。
69+
const assetPath = string(record.assetPath ?? record.suggestedPath);
6670
const externalUrl = string(record.accessURL ?? record.url);
6771
const node: DeckIrNode = { id, type: kind, parentId, children: [], order: nodes.length, ...(text ? { text } : {}),
6872
...(page ? { page } : {}), sourceRef: { path: locator, ...(page ? { page } : {}) },
6973
...(xfrm && [xfrm.x, xfrm.y, xfrm.cx, xfrm.cy].every((item) => typeof item === 'number') ?
7074
{ bbox: [xfrm.x as number, xfrm.y as number, (xfrm.x as number) + (xfrm.cx as number), (xfrm.y as number) + (xfrm.cy as number)] } : {}),
7175
extensions: { cloud: { id: record.id, name: record.name, style: record.style }, ...(assetPath ? { assetPath } : {}), ...(externalUrl ? { externalUrl } : {}) } };
7276
nodes.push(node); if (parentId) nodes.find((item) => item.id === parentId)?.children.push(id); nextParent = id;
77+
// 表格必须按 table → table_row → table_cell 发,和 local/pptx、local/docx、result3 三个
78+
// 适配器一致:Markdown 渲染器只认这三种类型,让通用遍历把单元格摊成一串 shape 子节点,
79+
// 整张表会在渲染时被静默丢掉——IR 里看得见,产物里一个字都没有。
80+
if (kind.includes('table') && emitCloudTable(record, node, nodes, hash, locator, page)) {
81+
skipKeys.add('table');
82+
}
7383
}
7484
for (const [key, child] of Object.entries(record)) {
75-
if (['style', 'xfrm', 'txBody', 'text', 't', 'name', 'id', 'type'].includes(key)) continue;
85+
if (skipKeys.has(key)) continue;
7686
if (child && typeof child === 'object') walkCloud(child, nodes, hash, `${locator}/${key}`, page, nextParent);
7787
}
7888
}
7989

90+
/** 遍历时不再下钻的键:要么已在节点上表达,要么由更专门的分支接管。 */
91+
const WALK_SKIP_KEYS = ['style', 'xfrm', 'txBody', 'text', 't', 'name', 'id', 'type'] as const;
92+
93+
/**
94+
* 把云端表格结构展开成行列节点;认不出行列就返回 false,交回通用遍历。
95+
*
96+
* 行与单元格的键名各格式不同(pptx 是 `table.trs[].cells[]`),所以按候选键取第一个命中的,
97+
* 而不是写死某一种格式。
98+
*/
99+
function emitCloudTable(
100+
record: Record<string, unknown>,
101+
table: DeckIrNode,
102+
nodes: DeckIrNode[],
103+
hash: string,
104+
locator: string,
105+
page?: number,
106+
): boolean {
107+
const container = object(record.table) ?? record;
108+
const rows = array(container.trs ?? container.rows).map((row) =>
109+
array(object(row)?.cells ?? object(row)?.tcs).flatMap((cell) => {
110+
const parsed = object(cell);
111+
return parsed ? [parsed] : [];
112+
}));
113+
if (!rows.some((cells) => cells.length > 0)) return false;
114+
table.extensions = { ...table.extensions, rows: rows.length,
115+
columns: Math.max(...rows.map((cells) => cells.length)) };
116+
rows.forEach((cells, rowIndex) => {
117+
const rowLocator = `${locator}/row/${rowIndex}`;
118+
const rowId = stableId(hash, `cloud:${rowLocator}`);
119+
const row: DeckIrNode = { id: rowId, type: 'table_row', parentId: table.id, children: [], order: nodes.length,
120+
...(page ? { page } : {}), sourceRef: { path: rowLocator, ...(page ? { page } : {}) } };
121+
nodes.push(row); table.children.push(rowId);
122+
cells.forEach((cell, column) => {
123+
const cellLocator = `${rowLocator}/cell/${column}`;
124+
const cellId = stableId(hash, `cloud:${cellLocator}`);
125+
const text = cloudText(cell);
126+
nodes.push({ id: cellId, type: 'table_cell', parentId: rowId, children: [], order: nodes.length,
127+
...(text ? { text } : {}), ...(page ? { page } : {}),
128+
sourceRef: { path: cellLocator, ...(page ? { page } : {}) },
129+
extensions: { row: rowIndex, column,
130+
gridSpan: number(cell.colSpan ?? cell.gridSpan) ?? 1, rowSpan: number(cell.rowSpan) ?? 1,
131+
...(cell.hMerge === true ? { hMerge: true } : {}), ...(cell.vMerge === true ? { vMerge: true } : {}) } });
132+
row.children.push(cellId);
133+
});
134+
});
135+
return true;
136+
}
137+
138+
/**
139+
* 节点类型。
140+
*
141+
* 解析器自报的 `type` 是它的形状分类(pptx 一律是 "Shape"),不是文档语义:整篇因此落成
142+
* 清一色的 shape,Markdown 一个标题都没有(实测 99 页文档本地 96 个 heading、云端 0 个)。
143+
* 占位符类型才是 OOXML 里「这是标题」的确定性证据,与 local/pptx/parser.ts 用的是同一条
144+
* 判据 —— 两个引擎的产物必须在这件事上对得上,否则同一份文件换引擎结构就变了。
145+
*
146+
* 只在有文字时改判,空的标题占位符仍按原类型走:那些节点靠类型词命中才得以建出来,
147+
* 改成 heading 会让它们从 IR 里整个消失。
148+
*/
149+
function cloudKind(record: Record<string, unknown>, text: string): string {
150+
const placeholder = string(object(record.ph)?.type);
151+
const declared = string(record.type)?.toLowerCase();
152+
if (text && (placeholder === 'title' || placeholder === 'ctrTitle')) return 'heading';
153+
if (text && declared === 'shape') return 'text';
154+
return declared ?? (record.txBody ? 'shape' : record.table ? 'table' : text ? 'text' : 'object');
155+
}
156+
80157
function cloudText(record: Record<string, unknown>): string {
81158
if (typeof record.text === 'string') return record.text;
82159
if (typeof record.t === 'string') return record.t;

‎tests/unit/cloud-adapter.test.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { cloudResultToCandidate } from '../../src/ir/cloud-adapter.js';
3+
import { renderMarkdown } from '../../src/views/markdown.js';
4+
import type { ParseResult } from '../../src/cloud/parse-facade.js';
5+
6+
const source = { sha256: 'a'.repeat(64), name: 'fixture.pptx', bytes: 1 };
7+
8+
const parse = (spTree: unknown[]): Promise<ReturnType<typeof cloudResultToCandidate>> =>
9+
cloudResultToCandidate(
10+
{
11+
taskId: 'task', type: 'pptx.parse', irKey: 'ir/fixture', irSchemaVersion: 'pptx.v1',
12+
ir: { slides: [{ _ref: 'slide1', spTree }], files: {}, images: [] },
13+
} as ParseResult,
14+
source,
15+
) as never;
16+
17+
const cell = (text: string, extra: Record<string, unknown> = {}) => ({
18+
text, txBody: { children: [{ children: [{ t: text }] }] }, ...extra,
19+
});
20+
21+
describe('cloud IR adapter', () => {
22+
it('expands tables into table_row and table_cell so Markdown can render them', async () => {
23+
const candidate = await parse([
24+
{
25+
id: 1, name: 'Table 1', type: 'Table', xfrm: { x: 0, y: 0, cx: 400, cy: 100 },
26+
table: {
27+
grid: { cols: [] },
28+
trs: [
29+
{ cells: [cell('Name'), cell('Qty')] },
30+
{ cells: [cell('Widget'), cell('12')] },
31+
],
32+
},
33+
},
34+
]);
35+
36+
const types = candidate.ir.document.nodes.map((node) => node.type);
37+
expect(types).toEqual(['table', 'table_row', 'table_cell', 'table_cell', 'table_row', 'table_cell', 'table_cell']);
38+
39+
const table = candidate.ir.document.nodes[0]!;
40+
expect(table.extensions).toMatchObject({ rows: 2, columns: 2 });
41+
// 单元格摊成 table 的直接子节点时渲染器一个字都取不到,这里锁住层级。
42+
const rows = candidate.ir.document.nodes.filter((node) => node.type === 'table_row');
43+
expect(rows.map((row) => row.parentId)).toEqual([table.id, table.id]);
44+
expect(rows.every((row) => row.children.length === 2)).toBe(true);
45+
46+
expect(renderMarkdown(candidate.ir).markdown).toContain('| Name | Qty |\n| --- | --- |\n| Widget | 12 |');
47+
});
48+
49+
it('carries merge spans through to the cells', async () => {
50+
const candidate = await parse([
51+
{
52+
id: 1, name: 'Table 1', type: 'Table',
53+
table: { grid: { cols: [] }, trs: [{ cells: [cell('Wide', { colSpan: 2 }), cell('', { hMerge: true })] }] },
54+
},
55+
]);
56+
57+
const cells = candidate.ir.document.nodes.filter((node) => node.type === 'table_cell');
58+
expect(cells[0]!.extensions).toMatchObject({ row: 0, column: 0, gridSpan: 2, rowSpan: 1 });
59+
expect(cells[1]!.extensions).toMatchObject({ column: 1, hMerge: true });
60+
});
61+
62+
it('leaves records without a recognizable grid to the generic walk', async () => {
63+
const candidate = await parse([
64+
{ id: 1, name: 'Empty', type: 'Table', table: { grid: { cols: [] }, trs: [] } },
65+
]);
66+
67+
expect(candidate.ir.document.nodes.map((node) => node.type)).toEqual(['table']);
68+
});
69+
70+
it('types title placeholders as headings and text shapes as text', async () => {
71+
const candidate = await parse([
72+
{ id: 1, name: 'Title 1', type: 'Shape', ph: { type: 'title' },
73+
txBody: { children: [{ children: [{ t: 'Application architectures' }] }] }, text: 'Application architectures' },
74+
{ id: 2, name: 'Body 1', type: 'Shape', ph: { type: 'body' },
75+
txBody: { children: [{ children: [{ t: 'Client-server' }] }] }, text: 'Client-server' },
76+
// 空的标题占位符靠类型词才建得出节点,改判 heading 会让它整个消失。
77+
{ id: 3, name: 'Empty title', type: 'Shape', ph: { type: 'ctrTitle' }, xfrm: { x: 0, y: 0, cx: 10, cy: 10 } },
78+
]);
79+
80+
expect(candidate.ir.document.nodes.map((node) => node.type)).toEqual(['heading', 'text', 'shape']);
81+
expect(renderMarkdown(candidate.ir).markdown).toContain('## Application architectures');
82+
});
83+
84+
it('does not mistake a freeform geometry path for an asset pointer', async () => {
85+
const candidate = await parse([
86+
{ id: 1, name: 'Freeform 9', type: 'Shape', path: 'M 0 0 L 10 10 Z', xfrm: { x: 0, y: 0, cx: 10, cy: 10 } },
87+
]);
88+
89+
const [shape] = candidate.ir.document.nodes;
90+
expect(shape!.extensions?.assetPath).toBeUndefined();
91+
expect(shape!.issues ?? []).toEqual([]);
92+
// 几何路径被当成资产时会凭空产出这条告警,并把整份产物顶成 degraded。
93+
expect(candidate.ir.quality.checks.map((check) => check.code)).not.toContain('cloud_asset_unavailable');
94+
expect(candidate.ir.quality.status).toBe('pass');
95+
});
96+
97+
it('still reports a real asset that the cloud could not materialize', async () => {
98+
const candidate = await parse([
99+
{ id: 1, name: 'Picture 1', type: 'Picture', assetPath: 'assets/missing.png' },
100+
]);
101+
102+
expect(candidate.ir.document.nodes[0]!.issues).toEqual(['missing_media']);
103+
expect(candidate.ir.quality.checks.map((check) => check.code)).toContain('cloud_asset_unavailable');
104+
});
105+
});

0 commit comments

Comments
 (0)