@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.
@@ -0,0 +1,1082 @@
1
+ import { fromFlatOpc } from '@coco-vexel/coco-office/pack';
2
+ import { ooxmlToJson } from './ooxml-to-json.mjs';
3
+ import { esc, kid, kidsOf, parseXml } from './xml.mjs';
4
+
5
+ const CT = {
6
+ header: 'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml',
7
+ footer: 'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml',
8
+ footnotes: 'application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml',
9
+ endnotes: 'application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml',
10
+ comments: 'application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml',
11
+ commentsExtended: 'application/vnd.ms-word.commentsExtended+xml',
12
+ };
13
+
14
+ const REL = {
15
+ header: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header',
16
+ footer: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer',
17
+ image: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
18
+ };
19
+
20
+ const GAP_DOMAIN_ORDER = ['comments', 'headersFooters', 'notes', 'bookmarks', 'media', 'drawings'];
21
+
22
+ function serializeXmlPart(part) {
23
+ if (part?._originalBytes) {
24
+ return new TextDecoder().decode(part._originalBytes);
25
+ }
26
+ if (!part?.xmlDom?.documentElement) {
27
+ throw new Error('flatOpcToOnlyOfficeWriteback: missing XML DOM for word/document.xml');
28
+ }
29
+ if (typeof XMLSerializer !== 'function') {
30
+ throw new Error(
31
+ 'flatOpcToOnlyOfficeWriteback: XMLSerializer is unavailable and part has no original bytes',
32
+ );
33
+ }
34
+ return new XMLSerializer().serializeToString(part.xmlDom.documentElement);
35
+ }
36
+
37
+ function emptyGapDomain(domain) {
38
+ return {
39
+ domain,
40
+ present: false,
41
+ count: 0,
42
+ parts: [],
43
+ status: 'pending',
44
+ };
45
+ }
46
+
47
+ function serializeOptionalXmlPart(part) {
48
+ return part ? serializeXmlPart(part) : '';
49
+ }
50
+
51
+ function textContent(node) {
52
+ if (!node) return '';
53
+ if (typeof node.text === 'string') return node.text;
54
+ return (node.kids || []).map(textContent).join('');
55
+ }
56
+
57
+ function parseCommentDefinitions(commentsXml) {
58
+ if (!commentsXml) return new Map();
59
+ const root = parseXml(commentsXml);
60
+ const comments = new Map();
61
+ for (const node of kidsOf(root, 'w:comment')) {
62
+ const id = String(node.attrs?.['w:id'] ?? '');
63
+ if (!id) continue;
64
+ comments.set(id, {
65
+ id,
66
+ author: String(node.attrs?.['w:author'] ?? ''),
67
+ date: String(node.attrs?.['w:date'] ?? ''),
68
+ text: textContent(node),
69
+ });
70
+ }
71
+ return comments;
72
+ }
73
+
74
+ function parseCommentAnchors(documentXml) {
75
+ if (!documentXml) return new Map();
76
+ const root = parseXml(documentXml);
77
+ const body = kid(root, 'w:body');
78
+ const anchors = new Map();
79
+ const active = new Map();
80
+
81
+ function visit(node, paragraphText = '') {
82
+ if (!node || typeof node !== 'object') return;
83
+ if (node.tag === 'w:p') {
84
+ const currentParagraphText = textContent(node);
85
+ for (const child of node.kids || []) visit(child, currentParagraphText);
86
+ return;
87
+ }
88
+ if (node.tag === 'w:commentRangeStart') {
89
+ const id = String(node.attrs?.['w:id'] ?? '');
90
+ if (id) active.set(id, { anchorText: '', paragraphText });
91
+ return;
92
+ }
93
+ if (node.tag === 'w:commentRangeEnd') {
94
+ const id = String(node.attrs?.['w:id'] ?? '');
95
+ if (id && active.has(id)) {
96
+ anchors.set(id, active.get(id));
97
+ active.delete(id);
98
+ }
99
+ return;
100
+ }
101
+ if (node.tag === 'w:t') {
102
+ const text = textContent(node);
103
+ for (const current of active.values()) current.anchorText += text;
104
+ return;
105
+ }
106
+ for (const child of node.kids || []) visit(child, paragraphText);
107
+ }
108
+
109
+ visit(body || root);
110
+ return anchors;
111
+ }
112
+
113
+ function buildCommentItems(pkg, documentXml) {
114
+ const commentsPart = pkg.parts.get('word/comments.xml');
115
+ if (!commentsPart) return [];
116
+ const comments = parseCommentDefinitions(serializeOptionalXmlPart(commentsPart));
117
+ const anchors = parseCommentAnchors(documentXml);
118
+ return [...comments.values()].map((comment) => {
119
+ const anchor = anchors.get(comment.id);
120
+ return {
121
+ ...comment,
122
+ anchorText: anchor?.anchorText ?? '',
123
+ paragraphText: anchor?.paragraphText ?? '',
124
+ };
125
+ });
126
+ }
127
+
128
+ function parseNoteDefinitions(pkg, partPath, notesXml, kind) {
129
+ if (!notesXml) return new Map();
130
+ const root = parseXml(inlineStoryImages(pkg, partPath, notesXml));
131
+ const childTag = kind === 'endnote' ? 'w:endnote' : 'w:footnote';
132
+ const notes = new Map();
133
+ for (const node of kidsOf(root, childTag)) {
134
+ const id = String(node.attrs?.['w:id'] ?? '');
135
+ if (!id) continue;
136
+ const type = String(node.attrs?.['w:type'] ?? '');
137
+ if (type === 'separator' || type === 'continuationSeparator') continue;
138
+ if (Number(id) < 1) continue;
139
+ const bodyKids = (node?.kids || []).filter(
140
+ (child) => child?.tag === 'w:p' || child?.tag === 'w:tbl',
141
+ );
142
+ const bodyXml = bodyKids.map((child) => serializeMiniXml(child)).join('');
143
+ notes.set(id, {
144
+ kind,
145
+ id,
146
+ type,
147
+ text: textContent(node),
148
+ bodyJson: ooxmlToJson(
149
+ `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>${bodyXml}</w:body></w:document>`,
150
+ ).doc,
151
+ });
152
+ }
153
+ return notes;
154
+ }
155
+
156
+ function parseNoteAnchors(documentXml) {
157
+ if (!documentXml) return new Map();
158
+ const root = parseXml(documentXml);
159
+ const body = kid(root, 'w:body');
160
+ const anchors = new Map();
161
+
162
+ function visitParagraph(paragraph) {
163
+ let text = '';
164
+ function visit(node) {
165
+ if (!node || typeof node !== 'object') return;
166
+ if (node.tag === 'w:t' || node.tag === 'w:delText') {
167
+ text += textContent(node);
168
+ return;
169
+ }
170
+ if (node.tag === 'w:footnoteReference' || node.tag === 'w:endnoteReference') {
171
+ const id = String(node.attrs?.['w:id'] ?? '');
172
+ const kind = node.tag === 'w:endnoteReference' ? 'endnote' : 'footnote';
173
+ if (id) anchors.set(`${kind}:${id}`, text.trim());
174
+ return;
175
+ }
176
+ for (const child of node.kids || []) visit(child);
177
+ }
178
+ visit(paragraph);
179
+ }
180
+
181
+ function visitBlocks(node) {
182
+ if (!node || typeof node !== 'object') return;
183
+ if (node.tag === 'w:p') {
184
+ visitParagraph(node);
185
+ return;
186
+ }
187
+ for (const child of node.kids || []) visitBlocks(child);
188
+ }
189
+
190
+ visitBlocks(body || root);
191
+ return anchors;
192
+ }
193
+
194
+ function buildNoteItems(pkg, documentXml) {
195
+ const anchors = parseNoteAnchors(documentXml);
196
+ const definitions = [
197
+ ...parseNoteDefinitions(
198
+ pkg,
199
+ 'word/footnotes.xml',
200
+ serializeOptionalXmlPart(pkg.parts.get('word/footnotes.xml')),
201
+ 'footnote',
202
+ ).values(),
203
+ ...parseNoteDefinitions(
204
+ pkg,
205
+ 'word/endnotes.xml',
206
+ serializeOptionalXmlPart(pkg.parts.get('word/endnotes.xml')),
207
+ 'endnote',
208
+ ).values(),
209
+ ];
210
+ return definitions.map((note) => ({
211
+ ...note,
212
+ anchorText: anchors.get(`${note.kind}:${note.id}`) ?? '',
213
+ }));
214
+ }
215
+
216
+ function parseBookmarkItems(documentXml) {
217
+ if (!documentXml) return [];
218
+ const root = parseXml(documentXml);
219
+ const body = kid(root, 'w:body');
220
+ const active = new Map();
221
+ const items = [];
222
+
223
+ function visit(node) {
224
+ if (!node || typeof node !== 'object') return;
225
+ if (node.tag === 'w:bookmarkStart') {
226
+ const id = String(node.attrs?.['w:id'] ?? '');
227
+ const name = String(node.attrs?.['w:name'] ?? '');
228
+ if (id && name && !name.startsWith('_')) {
229
+ active.set(id, { id, name, anchorText: '' });
230
+ }
231
+ return;
232
+ }
233
+ if (node.tag === 'w:bookmarkEnd') {
234
+ const id = String(node.attrs?.['w:id'] ?? '');
235
+ const bookmark = active.get(id);
236
+ if (bookmark) {
237
+ items.push({ ...bookmark, anchorText: bookmark.anchorText.trim() });
238
+ active.delete(id);
239
+ }
240
+ return;
241
+ }
242
+ if (node.tag === 'w:t' || node.tag === 'w:delText') {
243
+ const text = textContent(node);
244
+ for (const bookmark of active.values()) bookmark.anchorText += text;
245
+ return;
246
+ }
247
+ for (const child of node.kids || []) visit(child);
248
+ }
249
+
250
+ visit(body || root);
251
+ return items.filter((item) => item.name && item.anchorText);
252
+ }
253
+
254
+ function normalizePartPath(path) {
255
+ return String(path || '').replace(/^\/+/, '');
256
+ }
257
+
258
+ function resolveWordTarget(target) {
259
+ const clean = String(target || '').replace(/^\/+/, '');
260
+ if (!clean) return '';
261
+ return clean.startsWith('word/') ? clean : `word/${clean}`;
262
+ }
263
+
264
+ function parseDocumentRelationships(pkg) {
265
+ const relsPart = pkg.parts.get('word/_rels/document.xml.rels');
266
+ if (!relsPart) return new Map();
267
+ const root = parseXml(serializeOptionalXmlPart(relsPart));
268
+ const rels = new Map();
269
+ for (const rel of kidsOf(root, 'Relationship')) {
270
+ const id = String(rel.attrs?.Id ?? '');
271
+ if (!id) continue;
272
+ rels.set(id, {
273
+ id,
274
+ type: String(rel.attrs?.Type ?? ''),
275
+ target:
276
+ String(rel.attrs?.TargetMode ?? '') === 'External'
277
+ ? String(rel.attrs?.Target ?? '')
278
+ : resolveWordTarget(rel.attrs?.Target),
279
+ targetMode: String(rel.attrs?.TargetMode ?? ''),
280
+ });
281
+ }
282
+ return rels;
283
+ }
284
+
285
+ function parseHeaderFooterReferences(documentXml, rels) {
286
+ const root = parseXml(documentXml);
287
+ const body = kid(root, 'w:body');
288
+ const refs = [];
289
+
290
+ function visit(node) {
291
+ if (!node || typeof node !== 'object') return;
292
+ if (node.tag === 'w:headerReference' || node.tag === 'w:footerReference') {
293
+ const relId = String(node.attrs?.['r:id'] ?? '');
294
+ const rel = rels.get(relId);
295
+ if (rel) {
296
+ refs.push({
297
+ kind: rel.type === REL.footer ? 'footer' : 'header',
298
+ type: String(node.attrs?.['w:type'] ?? 'default'),
299
+ relId,
300
+ partPath: rel.target,
301
+ });
302
+ }
303
+ return;
304
+ }
305
+ for (const child of node.kids || []) visit(child);
306
+ }
307
+
308
+ visit(body || root);
309
+ return refs;
310
+ }
311
+
312
+ function parseSectionProperties(documentXml) {
313
+ const root = parseXml(documentXml);
314
+ const body = kid(root, 'w:body');
315
+ const sections = [];
316
+ const bodyKids = body?.kids || [];
317
+ let blockIndex = -1;
318
+
319
+ for (const block of bodyKids) {
320
+ if (!block || typeof block !== 'object') continue;
321
+ if (block.tag === 'w:sectPr') continue;
322
+ if (block.tag !== 'w:p' && block.tag !== 'w:tbl') continue;
323
+ blockIndex++;
324
+ const pPr = kid(block, 'w:pPr');
325
+ const sectPr = kid(pPr, 'w:sectPr');
326
+ if (sectPr) {
327
+ sections.push({
328
+ node: sectPr,
329
+ boundary: 'paragraph',
330
+ endBlockIndex: blockIndex,
331
+ });
332
+ }
333
+ }
334
+
335
+ const finalSectPr = kid(body, 'w:sectPr');
336
+ if (finalSectPr) {
337
+ sections.push({
338
+ node: finalSectPr,
339
+ boundary: 'body',
340
+ endBlockIndex: Math.max(0, blockIndex),
341
+ });
342
+ }
343
+
344
+ return sections;
345
+ }
346
+
347
+ function sectionReferenceMap(sectPr, rels, kind) {
348
+ const tag = kind === 'footer' ? 'w:footerReference' : 'w:headerReference';
349
+ const relType = kind === 'footer' ? REL.footer : REL.header;
350
+ const refs = new Map();
351
+ for (const node of kidsOf(sectPr, tag)) {
352
+ const type = String(node.attrs?.['w:type'] ?? 'default');
353
+ const relId = String(node.attrs?.['r:id'] ?? '');
354
+ const rel = rels.get(relId);
355
+ refs.set(type, {
356
+ kind,
357
+ type,
358
+ relId,
359
+ partPath: rel ? rel.target : '',
360
+ relFound: !!rel,
361
+ relTypeMatches: !!rel && rel.type === relType,
362
+ });
363
+ }
364
+ return refs;
365
+ }
366
+
367
+ function sectionVariantState(pkg, refs, type, sectionIndex) {
368
+ const ref = refs.get(type);
369
+ if (!ref) {
370
+ return sectionIndex === 0
371
+ ? { state: 'empty', type }
372
+ : { state: 'inherited', type, inheritedFrom: sectionIndex - 1 };
373
+ }
374
+ const partPath = normalizePartPath(ref.partPath);
375
+ const part = partPath ? pkg.parts.get(partPath) : null;
376
+ const xml = serializeOptionalXmlPart(part);
377
+ const text = part ? textContent(parseXml(xml)) : '';
378
+ return {
379
+ state: text ? 'own' : 'empty',
380
+ type,
381
+ relId: ref.relId,
382
+ partPath,
383
+ text,
384
+ bodyJson: part ? storyPartToBodyJson(pkg, partPath, xml) : undefined,
385
+ unsupportedFields: part ? collectUnsupportedStoryFields(xml) : [],
386
+ relFound: ref.relFound,
387
+ relTypeMatches: ref.relTypeMatches,
388
+ partFound: !!part,
389
+ };
390
+ }
391
+
392
+ function collectUnsupportedStoryFields(xml) {
393
+ if (!xml) return [];
394
+ const root = parseXml(xml);
395
+ const fields = [];
396
+ let index = 0;
397
+
398
+ function visit(node) {
399
+ if (!node || typeof node !== 'object') return;
400
+ if (node.tag === 'w:instrText') {
401
+ const instruction = textContent(node).trim().replace(/\s+/g, ' ');
402
+ const fieldType = instruction.split(/\s+/)[0]?.toUpperCase() ?? '';
403
+ if (['DATE', 'TIME', 'CREATEDATE', 'SAVEDATE', 'PRINTDATE'].includes(fieldType)) {
404
+ fields.push({
405
+ index: index++,
406
+ fieldType,
407
+ instruction,
408
+ unsupportedReason: 'dynamic-date-field',
409
+ });
410
+ }
411
+ }
412
+ for (const child of node.kids || []) visit(child);
413
+ }
414
+
415
+ visit(root);
416
+ return fields;
417
+ }
418
+
419
+ function storyPartToBodyJson(pkg, partPath, xml) {
420
+ if (!xml) return undefined;
421
+ const root = parseXml(inlineStoryImages(pkg, partPath, xml));
422
+ const bodyKids = (root?.kids || []).filter(
423
+ (child) => child?.tag === 'w:p' || child?.tag === 'w:tbl',
424
+ );
425
+ const bodyXml = bodyKids.map((child) => serializeMiniXml(child)).join('');
426
+ return ooxmlToJson(
427
+ `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>${bodyXml}</w:body></w:document>`,
428
+ ).doc;
429
+ }
430
+
431
+ function inlineStoryImages(pkg, partPath, xml) {
432
+ const rels = parsePartRelationships(pkg, partPath);
433
+ return String(xml).replace(/<w:drawing[\s\S]*?<\/w:drawing>/g, (drawingXml) => {
434
+ const drawing = drawingJsonFromXml(pkg, rels, drawingXml);
435
+ return drawing ? `<oo:ci oo:v="${esc(JSON.stringify(drawing))}"/>` : drawingXml;
436
+ });
437
+ }
438
+
439
+ function parsePartRelationships(pkg, partPath) {
440
+ const relsPath = relsPathForPart(partPath);
441
+ const relsPart = pkg.parts.get(relsPath);
442
+ if (!relsPart) return new Map();
443
+ const root = parseXml(serializeOptionalXmlPart(relsPart));
444
+ const rels = new Map();
445
+ for (const rel of kidsOf(root, 'Relationship')) {
446
+ const id = String(rel.attrs?.Id ?? '');
447
+ if (!id) continue;
448
+ const targetMode = String(rel.attrs?.TargetMode ?? '');
449
+ rels.set(id, {
450
+ id,
451
+ type: String(rel.attrs?.Type ?? ''),
452
+ target:
453
+ targetMode === 'External'
454
+ ? String(rel.attrs?.Target ?? '')
455
+ : resolvePartTarget(partPath, rel.attrs?.Target),
456
+ targetMode,
457
+ });
458
+ }
459
+ return rels;
460
+ }
461
+
462
+ function relsPathForPart(partPath) {
463
+ const clean = normalizePartPath(partPath);
464
+ const slash = clean.lastIndexOf('/');
465
+ return slash < 0
466
+ ? `_rels/${clean}.rels`
467
+ : `${clean.slice(0, slash)}/_rels/${clean.slice(slash + 1)}.rels`;
468
+ }
469
+
470
+ function resolvePartTarget(sourcePartPath, target) {
471
+ let value = String(target || '').trim();
472
+ if (!value) return '';
473
+ if (value.startsWith('/')) return normalizePartPath(value);
474
+ const base = normalizePartPath(sourcePartPath).split('/').slice(0, -1);
475
+ for (const segment of value.split('/')) {
476
+ if (!segment || segment === '.') continue;
477
+ if (segment === '..') base.pop();
478
+ else base.push(segment);
479
+ }
480
+ return base.join('/');
481
+ }
482
+
483
+ function drawingJsonFromXml(pkg, rels, drawingXml) {
484
+ const analysis = analyzeDrawingXml(pkg, 'unknown', 0, rels, drawingXml);
485
+ if (analysis.status !== 'inlined') return null;
486
+ const root = parseXml(drawingXml);
487
+ const extent = findNode(root, 'wp:extent') || findNode(root, 'a:ext');
488
+ const docPr = findNode(root, 'wp:docPr');
489
+ const cx = numberValue(extent?.attrs?.cx) || 1;
490
+ const cy = numberValue(extent?.attrs?.cy) || 1;
491
+ return {
492
+ type: 'paraDrawing',
493
+ docPr: {
494
+ id: numberValue(docPr?.attrs?.id) || 1,
495
+ name: String(docPr?.attrs?.name ?? 'Picture'),
496
+ descr: docPr?.attrs?.descr == null ? undefined : String(docPr.attrs.descr),
497
+ title: docPr?.attrs?.title == null ? undefined : String(docPr.attrs.title),
498
+ },
499
+ effectExtent: { l: 0, t: 0, r: 0, b: 0 },
500
+ extent: { cx, cy },
501
+ graphic: {
502
+ type: 'image',
503
+ id: '',
504
+ extX: 0,
505
+ extY: 0,
506
+ nvPicPr: {
507
+ cNvPr: {
508
+ id: numberValue(docPr?.attrs?.id) || 1,
509
+ name: String(docPr?.attrs?.name ?? 'Picture'),
510
+ descr: docPr?.attrs?.descr == null ? null : String(docPr.attrs.descr),
511
+ title: docPr?.attrs?.title == null ? null : String(docPr.attrs.title),
512
+ hidden: null,
513
+ },
514
+ nvPr: { isPhoto: null, userDrawn: null },
515
+ cNvPicPr: { picLocks: { noChangeAspect: true } },
516
+ },
517
+ blipFill: {
518
+ type: 'blipFill',
519
+ blip: [],
520
+ srcRect: null,
521
+ tile: null,
522
+ stretch: { fillRect: null },
523
+ rotWithShape: null,
524
+ rasterImageId: analysis.rasterImageId,
525
+ },
526
+ spPr: {
527
+ fill: null,
528
+ ln: null,
529
+ effectDag: null,
530
+ effectLst: null,
531
+ xfrm: {
532
+ ext: { cx, cy },
533
+ off: { x: 0, y: 0 },
534
+ flipH: null,
535
+ flipV: null,
536
+ rot: null,
537
+ chOffX: null,
538
+ chOffY: null,
539
+ chExtX: null,
540
+ chExtY: null,
541
+ },
542
+ bwMode: 0,
543
+ },
544
+ },
545
+ cNvGraphicFramePr: { graphicFrameLocks: { noChangeAspect: true } },
546
+ positionH: { relativeFrom: 'column', posOffset: 0, percent: false },
547
+ positionV: { relativeFrom: 'paragraph', posOffset: 0, percent: false },
548
+ simplePos: { x: 0, y: 0, use: false },
549
+ distB: 0,
550
+ distL: 0,
551
+ distR: 0,
552
+ distT: 0,
553
+ allowOverlap: true,
554
+ behindDoc: false,
555
+ hidden: null,
556
+ layoutInCell: true,
557
+ locked: false,
558
+ relativeHeight: 0,
559
+ wrapType: 'none',
560
+ drawingType: 'inline',
561
+ };
562
+ }
563
+
564
+ function analyzeDrawingXml(pkg, partPath, index, rels, drawingXml) {
565
+ const root = parseXml(drawingXml);
566
+ const inline = findNode(root, 'wp:inline');
567
+ const anchor = findNode(root, 'wp:anchor');
568
+ const graphicData = findNode(root, 'a:graphicData');
569
+ const blip = findNode(root, 'a:blip');
570
+ const relId = String(blip?.attrs?.['r:embed'] ?? '');
571
+ const linkRelId = String(blip?.attrs?.['r:link'] ?? '');
572
+ const effectiveRelId = relId || linkRelId;
573
+ const rel = effectiveRelId ? rels.get(effectiveRelId) : null;
574
+ const mediaPart = rel?.target ? pkg.parts.get(rel.target) : null;
575
+ const base = {
576
+ partPath: normalizePartPath(partPath),
577
+ index,
578
+ drawingType: anchor ? 'anchor' : inline ? 'inline' : 'unknown',
579
+ relationshipId: effectiveRelId,
580
+ relationshipType: rel?.type ?? '',
581
+ target: rel?.target ?? '',
582
+ targetMode: rel?.targetMode ?? '',
583
+ contentType: mediaPart?.contentType ?? '',
584
+ graphicDataUri: String(graphicData?.attrs?.uri ?? ''),
585
+ };
586
+
587
+ if (anchor) return { ...base, status: 'unsupported', unsupportedReason: 'floating-anchor' };
588
+ if (!inline)
589
+ return { ...base, status: 'unsupported', unsupportedReason: 'unknown-drawing-layout' };
590
+ const graphicReason = unsupportedGraphicReason(base.graphicDataUri, drawingXml);
591
+ if (graphicReason) return { ...base, status: 'unsupported', unsupportedReason: graphicReason };
592
+ if (!blip) return { ...base, status: 'unsupported', unsupportedReason: 'missing-blip' };
593
+ if (linkRelId || rel?.targetMode === 'External')
594
+ return { ...base, status: 'unsupported', unsupportedReason: 'external-image' };
595
+ if (!rel) return { ...base, status: 'unsupported', unsupportedReason: 'missing-relationship' };
596
+ if (rel.type && rel.type !== REL.image)
597
+ return { ...base, status: 'unsupported', unsupportedReason: 'non-image-relationship' };
598
+ if (!mediaPart?.bytes)
599
+ return { ...base, status: 'unsupported', unsupportedReason: 'missing-media-part' };
600
+ if (!String(mediaPart.contentType || '').startsWith('image/'))
601
+ return { ...base, status: 'unsupported', unsupportedReason: 'non-image-media' };
602
+ if (base.graphicDataUri && !base.graphicDataUri.includes('/picture'))
603
+ return { ...base, status: 'unsupported', unsupportedReason: 'non-picture-graphic' };
604
+
605
+ return {
606
+ ...base,
607
+ status: 'inlined',
608
+ rasterImageId: `data:${mediaPart.contentType};base64,${bytesToBase64(mediaPart.bytes)}`,
609
+ };
610
+ }
611
+
612
+ function unsupportedGraphicReason(graphicDataUri, drawingXml) {
613
+ const uri = String(graphicDataUri || '');
614
+ const xml = String(drawingXml || '');
615
+ if (!uri || uri.includes('/picture')) return '';
616
+ if (uri.includes('/chart')) return 'chart-drawing';
617
+ if (uri.includes('/diagram')) return 'diagram-drawing';
618
+ if (xml.includes(':txbx') || xml.includes('<w:txbxContent')) return 'text-box-drawing';
619
+ if (uri.includes('wordprocessingShape') || uri.includes('/shape')) return 'shape-drawing';
620
+ return 'non-picture-graphic';
621
+ }
622
+
623
+ function findNode(node, tag) {
624
+ if (!node || typeof node !== 'object') return null;
625
+ if (node.tag === tag) return node;
626
+ for (const child of node.kids || []) {
627
+ const found = findNode(child, tag);
628
+ if (found) return found;
629
+ }
630
+ return null;
631
+ }
632
+
633
+ function numberValue(value) {
634
+ const n = Number(value);
635
+ return Number.isFinite(n) ? n : undefined;
636
+ }
637
+
638
+ function bytesToBase64(bytes) {
639
+ if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
640
+ let binary = '';
641
+ for (const byte of bytes) binary += String.fromCharCode(byte);
642
+ return btoa(binary);
643
+ }
644
+
645
+ function serializeMiniXml(node) {
646
+ if (!node || typeof node !== 'object') return '';
647
+ if (typeof node.text === 'string')
648
+ return node.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
649
+ const attrs = Object.entries(node.attrs || {})
650
+ .map(
651
+ ([key, value]) => ` ${key}="${String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"`,
652
+ )
653
+ .join('');
654
+ const kids = (node.kids || []).map(serializeMiniXml).join('');
655
+ return `<${node.tag}${attrs}>${kids}</${node.tag}>`;
656
+ }
657
+
658
+ function numberAttr(node, name) {
659
+ if (!node?.attrs || node.attrs[name] == null) return undefined;
660
+ const value = Number(node.attrs[name]);
661
+ return Number.isFinite(value) ? value : undefined;
662
+ }
663
+
664
+ function parsePageSize(sectPr) {
665
+ const pgSz = kid(sectPr, 'w:pgSz');
666
+ if (!pgSz) return undefined;
667
+ const width = numberAttr(pgSz, 'w:w');
668
+ const height = numberAttr(pgSz, 'w:h');
669
+ if (width == null && height == null) return undefined;
670
+ return {
671
+ width,
672
+ height,
673
+ orient: String(pgSz.attrs?.['w:orient'] ?? ''),
674
+ };
675
+ }
676
+
677
+ function parsePageMargins(sectPr) {
678
+ const pgMar = kid(sectPr, 'w:pgMar');
679
+ if (!pgMar) return undefined;
680
+ const margins = {
681
+ top: numberAttr(pgMar, 'w:top'),
682
+ right: numberAttr(pgMar, 'w:right'),
683
+ bottom: numberAttr(pgMar, 'w:bottom'),
684
+ left: numberAttr(pgMar, 'w:left'),
685
+ header: numberAttr(pgMar, 'w:header'),
686
+ footer: numberAttr(pgMar, 'w:footer'),
687
+ gutter: numberAttr(pgMar, 'w:gutter'),
688
+ };
689
+ return Object.values(margins).some((value) => value != null) ? margins : undefined;
690
+ }
691
+
692
+ function parsePageNumbering(sectPr) {
693
+ const pgNumType = kid(sectPr, 'w:pgNumType');
694
+ if (!pgNumType) return undefined;
695
+ const start = numberAttr(pgNumType, 'w:start');
696
+ const numbering = {
697
+ start,
698
+ fmt: String(pgNumType.attrs?.['w:fmt'] ?? ''),
699
+ chapStyle: String(pgNumType.attrs?.['w:chapStyle'] ?? ''),
700
+ chapSep: String(pgNumType.attrs?.['w:chapSep'] ?? ''),
701
+ };
702
+ return Object.values(numbering).some((value) => value !== '' && value != null)
703
+ ? numbering
704
+ : undefined;
705
+ }
706
+
707
+ function parseColumns(sectPr) {
708
+ const cols = kid(sectPr, 'w:cols');
709
+ if (!cols) return undefined;
710
+ const colNodes = kidsOf(cols, 'w:col');
711
+ const col = colNodes.map((node) => ({
712
+ width: numberAttr(node, 'w:w'),
713
+ space: numberAttr(node, 'w:space'),
714
+ }));
715
+ const columns = {
716
+ equalWidth: String(cols.attrs?.['w:equalWidth'] ?? '1') !== '0',
717
+ count: numberAttr(cols, 'w:num'),
718
+ space: numberAttr(cols, 'w:space'),
719
+ sep: String(cols.attrs?.['w:sep'] ?? ''),
720
+ col,
721
+ };
722
+ return columns.count != null || columns.space != null || col.length > 0 ? columns : undefined;
723
+ }
724
+
725
+ function buildHeaderFooterSections(pkg, documentXml) {
726
+ const rels = parseDocumentRelationships(pkg);
727
+ const sections = parseSectionProperties(documentXml);
728
+ const types = ['default', 'first', 'even'];
729
+ return sections.map((section, sectionIndex) => {
730
+ const sectPr = section.node;
731
+ const headerRefs = sectionReferenceMap(sectPr, rels, 'header');
732
+ const footerRefs = sectionReferenceMap(sectPr, rels, 'footer');
733
+ const previousEndBlockIndex = sectionIndex > 0 ? sections[sectionIndex - 1].endBlockIndex : -1;
734
+ return {
735
+ index: sectionIndex,
736
+ startBlockIndex: Math.max(0, previousEndBlockIndex + 1),
737
+ endBlockIndex: section.endBlockIndex,
738
+ boundary: section.boundary,
739
+ type: String(kid(sectPr, 'w:type')?.attrs?.['w:val'] ?? 'nextPage'),
740
+ titlePage: !!kid(sectPr, 'w:titlePg'),
741
+ pageSize: parsePageSize(sectPr),
742
+ pageMargins: parsePageMargins(sectPr),
743
+ pageNumbering: parsePageNumbering(sectPr),
744
+ columns: parseColumns(sectPr),
745
+ headers: Object.fromEntries(
746
+ types.map((type) => [type, sectionVariantState(pkg, headerRefs, type, sectionIndex)]),
747
+ ),
748
+ footers: Object.fromEntries(
749
+ types.map((type) => [type, sectionVariantState(pkg, footerRefs, type, sectionIndex)]),
750
+ ),
751
+ };
752
+ });
753
+ }
754
+
755
+ function buildHeaderFooterItems(pkg, documentXml) {
756
+ const rels = parseDocumentRelationships(pkg);
757
+ const refs = parseHeaderFooterReferences(documentXml, rels);
758
+ const seen = new Set();
759
+ const items = [];
760
+ for (const ref of refs) {
761
+ const key = `${ref.kind}|${ref.type}|${ref.partPath}`;
762
+ if (seen.has(key)) continue;
763
+ seen.add(key);
764
+ const part = pkg.parts.get(normalizePartPath(ref.partPath));
765
+ if (!part) continue;
766
+ items.push({
767
+ kind: ref.kind,
768
+ type: ref.type,
769
+ partPath: normalizePartPath(ref.partPath),
770
+ text: textContent(parseXml(serializeOptionalXmlPart(part))),
771
+ });
772
+ }
773
+ return items;
774
+ }
775
+
776
+ function stripCommentMarkers(value) {
777
+ if (Array.isArray(value)) {
778
+ return value
779
+ .filter(
780
+ (item) =>
781
+ !['commentRangeStart', 'commentRangeEnd', 'commentReference'].includes(item?.type),
782
+ )
783
+ .map(stripCommentMarkers);
784
+ }
785
+ if (!value || typeof value !== 'object') return value;
786
+ const out = {};
787
+ for (const [key, child] of Object.entries(value)) {
788
+ out[key] = stripCommentMarkers(child);
789
+ }
790
+ return out;
791
+ }
792
+
793
+ function stripBookmarkMarkers(value) {
794
+ if (Array.isArray(value)) {
795
+ return value
796
+ .filter((item) => !['bookmarkStart', 'bookmarkEnd'].includes(item?.type))
797
+ .map(stripBookmarkMarkers);
798
+ }
799
+ if (!value || typeof value !== 'object') return value;
800
+ const out = {};
801
+ for (const [key, child] of Object.entries(value)) {
802
+ out[key] = stripBookmarkMarkers(child);
803
+ }
804
+ return out;
805
+ }
806
+
807
+ function addGapPart(gaps, domain, part) {
808
+ const gap = gaps[domain];
809
+ gap.present = true;
810
+ gap.count += 1;
811
+ gap.parts.push({
812
+ path: part.path,
813
+ contentType: part.contentType,
814
+ });
815
+ }
816
+
817
+ function drawingXmlFragments(xml) {
818
+ return [...String(xml || '').matchAll(/<w:drawing[\s\S]*?<\/w:drawing>/g)].map(
819
+ (match) => match[0],
820
+ );
821
+ }
822
+
823
+ function isStoryXmlPart(part) {
824
+ return (
825
+ part?.path === 'word/document.xml' ||
826
+ part?.contentType === CT.header ||
827
+ part?.contentType === CT.footer ||
828
+ part?.contentType === CT.footnotes ||
829
+ part?.contentType === CT.endnotes
830
+ );
831
+ }
832
+
833
+ function collectDrawingItems(pkg) {
834
+ const items = [];
835
+ for (const part of pkg.parts.values()) {
836
+ if (!isStoryXmlPart(part)) continue;
837
+ const partPath = normalizePartPath(part.path);
838
+ const rels = parsePartRelationships(pkg, partPath);
839
+ const xml = serializeOptionalXmlPart(part);
840
+ drawingXmlFragments(xml).forEach((drawingXml, index) => {
841
+ items.push(analyzeDrawingXml(pkg, partPath, index, rels, drawingXml));
842
+ });
843
+ }
844
+ return items;
845
+ }
846
+
847
+ function stripInternalDiagnosticFields(item) {
848
+ const { rasterImageId, ...publicItem } = item;
849
+ return publicItem;
850
+ }
851
+
852
+ function enrichMediaAndDrawingGaps(pkg, gaps) {
853
+ const drawingItems = collectDrawingItems(pkg);
854
+ if (drawingItems.length > 0) {
855
+ gaps.drawings.present = true;
856
+ gaps.drawings.count = drawingItems.length;
857
+ gaps.drawings.items = drawingItems.map(stripInternalDiagnosticFields);
858
+ gaps.drawings.inlinedCount = drawingItems.filter((item) => item.status === 'inlined').length;
859
+ gaps.drawings.unsupportedCount = drawingItems.filter(
860
+ (item) => item.status === 'unsupported',
861
+ ).length;
862
+ gaps.drawings.status = gaps.drawings.unsupportedCount > 0 ? 'pending' : 'payload-ready';
863
+ }
864
+
865
+ const mediaItems = [...pkg.parts.values()]
866
+ .filter((part) => part.path.startsWith('word/media/'))
867
+ .map((part) => {
868
+ const partPath = normalizePartPath(part.path);
869
+ const references = drawingItems.filter((item) => item.target === partPath);
870
+ const unsupportedReferences = references.filter((item) => item.status === 'unsupported');
871
+ const inlinedReferences = references.filter((item) => item.status === 'inlined');
872
+ return {
873
+ path: partPath,
874
+ contentType: part.contentType,
875
+ status:
876
+ references.length === 0
877
+ ? 'unreferenced'
878
+ : unsupportedReferences.length > 0
879
+ ? 'referenced-by-unsupported-drawing'
880
+ : 'inlined',
881
+ references: references.map((item) => ({
882
+ partPath: item.partPath,
883
+ index: item.index,
884
+ drawingType: item.drawingType,
885
+ status: item.status,
886
+ unsupportedReason: item.unsupportedReason,
887
+ })),
888
+ inlinedReferences: inlinedReferences.length,
889
+ unsupportedReferences: unsupportedReferences.length,
890
+ };
891
+ });
892
+
893
+ if (mediaItems.length > 0) {
894
+ gaps.media.items = mediaItems;
895
+ gaps.media.inlinedCount = mediaItems.filter((item) => item.status === 'inlined').length;
896
+ gaps.media.unsupportedCount = mediaItems.filter((item) => item.status !== 'inlined').length;
897
+ gaps.media.status = gaps.media.unsupportedCount > 0 ? 'pending' : 'payload-ready';
898
+ }
899
+ }
900
+
901
+ function gapHasUnsupported(domain, gap) {
902
+ if (!gap.present) return false;
903
+ if (domain === 'media' || domain === 'drawings') return Number(gap.unsupportedCount || 0) > 0;
904
+ return true;
905
+ }
906
+
907
+ function buildUnsupportedItems(gaps) {
908
+ const items = [];
909
+ for (const drawing of gaps.drawings.items || []) {
910
+ if (drawing.status !== 'unsupported') continue;
911
+ items.push({
912
+ domain: 'drawings',
913
+ partPath: drawing.partPath,
914
+ index: drawing.index,
915
+ status: drawing.status,
916
+ reason: drawing.unsupportedReason,
917
+ drawingType: drawing.drawingType,
918
+ relationshipId: drawing.relationshipId,
919
+ target: drawing.target,
920
+ targetMode: drawing.targetMode,
921
+ contentType: drawing.contentType,
922
+ graphicDataUri: drawing.graphicDataUri,
923
+ });
924
+ }
925
+ for (const media of gaps.media.items || []) {
926
+ if (media.status === 'inlined') continue;
927
+ items.push({
928
+ domain: 'media',
929
+ partPath: media.path,
930
+ status: media.status,
931
+ reason: media.status,
932
+ contentType: media.contentType,
933
+ references: media.references,
934
+ });
935
+ }
936
+ for (const section of gaps.headersFooters.sections || []) {
937
+ const numbering = section.pageNumbering;
938
+ if (numbering && (numbering.fmt || numbering.chapStyle || numbering.chapSep)) {
939
+ items.push({
940
+ domain: 'headersFooters',
941
+ index: section.index,
942
+ status: 'unsupported',
943
+ reason: 'page-numbering-format',
944
+ property: 'pageNumbering',
945
+ value: {
946
+ fmt: numbering.fmt,
947
+ chapStyle: numbering.chapStyle,
948
+ chapSep: numbering.chapSep,
949
+ },
950
+ });
951
+ }
952
+ for (const kind of ['headers', 'footers']) {
953
+ for (const slot of Object.values(section[kind] || {})) {
954
+ for (const field of slot.unsupportedFields || []) {
955
+ items.push({
956
+ domain: 'headersFooters',
957
+ partPath: slot.partPath,
958
+ index: field.index,
959
+ status: 'unsupported',
960
+ reason: field.unsupportedReason,
961
+ fieldType: field.fieldType,
962
+ instruction: field.instruction,
963
+ });
964
+ }
965
+ }
966
+ }
967
+ }
968
+ return items;
969
+ }
970
+
971
+ function unsupportedSummary(items) {
972
+ if (items.length === 0) return '';
973
+ const counts = new Map();
974
+ for (const item of items) {
975
+ const key = `${item.domain}:${item.reason || item.status || 'unsupported'}`;
976
+ counts.set(key, (counts.get(key) || 0) + 1);
977
+ }
978
+ return [...counts.entries()].map(([key, count]) => `${key}=${count}`).join(', ');
979
+ }
980
+
981
+ function inspectPackage(pkg) {
982
+ const gaps = Object.fromEntries(
983
+ GAP_DOMAIN_ORDER.map((domain) => [domain, emptyGapDomain(domain)]),
984
+ );
985
+
986
+ for (const part of pkg.parts.values()) {
987
+ if (part.contentType === CT.comments) addGapPart(gaps, 'comments', part);
988
+ if (part.contentType === CT.commentsExtended) addGapPart(gaps, 'comments', part);
989
+ if (part.contentType === CT.header || part.contentType === CT.footer)
990
+ addGapPart(gaps, 'headersFooters', part);
991
+ if (part.contentType === CT.footnotes || part.contentType === CT.endnotes)
992
+ addGapPart(gaps, 'notes', part);
993
+ if (part.path.startsWith('word/media/')) addGapPart(gaps, 'media', part);
994
+ if (part.path.startsWith('word/drawings/') || part.path.includes('/drawings/'))
995
+ addGapPart(gaps, 'drawings', part);
996
+ }
997
+ enrichMediaAndDrawingGaps(pkg, gaps);
998
+
999
+ const unsupportedDomains = GAP_DOMAIN_ORDER.filter((domain) =>
1000
+ gapHasUnsupported(domain, gaps[domain]),
1001
+ );
1002
+ return { gaps, unsupportedDomains };
1003
+ }
1004
+
1005
+ /**
1006
+ * Convert a complete after-state Flat OPC package into the OnlyOffice JSON body
1007
+ * payload consumed by `Api.FromJSON`.
1008
+ *
1009
+ * This adapter is the writeback translation seam:
1010
+ * Flat OPC -> core package model -> oojson body JSON + gap diagnostics.
1011
+ *
1012
+ * The OnlyOffice host must treat the returned data as an execution plan and
1013
+ * must not parse OOXML itself.
1014
+ */
1015
+ export function flatOpcToOnlyOfficeWriteback(flatOpc, options = {}) {
1016
+ const pkg = fromFlatOpc(flatOpc);
1017
+ const documentPart = pkg.parts.get('word/document.xml');
1018
+ const documentXml = serializeXmlPart(documentPart);
1019
+ const { gaps, unsupportedDomains } = inspectPackage(pkg);
1020
+ const commentItems = buildCommentItems(pkg, documentXml);
1021
+ if (commentItems.length > 0) {
1022
+ gaps.comments.items = commentItems;
1023
+ gaps.comments.status = 'payload-ready';
1024
+ }
1025
+ const noteItems = buildNoteItems(pkg, documentXml);
1026
+ if (noteItems.length > 0) {
1027
+ gaps.notes.items = noteItems;
1028
+ gaps.notes.status = 'payload-ready';
1029
+ }
1030
+ const bookmarkItems = parseBookmarkItems(documentXml);
1031
+ if (bookmarkItems.length > 0) {
1032
+ gaps.bookmarks.present = true;
1033
+ gaps.bookmarks.count = bookmarkItems.length;
1034
+ gaps.bookmarks.items = bookmarkItems;
1035
+ gaps.bookmarks.status = 'payload-ready';
1036
+ }
1037
+ const headerFooterItems = buildHeaderFooterItems(pkg, documentXml);
1038
+ const headerFooterSections = buildHeaderFooterSections(pkg, documentXml);
1039
+ if (headerFooterItems.length > 0) {
1040
+ gaps.headersFooters.items = headerFooterItems;
1041
+ gaps.headersFooters.status = 'payload-ready';
1042
+ }
1043
+ if (headerFooterSections.length > 0) {
1044
+ gaps.headersFooters.sections = headerFooterSections;
1045
+ gaps.headersFooters.status = 'payload-ready';
1046
+ }
1047
+ const warnings = [];
1048
+
1049
+ if (unsupportedDomains.length > 0) {
1050
+ warnings.push(
1051
+ `OnlyOffice writeback adapter produced bodyJson only; gap domains pending rehydration: ${unsupportedDomains.join(', ')}`,
1052
+ );
1053
+ }
1054
+ const unsupportedItems = buildUnsupportedItems(gaps);
1055
+ const unsupportedItemSummary = unsupportedSummary(unsupportedItems);
1056
+ if (unsupportedItemSummary) {
1057
+ warnings.push(`OnlyOffice writeback unsupported items: ${unsupportedItemSummary}`);
1058
+ }
1059
+
1060
+ const bodyDocumentXml = inlineStoryImages(pkg, 'word/document.xml', documentXml);
1061
+ const bodyJson = stripBookmarkMarkers(
1062
+ stripCommentMarkers(
1063
+ ooxmlToJson(bodyDocumentXml, undefined, { relationships: parseDocumentRelationships(pkg) })
1064
+ .doc,
1065
+ ),
1066
+ );
1067
+ if (options.failOnUnsupported === true && unsupportedDomains.length > 0) {
1068
+ throw new Error(
1069
+ `OnlyOffice writeback has unsupported gap domains: ${unsupportedDomains.join(', ')}`,
1070
+ );
1071
+ }
1072
+
1073
+ return {
1074
+ bodyJson,
1075
+ gaps,
1076
+ diagnostics: {
1077
+ warnings,
1078
+ unsupportedDomains,
1079
+ unsupportedItems,
1080
+ },
1081
+ };
1082
+ }