@coco-vexel/coco-office-oojson 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ const LINE_PRESETS = new Set([
2
+ "line", "straightConnector1",
3
+ "bentConnector2", "bentConnector3", "bentConnector4", "bentConnector5",
4
+ "curvedConnector2", "curvedConnector3", "curvedConnector4", "curvedConnector5",
5
+ ]);
6
+
7
+ function findNode(node, tag) {
8
+ if (!node || typeof node !== "object") return null;
9
+ if (node.tag === tag) return node;
10
+ for (const child of node.kids || []) {
11
+ const found = findNode(child, tag);
12
+ if (found) return found;
13
+ }
14
+ return null;
15
+ }
16
+
17
+ function nodeText(node) {
18
+ if (!node || typeof node !== "object") return "";
19
+ if (node.text != null) return String(node.text);
20
+ return (node.kids || []).map(nodeText).join("");
21
+ }
22
+
23
+ function numberValue(value) {
24
+ const n = Number(value);
25
+ return Number.isFinite(n) ? n : undefined;
26
+ }
27
+
28
+ function boolValue(value, fallback = false) {
29
+ if (value == null || value === "") return fallback;
30
+ return ["1", "true", "t", "on"].includes(String(value).toLowerCase());
31
+ }
32
+
33
+ function point(node, tag) {
34
+ const value = findNode(node, tag);
35
+ return { x: numberValue(value?.attrs?.x) ?? 0, y: numberValue(value?.attrs?.y) ?? 0 };
36
+ }
37
+
38
+ function extent(node, tag) {
39
+ const value = findNode(node, tag);
40
+ return { cx: numberValue(value?.attrs?.cx) ?? 0, cy: numberValue(value?.attrs?.cy) ?? 0 };
41
+ }
42
+
43
+ function position(container, tag, defaultRelativeFrom) {
44
+ const value = findNode(container, tag);
45
+ return {
46
+ relativeFrom: String(value?.attrs?.relativeFrom ?? defaultRelativeFrom),
47
+ posOffset: numberValue(nodeText(findNode(value, "wp:posOffset"))) ?? 0,
48
+ percent: false,
49
+ };
50
+ }
51
+
52
+ function solidFill(node) {
53
+ const hex = String(findNode(node, "a:srgbClr")?.attrs?.val ?? "").trim();
54
+ if (!/^[0-9a-f]{6}$/i.test(hex)) return null;
55
+ const rgba = {
56
+ red: Number.parseInt(hex.slice(0, 2), 16),
57
+ green: Number.parseInt(hex.slice(2, 4), 16),
58
+ blue: Number.parseInt(hex.slice(4, 6), 16),
59
+ alpha: 255,
60
+ };
61
+ return {
62
+ fill: {
63
+ color: { rgba, color: { rgba, type: "srgb" }, mods: [], type: "uniColor" },
64
+ type: "solid",
65
+ },
66
+ transparent: null,
67
+ type: "fill",
68
+ fillType: "solid",
69
+ };
70
+ }
71
+
72
+ function presetGeometry(preset) {
73
+ return {
74
+ ahLst: { ahPolar: [], ahXY: [] },
75
+ avLst: {},
76
+ adjLst: {},
77
+ cnxLst: [
78
+ { pos: { x: "l", y: "t" }, ang: "cd4" },
79
+ { pos: { x: "r", y: "b" }, ang: "_3cd4" },
80
+ ],
81
+ gdLst: [],
82
+ pathLst: [
83
+ {
84
+ commands: [
85
+ { id: "moveTo", pt: { x: "l", y: "t" } },
86
+ { id: "lnTo", pt: { x: "r", y: "b" } },
87
+ ],
88
+ extrusionOk: false,
89
+ fill: "norm",
90
+ stroke: true,
91
+ },
92
+ ],
93
+ rect: null,
94
+ preset,
95
+ };
96
+ }
97
+
98
+ function lineToJson(drawing) {
99
+ const preset = String(findNode(drawing, "a:prstGeom")?.attrs?.prst ?? "");
100
+ if (!LINE_PRESETS.has(preset)) return null;
101
+ const anchor = findNode(drawing, "wp:anchor");
102
+ const inline = findNode(drawing, "wp:inline");
103
+ const container = anchor || inline;
104
+ if (!container) return null;
105
+
106
+ const docPr = findNode(container, "wp:docPr");
107
+ const effect = findNode(container, "wp:effectExtent");
108
+ const outerExtent = extent(container, "wp:extent");
109
+ const xfrm = findNode(drawing, "a:xfrm");
110
+ const innerExtent = extent(xfrm, "a:ext");
111
+ const line = findNode(drawing, "a:ln");
112
+ const simplePos = findNode(container, "wp:simplePos");
113
+ const id = numberValue(docPr?.attrs?.id) ?? 1;
114
+ const metadata = {
115
+ id,
116
+ name: String(docPr?.attrs?.name ?? `Line ${id}`),
117
+ descr: docPr?.attrs?.descr == null ? null : String(docPr.attrs.descr),
118
+ title: docPr?.attrs?.title == null ? null : String(docPr.attrs.title),
119
+ hidden: null,
120
+ };
121
+ const shapeMetadata = {
122
+ id: -1,
123
+ name: "",
124
+ descr: null,
125
+ title: null,
126
+ hidden: null,
127
+ };
128
+ const connectorLocks = findNode(drawing, "a:cxnSpLocks");
129
+
130
+ return {
131
+ type: "paraDrawing",
132
+ docPr: metadata,
133
+ effectExtent: {
134
+ l: numberValue(effect?.attrs?.l) ?? 0, t: numberValue(effect?.attrs?.t) ?? 0,
135
+ r: numberValue(effect?.attrs?.r) ?? 0, b: numberValue(effect?.attrs?.b) ?? 0,
136
+ },
137
+ extent: outerExtent,
138
+ graphic: {
139
+ bWordShape: true,
140
+ extX: innerExtent.cx || outerExtent.cx,
141
+ extY: innerExtent.cy || outerExtent.cy,
142
+ nvCxnSpPr: {
143
+ cNvPr: shapeMetadata,
144
+ nvPr: { isPhoto: null, userDrawn: null },
145
+ cNvCxnSpPr: {
146
+ cxnSpLocks: {
147
+ noChangeShapeType: boolValue(
148
+ connectorLocks?.attrs?.noChangeShapeType,
149
+ true,
150
+ ),
151
+ },
152
+ },
153
+ },
154
+ spPr: {
155
+ custGeom: presetGeometry(preset), effectDag: null, effectLst: null,
156
+ ln: {
157
+ fill: solidFill(line),
158
+ lineJoin: findNode(line, "a:round") ? { type: "round", lim: null } : null,
159
+ algn: null, cap: line?.attrs?.cap ?? null, cmpd: line?.attrs?.cmpd ?? null,
160
+ w: numberValue(line?.attrs?.w) ?? 0,
161
+ headEnd: null,
162
+ prstDash: String(findNode(line, "a:prstDash")?.attrs?.val ?? "solid"),
163
+ tailEnd: null, type: "stroke",
164
+ },
165
+ fill: {
166
+ fill: { type: "noFill" }, transparent: null, type: "fill", fillType: "noFill",
167
+ },
168
+ xfrm: {
169
+ ext: { cx: innerExtent.cx || outerExtent.cx, cy: innerExtent.cy },
170
+ off: point(xfrm, "a:off"),
171
+ flipH: xfrm?.attrs?.flipH == null ? null : boolValue(xfrm.attrs.flipH),
172
+ flipV: xfrm?.attrs?.flipV == null ? null : boolValue(xfrm.attrs.flipV),
173
+ rot: numberValue(xfrm?.attrs?.rot) ?? null,
174
+ chOffX: null, chOffY: null, chExtX: null, chExtY: null,
175
+ },
176
+ bwMode: 0,
177
+ },
178
+ content: null,
179
+ modelId: null,
180
+ type: "connectShape",
181
+ id: String(id),
182
+ },
183
+ cNvGraphicFramePr: { graphicFrameLocks: {} },
184
+ positionH: position(container, "wp:positionH", "column"),
185
+ positionV: position(container, "wp:positionV", "paragraph"),
186
+ simplePos: {
187
+ x: numberValue(simplePos?.attrs?.x) ?? 0,
188
+ y: numberValue(simplePos?.attrs?.y) ?? 0,
189
+ use: boolValue(container.attrs?.simplePos, false),
190
+ },
191
+ distB: numberValue(container.attrs?.distB) ?? 0,
192
+ distL: numberValue(container.attrs?.distL) ?? 0,
193
+ distR: numberValue(container.attrs?.distR) ?? 0,
194
+ distT: numberValue(container.attrs?.distT) ?? 0,
195
+ allowOverlap: boolValue(container.attrs?.allowOverlap, true),
196
+ behindDoc: boolValue(container.attrs?.behindDoc, false),
197
+ hidden: null,
198
+ layoutInCell: boolValue(container.attrs?.layoutInCell, true),
199
+ locked: boolValue(container.attrs?.locked, false),
200
+ relativeHeight: numberValue(container.attrs?.relativeHeight) ?? 0,
201
+ wrapType: "none",
202
+ drawingType: anchor ? "anchor" : "inline",
203
+ };
204
+ }
205
+
206
+ export function ooxmlDrawingToJson(node) {
207
+ const drawing = node?.tag === "w:drawing" ? node : findNode(node, "w:drawing");
208
+ return drawing ? lineToJson(drawing) : null;
209
+ }
@@ -0,0 +1,305 @@
1
+ // 工具②:OOXML(WordprocessingML body) → ONLYOFFICE-JSON。
2
+ // 写链路用:agent 改完的 OOXML → 本工具转 ONLYOFFICE-JSON → FromJSON+RemoveAll+InsertContent 灌回编辑器。
3
+ // FromJSON 严格:段落必须有 changes:[]、顶层必须有 paraPr。
4
+ // 无损保证:每个属性袋/元素 = { ...oo:x 兜底, ...本侧模型解析 },模型键覆盖兜底(反映 agent 编辑)。
5
+ import { parseXml, kid, kidsOf } from "./xml.mjs";
6
+ import { colorToJson, highlightToJson, mergeAutoRgb } from "./color.mjs";
7
+ import { readExtra } from "./extra.mjs";
8
+ import { ooxmlDrawingToJson } from "./ooxml-drawing-to-json.mjs";
9
+
10
+ // 数值用 Number 而非 parseInt:ONLYOFFICE 宽度等可为小数(如 tcW.w=22.36),parseInt 会截断丢精度。
11
+ const a2i = (v) => (v == null || v === "" ? null : Number(v));
12
+
13
+ // 缺口收集:解析时凡遇本侧未解析、且未被兜底属性覆盖的元素即记入(理论上应为空)。
14
+ export class Gaps {
15
+ constructor() { this.counts = new Map(); }
16
+ add(key) { this.counts.set(key, (this.counts.get(key) || 0) + 1); }
17
+ merge(other) { for (const [k, v] of other.counts) this.counts.set(k, (this.counts.get(k) || 0) + v); }
18
+ top(n = 50) { return [...this.counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, n); }
19
+ get size() { return this.counts.size; }
20
+ }
21
+
22
+ function xmlShd(node) {
23
+ if (!node) return undefined;
24
+ const o = { val: node.attrs["w:val"], color: mergeAutoRgb(colorToJson(node.attrs["w:color"]), node.attrs["oo:crgb"]) };
25
+ if (node.attrs["w:fill"]) o.fill = mergeAutoRgb(colorToJson(node.attrs["w:fill"]), node.attrs["oo:frgb"]);
26
+ return { ...readExtra(node), ...o }; // themeColor/themeFill 等经 oo:x 兜底
27
+ }
28
+ function xmlPpToJson(pPrNode, gaps) {
29
+ const o = { bFromDocument: true, type: "paraPr" };
30
+ if (!pPrNode) return o;
31
+ const g = (t) => kid(pPrNode, t);
32
+ if (g("w:pStyle")) o.pStyle = g("w:pStyle").attrs["w:val"];
33
+ for (const t of ["widowControl", "keepNext", "keepLines", "pageBreakBefore", "contextualSpacing", "suppressLineNumbers"])
34
+ if (g("w:" + t)) o[t] = true;
35
+ if (g("w:shd")) o.shd = xmlShd(g("w:shd"));
36
+ const sp = g("w:spacing");
37
+ if (sp) {
38
+ o.spacing = {};
39
+ if (sp.attrs["w:before"]) o.spacing.before = a2i(sp.attrs["w:before"]);
40
+ if (sp.attrs["w:after"]) o.spacing.after = a2i(sp.attrs["w:after"]);
41
+ if (sp.attrs["w:line"]) o.spacing.line = a2i(sp.attrs["w:line"]);
42
+ if (sp.attrs["w:lineRule"]) o.spacing.lineRule = sp.attrs["w:lineRule"];
43
+ if (sp.attrs["w:beforeAutospacing"]) o.spacing.beforeAutoSpacing = sp.attrs["w:beforeAutospacing"] === "1" ? "on" : "off";
44
+ if (sp.attrs["w:afterAutospacing"]) o.spacing.afterAutoSpacing = sp.attrs["w:afterAutospacing"] === "1" ? "on" : "off";
45
+ }
46
+ const ind = g("w:ind");
47
+ if (ind) {
48
+ o.ind = {};
49
+ if (ind.attrs["w:left"]) o.ind.left = a2i(ind.attrs["w:left"]);
50
+ if (ind.attrs["w:right"]) o.ind.right = a2i(ind.attrs["w:right"]);
51
+ if (ind.attrs["w:firstLine"]) o.ind.firstLine = a2i(ind.attrs["w:firstLine"]);
52
+ }
53
+ if (g("w:jc")) o.jc = g("w:jc").attrs["w:val"];
54
+ return { ...readExtra(pPrNode), ...o };
55
+ }
56
+ function xmlRpToJson(rPrNode, gaps) {
57
+ const o = { bFromDocument: true, type: "textPr" };
58
+ if (!rPrNode) return o;
59
+ const g = (t) => kid(rPrNode, t);
60
+ const rf = g("w:rFonts");
61
+ if (rf) { o.rFonts = {}; for (const k of ["ascii", "hAnsi", "eastAsia", "cs", "hint", "asciiTheme", "hAnsiTheme", "eastAsiaTheme", "cstheme"]) if (rf.attrs["w:" + k]) o.rFonts[k] = rf.attrs["w:" + k]; }
62
+ const tgl = (name) => { const e = g("w:" + name); if (!e) return undefined; const v = e.attrs["w:val"]; return !(v === "false" || v === "0"); };
63
+ for (const t of ["b", "bCs", "i", "iCs"]) { const v = tgl(t); if (v !== undefined) o[t] = v; }
64
+ if (g("w:color")) o.color = mergeAutoRgb(colorToJson(g("w:color").attrs["w:val"]), g("w:color").attrs["oo:rgb"]);
65
+ if (g("w:spacing")) o.spacing = a2i(g("w:spacing").attrs["w:val"]);
66
+ if (g("w:sz")) o.sz = a2i(g("w:sz").attrs["w:val"]);
67
+ if (g("w:szCs")) o.szCs = a2i(g("w:szCs").attrs["w:val"]);
68
+ if (g("w:shd")) o.shd = xmlShd(g("w:shd"));
69
+ // highlight 不在此解析:OOXML 仅 16 命名色,非精确 RGB 会被最近邻映射丢精度;由 rPr 的 oo:x 兜底精确还原。
70
+ if (g("w:vertAlign")) o.vertAlign = g("w:vertAlign").attrs["w:val"];
71
+ const lang = g("w:lang");
72
+ if (lang) { o.lang = {}; for (const k of ["val", "eastAsia", "bidi"]) if (lang.attrs["w:" + k]) o.lang[k] = lang.attrs["w:" + k]; }
73
+ return { ...readExtra(rPrNode), ...o };
74
+ }
75
+ function runToJson(rNode, gaps) {
76
+ const content = [];
77
+ for (const c of rNode.kids) {
78
+ // w:delText:被 <w:del> 追踪删除的文字载体(语义同 w:t,只是位于删除修订里)。
79
+ if (c.tag === "w:t" || c.tag === "w:delText") content.push((c.kids || []).filter((k) => k.text != null).map((k) => k.text).join(""));
80
+ else if (c.tag === "w:br") { const ty = c.attrs["w:type"]; content.push({ type: "break", breakType: ty === "column" ? "column" : "page" }); }
81
+ else if (c.tag === "w:tab") content.push({ type: "tab" });
82
+ else if (c.tag === "oo:ci") { try { content.push(JSON.parse(c.attrs["oo:v"])); } catch { /* 兜底损坏则跳过 */ } }
83
+ else if (c.tag === "w:drawing" || c.tag === "mc:AlternateContent") {
84
+ const drawing = ooxmlDrawingToJson(c);
85
+ if (drawing) content.push(drawing);
86
+ else gaps && gaps.add("w:r>" + c.tag);
87
+ } else if (c.tag && c.tag !== "w:rPr") gaps && gaps.add("w:r>" + c.tag);
88
+ }
89
+ const ex = readExtra(rNode);
90
+ return { ...ex, bFromDocument: true, rPr: xmlRpToJson(kid(rNode, "w:rPr"), gaps), content, footnotes: ex.footnotes || [], endnotes: ex.endnotes || [], reviewType: ex.reviewType || "common", type: "run" };
91
+ }
92
+ // <w:ins>/<w:del>(及 move 的 <w:moveTo>/<w:moveFrom>)是 run 的**修订包裹器**:包住一段
93
+ // 被插入/删除的 run。ONLYOFFICE-JSON 不用包裹器,而是给每个 run 打 reviewType + reviewInfo。
94
+ // 这里把包裹器拆开、递归内部 run 并标注——否则 SDK 的 trackChanges(校对)产出的真实
95
+ // <w:ins>/<w:del> 会被当未建模元素整个丢掉 → 插入/删除的文字在写回时全部丢失。
96
+ const REV_TAG_TYPE = { "w:ins": "add", "w:del": "remove", "w:moveTo": "add", "w:moveFrom": "remove" };
97
+ function revInfoFromEl(el) {
98
+ return {
99
+ userId: "",
100
+ author: el.attrs["w:author"] || "",
101
+ date: el.attrs["w:date"] || "",
102
+ moveType: "noMove",
103
+ prevType: -1,
104
+ };
105
+ }
106
+ /** 把一个修订包裹器里的 run 递归收进 content(每个 run 标 reviewType/reviewInfo)。支持一层嵌套(move)。 */
107
+ function pushRevisionRuns(el, reviewType, reviewInfo, content, gaps) {
108
+ for (const c of el.kids) {
109
+ if (c.tag === "w:r") content.push({ ...runToJson(c, gaps), reviewType, reviewInfo });
110
+ else if (REV_TAG_TYPE[c.tag]) pushRevisionRuns(c, REV_TAG_TYPE[c.tag], revInfoFromEl(c), content, gaps);
111
+ else if (c.tag) gaps && gaps.add(el.tag + ">" + c.tag);
112
+ }
113
+ }
114
+ function hyperlinkToJson(node, gaps, rels) {
115
+ const content = [];
116
+ for (const c of node.kids || []) {
117
+ if (c.tag === "w:r") content.push(runToJson(c, gaps));
118
+ else if (REV_TAG_TYPE[c.tag]) pushRevisionRuns(c, REV_TAG_TYPE[c.tag], revInfoFromEl(c), content, gaps);
119
+ else if (c.tag === "oo:pi") { try { content.push(JSON.parse(c.attrs["oo:v"])); } catch { /* skip invalid fallback payload */ } }
120
+ else if (c.tag) gaps && gaps.add("w:hyperlink>" + c.tag);
121
+ }
122
+ const relId = node.attrs["r:id"];
123
+ const rel = relId && rels && typeof rels.get === "function" ? rels.get(relId) : null;
124
+ const value = rel?.target || node.attrs["w:anchor"] || "";
125
+ const tooltip = node.attrs["w:tooltip"] || "";
126
+ return { ...readExtra(node), ...(tooltip ? { tooltip } : {}), ...(value ? { value } : {}), content, type: "hyperlink" };
127
+ }
128
+
129
+ function applyParagraphTextPr(item, paragraphRPr) {
130
+ if (!item || !paragraphRPr) return item;
131
+ if (item.type === "run") {
132
+ return {
133
+ ...item,
134
+ rPr: {
135
+ ...(item.rPr || {}),
136
+ ...paragraphRPr,
137
+ rFonts: {
138
+ ...(item.rPr?.rFonts || {}),
139
+ ...(paragraphRPr.rFonts || {}),
140
+ },
141
+ },
142
+ };
143
+ }
144
+ if (item.type === "hyperlink" && Array.isArray(item.content)) {
145
+ return {
146
+ ...item,
147
+ content: item.content.map((child) => applyParagraphTextPr(child, paragraphRPr)),
148
+ };
149
+ }
150
+ return item;
151
+ }
152
+
153
+ function paraToJson(pNode, gaps, rels) {
154
+ const pPrNode = kid(pNode, "w:pPr");
155
+ // 段落 mark rPr 在 <w:pPr><w:rPr> 里:既是段落 rPr,也是末尾 endRun 的 rPr
156
+ const markRpr = xmlRpToJson(pPrNode && kid(pPrNode, "w:rPr"), gaps);
157
+ // 按文档顺序走内容项:run / oo:pi(commentRangeStart 等未建模项)
158
+ const content = [];
159
+ for (const c of pNode.kids) {
160
+ if (c.tag === "w:r") content.push(runToJson(c, gaps));
161
+ else if (c.tag === "w:hyperlink") content.push(hyperlinkToJson(c, gaps, rels));
162
+ else if (REV_TAG_TYPE[c.tag]) pushRevisionRuns(c, REV_TAG_TYPE[c.tag], revInfoFromEl(c), content, gaps);
163
+ else if (c.tag === "oo:pi") { try { content.push(JSON.parse(c.attrs["oo:v"])); } catch { /* 兜底损坏则跳过 */ } }
164
+ else if (c.tag === "w:commentRangeStart") { content.push({ type: "commentRangeStart", id: Number(c.attrs["w:id"]) }); }
165
+ else if (c.tag === "w:commentRangeEnd") { content.push({ type: "commentRangeEnd", id: Number(c.attrs["w:id"]) }); }
166
+ else if (c.tag === "w:commentReference") { content.push({ type: "commentReference", id: Number(c.attrs["w:id"]) }); }
167
+ else if (c.tag && c.tag !== "w:pPr") gaps && gaps.add("w:p>" + c.tag);
168
+ }
169
+ // 补回段落标记 endRun(ONLYOFFICE 每段 content 末尾都有);oo:er 还原其修订等非默认字段。
170
+ // WPS stores effective paragraph text formatting in pPr/rPr while leaving stale,
171
+ // partial values on individual runs. ONLYOFFICE FromJSON does not apply the
172
+ // paragraph-level textPr to those runs, so materialize the effective properties.
173
+ for (let i = 0; i < content.length; i += 1) {
174
+ content[i] = applyParagraphTextPr(content[i], markRpr);
175
+ }
176
+ const er = readExtra(pNode, "oo:er");
177
+ content.push({ bFromDocument: true, footnotes: [], endnotes: [], reviewType: "common", ...er, rPr: markRpr, content: [], type: "endRun" });
178
+ const ex = readExtra(pNode);
179
+ return {
180
+ ...ex,
181
+ bFromDocument: true,
182
+ pPr: xmlPpToJson(pPrNode, gaps),
183
+ rPr: markRpr,
184
+ content,
185
+ changes: ex.changes || [],
186
+ type: "paragraph",
187
+ };
188
+ }
189
+
190
+ // 块级内容数组(body 或单元格):w:p→段落、w:tbl→表格、oo:blk→未建模块(blockLvlSdt 等)无损还原。
191
+ function blocksFromKids(kids, gaps, rels) {
192
+ const out = [];
193
+ for (const c of kids || []) {
194
+ if (c.tag === "w:p") out.push(paraToJson(c, gaps, rels));
195
+ else if (c.tag === "w:tbl") out.push(tableToJson(c, gaps, rels));
196
+ else if (c.tag === "oo:blk") { try { out.push(JSON.parse(c.attrs["oo:v"])); } catch { /* 兜底损坏则跳过 */ } }
197
+ else if (c.tag && c.tag !== "w:sectPr" && c.tag !== "w:tcPr" && c.tag !== "w:trPr" && c.tag !== "w:tblPr" && c.tag !== "w:tblGrid") gaps && gaps.add("blk>" + c.tag);
198
+ }
199
+ return out;
200
+ }
201
+
202
+ // ── 表格 ──
203
+ const BDR_SIDES = ["top", "left", "bottom", "right", "insideH", "insideV", "start", "end", "between", "bar"];
204
+ function xmlBdrEl(node) {
205
+ const o = { color: mergeAutoRgb(colorToJson(node.attrs["w:color"]), node.attrs["oo:rgb"]), sz: a2i(node.attrs["w:sz"]) || 0, space: a2i(node.attrs["w:space"]) || 0, value: node.attrs["w:val"] };
206
+ return { ...readExtra(node), ...o }; // themeColor 等经 oo:x 兜底
207
+ }
208
+ function xmlBorders(node) {
209
+ if (!node) return undefined;
210
+ const o = {};
211
+ for (const c of node.kids) { const s = (c.tag || "").replace(/^w:/, ""); if (BDR_SIDES.includes(s)) o[s] = xmlBdrEl(c); }
212
+ return o; // 空元素 → {}(保留"显式无边框"语义,不丢键)
213
+ }
214
+ function xmlWidth(node) {
215
+ if (!node) return undefined;
216
+ return { type: node.attrs["w:type"] || "auto", w: a2i(node.attrs["w:w"]) || 0 };
217
+ }
218
+ function xmlMar(node) {
219
+ if (!node) return undefined;
220
+ const o = {};
221
+ for (const c of node.kids) { const s = (c.tag || "").replace(/^w:/, ""); if (["top", "left", "bottom", "right", "start", "end"].includes(s)) o[s] = xmlWidth(c); }
222
+ return Object.keys(o).length ? o : undefined;
223
+ }
224
+ function tblPrToJson(node, gaps) {
225
+ const o = { type: "tablePr" };
226
+ if (!node) return o;
227
+ const g = (t) => kid(node, t);
228
+ if (g("w:tblStyle")) o.tblStyle = g("w:tblStyle").attrs["w:val"];
229
+ if (g("w:tblW")) o.tblW = xmlWidth(g("w:tblW"));
230
+ if (g("w:jc")) o.jc = g("w:jc").attrs["w:val"];
231
+ if (g("w:tblBorders")) o.tblBorders = xmlBorders(g("w:tblBorders"));
232
+ if (g("w:tblCellMar")) o.tblCellMar = xmlMar(g("w:tblCellMar"));
233
+ if (g("w:tblLayout")) o.tblLayout = g("w:tblLayout").attrs["w:type"];
234
+ if (g("w:tblLook")) { const a = g("w:tblLook").attrs; o.tblLook = {}; for (const k of ["firstColumn", "firstRow", "lastColumn", "lastRow", "noHBand", "noVBand"]) if (a["w:" + k] != null) o.tblLook[k] = a["w:" + k] === "1" || a["w:" + k] === "true"; }
235
+ // tblInd(数字而非宽度对象)/tblOverlap/tblpPr/inline 等 → 由 oo:x 兜底,不在此解析以免写错形态
236
+ return { ...readExtra(node), ...o };
237
+ }
238
+ function tcPrToJson(node, gaps) {
239
+ const o = { type: "tableCellPr" };
240
+ if (!node) return o;
241
+ const g = (t) => kid(node, t);
242
+ if (g("w:tcW")) o.tcW = xmlWidth(g("w:tcW"));
243
+ if (g("w:gridSpan")) o.gridSpan = a2i(g("w:gridSpan").attrs["w:val"]);
244
+ if (g("w:vMerge")) o.vMerge = g("w:vMerge").attrs["w:val"] || "continue";
245
+ if (g("w:tcBorders")) o.tcBorders = xmlBorders(g("w:tcBorders"));
246
+ if (g("w:shd")) o.shd = xmlShd(g("w:shd"));
247
+ if (g("w:vAlign")) o.vAlign = g("w:vAlign").attrs["w:val"];
248
+ return { ...readExtra(node), ...o };
249
+ }
250
+ function trPrToJson(node, gaps) {
251
+ const o = { type: "tableRowPr" };
252
+ if (!node) return o;
253
+ const g = (t) => kid(node, t);
254
+ if (g("w:tblHeader")) o.tblHeader = true;
255
+ if (g("w:jc")) o.jc = g("w:jc").attrs["w:val"];
256
+ if (g("w:trHeight")) o.trHeight = { val: a2i(g("w:trHeight").attrs["w:val"]), hRule: g("w:trHeight").attrs["w:hRule"] };
257
+ return { ...readExtra(node), ...o };
258
+ }
259
+ let _cellId = 1;
260
+ function cellToJson(tc, gaps, rels) {
261
+ const blocks = blocksFromKids(tc.kids, gaps, rels);
262
+ const inner = blocks.length ? blocks : [paraToJson({ tag: "w:p", attrs: {}, kids: [] }, gaps, rels)];
263
+ const ex = readExtra(tc);
264
+ // cell.content 是 DocumentContent 壳;id 优先用兜底里的原值,缺失才用计数器(agent 新增单元格)
265
+ return { ...ex, content: { bPresentation: false, content: inner, type: "docContent" }, tcPr: tcPrToJson(kid(tc, "w:tcPr"), gaps), id: ex.id != null ? ex.id : String(_cellId++), type: "tblCell" };
266
+ }
267
+ function rowToJson(tr, gaps, rels) {
268
+ const ex = readExtra(tr);
269
+ return {
270
+ ...ex,
271
+ content: kidsOf(tr, "w:tc").map((tc) => cellToJson(tc, gaps, rels)),
272
+ reviewInfo: ex.reviewInfo || { userId: "", author: "", date: "", moveType: "noMove", prevType: -1 },
273
+ reviewType: ex.reviewType || "common",
274
+ trPr: trPrToJson(kid(tr, "w:trPr"), gaps),
275
+ type: "tblRow",
276
+ };
277
+ }
278
+ function tableToJson(tbl, gaps, rels) {
279
+ const tblGrid = kidsOf(kid(tbl, "w:tblGrid"), "w:gridCol").map((g) => ({ w: a2i(g.attrs["w:w"]), type: "gridCol" }));
280
+ const ex = readExtra(tbl);
281
+ return {
282
+ ...ex,
283
+ bPresentation: false,
284
+ tblGrid,
285
+ tblPr: tblPrToJson(kid(tbl, "w:tblPr"), gaps),
286
+ content: kidsOf(tbl, "w:tr").map((tr) => rowToJson(tr, gaps, rels)),
287
+ changes: ex.changes || [],
288
+ type: "table",
289
+ };
290
+ }
291
+
292
+ /**
293
+ * OOXML(document.xml) → ONLYOFFICE-JSON 文档对象
294
+ * @returns { doc, gaps }
295
+ */
296
+ export function ooxmlToJson(xml, gaps = new Gaps(), options = {}) {
297
+ _cellId = 1;
298
+ const docNode = parseXml(xml);
299
+ const body = kid(docNode, "w:body");
300
+ const empty = { type: "document", paraPr: { bFromDocument: true, type: "paraPr" }, content: [] };
301
+ if (!body) return { doc: empty, gaps };
302
+ const content = blocksFromKids(body.kids, gaps, options.relationships || options.rels);
303
+ const ex = readExtra(body); // 文档级 paraPr 等
304
+ return { doc: { type: "document", paraPr: { bFromDocument: true, type: "paraPr" }, ...ex, content }, gaps };
305
+ }