@beyondwork/docx-react-component 1.0.11 → 1.0.13

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.
Files changed (40) hide show
  1. package/README.md +8 -2
  2. package/package.json +35 -21
  3. package/src/api/public-types.ts +103 -1
  4. package/src/core/commands/formatting-commands.ts +742 -0
  5. package/src/core/commands/image-commands.ts +84 -2
  6. package/src/core/commands/structural-helpers.ts +309 -0
  7. package/src/core/commands/table-structure-commands.ts +721 -0
  8. package/src/core/commands/text-commands.ts +166 -1
  9. package/src/core/state/editor-state.ts +318 -9
  10. package/src/formats/xlsx/io/parse-sheet.ts +177 -7
  11. package/src/formats/xlsx/io/parse-styles.ts +2 -0
  12. package/src/formats/xlsx/io/xlsx-session.ts +18 -12
  13. package/src/formats/xlsx/model/sheet.ts +81 -1
  14. package/src/formats/xlsx/model/workbook.ts +10 -6
  15. package/src/io/docx-session.ts +392 -22
  16. package/src/io/export/export-session.ts +55 -0
  17. package/src/io/export/serialize-footnotes.ts +5 -20
  18. package/src/io/export/serialize-headers-footers.ts +5 -31
  19. package/src/io/export/serialize-main-document.ts +78 -5
  20. package/src/io/normalize/normalize-text.ts +90 -1
  21. package/src/io/ooxml/parse-footnotes.ts +68 -5
  22. package/src/io/ooxml/parse-headers-footers.ts +67 -9
  23. package/src/io/ooxml/parse-main-document.ts +169 -6
  24. package/src/io/opc/package-reader.ts +3 -3
  25. package/src/io/source-package-provenance.ts +241 -0
  26. package/src/model/canonical-document.ts +450 -2
  27. package/src/model/cds-1.0.0.ts +5 -2
  28. package/src/model/snapshot.ts +190 -19
  29. package/src/preservation/package-preservation.ts +0 -7
  30. package/src/runtime/document-runtime.ts +7 -1
  31. package/src/runtime/read-only-diagnostics-runtime.ts +1 -1
  32. package/src/runtime/surface-projection.ts +200 -17
  33. package/src/runtime/table-commands.ts +79 -0
  34. package/src/runtime/table-schema.ts +9 -0
  35. package/src/ui/WordReviewEditor.tsx +708 -16
  36. package/src/ui-tailwind/editor-surface/pm-schema.ts +121 -5
  37. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +73 -7
  38. package/src/ui-tailwind/editor-surface/search-plugin.ts +76 -16
  39. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +162 -14
  40. package/src/validation/compatibility-engine.ts +208 -0
@@ -26,6 +26,9 @@ import { readOpcPackage, type OpcPackage } from "./opc/package-reader.ts";
26
26
  import { parseMainDocumentXml } from "./ooxml/parse-main-document.ts";
27
27
  import { normalizeParsedTextDocument } from "./normalize/normalize-text.ts";
