@beyondwork/docx-react-component 1.0.10 → 1.0.12

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.
@@ -7,6 +7,7 @@ import {
7
7
  comparePartPaths,
8
8
  getRelationshipsPartPath,
9
9
  normalizePartPath,
10
+ resolveRelationshipTarget,
10
11
  } from "../ooxml/part-manifest.ts";
11
12
  import type { OpcPackage } from "../opc/package-reader.ts";
12
13
  import { writeOpcPackage } from "../opc/package-writer.ts";
@@ -71,6 +72,43 @@ export class ExportSession {
71
72
  return this;
72
73
  }
73
74
 
75
+ public ensurePackageRelationship(input: {
76
+ type: string;
77
+ target: string;
78
+ targetMode?: OpcRelationship["targetMode"];
79
+ preferredId?: string;
80
+ }): void {
81
+ const targetMode = input.targetMode ?? "internal";
82
+ const normalizedTarget =
83
+ targetMode === "internal" ? normalizePartPath(input.target) : input.target;
84
+ const existing = this.packageRelationships.find((relationship) => {
85
+ if (relationship.type !== input.type || relationship.targetMode !== targetMode) {
86
+ return false;
87
+ }
88
+
89
+ return targetMode === "internal"
90
+ ? resolveRelationshipTarget(null, relationship) === normalizedTarget
91
+ : relationship.target === normalizedTarget;
92
+ });
93
+ if (existing) {
94
+ return;
95
+ }
96
+
97
+ const relationshipId = createUniqueRelationshipId(
98
+ new Set(this.packageRelationships.map((relationship) => relationship.id)),
99
+ input.preferredId ?? "rIdPackage1",
100
+ );
101
+ this.packageRelationships.push({
102
+ id: relationshipId,
103
+ type: input.type,
104
+ target:
105
+ targetMode === "internal"
106
+ ? toPackageRelationshipTarget(normalizedTarget)
107
+ : normalizedTarget,
108
+ targetMode,
109
+ });
110
+ }
111
+
74
112
  public toPackage(): OpcPackage {
75
113
  reattachPreservedParts(
76
114
  this.sourcePackage,
@@ -162,4 +200,21 @@ function cloneRelationship(relationship: OpcRelationship): OpcRelationship {
162
200
  return { ...relationship };
163
201
  }
164
202
 
203
+ function createUniqueRelationshipId(existingIds: ReadonlySet<string>, preferredId: string): string {
204
+ if (!existingIds.has(preferredId)) {
205
+ return preferredId;
206
+ }
207
+
208
+ let nextIndex = 2;
209
+ while (existingIds.has(`${preferredId}-${nextIndex}`)) {
210
+ nextIndex += 1;
211
+ }
212
+
213
+ return `${preferredId}-${nextIndex}`;
214
+ }
215
+
216
+ function toPackageRelationshipTarget(partPath: string): string {
217
+ return normalizePartPath(partPath).slice(1);
218
+ }
219
+
165
220
  export { buildManifest };
@@ -71,8 +71,7 @@ function serializeNoteDefinition(
71
71
  if (block.type === "paragraph") {
72
72
  return serializeParagraph(block);
73
73
  }
74
- // opaque_block: emit empty paragraph
75
- return `<w:p/>`;
74
+ throw new Error(`Cannot safely serialize ${block.type} content in note sub-parts.`);
76
75
  })
77
76
  .join("");
78
77
 
@@ -135,24 +134,10 @@ function serializeInlineNode(node: InlineNode): string {
135
134
  return `<w:r><w:rPr><w:rStyle w:val="${styleVal}"/></w:rPr>${refElement}</w:r>`;
136
135
  }
137
136
  case "opaque_inline":
138
- return "";
139
- case "hyperlink": {
140
- return node.children
141
- .map((child) => {
142
- if (child.type === "text") {
143
- const preserve = requiresPreservedSpace(child.text)
144
- ? ` xml:space="preserve"`
145
- : "";
146
- return `<w:r><w:t${preserve}>${escapeXml(child.text)}</w:t></w:r>`;
147
- }
148
- if (child.type === "tab") return "<w:r><w:tab/></w:r>";
149
- if (child.type === "hard_break") return "<w:r><w:br/></w:r>";
150
- return "";
151
- })
152
- .join("");
153
- }
137
+ throw new Error(`Cannot safely serialize ${node.type} content in note sub-parts.`);
138
+ case "hyperlink":
154
139
  default:
155
- return "";
140
+ throw new Error(`Cannot safely serialize ${node.type} content in note sub-parts.`);
156
141
  }
