@coco-vexel/coco-office-oojson 0.0.0-stage → 1.0.1
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.
- package/LICENSE +202 -0
- package/README.md +99 -2
- package/THIRD_PARTY_NOTICES.md +1188 -0
- package/dist/oojson.global.js +30 -0
- package/package.json +45 -3
- package/src/color.mjs +38 -0
- package/src/extra.mjs +37 -0
- package/src/flat-opc.mjs +394 -0
- package/src/index.mjs +39 -0
- package/src/json-to-ooxml.mjs +605 -0
- package/src/long-tail-parts.mjs +424 -0
- package/src/numbering-to-ooxml.mjs +120 -0
- package/src/ooxml-drawing-to-json.mjs +209 -0
- package/src/ooxml-to-json.mjs +305 -0
- package/src/ppt-content.mjs +346 -0
- package/src/ppt-deck.mjs +248 -0
- package/src/styles-to-ooxml.mjs +302 -0
- package/src/writeback.mjs +1082 -0
- package/src/xml.mjs +66 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { blocksToXml } from "./json-to-ooxml.mjs";
|
|
2
|
+
|
|
3
|
+
const CT = {
|
|
4
|
+
header: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
|
|
5
|
+
footer: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
|
|
6
|
+
comments: "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
|
|
7
|
+
commentsExtended: "application/vnd.ms-word.commentsExtended+xml",
|
|
8
|
+
footnotes: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
|
|
9
|
+
endnotes: "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const REL = {
|
|
13
|
+
header: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header",
|
|
14
|
+
footer: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer",
|
|
15
|
+
comments: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
|
|
16
|
+
commentsExtended: "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
|
|
17
|
+
image: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
|
|
18
|
+
footnotes: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes",
|
|
19
|
+
endnotes: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function cloneJson(value) {
|
|
23
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function contentBlocks(ref) {
|
|
27
|
+
const content = ref?.content;
|
|
28
|
+
if (Array.isArray(content?.content)) return content.content;
|
|
29
|
+
if (Array.isArray(content)) return content;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function wordPartXml(root, blocks) {
|
|
34
|
+
return (
|
|
35
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
|
36
|
+
`<w:${root} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ` +
|
|
37
|
+
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ` +
|
|
38
|
+
`xmlns:oo="urn:coco:oojson">` +
|
|
39
|
+
blocksToXml(blocks) +
|
|
40
|
+
`</w:${root}>`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function walkBlocks(blocks, visit) {
|
|
45
|
+
for (const block of blocks || []) {
|
|
46
|
+
if (!block || typeof block !== "object") continue;
|
|
47
|
+
if (Array.isArray(block.content)) {
|
|
48
|
+
for (const item of block.content) visit(item);
|
|
49
|
+
}
|
|
50
|
+
const nested = block.content?.content;
|
|
51
|
+
if (Array.isArray(nested)) walkBlocks(nested, visit);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function commentMetadata(doc) {
|
|
56
|
+
return Array.isArray(doc?.commentMetadata) ? doc.commentMetadata : [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function commentDate(value) {
|
|
60
|
+
if (value == null || value === "") return "";
|
|
61
|
+
const date = typeof value === "number" ? new Date(value) : new Date(String(value));
|
|
62
|
+
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findCommentMetadata(doc, comment) {
|
|
66
|
+
const metadata = commentMetadata(doc);
|
|
67
|
+
// 先按 id 匹配(commentMetadata 的 id 字段,来自 GetAllComments().GetId())
|
|
68
|
+
if (comment.id != null && comment.id !== "") {
|
|
69
|
+
const byId = metadata.find((item) => String(item?.id ?? "") === String(comment.id));
|
|
70
|
+
if (byId) return byId;
|
|
71
|
+
}
|
|
72
|
+
// 再按 (author, text) 匹配(退化兼容)
|
|
73
|
+
return metadata.find((item) =>
|
|
74
|
+
String(item?.author ?? item?.authorName ?? "") === String(comment.author ?? "") &&
|
|
75
|
+
String(item?.text ?? "") === String(comment.text ?? "")
|
|
76
|
+
) || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 收集批注定义列表。
|
|
81
|
+
*
|
|
82
|
+
* 两种来源:
|
|
83
|
+
* ① 从 body 的 commentRangeStart/End 标记收集(SDK 产出或上一轮重建的 oojson)
|
|
84
|
+
* ② 从 doc.commentMetadata 直接收集(OnlyOffice 原生 ToJSON 仅输出元数据,无 body 标记)
|
|
85
|
+
*
|
|
86
|
+
* 情况②时,还会尝试把标记插入 body(searchQuoteInBody),使后续序列化生成
|
|
87
|
+
* commentRangeStart/End/Reference 元素。
|
|
88
|
+
*/
|
|
89
|
+
function collectComments(doc) {
|
|
90
|
+
const comments = new Map();
|
|
91
|
+
walkBlocks(doc?.content, (item) => {
|
|
92
|
+
if (!item || typeof item !== "object") return;
|
|
93
|
+
if ((item.type !== "commentRangeStart" && item.type !== "commentRangeEnd" && item.type !== "commentReference") || item.id == null) return;
|
|
94
|
+
const id = String(item.id);
|
|
95
|
+
const prev = comments.get(id) || { id, author: "", text: "", date: "", solved: undefined };
|
|
96
|
+
const next = {
|
|
97
|
+
id,
|
|
98
|
+
author: item.author ?? prev.author ?? "",
|
|
99
|
+
text: item.text ?? prev.text ?? "",
|
|
100
|
+
date: item.date ?? prev.date ?? "",
|
|
101
|
+
solved: prev.solved,
|
|
102
|
+
};
|
|
103
|
+
const meta = findCommentMetadata(doc, next);
|
|
104
|
+
if (meta?.time != null || meta?.date != null) next.date = commentDate(meta.time ?? meta.date);
|
|
105
|
+
if (typeof meta?.solved === "boolean") next.solved = meta.solved;
|
|
106
|
+
comments.set(id, next);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 如果 body 中没有 commentRangeStart 标记,但有 commentMetadata:
|
|
110
|
+
// 直接从元数据收集批注,并尝试在 body 中插入锚点标记
|
|
111
|
+
if (comments.size === 0) {
|
|
112
|
+
const metaList = commentMetadata(doc);
|
|
113
|
+
for (const meta of metaList) {
|
|
114
|
+
const id = String(meta.id ?? "");
|
|
115
|
+
if (!id) continue;
|
|
116
|
+
comments.set(id, {
|
|
117
|
+
id,
|
|
118
|
+
author: meta.author ?? meta.authorName ?? "",
|
|
119
|
+
text: meta.text ?? "",
|
|
120
|
+
date: commentDate(meta.time ?? meta.date),
|
|
121
|
+
solved: typeof meta.solved === "boolean" ? meta.solved : undefined,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// 尝试在 body 中插入锚点标记(基于 quote/anchorText 搜索)
|
|
125
|
+
if (metaList.length > 0 && doc?.content) {
|
|
126
|
+
insertCommentMarkersFromMetadata(doc, metaList);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [...comments.values()].sort((a, b) => Number(a.id) - Number(b.id));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 从 commentMetadata 的 quote 字段,在 body 的 run 文本中搜索锚文本,
|
|
135
|
+
* 插入 commentRangeStart/commentRangeEnd/commentReference 到内容数组。
|
|
136
|
+
*
|
|
137
|
+
* 只处理首段首次匹配,不跨段。匹配失败不报错(仅标记丢失,但批注定数和
|
|
138
|
+
* 文本仍会出现在 comments.xml 中)。
|
|
139
|
+
*/
|
|
140
|
+
function insertCommentMarkersFromMetadata(doc, metaList) {
|
|
141
|
+
const blocks = doc.content;
|
|
142
|
+
if (!Array.isArray(blocks)) return;
|
|
143
|
+
for (const meta of metaList) {
|
|
144
|
+
const quote = meta.quote ?? meta.anchorText ?? "";
|
|
145
|
+
if (!quote || String(quote).trim().length === 0) continue;
|
|
146
|
+
const id = String(meta.id ?? "");
|
|
147
|
+
if (!id) continue;
|
|
148
|
+
|
|
149
|
+
// 在每个 paragraph 的 run 内容中搜索
|
|
150
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
151
|
+
const block = blocks[bi];
|
|
152
|
+
if (block?.type !== "paragraph" || !Array.isArray(block.content)) continue;
|
|
153
|
+
const content = block.content;
|
|
154
|
+
// 构建连续文本
|
|
155
|
+
let fullText = "";
|
|
156
|
+
const runIndexes = [];
|
|
157
|
+
for (let ci = 0; ci < content.length; ci++) {
|
|
158
|
+
const item = content[ci];
|
|
159
|
+
if (item?.type === "run" && Array.isArray(item.content)) {
|
|
160
|
+
for (const t of item.content) {
|
|
161
|
+
if (typeof t === "string") {
|
|
162
|
+
fullText += t;
|
|
163
|
+
runIndexes.push(ci);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// 找首段首次匹配
|
|
169
|
+
const idx = fullText.indexOf(quote);
|
|
170
|
+
if (idx < 0) {
|
|
171
|
+
// 尝试取 quote 前 40 字(部分 quote 可能过长)
|
|
172
|
+
const short = quote.slice(0, 40);
|
|
173
|
+
const idx2 = short.length >= 5 ? fullText.indexOf(short) : -1;
|
|
174
|
+
if (idx2 < 0) continue;
|
|
175
|
+
}
|
|
176
|
+
// 简化策略:在段落首插入 commentRangeStart,末插入 commentRangeEnd + commentReference
|
|
177
|
+
// 精确位置匹配需跨 run 拆分,留后续完善
|
|
178
|
+
// 在第一个 run 前插入 start
|
|
179
|
+
let firstRunIdx = -1;
|
|
180
|
+
let lastRunIdx = -1;
|
|
181
|
+
for (let ci = 0; ci < content.length; ci++) {
|
|
182
|
+
if (content[ci]?.type === "run") {
|
|
183
|
+
if (firstRunIdx < 0) firstRunIdx = ci;
|
|
184
|
+
lastRunIdx = ci;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (firstRunIdx < 0) continue;
|
|
188
|
+
// 插入 commentRangeStart 在首 run 前;commentRangeEnd 在末 run 后。
|
|
189
|
+
// 注意:commentRangeEnd 在 json-to-ooxml.mjs 的 paraItemXml 中会自动输出
|
|
190
|
+
// commentReference,不需要额外插入。
|
|
191
|
+
content.splice(firstRunIdx, 0, { type: "commentRangeStart", id });
|
|
192
|
+
// 插入 start 后 runs 偏移 +1
|
|
193
|
+
content.splice(lastRunIdx + 2, 0, { type: "commentRangeEnd", id });
|
|
194
|
+
break; // 只处理首段匹配
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function commentXmlAttrs(comment) {
|
|
200
|
+
const date = comment.date ? ` w:date="${escapeXml(comment.date)}"` : "";
|
|
201
|
+
return `w:id="${escapeXml(comment.id)}" w:author="${escapeXml(comment.author || "")}"${date}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function commentParaId(comment) {
|
|
205
|
+
const numeric = Number(comment.id);
|
|
206
|
+
if (Number.isFinite(numeric)) return (numeric >>> 0).toString(16).toUpperCase().padStart(8, "0").slice(-8);
|
|
207
|
+
let hash = 0;
|
|
208
|
+
for (const ch of String(comment.id)) hash = ((hash * 31) + ch.charCodeAt(0)) >>> 0;
|
|
209
|
+
return hash.toString(16).toUpperCase().padStart(8, "0");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function commentsXml(comments) {
|
|
213
|
+
return (
|
|
214
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
|
215
|
+
`<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml">` +
|
|
216
|
+
comments.map((comment) =>
|
|
217
|
+
`<w:comment ${commentXmlAttrs(comment)}>` +
|
|
218
|
+
`<w:p w14:paraId="${commentParaId(comment)}"><w:r><w:t xml:space="preserve">${escapeXml(comment.text || "")}</w:t></w:r></w:p>` +
|
|
219
|
+
`</w:comment>`
|
|
220
|
+
).join("") +
|
|
221
|
+
`</w:comments>`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function commentsExtendedXml(comments) {
|
|
226
|
+
const solved = comments.filter((comment) => comment.solved === true);
|
|
227
|
+
if (!solved.length) return "";
|
|
228
|
+
return (
|
|
229
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
|
230
|
+
`<w15:commentsEx xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml">` +
|
|
231
|
+
solved.map((comment) => `<w15:commentEx w15:paraId="${commentParaId(comment)}" w15:done="1"/>`).join("") +
|
|
232
|
+
`</w15:commentsEx>`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function escapeXml(s) {
|
|
237
|
+
return String(s)
|
|
238
|
+
.replace(/&/g, "&")
|
|
239
|
+
.replace(/</g, "<")
|
|
240
|
+
.replace(/>/g, ">")
|
|
241
|
+
.replace(/"/g, """)
|
|
242
|
+
.replace(/'/g, "'");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function appendCommentParts(doc, extraParts, summary) {
|
|
246
|
+
const comments = collectComments(doc);
|
|
247
|
+
if (!comments.length) return;
|
|
248
|
+
summary.comments = comments.length;
|
|
249
|
+
extraParts.push({
|
|
250
|
+
name: "/word/comments.xml",
|
|
251
|
+
contentType: CT.comments,
|
|
252
|
+
xml: commentsXml(comments),
|
|
253
|
+
rel: {
|
|
254
|
+
id: "rIdCocoComments",
|
|
255
|
+
type: REL.comments,
|
|
256
|
+
target: "comments.xml",
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
const extendedXml = commentsExtendedXml(comments);
|
|
260
|
+
if (extendedXml) {
|
|
261
|
+
extraParts.push({
|
|
262
|
+
name: "/word/commentsExtended.xml",
|
|
263
|
+
contentType: CT.commentsExtended,
|
|
264
|
+
xml: extendedXml,
|
|
265
|
+
rel: {
|
|
266
|
+
id: "rIdCocoCommentsExtended",
|
|
267
|
+
type: REL.commentsExtended,
|
|
268
|
+
target: "commentsExtended.xml",
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function parseDataUri(uri) {
|
|
274
|
+
const m = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/.exec(String(uri || ""));
|
|
275
|
+
if (!m) return null;
|
|
276
|
+
const contentType = m[1];
|
|
277
|
+
const ext = contentType === "image/jpeg" ? "jpg" : contentType === "image/png" ? "png" : contentType.split("/")[1] || "bin";
|
|
278
|
+
return { contentType, ext, data: m[2].replace(/\s+/g, "") };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function collectNotes(doc, key) {
|
|
282
|
+
const notes = new Map();
|
|
283
|
+
walkBlocks(doc?.content, (item) => {
|
|
284
|
+
if (!item || item.type !== "run" || !Array.isArray(item[key])) return;
|
|
285
|
+
for (const note of item[key]) {
|
|
286
|
+
if (!note || note.id == null || !Array.isArray(note.content)) continue;
|
|
287
|
+
notes.set(String(note.id), { id: String(note.id), type: note.type, content: note.content });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
return [...notes.values()].sort((a, b) => Number(a.id) - Number(b.id));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function noteXmlAttrs(note) {
|
|
294
|
+
const type = note.type ? ` w:type="${escapeXml(note.type)}"` : "";
|
|
295
|
+
return `w:id="${escapeXml(note.id)}"${type}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function isNoteKind(note, type, id) {
|
|
299
|
+
return note?.type === type || String(note?.id) === String(id);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function defaultNoteXml(child, type, id) {
|
|
303
|
+
const marker = type === "separator" ? "separator" : "continuationSeparator";
|
|
304
|
+
return `<w:${child} w:type="${type}" w:id="${id}"><w:p><w:r><w:${marker}/></w:r></w:p></w:${child}>`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function noteContentCount(notes) {
|
|
308
|
+
return notes.filter((note) => !isNoteKind(note, "separator", -1) && !isNoteKind(note, "continuationSeparator", 0)).length;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function noteEntryXml(child, note) {
|
|
312
|
+
return `<w:${child} ${noteXmlAttrs(note)}>${blocksToXml(note.content)}</w:${child}>`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function notesXml(root, child, notes) {
|
|
316
|
+
const separator = notes.find((note) => isNoteKind(note, "separator", -1));
|
|
317
|
+
const continuation = notes.find((note) => isNoteKind(note, "continuationSeparator", 0));
|
|
318
|
+
const bodyNotes = notes.filter((note) => !isNoteKind(note, "separator", -1) && !isNoteKind(note, "continuationSeparator", 0));
|
|
319
|
+
return (
|
|
320
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
|
321
|
+
`<w:${root} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:oo="urn:coco:oojson">` +
|
|
322
|
+
(separator ? noteEntryXml(child, separator) : defaultNoteXml(child, "separator", -1)) +
|
|
323
|
+
(continuation ? noteEntryXml(child, continuation) : defaultNoteXml(child, "continuationSeparator", 0)) +
|
|
324
|
+
bodyNotes.map((note) => noteEntryXml(child, note)).join("") +
|
|
325
|
+
`</w:${root}>`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function appendNoteParts(doc, extraParts, summary) {
|
|
330
|
+
const footnotes = collectNotes(doc, "footnotes");
|
|
331
|
+
if (footnotes.length) {
|
|
332
|
+
summary.footnotes = noteContentCount(footnotes);
|
|
333
|
+
extraParts.push({
|
|
334
|
+
name: "/word/footnotes.xml",
|
|
335
|
+
contentType: CT.footnotes,
|
|
336
|
+
xml: notesXml("footnotes", "footnote", footnotes),
|
|
337
|
+
rel: { id: "rIdCocoFootnotes", type: REL.footnotes, target: "footnotes.xml" },
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
const endnotes = collectNotes(doc, "endnotes");
|
|
341
|
+
if (endnotes.length) {
|
|
342
|
+
summary.endnotes = noteContentCount(endnotes);
|
|
343
|
+
extraParts.push({
|
|
344
|
+
name: "/word/endnotes.xml",
|
|
345
|
+
contentType: CT.endnotes,
|
|
346
|
+
xml: notesXml("endnotes", "endnote", endnotes),
|
|
347
|
+
rel: { id: "rIdCocoEndnotes", type: REL.endnotes, target: "endnotes.xml" },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function appendMediaPartsFromBlocks(blocks, extraParts, summary, sourcePart = "/word/document.xml") {
|
|
352
|
+
walkBlocks(blocks, (item) => {
|
|
353
|
+
if (!item || item.type !== "run" || !Array.isArray(item.content)) return;
|
|
354
|
+
for (const child of item.content) {
|
|
355
|
+
if (!child || child.type !== "paraDrawing") continue;
|
|
356
|
+
const parsed = parseDataUri(child.graphic?.blipFill?.rasterImageId);
|
|
357
|
+
if (!parsed) continue;
|
|
358
|
+
const index = summary.media + 1;
|
|
359
|
+
const relId = `rIdCocoImage${index}`;
|
|
360
|
+
child.graphic.blipFill.rasterImageId = relId;
|
|
361
|
+
summary.media = index;
|
|
362
|
+
const rel = {
|
|
363
|
+
id: relId,
|
|
364
|
+
type: REL.image,
|
|
365
|
+
target: `media/image${index}.${parsed.ext}`,
|
|
366
|
+
};
|
|
367
|
+
if (sourcePart !== "/word/document.xml") rel.source = sourcePart;
|
|
368
|
+
extraParts.push({
|
|
369
|
+
name: `/word/media/image${index}.${parsed.ext}`,
|
|
370
|
+
contentType: parsed.contentType,
|
|
371
|
+
binary: parsed.data,
|
|
372
|
+
rel,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function appendMediaParts(doc, extraParts, summary) {
|
|
379
|
+
appendMediaPartsFromBlocks(doc?.content, extraParts, summary);
|
|
380
|
+
}
|
|
381
|
+
function appendHeaderFooterParts(doc, extraParts, summary) {
|
|
382
|
+
const sectPr = doc?.sectPr || doc?.sectionPr;
|
|
383
|
+
if (!sectPr || typeof sectPr !== "object") return;
|
|
384
|
+
const groups = [
|
|
385
|
+
{ key: "headerReference", part: "header", root: "hdr", contentType: CT.header, relType: REL.header },
|
|
386
|
+
{ key: "footerReference", part: "footer", root: "ftr", contentType: CT.footer, relType: REL.footer },
|
|
387
|
+
];
|
|
388
|
+
for (const group of groups) {
|
|
389
|
+
const refs = sectPr[group.key];
|
|
390
|
+
if (!refs || typeof refs !== "object") continue;
|
|
391
|
+
for (const type of ["first", "default", "even"]) {
|
|
392
|
+
const ref = refs[type];
|
|
393
|
+
const blocks = contentBlocks(ref);
|
|
394
|
+
if (!blocks) continue;
|
|
395
|
+
const index = summary[group.part] + 1;
|
|
396
|
+
summary[group.part] = index;
|
|
397
|
+
const relId = `rIdCoco${group.part[0].toUpperCase()}${group.part.slice(1)}${index}`;
|
|
398
|
+
const name = `/word/${group.part}${index}.xml`;
|
|
399
|
+
appendMediaPartsFromBlocks(blocks, extraParts, summary, name);
|
|
400
|
+
extraParts.push({
|
|
401
|
+
name,
|
|
402
|
+
contentType: group.contentType,
|
|
403
|
+
xml: wordPartXml(group.root, blocks),
|
|
404
|
+
rel: {
|
|
405
|
+
id: relId,
|
|
406
|
+
type: group.relType,
|
|
407
|
+
target: `${group.part}${index}.xml`,
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
refs[type] = { id: relId };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function withPackageLongTailParts(ooJsonDoc) {
|
|
416
|
+
const doc = cloneJson(ooJsonDoc);
|
|
417
|
+
const extraParts = [];
|
|
418
|
+
const summary = { header: 0, footer: 0, comments: 0, media: 0, footnotes: 0, endnotes: 0 };
|
|
419
|
+
appendHeaderFooterParts(doc, extraParts, summary);
|
|
420
|
+
appendCommentParts(doc, extraParts, summary);
|
|
421
|
+
appendNoteParts(doc, extraParts, summary);
|
|
422
|
+
appendMediaParts(doc, extraParts, summary);
|
|
423
|
+
return { doc, extraParts, summary };
|
|
424
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Tool 3b: ONLYOFFICE numbering definitions -> OOXML numbering.xml.
|
|
2
|
+
// Stable source: Api.GetDocument().ToJSON(false, true, true, true, true, true).
|
|
3
|
+
// The fifth argument exports the native numbering map used by paragraph pPr.numPr.
|
|
4
|
+
import { esc, attrStr } from "./xml.mjs";
|
|
5
|
+
import { extraAttr } from "./extra.mjs";
|
|
6
|
+
import { stylePpToXml, styleRpToXml } from "./styles-to-ooxml.mjs";
|
|
7
|
+
|
|
8
|
+
function valOf(v) {
|
|
9
|
+
return typeof v === "object" && v !== null ? v.val ?? v.value ?? v.type : v;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function entriesOf(obj) {
|
|
13
|
+
if (!obj || typeof obj !== "object") return [];
|
|
14
|
+
if (Array.isArray(obj)) return obj.map((v, i) => [String(v?.abstractNumId ?? v?.numId ?? i), v]);
|
|
15
|
+
return Object.keys(obj).map((k) => [k, obj[k]]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function attrVal(obj, keys) {
|
|
19
|
+
for (const key of keys) {
|
|
20
|
+
if (obj && obj[key] != null) return obj[key];
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function simpleVal(tag, value) {
|
|
26
|
+
const v = valOf(value);
|
|
27
|
+
return v == null ? "" : `<w:${tag} w:val="${esc(String(v))}"/>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function lvlToXml(lvl, fallbackIlvl) {
|
|
31
|
+
if (!lvl || typeof lvl !== "object") return "";
|
|
32
|
+
const ilvl = attrVal(lvl, ["ilvl", "level", "lvl"]);
|
|
33
|
+
const attrs = { "w:ilvl": String(ilvl ?? fallbackIlvl ?? 0) };
|
|
34
|
+
let inner = "";
|
|
35
|
+
inner += simpleVal("start", lvl.start);
|
|
36
|
+
inner += simpleVal("numFmt", lvl.numFmt);
|
|
37
|
+
inner += simpleVal("lvlRestart", lvl.lvlRestart ?? lvl.restart);
|
|
38
|
+
inner += simpleVal("pStyle", lvl.pStyle);
|
|
39
|
+
if (lvl.isLgl === true || lvl.legal === true || lvl.isLegal === true) inner += `<w:isLgl/>`;
|
|
40
|
+
inner += simpleVal("suff", lvl.suff);
|
|
41
|
+
inner += simpleVal("lvlPicBulletId", lvl.lvlPicBulletId ?? lvl.picBulletId);
|
|
42
|
+
if (lvl.lvlText != null) inner += `<w:lvlText w:val="${esc(String(lvl.lvlText))}"/>`;
|
|
43
|
+
inner += simpleVal("lvlJc", lvl.lvlJc);
|
|
44
|
+
inner += stylePpToXml(lvl.pPr);
|
|
45
|
+
inner += styleRpToXml(lvl.rPr);
|
|
46
|
+
const ex = extraAttr(lvl, [
|
|
47
|
+
"ilvl", "level", "lvl", "start", "numFmt", "lvlRestart", "restart", "pStyle", "isLgl", "legal", "isLegal", "suff", "lvlPicBulletId", "picBulletId", "lvlText", "lvlJc", "pPr", "rPr", "type",
|
|
48
|
+
]);
|
|
49
|
+
return `<w:lvl${attrStr(attrs)}${ex}>${inner}</w:lvl>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function lvlListXml(levels) {
|
|
53
|
+
if (!levels) return "";
|
|
54
|
+
if (Array.isArray(levels)) return levels.map((lvl, i) => lvlToXml(lvl, i)).join("");
|
|
55
|
+
if (typeof levels !== "object") return "";
|
|
56
|
+
return Object.keys(levels).map((key) => lvlToXml(levels[key], Number(key))).join("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function abstractNumToXml(key, def) {
|
|
60
|
+
if (!def || typeof def !== "object") return "";
|
|
61
|
+
const id = def.abstractNumId ?? def.id ?? key;
|
|
62
|
+
if (id == null || id === "") return "";
|
|
63
|
+
let inner = "";
|
|
64
|
+
inner += simpleVal("nsid", def.nsid);
|
|
65
|
+
inner += simpleVal("multiLevelType", def.multiLevelType);
|
|
66
|
+
inner += simpleVal("tmpl", def.tmpl);
|
|
67
|
+
inner += simpleVal("name", def.name);
|
|
68
|
+
inner += simpleVal("styleLink", def.styleLink);
|
|
69
|
+
inner += simpleVal("numStyleLink", def.numStyleLink);
|
|
70
|
+
inner += lvlListXml(def.lvl ?? def.levels ?? def.Ec);
|
|
71
|
+
const ex = extraAttr(def, [
|
|
72
|
+
"abstractNumId", "id", "nsid", "multiLevelType", "tmpl", "name", "styleLink", "numStyleLink", "lvl", "levels", "Ec", "type",
|
|
73
|
+
]);
|
|
74
|
+
return `<w:abstractNum w:abstractNumId="${esc(String(id))}"${ex}>${inner}</w:abstractNum>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function lvlOverrideToXml(override, fallbackIlvl) {
|
|
78
|
+
if (!override || typeof override !== "object") return "";
|
|
79
|
+
const ilvl = override.ilvl ?? override.level ?? fallbackIlvl ?? 0;
|
|
80
|
+
let inner = "";
|
|
81
|
+
inner += simpleVal("startOverride", override.startOverride);
|
|
82
|
+
inner += lvlToXml(override.lvl ?? override.levelDef, ilvl);
|
|
83
|
+
const ex = extraAttr(override, ["ilvl", "level", "startOverride", "lvl", "levelDef", "type"]);
|
|
84
|
+
return `<w:lvlOverride w:ilvl="${esc(String(ilvl))}"${ex}>${inner}</w:lvlOverride>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function lvlOverrideListXml(overrides) {
|
|
88
|
+
if (!overrides) return "";
|
|
89
|
+
if (Array.isArray(overrides)) return overrides.map((o, i) => lvlOverrideToXml(o, i)).join("");
|
|
90
|
+
if (typeof overrides !== "object") return "";
|
|
91
|
+
return Object.keys(overrides).map((key) => lvlOverrideToXml(overrides[key], Number(key))).join("");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function numToXml(key, def) {
|
|
95
|
+
if (!def || typeof def !== "object") return "";
|
|
96
|
+
const id = def.numId ?? def.id ?? key;
|
|
97
|
+
if (id == null || id === "") return "";
|
|
98
|
+
const abstractNumId = def.abstractNumId ?? def.abstractId ?? def.numStyleLink;
|
|
99
|
+
let inner = "";
|
|
100
|
+
if (abstractNumId != null) inner += `<w:abstractNumId w:val="${esc(String(abstractNumId))}"/>`;
|
|
101
|
+
inner += lvlOverrideListXml(def.lvlOverride ?? def.overrides);
|
|
102
|
+
const ex = extraAttr(def, ["numId", "id", "abstractNumId", "abstractId", "numStyleLink", "lvlOverride", "overrides", "type"]);
|
|
103
|
+
return `<w:num w:numId="${esc(String(id))}"${ex}>${inner}</w:num>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildNumberingXml(numbering) {
|
|
107
|
+
if (!numbering || typeof numbering !== "object") return undefined;
|
|
108
|
+
const abstractDefs = numbering.abstractNum ?? numbering.abstractNums;
|
|
109
|
+
const numDefs = numbering.num ?? numbering.nums;
|
|
110
|
+
const abstractXml = entriesOf(abstractDefs).map(([key, def]) => abstractNumToXml(key, def)).join("");
|
|
111
|
+
const numXml = entriesOf(numDefs).map(([key, def]) => numToXml(key, def)).join("");
|
|
112
|
+
if (!abstractXml && !numXml) return undefined;
|
|
113
|
+
return (
|
|
114
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
|
115
|
+
`<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:oo="urn:coco:oojson">` +
|
|
116
|
+
abstractXml +
|
|
117
|
+
numXml +
|
|
118
|
+
`</w:numbering>`
|
|
119
|
+
);
|
|
120
|
+
}
|