28
28
  import {
29
+ CONTENT_TYPES_PATH,
30
+ PACKAGE_RELATIONSHIPS_PATH,
31
+ getRelationshipsPartPath,
29
32
  normalizePartPath,
30
33
  resolveRelationshipTarget,
31
34
  type OpcRelationship,
@@ -58,6 +61,7 @@ import {
58
61
  type ImportDiagnosticsResult,
59
62
  } from "../validation/import-diagnostics.ts";
60
63
  import type {
64
+ BlockNode,
61
65
  FootnoteCollection,
62
66
  HeaderDocument,
63
67
  FooterDocument,
@@ -97,6 +101,7 @@ import {
97
101
  WORD_FOOTNOTES_CONTENT_TYPE,
98
102
  WORD_ENDNOTES_CONTENT_TYPE,
99
103
  } from "./export/serialize-footnotes.ts";
104
+ import { createPersistedSourcePackage } from "./source-package-provenance.ts";
100
105
 
101
106
  const MAIN_DOCUMENT_PATH = "/word/document.xml";
102
107
  const NUMBERING_PART_PATH = "/word/numbering.xml";
@@ -104,8 +109,14 @@ const COMMENTS_PART_PATH = "/word/comments.xml";
104
109
  const COMMENTS_EXTENDED_PART_PATH = "/word/commentsExtended.xml";
105
110
  const COMMENTS_IDS_PART_PATH = "/word/commentsIds.xml";
106
111
  const PEOPLE_PART_PATH = "/word/people.xml";
112
+ const APP_PROPERTIES_PART_PATH = "/docProps/app.xml";
113
+ const CORE_PROPERTIES_PART_PATH = "/docProps/core.xml";
107
114
  const MAIN_DOCUMENT_CONTENT_TYPE =
108
115
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
116
+ const APP_PROPERTIES_CONTENT_TYPE =
117
+ "application/vnd.openxmlformats-officedocument.extended-properties+xml";
118
+ const CORE_PROPERTIES_CONTENT_TYPE =
119
+ "application/vnd.openxmlformats-package.core-properties+xml";
109
120
  const NUMBERING_RELATIONSHIP_TYPE =
110
121
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
111
122
  const COMMENTS_CONTENT_TYPE =
@@ -124,6 +135,12 @@ const COMMENTS_IDS_RELATIONSHIP_TYPE =
124
135
  "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds";
125
136
  const PEOPLE_RELATIONSHIP_TYPE =
126
137
  "http://schemas.microsoft.com/office/2011/relationships/people";
138
+ const APP_PROPERTIES_RELATIONSHIP_TYPE =
139
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
140
+ const CORE_PROPERTIES_RELATIONSHIP_TYPE =
141
+ "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
142
+ const OFFICE_DOCUMENT_RELATIONSHIP_TYPE =
143
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument";
127
144
  const HEADER_RELATIONSHIP_TYPE =
128
145
  "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
129
146
  const FOOTER_RELATIONSHIP_TYPE =
@@ -155,6 +172,7 @@ export interface LoadedDocxEditorSession {
155
172
  interface ImportedDocxState {
156
173
  sourceBytes: Uint8Array;
157
174
  sourcePackage: OpcPackage;
175
+ sourceDocumentPartPath: string;
158
176
  sourceDocumentRelationships: readonly OpcRelationship[];
159
177
  sourceDocumentAttributes: Record<string, string>;
160
178
  sourceNumberingPartPath?: string;
@@ -216,7 +234,11 @@ export function loadDocxEditorSession(
216
234
  );
217
235
  }
218
236
 
219
- const brokenRelationshipIssues = collectBrokenInternalRelationshipIssues(sourcePackage);
237
+ const mainDocumentPath = resolveMainDocumentPartPath(sourcePackage);
238
+ const brokenRelationshipIssues = collectBrokenInternalRelationshipIssues(
239
+ sourcePackage,
240
+ mainDocumentPath,
241
+ );
220
242
  if (brokenRelationshipIssues.length > 0) {
221
243
  return createDiagnosticsSession(
222
244
  options,
@@ -233,8 +255,7 @@ export function loadDocxEditorSession(
233
255
  );
234
256
  }
235
257
 
236
- const documentPart = sourcePackage.parts.get(MAIN_DOCUMENT_PATH);
237
- if (!documentPart) {
258
+ if (!mainDocumentPath) {
238
259
  return createDiagnosticsSession(
239
260
  options,
240
261
  createPackageImportDiagnostics({
@@ -243,11 +264,30 @@ export function loadDocxEditorSession(
243
264
  );
244
265
  }
245
266
 
267
+ const documentPart = sourcePackage.parts.get(mainDocumentPath);
268
+ if (!documentPart) {
269
+ return createDiagnosticsSession(
270
+ options,
271
+ createPackageImportDiagnostics({
272
+ issue: createMissingPartIssue(mainDocumentPath),
273
+ }),
274
+ );
275
+ }
276
+ if (documentPart.contentType !== MAIN_DOCUMENT_CONTENT_TYPE) {
277
+ return createDiagnosticsSession(
278
+ options,
279
+ createValidationImportDiagnostics({
280
+ message: `DOCX main document part ${mainDocumentPath} must use content type ${MAIN_DOCUMENT_CONTENT_TYPE}.`,
281
+ }),
282
+ );
283
+ }
284
+
246
285
  try {
247
286
  const sourceDocumentXml = decodeUtf8(documentPart.bytes);
248
287
  const importedRevisions = parseRevisionsFromDocumentXml(sourceDocumentXml);
249
288
  const numberingPartPath = resolveDocumentRelatedPartPath(
250
289
  sourcePackage,
290
+ mainDocumentPath,
251
291
  documentPart.relationships,
252
292
  NUMBERING_RELATIONSHIP_TYPE,
253
293
  NUMBERING_PART_PATH,
@@ -262,27 +302,34 @@ export function loadDocxEditorSession(
262
302
  sourceDocumentXml,
263
303
  documentPart.relationships,
264
304
  mediaParts,
265
- MAIN_DOCUMENT_PATH,
305
+ mainDocumentPath,
266
306
  );
267
307
  const normalizedDocument = normalizeParsedTextDocument(
268
308
  parsedDocument,
269
- MAIN_DOCUMENT_PATH,
309
+ mainDocumentPath,
310
+ );
311
+ const commentsPartPath = resolveCommentsPartPath(
312
+ sourcePackage,
313
+ mainDocumentPath,
314
+ documentPart.relationships,
270
315
  );
271
- const commentsPartPath = resolveCommentsPartPath(sourcePackage, documentPart.relationships);
272
316
  const commentsExtendedPartPath = resolveDocumentRelatedPartPath(
273
317
  sourcePackage,
318
+ mainDocumentPath,
274
319
  documentPart.relationships,
275
320
  COMMENTS_EXTENDED_RELATIONSHIP_TYPE,
276
321
  COMMENTS_EXTENDED_PART_PATH,
277
322
  );
278
323
  const commentsIdsPartPath = resolveDocumentRelatedPartPath(
279
324
  sourcePackage,
325
+ mainDocumentPath,
280
326
  documentPart.relationships,
281
327
  COMMENTS_IDS_RELATIONSHIP_TYPE,
282
328
  COMMENTS_IDS_PART_PATH,
283
329
  );
284
330
  const peoplePartPath = resolveDocumentRelatedPartPath(
285
331
  sourcePackage,
332
+ mainDocumentPath,
286
333
  documentPart.relationships,
287
334
  PEOPLE_RELATIONSHIP_TYPE,
288
335
  PEOPLE_PART_PATH,
@@ -344,7 +391,7 @@ export function loadDocxEditorSession(
344
391
  continue;
345
392
  }
346
393
 
347
- const partPath = resolveRelationshipTarget(MAIN_DOCUMENT_PATH, relationship);
394
+ const partPath = resolveRelationshipTarget(mainDocumentPath, relationship);
348
395
  const partBytes = sourcePackage.parts.get(partPath)?.bytes;
349
396
  if (!partBytes) {
350
397
  continue;
@@ -374,6 +421,7 @@ export function loadDocxEditorSession(
374
421
 
375
422
  const footnotesPartPath = resolveDocumentRelatedPartPath(
376
423
  sourcePackage,
424
+ mainDocumentPath,
377
425
  documentPart.relationships,
378
426
  FOOTNOTES_RELATIONSHIP_TYPE,
379
427
  FOOTNOTES_PART_PATH,
@@ -383,6 +431,7 @@ export function loadDocxEditorSession(
383
431
  )?.id;
384
432
  const endnotesPartPath = resolveDocumentRelatedPartPath(
385
433
  sourcePackage,
434
+ mainDocumentPath,
386
435
  documentPart.relationships,
387
436
  ENDNOTES_RELATIONSHIP_TYPE,
388
437
  ENDNOTES_PART_PATH,
@@ -409,7 +458,7 @@ export function loadDocxEditorSession(
409
458
  r.targetMode === "internal",
410
459
  );
411
460
  const themePartPath = themeRelationship
412
- ? resolveRelationshipTarget(MAIN_DOCUMENT_PATH, themeRelationship)
461
+ ? resolveRelationshipTarget(mainDocumentPath, themeRelationship)
413
462
  : undefined;
414
463
  const parsedTheme =
415
464
  themePartPath && sourcePackage.parts.has(themePartPath)
@@ -444,6 +493,7 @@ export function loadDocxEditorSession(
444
493
  packageParts: {
445
494
  ...normalizedDocument.preservation.packageParts,
446
495
  ...collectPreservedPackageParts(sourcePackage, [
496
+ mainDocumentPath,
447
497
  numberingPartPath,
448
498
  commentsPartPath,
449
499
  commentsExtendedPartPath,
@@ -454,6 +504,7 @@ export function loadDocxEditorSession(
454
504
  },
455
505
  diagnostics: {
456
506
  warnings: [
507
+ ...createBrokenRelationshipWarnings(sourcePackage, mainDocumentPath),
457
508
  ...normalizedDocument.diagnostics.warnings,
458
509
  ...normalizedRevisions.diagnostics.map((diagnostic, index) => ({
459
510
  diagnosticId: `diagnostic:revision-import-${index + 1}`,
@@ -485,10 +536,12 @@ export function loadDocxEditorSession(
485
536
  timestamp,
486
537
  document,
487
538
  compatibility: toPublicCompatibilityReport(compatibility),
539
+ sourcePackage: createPersistedSourcePackage(sourceBytes, options.sourceLabel),
488
540
  });
489
541
  const importedState: ImportedDocxState = {
490
542
  sourceBytes: new Uint8Array(sourceBytes),
491
543
  sourcePackage,
544
+ sourceDocumentPartPath: mainDocumentPath,
492
545
  sourceDocumentRelationships: documentPart.relationships,
493
546
  sourceDocumentAttributes: extractDocumentRootAttributes(sourceDocumentXml),
494
547
  sourceNumberingPartPath: numberingPartPath,
@@ -736,7 +789,9 @@ function exportDocxEditorSession(
736
789
  }
737
790
 
738
791
  const exportSession = createExportSession(state.sourcePackage, [
739
- MAIN_DOCUMENT_PATH,
792
+ state.sourceDocumentPartPath,
793
+ APP_PROPERTIES_PART_PATH,
794
+ CORE_PROPERTIES_PART_PATH,
740
795
  numberingPartPath,
741
796
  commentsPartPath,
742
797
  commentsExtendedPartPath,
@@ -746,7 +801,7 @@ function exportDocxEditorSession(
746
801
  ]);
747
802
 
748
803
  exportSession.replaceOwnedPart({
749
- path: MAIN_DOCUMENT_PATH,
804
+ path: state.sourceDocumentPartPath,
750
805
  bytes: new TextEncoder().encode(annotatedDocument.documentXml),
751
806
  contentType: MAIN_DOCUMENT_CONTENT_TYPE,
752
807
  relationships: nextRelationships,
@@ -863,6 +918,8 @@ function exportDocxEditorSession(
863
918
  }
864
919
  }
865
920
 
921
+ ensureHostMetadataParts(exportSession, state.sourcePackage, currentDocument);
922
+
866
923
  return {
867
924
  bytes: exportSession.serialize(),
868
925
  mimeType: DOCX_MIME_TYPE,
@@ -881,6 +938,19 @@ function createImportedCanonicalDocument(input: {
881
938
  diagnostics: CanonicalDocumentEnvelope["diagnostics"];
882
939
  review: CanonicalDocumentEnvelope["review"];
883
940
  }): CanonicalDocumentEnvelope {
941
+ const paragraphStyles = Object.fromEntries(
942
+ [...collectReferencedParagraphStyleIds(input.content, input.subParts)]
943
+ .sort((left, right) => left.localeCompare(right))
944
+ .map((styleId) => [
945
+ styleId,
946
+ {
947
+ styleId,
948
+ displayName: styleId,
949
+ kind: "paragraph" as const,
950
+ isDefault: styleId === "Normal",
951
+ },
952
+ ]),
953
+ );
884
954
  return {
885
955
  schemaVersion: "cds/1.0.0",
886
956
  docId: createCanonicalDocumentId(input.documentId),
@@ -890,7 +960,7 @@ function createImportedCanonicalDocument(input: {
890
960
  customProperties: {},
891
961
  },
892
962
  styles: {
893
- paragraphs: {},
963
+ paragraphs: paragraphStyles,
894
964
  characters: {},
895
965
  tables: {},
896
966
  },
@@ -904,15 +974,60 @@ function createImportedCanonicalDocument(input: {
904
974
  };
905
975
  }
906
976
 
977
+ function collectReferencedParagraphStyleIds(
978
+ content: CanonicalDocumentEnvelope["content"],
979
+ subParts?: SubPartsCatalog,
980
+ ): Set<string> {
981
+ const styleIds = new Set<string>();
982
+
983
+ const visitBlocks = (blocks: ReadonlyArray<BlockNode>) => {
984
+ for (const block of blocks) {
985
+ if ("styleId" in block && typeof block.styleId === "string" && block.styleId.length > 0) {
986
+ styleIds.add(block.styleId);
987
+ }
988
+ if (block.type === "table") {
989
+ for (const row of block.rows) {
990
+ for (const cell of row.cells) {
991
+ visitBlocks(cell.children);
992
+ }
993
+ }
994
+ } else if (block.type === "sdt" || block.type === "custom_xml") {
995
+ visitBlocks(block.children);
996
+ }
997
+ }
998
+ };
999
+
1000
+ visitBlocks(content.children);
1001
+ if (subParts) {
1002
+ for (const header of subParts.headers) {
1003
+ visitBlocks(header.blocks);
1004
+ }
1005
+ for (const footer of subParts.footers) {
1006
+ visitBlocks(footer.blocks);
1007
+ }
1008
+ if (subParts.footnoteCollection) {
1009
+ for (const note of Object.values(subParts.footnoteCollection.footnotes)) {
1010
+ visitBlocks(note.blocks);
1011
+ }
1012
+ for (const note of Object.values(subParts.footnoteCollection.endnotes)) {
1013
+ visitBlocks(note.blocks);
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ return styleIds;
1019
+ }
1020
+
907
1021
  function createImportedSnapshot(input: {
908
1022
  documentId: string;
909
1023
  editorBuild: string;
910
1024
  timestamp: string;
911
1025
  document: CanonicalDocumentEnvelope;
912
1026
  compatibility: PersistedEditorSnapshot["compatibility"];
1027
+ sourcePackage?: PersistedEditorSnapshot["sourcePackage"];
913
1028
  }): PersistedEditorSnapshot {
914
1029
  return {
915
- snapshotVersion: "persisted-editor-snapshot/1",
1030
+ snapshotVersion: "persisted-editor-snapshot/2",
916
1031
  schemaVersion: input.document.schemaVersion,
917
1032
  documentId: input.documentId,
918
1033
  docId: input.document.docId,
@@ -923,6 +1038,7 @@ function createImportedSnapshot(input: {
923
1038
  canonicalDocument: input.document,
924
1039
  compatibility: input.compatibility,
925
1040
  warningLog: input.compatibility.warnings,
1041
+ sourcePackage: input.sourcePackage,
926
1042
  };
927
1043
  }
928
1044
 
@@ -1292,10 +1408,12 @@ function rangesIntersect(
1292
1408
 
1293
1409
  function resolveCommentsPartPath(
1294
1410
  sourcePackage: OpcPackage,
1411
+ sourceDocumentPartPath: string,
1295
1412
  relationships: readonly OpcRelationship[],
1296
1413
  ): string | undefined {
1297
1414
  return resolveDocumentRelatedPartPath(
1298
1415
  sourcePackage,
1416
+ sourceDocumentPartPath,
1299
1417
  relationships,
1300
1418
  COMMENTS_RELATIONSHIP_TYPE,
1301
1419
  COMMENTS_PART_PATH,
@@ -1304,6 +1422,7 @@ function resolveCommentsPartPath(
1304
1422
 
1305
1423
  function resolveDocumentRelatedPartPath(
1306
1424
  sourcePackage: OpcPackage,
1425
+ sourceDocumentPartPath: string,
1307
1426
  relationships: readonly OpcRelationship[],
1308
1427
  relationshipType: string,
1309
1428
  fallbackPartPath: string,
@@ -1317,10 +1436,23 @@ function resolveDocumentRelatedPartPath(
1317
1436
  return sourcePackage.parts.has(fallbackPartPath) ? fallbackPartPath : undefined;
1318
1437
  }
1319
1438
 
1320
- const targetPath = resolveRelationshipTarget(MAIN_DOCUMENT_PATH, relationship);
1439
+ const targetPath = resolveRelationshipTarget(sourceDocumentPartPath, relationship);
1321
1440
  return sourcePackage.parts.has(targetPath) ? targetPath : undefined;
1322
1441
  }
1323
1442
 
1443
+ function resolveMainDocumentPartPath(sourcePackage: OpcPackage): string | undefined {
1444
+ const relationship = sourcePackage.manifest.packageRelationships.find(
1445
+ (candidate) =>
1446
+ candidate.type === OFFICE_DOCUMENT_RELATIONSHIP_TYPE &&
1447
+ candidate.targetMode === "internal",
1448
+ );
1449
+ if (relationship) {
1450
+ return resolveRelationshipTarget(null, relationship);
1451
+ }
1452
+
1453
+ return sourcePackage.parts.has(MAIN_DOCUMENT_PATH) ? MAIN_DOCUMENT_PATH : undefined;
1454
+ }
1455
+
1324
1456
  function toRuntimeCommentRecords(
1325
1457
  threads: readonly CommentThread[],
1326
1458
  ): Record<string, CommentThreadRecord> {
@@ -1547,6 +1679,7 @@ function hasNumberingEntries(catalog: NumberingCatalog): boolean {
1547
1679
 
1548
1680
  function collectBrokenInternalRelationshipIssues(
1549
1681
  sourcePackage: OpcPackage,
1682
+ mainDocumentPath?: string,
1550
1683
  ): ReturnType<typeof createBrokenRelationshipIssue>[] {
1551
1684
  const brokenTargets = new Map<string, ReturnType<typeof createBrokenRelationshipIssue>>();
1552
1685
 
@@ -1556,7 +1689,15 @@ function collectBrokenInternalRelationshipIssues(
1556
1689
  }
1557
1690
 
1558
1691
  const target = resolveRelationshipTarget(null, relationship);
1559
- if (!sourcePackage.parts.has(target)) {
1692
+ if (
1693
+ !sourcePackage.parts.has(target) &&
1694
+ isFatalBrokenRelationship({
1695
+ relationshipSourcePath: null,
1696
+ relationship,
1697
+ targetPartPath: target,
1698
+ mainDocumentPath,
1699
+ })
1700
+ ) {
1560
1701
  brokenTargets.set(
1561
1702
  `package:${relationship.id}:${target}`,
1562
1703
  createBrokenRelationshipIssue({
@@ -1575,7 +1716,15 @@ function collectBrokenInternalRelationshipIssues(
1575
1716
  }
1576
1717
 
1577
1718
  const target = resolveRelationshipTarget(part.path, relationship);
1578
- if (!sourcePackage.parts.has(target)) {
1719
+ if (
1720
+ !sourcePackage.parts.has(target) &&
1721
+ isFatalBrokenRelationship({
1722
+ relationshipSourcePath: part.path,
1723
+ relationship,
1724
+ targetPartPath: target,
1725
+ mainDocumentPath,
1726
+ })
1727
+ ) {
1579
1728
  brokenTargets.set(
1580
1729
  `${part.path}:${relationship.id}:${target}`,
1581
1730
  createBrokenRelationshipIssue({
@@ -1604,6 +1753,82 @@ function summarizeBrokenRelationshipIssues(
1604
1753
  .join(", ")}${issues.length > 3 ? ", ..." : ""}.`;
1605
1754
  }
1606
1755
 
1756
+ function createBrokenRelationshipWarnings(
1757
+ sourcePackage: OpcPackage,
1758
+ mainDocumentPath?: string,
1759
+ ): CanonicalDocumentEnvelope["diagnostics"]["warnings"] {
1760
+ const warnings = new Map<string, CanonicalDocumentEnvelope["diagnostics"]["warnings"][number]>();
1761
+
1762
+ for (const relationship of sourcePackage.manifest.packageRelationships) {
1763
+ if (relationship.targetMode !== "internal") {
1764
+ continue;
1765
+ }
1766
+
1767
+ const target = resolveRelationshipTarget(null, relationship);
1768
+ if (
1769
+ sourcePackage.parts.has(target) ||
1770
+ isFatalBrokenRelationship({
1771
+ relationshipSourcePath: null,
1772
+ relationship,
1773
+ targetPartPath: target,
1774
+ mainDocumentPath,
1775
+ })
1776
+ ) {
1777
+ continue;
1778
+ }
1779
+
1780
+ warnings.set(`package:${relationship.id}:${target}`, {
1781
+ diagnosticId: `diagnostic:broken-relationship-package-${relationship.id}`,
1782
+ warningId: `warning:broken-relationship:${relationship.id}`,
1783
+ source: "preservation",
1784
+ message: `DOCX package has unresolved internal relationships outside the editor-owned graph: ${target}.`,
1785
+ });
1786
+ }
1787
+
1788
+ for (const part of sourcePackage.parts.values()) {
1789
+ for (const relationship of part.relationships) {
1790
+ if (relationship.targetMode !== "internal") {
1791
+ continue;
1792
+ }
1793
+
1794
+ const target = resolveRelationshipTarget(part.path, relationship);
1795
+ if (
1796
+ sourcePackage.parts.has(target) ||
1797
+ isFatalBrokenRelationship({
1798
+ relationshipSourcePath: part.path,
1799
+ relationship,
1800
+ targetPartPath: target,
1801
+ mainDocumentPath,
1802
+ })
1803
+ ) {
1804
+ continue;
1805
+ }
1806
+
1807
+ warnings.set(`${part.path}:${relationship.id}:${target}`, {
1808
+ diagnosticId: `diagnostic:broken-relationship-${relationship.id}`,
1809
+ warningId: `warning:broken-relationship:${relationship.id}`,
1810
+ source: "preservation",
1811
+ message: `DOCX package has unresolved internal relationships outside the editor-owned graph: ${target}.`,
1812
+ });
1813
+ }
1814
+ }
1815
+
1816
+ return [...warnings.values()];
1817
+ }
1818
+
1819
+ function isFatalBrokenRelationship(input: {
1820
+ relationshipSourcePath: string | null;
1821
+ relationship: OpcRelationship;
1822
+ targetPartPath: string;
1823
+ mainDocumentPath?: string;
1824
+ }): boolean {
1825
+ return (
1826
+ input.relationshipSourcePath === null &&
1827
+ input.relationship.type === OFFICE_DOCUMENT_RELATIONSHIP_TYPE &&
1828
+ input.targetPartPath === input.mainDocumentPath
1829
+ );
1830
+ }
1831
+
1607
1832
  function shouldPreservePackagePart(
1608
1833
  partPath: string,
1609
1834
  surfaceKind: OpcPackage["parts"] extends Map<string, infer T>
@@ -1618,7 +1843,6 @@ function shouldPreservePackagePart(
1618
1843
  }
1619
1844
 
1620
1845
  if (
1621
- partPath === MAIN_DOCUMENT_PATH ||
1622
1846
  ownedPartPaths.has(partPath) ||
1623
1847
  partPath.startsWith("/word/media/") ||
1624
1848
  CORE_NON_PRESERVED_PART_PATHS.has(partPath)
@@ -1700,13 +1924,7 @@ function isPackageImportError(error: unknown): boolean {
1700
1924
  const CORE_NON_PRESERVED_PART_PATHS = new Set([
1701
1925
  "/docProps/app.xml",
1702
1926
  "/docProps/core.xml",
1703
- "/docProps/custom.xml",
1704
- "/word/fontTable.xml",
1705
1927
  "/word/numbering.xml",
1706
- "/word/settings.xml",
1707
- "/word/styles.xml",
1708
- "/word/stylesWithEffects.xml",
1709
- "/word/webSettings.xml",
1710
1928
  ]);
1711
1929
 
1712
1930
  function createCommentsRelationshipId(
@@ -1751,6 +1969,10 @@ function canReuseSourceBytesForCurrentDocument(
1751
1969
  state: ImportedDocxState,
1752
1970
  document: CanonicalDocumentEnvelope,
1753
1971
  ): boolean {
1972
+ if (requiresHostMetadataNormalization(state.sourcePackage, state.sourceDocumentPartPath)) {
1973
+ return false;
1974
+ }
1975
+
1754
1976
  const commentThreads = Object.values(document.review.comments);
1755
1977
  const hasLiveComments = commentThreads.some((thread) => thread.anchor.kind !== "detached");
1756
1978
  if (!hasLiveComments) {
@@ -1764,3 +1986,151 @@ function canReuseSourceBytesForCurrentDocument(
1764
1986
  state.sourcePeoplePartPath,
1765
1987
  );
1766
1988
  }
1989
+
1990
+ function requiresHostMetadataNormalization(
1991
+ sourcePackage: OpcPackage,
1992
+ sourceDocumentPartPath: string,
1993
+ ): boolean {
1994
+ return (
1995
+ isSuspiciouslySkeletalWordPackage(sourcePackage, sourceDocumentPartPath) &&
1996
+ !hasHostSafeMetadataPackageStructure(sourcePackage)
1997
+ );
1998
+ }
1999
+
2000
+ function ensureHostMetadataParts(
2001
+ exportSession: ReturnType<typeof createExportSession>,
2002
+ sourcePackage: OpcPackage,
2003
+ document: CanonicalDocumentEnvelope,
2004
+ ): void {
2005
+ const corePropertiesPart = sourcePackage.parts.get(CORE_PROPERTIES_PART_PATH);
2006
+ if (!corePropertiesPart || corePropertiesPart.contentType !== CORE_PROPERTIES_CONTENT_TYPE) {
2007
+ exportSession.replaceOwnedPart({
2008
+ path: CORE_PROPERTIES_PART_PATH,
2009
+ bytes: corePropertiesPart?.bytes ?? new TextEncoder().encode(buildCorePropertiesXml(document)),
2010
+ contentType: CORE_PROPERTIES_CONTENT_TYPE,
2011
+ compression: corePropertiesPart?.compression,
2012
+ });
2013
+ }
2014
+
2015
+ const appPropertiesPart = sourcePackage.parts.get(APP_PROPERTIES_PART_PATH);
2016
+ if (!appPropertiesPart || appPropertiesPart.contentType !== APP_PROPERTIES_CONTENT_TYPE) {
2017
+ exportSession.replaceOwnedPart({
2018
+ path: APP_PROPERTIES_PART_PATH,
2019
+ bytes: appPropertiesPart?.bytes ?? new TextEncoder().encode(buildAppPropertiesXml()),
2020
+ contentType: APP_PROPERTIES_CONTENT_TYPE,
2021
+ compression: appPropertiesPart?.compression,
2022
+ });
2023
+ }
2024
+
2025
+ exportSession.ensurePackageRelationship({
2026
+ type: CORE_PROPERTIES_RELATIONSHIP_TYPE,
2027
+ target: CORE_PROPERTIES_PART_PATH,
2028
+ preferredId: "rIdDocPropsCore",
2029
+ });
2030
+ exportSession.ensurePackageRelationship({
2031
+ type: APP_PROPERTIES_RELATIONSHIP_TYPE,
2032
+ target: APP_PROPERTIES_PART_PATH,
2033
+ preferredId: "rIdDocPropsApp",
2034
+ });
2035
+ }
2036
+
2037
+ function hasHostSafeMetadataPackageStructure(sourcePackage: OpcPackage): boolean {
2038
+ const corePropertiesPart = sourcePackage.parts.get(CORE_PROPERTIES_PART_PATH);
2039
+ const appPropertiesPart = sourcePackage.parts.get(APP_PROPERTIES_PART_PATH);
2040
+ return (
2041
+ corePropertiesPart?.contentType === CORE_PROPERTIES_CONTENT_TYPE &&
2042
+ appPropertiesPart?.contentType === APP_PROPERTIES_CONTENT_TYPE &&
2043
+ hasPackageRelationshipTarget(
2044
+ sourcePackage,
2045
+ CORE_PROPERTIES_RELATIONSHIP_TYPE,
2046
+ CORE_PROPERTIES_PART_PATH,
2047
+ ) &&
2048
+ hasPackageRelationshipTarget(
2049
+ sourcePackage,
2050
+ APP_PROPERTIES_RELATIONSHIP_TYPE,
2051
+ APP_PROPERTIES_PART_PATH,
2052
+ )
2053
+ );
2054
+ }
2055
+
2056
+ function hasPackageRelationshipTarget(
2057
+ sourcePackage: OpcPackage,
2058
+ relationshipType: string,
2059
+ targetPartPath: string,
2060
+ ): boolean {
2061
+ return sourcePackage.manifest.packageRelationships.some(
2062
+ (relationship) =>
2063
+ relationship.type === relationshipType &&
2064
+ relationship.targetMode === "internal" &&
2065
+ resolveRelationshipTarget(null, relationship) === targetPartPath,
2066
+ );
2067
+ }
2068
+
2069
+ function isSuspiciouslySkeletalWordPackage(
2070
+ sourcePackage: OpcPackage,
2071
+ sourceDocumentPartPath: string,
2072
+ ): boolean {
2073
+ const allowedPaths = new Set<string>([
2074
+ CONTENT_TYPES_PATH,
2075
+ PACKAGE_RELATIONSHIPS_PATH,
2076
+ sourceDocumentPartPath,
2077
+ ]);
2078
+ const relationshipsPartPath = getRelationshipsPartPath(sourceDocumentPartPath);
2079
+ if (relationshipsPartPath) {
2080
+ allowedPaths.add(relationshipsPartPath);
2081
+ }
2082
+
2083
+ return [...sourcePackage.parts.keys()].every((partPath) => allowedPaths.has(partPath));
2084
+ }
2085
+
2086
+ function buildCorePropertiesXml(document: CanonicalDocumentEnvelope): string {
2087
+ const { metadata } = document;
2088
+ const keywords =
2089
+ Array.isArray(metadata.keywords) && metadata.keywords.length > 0
2090
+ ? metadata.keywords.join(", ")
2091
+ : undefined;
2092
+ const propertyLines = [
2093
+ xmlNode("dc:title", metadata.title),
2094
+ xmlNode("dc:subject", metadata.subject),
2095
+ xmlNode("dc:description", metadata.description),
2096
+ xmlNode("dc:language", metadata.language),
2097
+ xmlNode("cp:keywords", keywords),
2098
+ xmlNode("cp:category", metadata.category),
2099
+ xmlNode('dcterms:created xsi:type="dcterms:W3CDTF"', document.createdAt),
2100
+ xmlNode('dcterms:modified xsi:type="dcterms:W3CDTF"', document.updatedAt),
2101
+ ].filter((line): line is string => Boolean(line));
2102
+
2103
+ return [
2104
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
2105
+ `<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">`,
2106
+ ...propertyLines.map((line) => ` ${line}`),
2107
+ `</cp:coreProperties>`,
2108
+ ].join("\n");
2109
+ }
2110
+
2111
+ function buildAppPropertiesXml(): string {
2112
+ return [
2113
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`,
2114
+ `<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">`,
2115
+ ` <Application>React OOXML Office</Application>`,
2116
+ ` <AppVersion>1.0</AppVersion>`,
2117
+ `</Properties>`,
2118
+ ].join("\n");
2119
+ }
2120
+
2121
+ function xmlNode(tagName: string, value: string | undefined): string | undefined {
2122
+ if (typeof value !== "string" || value.length === 0) {
2123
+ return undefined;
2124
+ }
2125
+
2126
+ return `<${tagName}>${escapeXml(value)}</${tagName.split(" ", 1)[0]}>`;
2127
+ }
2128
+
2129
+ function escapeXml(value: string): string {
2130
+ return value
2131
+ .replace(/&/g, "&amp;")
2132
+ .replace(/</g, "&lt;")
2133
+ .replace(/>/g, "&gt;")
2134
+ .replace(/\"/g, "&quot;")
2135
+ .replace(/'/g, "&apos;");
2136
+ }