157
142
  }
158
143
 
@@ -180,7 +165,7 @@ function buildRunPropertiesXml(marks: TextMark[] | undefined): string {
180
165
  parts.push("<w:dstrike/>");
181
166
  break;
182
167
  default:
183
- break;
168
+ throw new Error(`Cannot safely serialize ${mark.type} marks in note sub-parts.`);
184
169
  }
185
170
  }
186
171
 
@@ -51,8 +51,7 @@ function serializeBlocks(
51
51
  if (block.type === "paragraph") {
52
52
  return serializeParagraph(block);
53
53
  }
54
- // opaque_block: emit empty paragraph to preserve structure
55
- return `<w:p/>`;
54
+ throw new Error(`Cannot safely serialize ${block.type} content in header/footer sub-parts.`);
56
55
  })
57
56
  .join("");
58
57
  }
@@ -108,32 +107,8 @@ function serializeInlineNode(node: InlineNode): string {
108
107
  return `<w:r><w:rPr><w:rStyle w:val="${node.noteKind === "footnote" ? "FootnoteReference" : "EndnoteReference"}"/></w:rPr>${refElement}</w:r>`;
109
108
  }
110
109
  case "opaque_inline":
111
- // Cannot reproduce opaque inline content without original XML; emit empty
112
- return "";
113
- case "hyperlink": {
114
- const childrenXml = node.children
115
- .map((child) => {
116
- switch (child.type) {
117
- case "text": {
118
- const properties = buildRunPropertiesXml(undefined);
119
- const preserve = requiresPreservedSpace(child.text)
120
- ? ` xml:space="preserve"`
121
- : "";
122
- return `<w:r>${properties}<w:t${preserve}>${escapeXml(child.text)}</w:t></w:r>`;
123
- }
124
- case "tab":
125
- return "<w:r><w:tab/></w:r>";
126
- case "hard_break":
127
- return "<w:r><w:br/></w:r>";
128
- default:
129
- return "";
130
- }
131
- })
132
- .join("");
133
- // Hyperlinks in headers/footers typically use bookmark anchors or external URLs.
134
- // Emit as a plain run since we don't retain the relationship ID here.
135
- return childrenXml;
136
- }
110
+ throw new Error(`Cannot safely serialize ${node.type} content in header/footer sub-parts.`);
111
+ case "hyperlink":
137
112
  case "image":
138
113
  case "field":
139
114
  case "bookmark_start":
@@ -146,7 +121,7 @@ function serializeInlineNode(node: InlineNode): string {
146
121
  case "wordart":
147
122
  case "vml_shape":
148
123
  default:
149
- return "";
124
+ throw new Error(`Cannot safely serialize ${node.type} content in header/footer sub-parts.`);
150
125
  }
151
126
  }
152
127
 
@@ -174,8 +149,7 @@ function buildRunPropertiesXml(marks: TextMark[] | undefined): string {
174
149
  parts.push("<w:dstrike/>");
175
150
  break;
176
151
  default:
177
- // Other mark types not parsed from headers/footers
178
- break;
152
+ throw new Error(`Cannot safely serialize ${mark.type} marks in header/footer sub-parts.`);
179
153
  }
180
154
  }
181
155
 
@@ -375,9 +375,25 @@ function serializeTableInlineNode(
375
375
  return `${hyperlinkOpen}${childrenXml}</w:hyperlink>`;
376
376
  }
377
377
  case "field":
378
+ return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
378
379
  case "bookmark_start":
380
+ return (
381
+ `<w:bookmarkStart w:id="${escapeAttribute(node.bookmarkId)}"` +
382
+ ` w:name="${escapeAttribute(node.name)}"/>`
383
+ );
379
384
  case "bookmark_end":
380
- case "footnote_ref":
385
+ return `<w:bookmarkEnd w:id="${escapeAttribute(node.bookmarkId)}"/>`;
386
+ case "footnote_ref": {
387
+ const refElement =
388
+ node.noteKind === "footnote"
389
+ ? `<w:footnoteReference w:id="${escapeAttribute(node.noteId)}"/>`
390
+ : `<w:endnoteReference w:id="${escapeAttribute(node.noteId)}"/>`;
391
+ const styleVal =
392
+ node.noteKind === "footnote"
393
+ ? "FootnoteReference"
394
+ : "EndnoteReference";
395
+ return `<w:r><w:rPr><w:rStyle w:val="${styleVal}"/></w:rPr>${refElement}</w:r>`;
396
+ }
381
397
  default:
382
398
  return "";
383
399
  }
@@ -787,10 +803,52 @@ function serializeInlineNode(
787
803
  boundaries,
788
804
  };
789
805
  }
790
- case "field":
791
- case "bookmark_start":
792
- case "bookmark_end":
793
- case "footnote_ref":
806
+ case "field": {
807
+ const xml = `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
808
+ const boundaries = new Map<number, number>();
809
+ boundaries.set(cursor, xmlOffset);
810
+ boundaries.set(cursor + 1, xmlOffset + xml.length);
811
+ return {
812
+ xml,
813
+ cursor: cursor + 1,
814
+ boundaries,
815
+ };
816
+ }
817
+ case "bookmark_start": {
818
+ const xml =
819
+ `<w:bookmarkStart w:id="${escapeAttribute(node.bookmarkId)}"` +
820
+ ` w:name="${escapeAttribute(node.name)}"/>`;
821
+ const boundaries = new Map<number, number>();
822
+ boundaries.set(cursor, xmlOffset);
823
+ boundaries.set(cursor, xmlOffset + xml.length);
824
+ return { xml, cursor, boundaries };
825
+ }
826
+ case "bookmark_end": {
827
+ const xml = `<w:bookmarkEnd w:id="${escapeAttribute(node.bookmarkId)}"/>`;
828
+ const boundaries = new Map<number, number>();
829
+ boundaries.set(cursor, xmlOffset);
830
+ boundaries.set(cursor, xmlOffset + xml.length);
831
+ return { xml, cursor, boundaries };
832
+ }
833
+ case "footnote_ref": {
834
+ const refElement =
835
+ node.noteKind === "footnote"
836
+ ? `<w:footnoteReference w:id="${escapeAttribute(node.noteId)}"/>`
837
+ : `<w:endnoteReference w:id="${escapeAttribute(node.noteId)}"/>`;
838
+ const styleVal =
839
+ node.noteKind === "footnote"
840
+ ? "FootnoteReference"
841
+ : "EndnoteReference";
842
+ const xml = `<w:r><w:rPr><w:rStyle w:val="${styleVal}"/></w:rPr>${refElement}</w:r>`;
843
+ const boundaries = new Map<number, number>();
844
+ boundaries.set(cursor, xmlOffset);
845
+ boundaries.set(cursor + 1, xmlOffset + xml.length);
846
+ return {
847
+ xml,
848
+ cursor: cursor + 1,
849
+ boundaries,
850
+ };
851
+ }
794
852
  default: {
795
853
  const boundaries = new Map<number, number>();
796
854
  boundaries.set(cursor, xmlOffset);
@@ -882,6 +940,21 @@ function serializeRunProperties(marks: TextMark[] | undefined): string {
882
940
  case "textFill":
883
941
  markParts.push(mark.xml);
884
942
  break;
943
+ case "fontFamily":
944
+ markParts.push(`<w:rFonts w:ascii="${escapeAttribute(mark.val)}" w:hAnsi="${escapeAttribute(mark.val)}"/>`);
945
+ break;
946
+ case "fontSize":
947
+ markParts.push(`<w:sz w:val="${mark.val}"/>`);
948
+ break;
949
+ case "textColor":
950
+ markParts.push(`<w:color w:val="${escapeAttribute(mark.color)}"/>`);
951
+ break;
952
+ case "smallCaps":
953
+ markParts.push("<w:smallCaps/>");
954
+ break;
955
+ case "allCaps":
956
+ markParts.push("<w:caps/>");
957
+ break;
885
958
  }
886
959
  }
887
960
 
@@ -147,6 +147,9 @@ function normalizeParagraph(
147
147
  ...(paragraph.keepLines ? { keepLines: paragraph.keepLines } : {}),
148
148
  ...(paragraph.outlineLevel !== undefined ? { outlineLevel: paragraph.outlineLevel } : {}),
149
149
  ...(paragraph.pageBreakBefore ? { pageBreakBefore: paragraph.pageBreakBefore } : {}),
150
+ ...(paragraph.bidi ? { bidi: paragraph.bidi } : {}),
151
+ ...(paragraph.borders ? { borders: paragraph.borders } : {}),
152
+ ...(paragraph.shading ? { shading: paragraph.shading } : {}),
150
153
  children,
151
154
  };
152
155
  }
@@ -287,6 +290,84 @@ function normalizeInlineChildren(
287
290
  state.cursor += 1;
288
291
  break;
289
292
  }
293
+ case "symbol":
294
+ normalized.push({
295
+ type: "symbol",
296
+ char: node.char,
297
+ font: node.font,
298
+ ...(node.marks && node.marks.length > 0 ? { marks: node.marks } : {}),
299
+ });
300
+ state.cursor += 1;
301
+ break;
302
+ case "column_break":
303
+ normalized.push({ type: "column_break" });
304
+ state.cursor += 1;
305
+ break;
306
+ case "chart_preview":
307
+ normalized.push({
308
+ type: "chart_preview",
309
+ ...(node.previewMediaId ? { previewMediaId: node.previewMediaId } : {}),
310
+ rawXml: node.rawXml,
311
+ });
312
+ state.cursor += 1;
313
+ break;
314
+ case "smartart_preview":
315
+ normalized.push({
316
+ type: "smartart_preview",
317
+ ...(node.previewMediaId ? { previewMediaId: node.previewMediaId } : {}),
318
+ rawXml: node.rawXml,
319
+ });
320
+ state.cursor += 1;
321
+ break;
322
+ case "shape":
323
+ normalized.push({
324
+ type: "shape",
325
+ ...(node.text ? { text: node.text } : {}),
326
+ ...(node.geometry ? { geometry: node.geometry } : {}),
327
+ rawXml: node.rawXml,
328
+ });
329
+ state.cursor += 1;
330
+ break;
331
+ case "wordart":
332
+ normalized.push({
333
+ type: "wordart",
334
+ text: node.text,
335
+ ...(node.geometry ? { geometry: node.geometry } : {}),
336
+ rawXml: node.rawXml,
337
+ });
338
+ state.cursor += 1;
339
+ break;
340
+ case "vml_shape":
341
+ normalized.push({
342
+ type: "vml_shape",
343
+ ...(node.text ? { text: node.text } : {}),
344
+ ...(node.shapeType ? { shapeType: node.shapeType } : {}),
345
+ rawXml: node.rawXml,
346
+ });
347
+ state.cursor += 1;
348
+ break;
349
+ case "bookmark_start":
350
+ normalized.push({
351
+ type: "bookmark_start",
352
+ bookmarkId: node.bookmarkId,
353
+ name: node.name,
354
+ });
355
+ break;
356
+ case "bookmark_end":
357
+ normalized.push({
358
+ type: "bookmark_end",
359
+ bookmarkId: node.bookmarkId,
360
+ });
361
+ break;
362
+ case "field":
363
+ normalized.push({
364
+ type: "field",
365
+ fieldType: node.fieldType,
366
+ instruction: node.instruction,
367
+ children: [],
368
+ });
369
+ state.cursor += 1;
370
+ break;
290
371
  }
291
372
  }
292
373
 
@@ -383,7 +464,15 @@ function sameMarks(left: TextMark[] | undefined, right: TextMark[] | undefined):
383
464
  }
384
465
 
385
466
  function normalizeMarks(marks: TextMark[] | undefined): string[] {
386
- return [...(marks ?? []).map((mark) => mark.type)].sort();
467
+ return [...(marks ?? []).map(serializeMark)].sort();
468
+ }
469
+
470
+ function serializeMark(mark: TextMark): string {
471
+ return JSON.stringify(
472
+ Object.fromEntries(
473
+ Object.entries(mark).sort(([left], [right]) => left.localeCompare(right)),
474
+ ),
475
+ );
387
476
  }
388
477
 
389
478
  function recordOpaqueFragment(
@@ -171,11 +171,11 @@ function parseParagraphElement(pElement: XmlElementNode): ParagraphNode {
171
171
  } else if (name === "r") {
172
172
  children.push(...parseRunElement(child));
173
173
  } else if (name === "hyperlink") {
174
- for (const hChild of child.children) {
175
- if (hChild.type === "element" && localName(hChild.name) === "r") {
176
- children.push(...parseRunElement(hChild));
177
- }
178
- }
174
+ children.push(parseHyperlinkElement(child));
175
+ } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
176
+ children.push(parseBookmarkElement(child));
177
+ } else if (name === "fldSimple") {
178
+ children.push(parseFieldElement(child));
179
179
  }
180
180
  }
181
181
 
@@ -224,12 +224,75 @@ function parseRunElement(rElement: XmlElementNode): InlineNode[] {
224
224
  if (noteId) {
225
225
  nodes.push({ type: "footnote_ref", noteId, noteKind: "endnote" });
226
226
  }
227
+ } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
228
+ nodes.push(parseBookmarkElement(child));
229
+ } else if (name === "fldChar" || name === "instrText") {
230
+ nodes.push(parseFieldElement(child));
227
231
  }
228
232
  }
229
233
 
230
234
  return nodes;
231
235
  }
232
236
 
237
+ function parseHyperlinkElement(element: XmlElementNode): Extract<InlineNode, { type: "hyperlink" }> {
238
+ const href = element.attributes["w:anchor"]
239
+ ? `#${element.attributes["w:anchor"]}`
240
+ : element.attributes["r:id"] ?? "relationship:unknown";
241
+ const children: Array<Extract<InlineNode, { type: "text" | "hard_break" | "tab" }>> = [];
242
+
243
+ for (const child of element.children) {
244
+ if (child.type === "element" && localName(child.name) === "r") {
245
+ for (const runChild of parseRunElement(child)) {
246
+ if (runChild.type === "text" || runChild.type === "hard_break" || runChild.type === "tab") {
247
+ children.push(runChild);
248
+ }
249
+ }
250
+ }
251
+ }
252
+
253
+ return {
254
+ type: "hyperlink",
255
+ href,
256
+ children,
257
+ };
258
+ }
259
+
260
+ function parseBookmarkElement(
261
+ element: XmlElementNode,
262
+ ): Extract<InlineNode, { type: "bookmark_start" | "bookmark_end" }> {
263
+ const bookmarkId = element.attributes["w:id"] ?? element.attributes.id ?? "0";
264
+ if (localName(element.name) === "bookmarkStart") {
265
+ return {
266
+ type: "bookmark_start",
267
+ bookmarkId,
268
+ name: element.attributes["w:name"] ?? element.attributes.name ?? "",
269
+ };
270
+ }
271
+
272
+ return {
273
+ type: "bookmark_end",
274
+ bookmarkId,
275
+ };
276
+ }
277
+
278
+ function parseFieldElement(element: XmlElementNode): Extract<InlineNode, { type: "field" }> {
279
+ const rawFieldType =
280
+ element.attributes["w:fldCharType"] ??
281
+ element.attributes.fldCharType ??
282
+ localName(element.name);
283
+ const fieldType: "simple" | "complex" = rawFieldType === "complex" ? "complex" : "simple";
284
+ const instruction =
285
+ element.attributes["w:instr"] ??
286
+ element.attributes.instr ??
287
+ extractTextContent(element);
288
+ return {
289
+ type: "field",
290
+ fieldType,
291
+ instruction,
292
+ children: [],
293
+ };
294
+ }
295
+
233
296
  function parseRunProperties(rElement: XmlElementNode): TextMark[] {
234
297
  const rPr = findChildElementOptional(rElement, "rPr");
235
298
  if (!rPr) {
@@ -214,16 +214,11 @@ function parseParagraphElement(pElement: XmlElementNode): ParagraphNode {
214
214
  } else if (name === "r") {
215
215
  children.push(...parseRunElement(child));
216
216
  } else if (name === "hyperlink") {
217
- // Simplified: collect text children from hyperlink runs
218
- for (const hChild of child.children) {
219
- if (hChild.type === "element" && localName(hChild.name) === "r") {
220
- children.push(...parseRunElement(hChild));
221
- }
222
- }
217
+ children.push(parseHyperlinkElement(child));
223
218
  } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
224
- // Skip bookmark nodes in headers/footers
225
- } else if (name === "fldChar" || name === "instrText") {
226
- // Skip field chars, handled via run parsing
219
+ children.push(parseBookmarkElement(child));
220
+ } else if (name === "fldSimple") {
221
+ children.push(parseFieldElement(child));
227
222
  }
228
223
  }
229
224
 
@@ -281,12 +276,75 @@ function parseRunElement(rElement: XmlElementNode): InlineNode[] {
281
276
  };
282
277
  nodes.push(ref);
283
278
  }
279
+ } else if (name === "bookmarkStart" || name === "bookmarkEnd") {
280
+ nodes.push(parseBookmarkElement(child));
281
+ } else if (name === "fldChar" || name === "instrText") {
282
+ nodes.push(parseFieldElement(child));
284
283
  }
285
284
  }
286
285
 
287
286
  return nodes;
288
287
  }
289
288
 
289
+ function parseHyperlinkElement(element: XmlElementNode): Extract<InlineNode, { type: "hyperlink" }> {
290
+ const href = element.attributes["w:anchor"]
291
+ ? `#${element.attributes["w:anchor"]}`
292
+ : element.attributes["r:id"] ?? "relationship:unknown";
293
+ const children: Array<Extract<InlineNode, { type: "text" | "hard_break" | "tab" }>> = [];
294
+
295
+ for (const child of element.children) {
296
+ if (child.type === "element" && localName(child.name) === "r") {
297
+ for (const runChild of parseRunElement(child)) {
298
+ if (runChild.type === "text" || runChild.type === "hard_break" || runChild.type === "tab") {
299
+ children.push(runChild);
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ return {
306
+ type: "hyperlink",
307
+ href,
308
+ children,
309
+ };
310
+ }
311
+
312
+ function parseBookmarkElement(
313
+ element: XmlElementNode,
314
+ ): Extract<InlineNode, { type: "bookmark_start" | "bookmark_end" }> {
315
+ const bookmarkId = element.attributes["w:id"] ?? element.attributes.id ?? "0";
316
+ if (localName(element.name) === "bookmarkStart") {
317
+ return {
318
+ type: "bookmark_start",
319
+ bookmarkId,
320
+ name: element.attributes["w:name"] ?? element.attributes.name ?? "",
321
+ };
322
+ }
323
+
324
+ return {
325
+ type: "bookmark_end",
326
+ bookmarkId,
327
+ };
328
+ }
329
+
330
+ function parseFieldElement(element: XmlElementNode): Extract<InlineNode, { type: "field" }> {
331
+ const rawFieldType =
332
+ element.attributes["w:fldCharType"] ??
333
+ element.attributes.fldCharType ??
334
+ localName(element.name);
335
+ const fieldType: "simple" | "complex" = rawFieldType === "complex" ? "complex" : "simple";
336
+ const instruction =
337
+ element.attributes["w:instr"] ??
338
+ element.attributes.instr ??
339
+ extractTextContent(element);
340
+ return {
341
+ type: "field",
342
+ fieldType,
343
+ instruction,
344
+ children: [],
345
+ };
346
+ }
347
+
290
348
  function parseRunProperties(rElement: XmlElementNode): TextMark[] {
291
349
  const rPr = findChildElementOptional(rElement, "rPr");
292
350
  if (!rPr) {