docxmlater 10.3.6 → 10.4.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.
Files changed (184) hide show
  1. package/README.md +158 -7
  2. package/dist/core/Document.d.ts +98 -3
  3. package/dist/core/Document.d.ts.map +1 -1
  4. package/dist/core/Document.js +740 -50
  5. package/dist/core/Document.js.map +1 -1
  6. package/dist/core/DocumentContent.d.ts.map +1 -1
  7. package/dist/core/DocumentContent.js +0 -8
  8. package/dist/core/DocumentContent.js.map +1 -1
  9. package/dist/core/DocumentGenerator.d.ts.map +1 -1
  10. package/dist/core/DocumentGenerator.js +9 -5
  11. package/dist/core/DocumentGenerator.js.map +1 -1
  12. package/dist/core/DocumentParser.d.ts.map +1 -1
  13. package/dist/core/DocumentParser.js +617 -104
  14. package/dist/core/DocumentParser.js.map +1 -1
  15. package/dist/core/RelationshipManager.d.ts.map +1 -1
  16. package/dist/core/RelationshipManager.js +4 -3
  17. package/dist/core/RelationshipManager.js.map +1 -1
  18. package/dist/elements/Bookmark.d.ts +7 -0
  19. package/dist/elements/Bookmark.d.ts.map +1 -1
  20. package/dist/elements/Bookmark.js +24 -4
  21. package/dist/elements/Bookmark.js.map +1 -1
  22. package/dist/elements/BookmarkManager.d.ts.map +1 -1
  23. package/dist/elements/BookmarkManager.js +4 -3
  24. package/dist/elements/BookmarkManager.js.map +1 -1
  25. package/dist/elements/CommonTypes.d.ts +2 -2
  26. package/dist/elements/CommonTypes.d.ts.map +1 -1
  27. package/dist/elements/CommonTypes.js +14 -1
  28. package/dist/elements/CommonTypes.js.map +1 -1
  29. package/dist/elements/Field.d.ts +1 -1
  30. package/dist/elements/Field.d.ts.map +1 -1
  31. package/dist/elements/Field.js +1 -1
  32. package/dist/elements/Field.js.map +1 -1
  33. package/dist/elements/Footer.d.ts +2 -0
  34. package/dist/elements/Footer.d.ts.map +1 -1
  35. package/dist/elements/Footer.js +6 -0
  36. package/dist/elements/Footer.js.map +1 -1
  37. package/dist/elements/Header.d.ts +2 -0
  38. package/dist/elements/Header.d.ts.map +1 -1
  39. package/dist/elements/Header.js +6 -0
  40. package/dist/elements/Header.js.map +1 -1
  41. package/dist/elements/Image.d.ts.map +1 -1
  42. package/dist/elements/Image.js +3 -0
  43. package/dist/elements/Image.js.map +1 -1
  44. package/dist/elements/Paragraph.d.ts +81 -1
  45. package/dist/elements/Paragraph.d.ts.map +1 -1
  46. package/dist/elements/Paragraph.js +515 -21
  47. package/dist/elements/Paragraph.js.map +1 -1
  48. package/dist/elements/Revision.d.ts +0 -1
  49. package/dist/elements/Revision.d.ts.map +1 -1
  50. package/dist/elements/Revision.js +0 -12
  51. package/dist/elements/Revision.js.map +1 -1
  52. package/dist/elements/RevisionManager.d.ts +0 -1
  53. package/dist/elements/RevisionManager.d.ts.map +1 -1
  54. package/dist/elements/RevisionManager.js +0 -2
  55. package/dist/elements/RevisionManager.js.map +1 -1
  56. package/dist/elements/Run.d.ts +16 -4
  57. package/dist/elements/Run.d.ts.map +1 -1
  58. package/dist/elements/Run.js +114 -22
  59. package/dist/elements/Run.js.map +1 -1
  60. package/dist/elements/Section.d.ts +7 -1
  61. package/dist/elements/Section.d.ts.map +1 -1
  62. package/dist/elements/Section.js +185 -4
  63. package/dist/elements/Section.js.map +1 -1
  64. package/dist/elements/Shape.js.map +1 -1
  65. package/dist/elements/Table.d.ts +30 -1
  66. package/dist/elements/Table.d.ts.map +1 -1
  67. package/dist/elements/Table.js +357 -40
  68. package/dist/elements/Table.js.map +1 -1
  69. package/dist/elements/TableCell.d.ts +3 -0
  70. package/dist/elements/TableCell.d.ts.map +1 -1
  71. package/dist/elements/TableCell.js +30 -3
  72. package/dist/elements/TableCell.js.map +1 -1
  73. package/dist/elements/TableGridChange.d.ts +0 -1
  74. package/dist/elements/TableGridChange.d.ts.map +1 -1
  75. package/dist/elements/TableGridChange.js +0 -10
  76. package/dist/elements/TableGridChange.js.map +1 -1
  77. package/dist/elements/TableRow.d.ts +4 -0
  78. package/dist/elements/TableRow.d.ts.map +1 -1
  79. package/dist/elements/TableRow.js +31 -3
  80. package/dist/elements/TableRow.js.map +1 -1
  81. package/dist/formatting/AbstractNumbering.d.ts +5 -0
  82. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  83. package/dist/formatting/AbstractNumbering.js +22 -0
  84. package/dist/formatting/AbstractNumbering.js.map +1 -1
  85. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  86. package/dist/formatting/NumberingLevel.js +3 -3
  87. package/dist/formatting/NumberingLevel.js.map +1 -1
  88. package/dist/formatting/Style.d.ts +1 -0
  89. package/dist/formatting/Style.d.ts.map +1 -1
  90. package/dist/formatting/Style.js +25 -59
  91. package/dist/formatting/Style.js.map +1 -1
  92. package/dist/formatting/StylesManager.d.ts +1 -0
  93. package/dist/formatting/StylesManager.d.ts.map +1 -1
  94. package/dist/formatting/StylesManager.js +12 -0
  95. package/dist/formatting/StylesManager.js.map +1 -1
  96. package/dist/helpers/CleanupHelper.js.map +1 -1
  97. package/dist/images/ImageOptimizer.d.ts.map +1 -1
  98. package/dist/images/ImageOptimizer.js +0 -1
  99. package/dist/images/ImageOptimizer.js.map +1 -1
  100. package/dist/index.d.ts +1 -1
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js.map +1 -1
  103. package/dist/managers/DrawingManager.d.ts.map +1 -1
  104. package/dist/managers/DrawingManager.js +4 -2
  105. package/dist/managers/DrawingManager.js.map +1 -1
  106. package/dist/types/formatting.d.ts +2 -2
  107. package/dist/types/formatting.d.ts.map +1 -1
  108. package/dist/types/formatting.js.map +1 -1
  109. package/dist/utils/ChangelogGenerator.d.ts +2 -2
  110. package/dist/utils/ChangelogGenerator.d.ts.map +1 -1
  111. package/dist/utils/ChangelogGenerator.js +4 -5
  112. package/dist/utils/ChangelogGenerator.js.map +1 -1
  113. package/dist/utils/InMemoryRevisionAcceptor.d.ts.map +1 -1
  114. package/dist/utils/InMemoryRevisionAcceptor.js +0 -1
  115. package/dist/utils/InMemoryRevisionAcceptor.js.map +1 -1
  116. package/dist/utils/RevisionAwareProcessor.d.ts +2 -2
  117. package/dist/utils/RevisionAwareProcessor.d.ts.map +1 -1
  118. package/dist/utils/RevisionAwareProcessor.js +2 -2
  119. package/dist/utils/RevisionAwareProcessor.js.map +1 -1
  120. package/dist/utils/SelectiveRevisionAcceptor.d.ts +0 -2
  121. package/dist/utils/SelectiveRevisionAcceptor.d.ts.map +1 -1
  122. package/dist/utils/SelectiveRevisionAcceptor.js +0 -26
  123. package/dist/utils/SelectiveRevisionAcceptor.js.map +1 -1
  124. package/dist/utils/ShadingResolver.d.ts.map +1 -1
  125. package/dist/utils/ShadingResolver.js.map +1 -1
  126. package/dist/utils/acceptRevisions.js +1 -1
  127. package/dist/utils/acceptRevisions.js.map +1 -1
  128. package/dist/utils/stripTrackedChanges.js +1 -1
  129. package/dist/utils/stripTrackedChanges.js.map +1 -1
  130. package/dist/utils/units.d.ts.map +1 -1
  131. package/dist/utils/units.js +1 -1
  132. package/dist/utils/units.js.map +1 -1
  133. package/dist/validation/RevisionAutoFixer.d.ts +2 -1
  134. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  135. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  136. package/package.json +10 -1
  137. package/src/constants/CLAUDE.md +28 -0
  138. package/src/core/CLAUDE.md +4 -0
  139. package/src/core/Document.ts +1770 -83
  140. package/src/core/DocumentContent.ts +0 -11
  141. package/src/core/DocumentGenerator.ts +11 -12
  142. package/src/core/DocumentParser.ts +654 -141
  143. package/src/core/RelationshipManager.ts +6 -3
  144. package/src/elements/Bookmark.ts +39 -4
  145. package/src/elements/BookmarkManager.ts +4 -3
  146. package/src/elements/CLAUDE.md +18 -2
  147. package/src/elements/CommonTypes.ts +35 -8
  148. package/src/elements/Field.ts +1 -1
  149. package/src/elements/Footer.ts +23 -0
  150. package/src/elements/Header.ts +25 -0
  151. package/src/elements/Image.ts +5 -0
  152. package/src/elements/Paragraph.ts +1069 -41
  153. package/src/elements/Revision.ts +0 -19
  154. package/src/elements/RevisionManager.ts +1 -3
  155. package/src/elements/Run.ts +265 -35
  156. package/src/elements/Section.ts +214 -8
  157. package/src/elements/Shape.ts +1 -1
  158. package/src/elements/Table.ts +850 -61
  159. package/src/elements/TableCell.ts +84 -10
  160. package/src/elements/TableGridChange.ts +2 -16
  161. package/src/elements/TableRow.ts +94 -9
  162. package/src/formatting/AbstractNumbering.ts +42 -1
  163. package/src/formatting/CLAUDE.md +4 -0
  164. package/src/formatting/NumberingLevel.ts +11 -7
  165. package/src/formatting/Style.ts +39 -71
  166. package/src/formatting/StylesManager.ts +36 -0
  167. package/src/helpers/CleanupHelper.ts +1 -1
  168. package/src/images/ImageOptimizer.ts +0 -3
  169. package/src/index.ts +1 -1
  170. package/src/managers/DrawingManager.ts +5 -3
  171. package/src/tracking/CLAUDE.md +30 -0
  172. package/src/types/CLAUDE.md +39 -0
  173. package/src/types/formatting.ts +2 -2
  174. package/src/utils/CLAUDE.md +15 -0
  175. package/src/utils/ChangelogGenerator.ts +4 -5
  176. package/src/utils/InMemoryRevisionAcceptor.ts +0 -9
  177. package/src/utils/RevisionAwareProcessor.ts +2 -3
  178. package/src/utils/SelectiveRevisionAcceptor.ts +0 -39
  179. package/src/utils/ShadingResolver.ts +0 -1
  180. package/src/utils/acceptRevisions.ts +1 -1
  181. package/src/utils/stripTrackedChanges.ts +1 -1
  182. package/src/utils/units.ts +2 -1
  183. package/src/validation/CLAUDE.md +40 -0
  184. package/src/validation/RevisionAutoFixer.ts +2 -1
@@ -6,6 +6,7 @@
6
6
  import { deepClone } from '../utils/deepClone';
7
7
  import { formatDateForXml } from '../utils/dateFormatting';
8
8
  import { logParagraphContent, logTextDirection } from '../utils/diagnostics';
9
+ import { isEqualFormatting } from '../utils/formatting';
9
10
  import { defaultLogger } from '../utils/logger';
10
11
  import { XMLBuilder, XMLElement } from '../xml/XMLBuilder';
11
12
  import { Bookmark } from './Bookmark';
@@ -14,14 +15,11 @@ import {
14
15
  // Import common types
15
16
  ParagraphAlignment as CommonParagraphAlignment,
16
17
  BorderStyle as CommonBorderStyle,
17
- ShadingPattern as CommonShadingPattern,
18
18
  BasicShadingPattern,
19
19
  TabAlignment as CommonTabAlignment,
20
20
  TabLeader as CommonTabLeader,
21
21
  TextDirection as CommonTextDirection,
22
22
  TextVerticalAlignment,
23
- BorderDefinition as CommonBorderDefinition,
24
- TabStop as CommonTabStop,
25
23
  ShadingConfig,
26
24
  buildShadingAttributes,
27
25
  } from './CommonTypes';
@@ -138,8 +136,8 @@ export interface FrameProperties {
138
136
  hSpace?: number;
139
137
  /** Vertical padding in twips */
140
138
  vSpace?: number;
141
- /** Text wrapping around frame */
142
- wrap?: 'around' | 'notBeside' | 'none' | 'tight';
139
+ /** Text wrapping around frame per ECMA-376 ST_Wrap (§17.18.104) */
140
+ wrap?: 'around' | 'auto' | 'none' | 'notBeside' | 'through' | 'tight';
143
141
  /** Drop cap style */
144
142
  dropCap?: 'none' | 'drop' | 'margin';
145
143
  /** Drop cap height in lines */
@@ -187,12 +185,20 @@ export interface ParagraphFormatting {
187
185
  firstLine?: number;
188
186
  hanging?: number;
189
187
  };
190
- /** Spacing in twips */
188
+ /** Spacing per ECMA-376 §17.3.1.33 (CT_Spacing) */
191
189
  spacing?: {
192
190
  before?: number;
193
191
  after?: number;
194
192
  line?: number;
195
193
  lineRule?: 'auto' | 'exact' | 'atLeast';
194
+ /** Spacing before in hundredths of a line */
195
+ beforeLines?: number;
196
+ /** Spacing after in hundredths of a line */
197
+ afterLines?: number;
198
+ /** Auto-calculate before spacing (overrides w:before when true) */
199
+ beforeAutospacing?: boolean;
200
+ /** Auto-calculate after spacing (overrides w:after when true) */
201
+ afterAutospacing?: boolean;
196
202
  };
197
203
  /** Keep with next paragraph */
198
204
  keepNext?: boolean;
@@ -211,6 +217,8 @@ export interface ParagraphFormatting {
211
217
  contextualSpacing?: boolean;
212
218
  /** Paragraph ID (Word 2010+) - required by modern Word for change tracking */
213
219
  paraId?: string;
220
+ /** Text ID (Word 2010+) - tracks text modifications for merge conflict resolution */
221
+ textId?: string;
214
222
  /** Paragraph borders (top, bottom, left, right, between, bar) */
215
223
  borders?: {
216
224
  top?: BorderDefinition;
@@ -682,13 +690,27 @@ export class Paragraph {
682
690
  */
683
691
  /**
684
692
  * Adds a hyperlink to the paragraph
685
- * @param url - Optional URL for the hyperlink
686
- * @returns Hyperlink object for fluent chaining
693
+ * NOTE: This method has overloaded return types:
694
+ * - `addHyperlink(url: string)` returns the `Hyperlink` object (for configuring the link)
695
+ * - `addHyperlink(hyperlink: Hyperlink)` returns `this` (for paragraph chaining)
696
+ *
697
+ * @example
698
+ * ```typescript
699
+ * // Pattern 1: Create and configure a new hyperlink
700
+ * const link = para.addHyperlink('https://example.com');
701
+ * link.setText('Visit Example');
702
+ *
703
+ * // Pattern 2: Add pre-built hyperlink (returns paragraph for chaining)
704
+ * para.addHyperlink(new Hyperlink({ url: 'https://example.com', text: 'Link' }))
705
+ * .addText(' more text after the link');
706
+ * ```
707
+ *
708
+ * @param url - URL string to create a new hyperlink
709
+ * @returns Hyperlink object for configuring the link
687
710
  */
688
711
  addHyperlink(url?: string): Hyperlink;
689
712
  /**
690
- * Adds an existing hyperlink to the paragraph
691
- * @param hyperlink - Existing Hyperlink object
713
+ * @param hyperlink - Existing Hyperlink object to add
692
714
  * @returns This paragraph for chaining
693
715
  */
694
716
  addHyperlink(hyperlink: Hyperlink): this;
@@ -1029,6 +1051,44 @@ export class Paragraph {
1029
1051
  return this;
1030
1052
  }
1031
1053
 
1054
+ /**
1055
+ * Adds a line break to the paragraph
1056
+ *
1057
+ * Creates a Run containing a line break element (`w:br`). This produces
1058
+ * a soft return (line break within the same paragraph), unlike creating
1059
+ * a new paragraph which produces a hard return.
1060
+ *
1061
+ * @returns This paragraph for chaining
1062
+ *
1063
+ * @example
1064
+ * ```typescript
1065
+ * para.addText('Line one');
1066
+ * para.addLineBreak();
1067
+ * para.addText('Line two (same paragraph)');
1068
+ * ```
1069
+ */
1070
+ addLineBreak(): this {
1071
+ const run = new Run('');
1072
+ run.addBreak();
1073
+ this.content.push(run);
1074
+ return this;
1075
+ }
1076
+
1077
+ /**
1078
+ * Adds a column break to the paragraph
1079
+ *
1080
+ * Inserts a column break element (`w:br w:type="column"`). In multi-column
1081
+ * layouts, forces subsequent content to the next column.
1082
+ *
1083
+ * @returns This paragraph for chaining
1084
+ */
1085
+ addColumnBreak(): this {
1086
+ const run = new Run('');
1087
+ run.addBreak('column');
1088
+ this.content.push(run);
1089
+ return this;
1090
+ }
1091
+
1032
1092
  /**
1033
1093
  * Sets the paragraph text content (replaces all existing content)
1034
1094
  *
@@ -1772,6 +1832,29 @@ export class Paragraph {
1772
1832
  return this.getText().trim().length === 0;
1773
1833
  }
1774
1834
 
1835
+ /**
1836
+ * Checks whether the paragraph text contains a substring
1837
+ *
1838
+ * Case-insensitive by default. A simpler alternative to `findText()`
1839
+ * when you just need a boolean check.
1840
+ *
1841
+ * @param text - Substring to search for
1842
+ * @param caseSensitive - Match case exactly (default: false)
1843
+ * @returns True if the paragraph contains the text
1844
+ *
1845
+ * @example
1846
+ * ```typescript
1847
+ * if (para.contains('TODO')) {
1848
+ * console.log('Found a TODO item');
1849
+ * }
1850
+ * ```
1851
+ */
1852
+ contains(text: string, caseSensitive = false): boolean {
1853
+ const paraText = caseSensitive ? this.getText() : this.getText().toLowerCase();
1854
+ const search = caseSensitive ? text : text.toLowerCase();
1855
+ return paraText.includes(search);
1856
+ }
1857
+
1775
1858
  /**
1776
1859
  * Gets the paragraph style ID
1777
1860
  *
@@ -2222,7 +2305,7 @@ export class Paragraph {
2222
2305
 
2223
2306
  // Resolve property conflicts: keepNext contradicts pageBreakBefore
2224
2307
  if (keepNext) {
2225
- this.formatting.pageBreakBefore = false;
2308
+ this.formatting.pageBreakBefore = undefined;
2226
2309
  }
2227
2310
 
2228
2311
  if (this.trackingContext?.isEnabled() && previousValue !== keepNext) {
@@ -2250,7 +2333,7 @@ export class Paragraph {
2250
2333
 
2251
2334
  // Resolve property conflicts: keepLines contradicts pageBreakBefore
2252
2335
  if (keepLines) {
2253
- this.formatting.pageBreakBefore = false;
2336
+ this.formatting.pageBreakBefore = undefined;
2254
2337
  }
2255
2338
 
2256
2339
  if (this.trackingContext?.isEnabled() && previousValue !== keepLines) {
@@ -2983,14 +3066,18 @@ export class Paragraph {
2983
3066
  pPrChildren.push(XMLBuilder.wSelf('pStyle', { 'w:val': this.formatting.style }));
2984
3067
  }
2985
3068
 
2986
- // 2. Keep with next paragraph
2987
- if (this.formatting.keepNext) {
2988
- pPrChildren.push(XMLBuilder.wSelf('keepNext'));
3069
+ // 2. Keep with next paragraph (CT_OnOff — emit val="0" to override style inheritance)
3070
+ if (this.formatting.keepNext !== undefined) {
3071
+ pPrChildren.push(
3072
+ XMLBuilder.wSelf('keepNext', { 'w:val': this.formatting.keepNext ? '1' : '0' })
3073
+ );
2989
3074
  }
2990
3075
 
2991
3076
  // 3. Keep lines together
2992
- if (this.formatting.keepLines) {
2993
- pPrChildren.push(XMLBuilder.wSelf('keepLines'));
3077
+ if (this.formatting.keepLines !== undefined) {
3078
+ pPrChildren.push(
3079
+ XMLBuilder.wSelf('keepLines', { 'w:val': this.formatting.keepLines ? '1' : '0' })
3080
+ );
2994
3081
  }
2995
3082
 
2996
3083
  // CT_PPrBase element order per ECMA-376:
@@ -3002,8 +3089,12 @@ export class Paragraph {
3002
3089
  // outlineLvl → divId → cnfStyle
3003
3090
 
3004
3091
  // 4. Page break before
3005
- if (this.formatting.pageBreakBefore) {
3006
- pPrChildren.push(XMLBuilder.wSelf('pageBreakBefore'));
3092
+ if (this.formatting.pageBreakBefore !== undefined) {
3093
+ pPrChildren.push(
3094
+ XMLBuilder.wSelf('pageBreakBefore', {
3095
+ 'w:val': this.formatting.pageBreakBefore ? '1' : '0',
3096
+ })
3097
+ );
3007
3098
  }
3008
3099
 
3009
3100
  // 5. Text frame properties (framePr)
@@ -3050,11 +3141,22 @@ export class Paragraph {
3050
3141
  }),
3051
3142
  ]);
3052
3143
  pPrChildren.push(numPr);
3144
+ } else if (this.formatting.numberingSuppressed) {
3145
+ // Per ECMA-376 §17.3.1.19, numId=0 explicitly removes numbering inherited from style
3146
+ const numPr = XMLBuilder.w('numPr', undefined, [
3147
+ XMLBuilder.wSelf('ilvl', { 'w:val': '0' }),
3148
+ XMLBuilder.wSelf('numId', { 'w:val': '0' }),
3149
+ ]);
3150
+ pPrChildren.push(numPr);
3053
3151
  }
3054
3152
 
3055
3153
  // 8. Suppress line numbers
3056
- if (this.formatting.suppressLineNumbers) {
3057
- pPrChildren.push(XMLBuilder.wSelf('suppressLineNumbers'));
3154
+ if (this.formatting.suppressLineNumbers !== undefined) {
3155
+ pPrChildren.push(
3156
+ XMLBuilder.wSelf('suppressLineNumbers', {
3157
+ 'w:val': this.formatting.suppressLineNumbers ? '1' : '0',
3158
+ })
3159
+ );
3058
3160
  }
3059
3161
 
3060
3162
  // 9. Paragraph borders
@@ -3126,8 +3228,12 @@ export class Paragraph {
3126
3228
  }
3127
3229
 
3128
3230
  // 12. Suppress automatic hyphenation
3129
- if (this.formatting.suppressAutoHyphens) {
3130
- pPrChildren.push(XMLBuilder.wSelf('suppressAutoHyphens'));
3231
+ if (this.formatting.suppressAutoHyphens !== undefined) {
3232
+ pPrChildren.push(
3233
+ XMLBuilder.wSelf('suppressAutoHyphens', {
3234
+ 'w:val': this.formatting.suppressAutoHyphens ? '1' : '0',
3235
+ })
3236
+ );
3131
3237
  }
3132
3238
 
3133
3239
  // 13. CJK paragraph properties
@@ -3176,12 +3282,18 @@ export class Paragraph {
3176
3282
  );
3177
3283
  }
3178
3284
 
3179
- // 16. Spacing (before/after/line)
3285
+ // 16. Spacing (before/after/line + autospacing/lines per ECMA-376 §17.3.1.33)
3180
3286
  if (this.formatting.spacing) {
3181
3287
  const spc = this.formatting.spacing;
3182
3288
  const attributes: Record<string, number | string> = {};
3183
3289
  if (spc.before !== undefined) attributes['w:before'] = spc.before;
3290
+ if (spc.beforeLines !== undefined) attributes['w:beforeLines'] = spc.beforeLines;
3291
+ if (spc.beforeAutospacing !== undefined)
3292
+ attributes['w:beforeAutospacing'] = spc.beforeAutospacing ? '1' : '0';
3184
3293
  if (spc.after !== undefined) attributes['w:after'] = spc.after;
3294
+ if (spc.afterLines !== undefined) attributes['w:afterLines'] = spc.afterLines;
3295
+ if (spc.afterAutospacing !== undefined)
3296
+ attributes['w:afterAutospacing'] = spc.afterAutospacing ? '1' : '0';
3185
3297
  if (spc.line !== undefined) attributes['w:line'] = spc.line;
3186
3298
  if (spc.lineRule) attributes['w:lineRule'] = spc.lineRule;
3187
3299
  if (Object.keys(attributes).length > 0) {
@@ -3190,31 +3302,45 @@ export class Paragraph {
3190
3302
  }
3191
3303
 
3192
3304
  // 17. Indentation (left/right/firstLine/hanging)
3305
+ // Per ECMA-376 §17.3.1.12, firstLine and hanging are mutually exclusive — hanging takes precedence
3193
3306
  if (this.formatting.indentation) {
3194
3307
  const ind = this.formatting.indentation;
3195
3308
  const attributes: Record<string, number> = {};
3196
3309
  if (ind.left !== undefined) attributes['w:left'] = ind.left;
3197
3310
  if (ind.right !== undefined) attributes['w:right'] = ind.right;
3198
- if (ind.firstLine !== undefined) attributes['w:firstLine'] = ind.firstLine;
3199
- if (ind.hanging !== undefined) attributes['w:hanging'] = ind.hanging;
3311
+ if (ind.hanging !== undefined) {
3312
+ attributes['w:hanging'] = ind.hanging;
3313
+ } else if (ind.firstLine !== undefined) {
3314
+ attributes['w:firstLine'] = ind.firstLine;
3315
+ }
3200
3316
  if (Object.keys(attributes).length > 0) {
3201
3317
  pPrChildren.push(XMLBuilder.wSelf('ind', attributes));
3202
3318
  }
3203
3319
  }
3204
3320
 
3205
3321
  // 18. Contextual spacing
3206
- if (this.formatting.contextualSpacing) {
3207
- pPrChildren.push(XMLBuilder.wSelf('contextualSpacing', { 'w:val': '1' }));
3322
+ if (this.formatting.contextualSpacing !== undefined) {
3323
+ pPrChildren.push(
3324
+ XMLBuilder.wSelf('contextualSpacing', {
3325
+ 'w:val': this.formatting.contextualSpacing ? '1' : '0',
3326
+ })
3327
+ );
3208
3328
  }
3209
3329
 
3210
3330
  // 19. Mirror indents
3211
- if (this.formatting.mirrorIndents) {
3212
- pPrChildren.push(XMLBuilder.wSelf('mirrorIndents'));
3331
+ if (this.formatting.mirrorIndents !== undefined) {
3332
+ pPrChildren.push(
3333
+ XMLBuilder.wSelf('mirrorIndents', { 'w:val': this.formatting.mirrorIndents ? '1' : '0' })
3334
+ );
3213
3335
  }
3214
3336
 
3215
3337
  // 20. Suppress text frame overlap
3216
- if (this.formatting.suppressOverlap) {
3217
- pPrChildren.push(XMLBuilder.wSelf('suppressOverlap'));
3338
+ if (this.formatting.suppressOverlap !== undefined) {
3339
+ pPrChildren.push(
3340
+ XMLBuilder.wSelf('suppressOverlap', {
3341
+ 'w:val': this.formatting.suppressOverlap ? '1' : '0',
3342
+ })
3343
+ );
3218
3344
  }
3219
3345
 
3220
3346
  // 21. Justification/Alignment
@@ -3361,9 +3487,11 @@ export class Paragraph {
3361
3487
 
3362
3488
  // Build child w:pPr element with previous properties
3363
3489
  // Per CT_PPrBase schema order: pStyle, keepNext, keepLines, pageBreakBefore,
3364
- // widowControl, numPr, suppressLineNumbers, pBdr, shd, tabs,
3365
- // suppressAutoHyphens, bidi, adjustRightInd, spacing, ind,
3366
- // contextualSpacing, mirrorIndents, jc, textDirection, textAlignment, outlineLvl
3490
+ // framePr, widowControl, numPr, suppressLineNumbers, pBdr, shd, tabs,
3491
+ // suppressAutoHyphens, kinsoku, wordWrap, overflowPunct, topLinePunct,
3492
+ // autoSpaceDE, autoSpaceDN, bidi, adjustRightInd, spacing, ind,
3493
+ // contextualSpacing, mirrorIndents, suppressOverlap, jc, textDirection,
3494
+ // textAlignment, outlineLvl
3367
3495
  const prevPPrChildren: XMLElement[] = [];
3368
3496
  if (change.previousProperties) {
3369
3497
  const prev = change.previousProperties;
@@ -3397,7 +3525,31 @@ export class Paragraph {
3397
3525
  );
3398
3526
  }
3399
3527
 
3400
- // 5. widowControl
3528
+ // 5. framePr (text frame properties)
3529
+ if (prev.framePr) {
3530
+ const fAttrs: Record<string, string> = {};
3531
+ const f = prev.framePr;
3532
+ if (f.w !== undefined) fAttrs['w:w'] = f.w.toString();
3533
+ if (f.h !== undefined) fAttrs['w:h'] = f.h.toString();
3534
+ if (f.hRule) fAttrs['w:hRule'] = f.hRule;
3535
+ if (f.x !== undefined) fAttrs['w:x'] = f.x.toString();
3536
+ if (f.y !== undefined) fAttrs['w:y'] = f.y.toString();
3537
+ if (f.xAlign) fAttrs['w:xAlign'] = f.xAlign;
3538
+ if (f.yAlign) fAttrs['w:yAlign'] = f.yAlign;
3539
+ if (f.hAnchor) fAttrs['w:hAnchor'] = f.hAnchor;
3540
+ if (f.vAnchor) fAttrs['w:vAnchor'] = f.vAnchor;
3541
+ if (f.hSpace !== undefined) fAttrs['w:hSpace'] = f.hSpace.toString();
3542
+ if (f.vSpace !== undefined) fAttrs['w:vSpace'] = f.vSpace.toString();
3543
+ if (f.wrap) fAttrs['w:wrap'] = f.wrap;
3544
+ if (f.dropCap) fAttrs['w:dropCap'] = f.dropCap;
3545
+ if (f.lines !== undefined) fAttrs['w:lines'] = f.lines.toString();
3546
+ if (f.anchorLock !== undefined) fAttrs['w:anchorLock'] = f.anchorLock ? '1' : '0';
3547
+ if (Object.keys(fAttrs).length > 0) {
3548
+ prevPPrChildren.push(XMLBuilder.wSelf('framePr', fAttrs));
3549
+ }
3550
+ }
3551
+
3552
+ // 6. widowControl
3401
3553
  if (prev.widowControl !== undefined) {
3402
3554
  prevPPrChildren.push(
3403
3555
  XMLBuilder.wSelf('widowControl', {
@@ -3422,6 +3574,13 @@ export class Paragraph {
3422
3574
  if (numPrChildren.length > 0) {
3423
3575
  prevPPrChildren.push(XMLBuilder.w('numPr', undefined, numPrChildren));
3424
3576
  }
3577
+ } else if (prev.numberingSuppressed) {
3578
+ prevPPrChildren.push(
3579
+ XMLBuilder.w('numPr', undefined, [
3580
+ XMLBuilder.wSelf('ilvl', { 'w:val': '0' }),
3581
+ XMLBuilder.wSelf('numId', { 'w:val': '0' }),
3582
+ ])
3583
+ );
3425
3584
  }
3426
3585
 
3427
3586
  // 7. suppressLineNumbers
@@ -3481,7 +3640,37 @@ export class Paragraph {
3481
3640
  }
3482
3641
  }
3483
3642
 
3484
- // 12. bidi
3643
+ // 12. CJK paragraph properties
3644
+ if (prev.kinsoku !== undefined) {
3645
+ prevPPrChildren.push(XMLBuilder.wSelf('kinsoku', { 'w:val': prev.kinsoku ? '1' : '0' }));
3646
+ }
3647
+ if (prev.wordWrap !== undefined) {
3648
+ prevPPrChildren.push(
3649
+ XMLBuilder.wSelf('wordWrap', { 'w:val': prev.wordWrap ? '1' : '0' })
3650
+ );
3651
+ }
3652
+ if (prev.overflowPunct !== undefined) {
3653
+ prevPPrChildren.push(
3654
+ XMLBuilder.wSelf('overflowPunct', { 'w:val': prev.overflowPunct ? '1' : '0' })
3655
+ );
3656
+ }
3657
+ if (prev.topLinePunct !== undefined) {
3658
+ prevPPrChildren.push(
3659
+ XMLBuilder.wSelf('topLinePunct', { 'w:val': prev.topLinePunct ? '1' : '0' })
3660
+ );
3661
+ }
3662
+ if (prev.autoSpaceDE !== undefined) {
3663
+ prevPPrChildren.push(
3664
+ XMLBuilder.wSelf('autoSpaceDE', { 'w:val': prev.autoSpaceDE ? '1' : '0' })
3665
+ );
3666
+ }
3667
+ if (prev.autoSpaceDN !== undefined) {
3668
+ prevPPrChildren.push(
3669
+ XMLBuilder.wSelf('autoSpaceDN', { 'w:val': prev.autoSpaceDN ? '1' : '0' })
3670
+ );
3671
+ }
3672
+
3673
+ // 13. bidi
3485
3674
  if (prev.bidi !== undefined) {
3486
3675
  prevPPrChildren.push(XMLBuilder.wSelf('bidi', { 'w:val': prev.bidi ? '1' : '0' }));
3487
3676
  }
@@ -3495,13 +3684,21 @@ export class Paragraph {
3495
3684
  );
3496
3685
  }
3497
3686
 
3498
- // 14. spacing
3687
+ // 14. spacing (all 8 CT_Spacing attributes)
3499
3688
  if (prev.spacing) {
3500
3689
  const spacingAttrs: Record<string, string> = {};
3501
3690
  if (prev.spacing.before !== undefined)
3502
3691
  spacingAttrs['w:before'] = prev.spacing.before.toString();
3692
+ if (prev.spacing.beforeLines !== undefined)
3693
+ spacingAttrs['w:beforeLines'] = prev.spacing.beforeLines.toString();
3694
+ if (prev.spacing.beforeAutospacing !== undefined)
3695
+ spacingAttrs['w:beforeAutospacing'] = prev.spacing.beforeAutospacing ? '1' : '0';
3503
3696
  if (prev.spacing.after !== undefined)
3504
3697
  spacingAttrs['w:after'] = prev.spacing.after.toString();
3698
+ if (prev.spacing.afterLines !== undefined)
3699
+ spacingAttrs['w:afterLines'] = prev.spacing.afterLines.toString();
3700
+ if (prev.spacing.afterAutospacing !== undefined)
3701
+ spacingAttrs['w:afterAutospacing'] = prev.spacing.afterAutospacing ? '1' : '0';
3505
3702
  if (prev.spacing.line !== undefined)
3506
3703
  spacingAttrs['w:line'] = prev.spacing.line.toString();
3507
3704
  if (prev.spacing.lineRule) spacingAttrs['w:lineRule'] = prev.spacing.lineRule;
@@ -3542,7 +3739,16 @@ export class Paragraph {
3542
3739
  );
3543
3740
  }
3544
3741
 
3545
- // 18. jc (alignment)
3742
+ // 18. suppressOverlap
3743
+ if (prev.suppressOverlap !== undefined) {
3744
+ prevPPrChildren.push(
3745
+ XMLBuilder.wSelf('suppressOverlap', {
3746
+ 'w:val': prev.suppressOverlap ? '1' : '0',
3747
+ })
3748
+ );
3749
+ }
3750
+
3751
+ // 19. jc (alignment)
3546
3752
  if (prev.alignment) {
3547
3753
  // Map 'justify' to 'both' per ECMA-376 ST_Jc enumeration
3548
3754
  const alignmentValue = prev.alignment === 'justify' ? 'both' : prev.alignment;
@@ -3559,12 +3765,29 @@ export class Paragraph {
3559
3765
  prevPPrChildren.push(XMLBuilder.wSelf('textAlignment', { 'w:val': prev.textAlignment }));
3560
3766
  }
3561
3767
 
3562
- // 21. outlineLvl
3768
+ // 21. textboxTightWrap
3769
+ if (prev.textboxTightWrap) {
3770
+ prevPPrChildren.push(
3771
+ XMLBuilder.wSelf('textboxTightWrap', { 'w:val': prev.textboxTightWrap })
3772
+ );
3773
+ }
3774
+
3775
+ // 22. outlineLvl
3563
3776
  if (prev.outlineLevel !== undefined) {
3564
3777
  prevPPrChildren.push(
3565
3778
  XMLBuilder.wSelf('outlineLvl', { 'w:val': prev.outlineLevel.toString() })
3566
3779
  );
3567
3780
  }
3781
+
3782
+ // 23. divId
3783
+ if (prev.divId !== undefined) {
3784
+ prevPPrChildren.push(XMLBuilder.wSelf('divId', { 'w:val': prev.divId.toString() }));
3785
+ }
3786
+
3787
+ // 24. cnfStyle
3788
+ if (prev.cnfStyle) {
3789
+ prevPPrChildren.push(XMLBuilder.wSelf('cnfStyle', { 'w:val': prev.cnfStyle }));
3790
+ }
3568
3791
  }
3569
3792
 
3570
3793
  // Create w:pPrChange element with child w:pPr
@@ -3682,11 +3905,14 @@ export class Paragraph {
3682
3905
  paragraphChildren.push(bookmark.toEndXML());
3683
3906
  }
3684
3907
 
3685
- // Add paragraph-level attributes (Word 2010+ requires w14:paraId)
3908
+ // Add paragraph-level attributes (Word 2010+ w14:paraId and w14:textId)
3686
3909
  const paragraphAttributes: Record<string, string> = {};
3687
3910
  if (this.formatting.paraId) {
3688
3911
  paragraphAttributes['w14:paraId'] = this.formatting.paraId;
3689
3912
  }
3913
+ if (this.formatting.textId) {
3914
+ paragraphAttributes['w14:textId'] = this.formatting.textId;
3915
+ }
3690
3916
 
3691
3917
  return XMLBuilder.w(
3692
3918
  'p',
@@ -3789,6 +4015,233 @@ export class Paragraph {
3789
4015
  return clonedParagraph;
3790
4016
  }
3791
4017
 
4018
+ /**
4019
+ * Serializes the paragraph to a portable JSON object
4020
+ *
4021
+ * Returns a plain object representing the paragraph's text content, inline
4022
+ * formatting, style, and paragraph-level formatting. Only Run content is
4023
+ * serialized — hyperlinks, revisions, and other non-Run elements are
4024
+ * represented by their text only.
4025
+ *
4026
+ * The output is designed for data pipelines, API responses, storage,
4027
+ * and debugging. Use `Paragraph.fromJSON()` to reconstruct.
4028
+ *
4029
+ * @returns JSON-serializable paragraph representation
4030
+ *
4031
+ * @example
4032
+ * ```typescript
4033
+ * const para = new Paragraph().addText('Hello ', { bold: true }).addText('World');
4034
+ * para.setStyle('Heading1').setAlignment('center');
4035
+ *
4036
+ * const json = para.toJSON();
4037
+ * // {
4038
+ * // text: 'Hello World',
4039
+ * // style: 'Heading1',
4040
+ * // alignment: 'center',
4041
+ * // runs: [
4042
+ * // { text: 'Hello ', formatting: { bold: true } },
4043
+ * // { text: 'World', formatting: {} }
4044
+ * // ]
4045
+ * // }
4046
+ * ```
4047
+ */
4048
+ toJSON(): {
4049
+ text: string;
4050
+ style?: string;
4051
+ alignment?: string;
4052
+ runs: { text: string; formatting: RunFormatting }[];
4053
+ numbering?: { numId: number; level: number };
4054
+ indentation?: { left?: number; right?: number; firstLine?: number; hanging?: number };
4055
+ spacing?: { before?: number; after?: number; line?: number; lineRule?: string };
4056
+ } {
4057
+ const runs = this.getRuns().map((run) => ({
4058
+ text: run.getText(),
4059
+ formatting: run.getFormatting(),
4060
+ }));
4061
+
4062
+ const result: ReturnType<Paragraph['toJSON']> = {
4063
+ text: this.getText(),
4064
+ runs,
4065
+ };
4066
+
4067
+ if (this.formatting.style) result.style = this.formatting.style;
4068
+ if (this.formatting.alignment) result.alignment = this.formatting.alignment;
4069
+ if (this.formatting.numbering) result.numbering = { ...this.formatting.numbering };
4070
+ if (this.formatting.indentation) result.indentation = { ...this.formatting.indentation };
4071
+ if (this.formatting.spacing) {
4072
+ result.spacing = {
4073
+ before: this.formatting.spacing.before,
4074
+ after: this.formatting.spacing.after,
4075
+ line: this.formatting.spacing.line,
4076
+ lineRule: this.formatting.spacing.lineRule,
4077
+ };
4078
+ }
4079
+
4080
+ return result;
4081
+ }
4082
+
4083
+ /**
4084
+ * Creates a Paragraph from a JSON object (as produced by `toJSON()`)
4085
+ *
4086
+ * Reconstructs a paragraph with runs, formatting, style, and layout
4087
+ * from a serialized representation. This enables round-trip serialization.
4088
+ *
4089
+ * @param data - JSON object matching the `toJSON()` output shape
4090
+ * @returns New Paragraph instance
4091
+ *
4092
+ * @example
4093
+ * ```typescript
4094
+ * const json = { text: 'Hello', style: 'Heading1', runs: [{ text: 'Hello', formatting: { bold: true } }] };
4095
+ * const para = Paragraph.fromJSON(json);
4096
+ * para.getText(); // 'Hello'
4097
+ * para.getStyle(); // 'Heading1'
4098
+ * ```
4099
+ */
4100
+ static fromJSON(data: {
4101
+ text?: string;
4102
+ style?: string;
4103
+ alignment?: string;
4104
+ runs?: { text: string; formatting?: RunFormatting }[];
4105
+ numbering?: { numId: number; level: number };
4106
+ indentation?: { left?: number; right?: number; firstLine?: number; hanging?: number };
4107
+ spacing?: { before?: number; after?: number; line?: number; lineRule?: string };
4108
+ }): Paragraph {
4109
+ const para = new Paragraph();
4110
+
4111
+ if (data.runs && data.runs.length > 0) {
4112
+ for (const runData of data.runs) {
4113
+ para.addText(runData.text, runData.formatting);
4114
+ }
4115
+ } else if (data.text) {
4116
+ para.addText(data.text);
4117
+ }
4118
+
4119
+ if (data.style) para.setStyle(data.style);
4120
+ if (data.alignment) para.setAlignment(data.alignment as ParagraphAlignment);
4121
+ if (data.numbering) para.setNumbering(data.numbering.numId, data.numbering.level);
4122
+ if (data.indentation) {
4123
+ if (data.indentation.left !== undefined) para.setLeftIndent(data.indentation.left);
4124
+ if (data.indentation.right !== undefined) para.setRightIndent(data.indentation.right);
4125
+ if (data.indentation.firstLine !== undefined)
4126
+ para.setFirstLineIndent(data.indentation.firstLine);
4127
+ if (data.indentation.hanging !== undefined) para.setHangingIndent(data.indentation.hanging);
4128
+ }
4129
+ if (data.spacing) {
4130
+ if (data.spacing.before !== undefined) para.setSpaceBefore(data.spacing.before);
4131
+ if (data.spacing.after !== undefined) para.setSpaceAfter(data.spacing.after);
4132
+ if (data.spacing.line !== undefined) {
4133
+ para.setLineSpacing(
4134
+ data.spacing.line,
4135
+ (data.spacing.lineRule as 'auto' | 'exact' | 'atLeast') ?? 'auto'
4136
+ );
4137
+ }
4138
+ }
4139
+
4140
+ return para;
4141
+ }
4142
+
4143
+ /**
4144
+ * Splits this paragraph at a character offset, returning the tail as a new paragraph
4145
+ *
4146
+ * Content from the offset onward is moved to a new Paragraph. The new paragraph
4147
+ * inherits this paragraph's style and formatting (deep-cloned). If the split point
4148
+ * falls within a Run, that run is split using `Run.splitAt()`.
4149
+ *
4150
+ * Only Run elements contribute to the character offset count. Non-Run content
4151
+ * (hyperlinks, revisions, fields) that appears after all affected runs is moved
4152
+ * to the new paragraph as-is.
4153
+ *
4154
+ * @param offset - Character position to split at (0-based). Content from this
4155
+ * position onward moves to the new paragraph.
4156
+ * @returns A new Paragraph containing content from `offset` onward
4157
+ *
4158
+ * @example
4159
+ * ```typescript
4160
+ * const para = new Paragraph().addText('Hello World');
4161
+ * const tail = para.splitAt(5);
4162
+ * para.getText(); // "Hello"
4163
+ * tail.getText(); // " World"
4164
+ * ```
4165
+ *
4166
+ * @example
4167
+ * ```typescript
4168
+ * // Insert a table between two halves of a paragraph
4169
+ * const tail = para.splitAt(offset);
4170
+ * const paraIndex = doc.getParagraphIndex(para);
4171
+ * doc.insertTableAt(paraIndex + 1, table);
4172
+ * doc.insertParagraphAt(paraIndex + 2, tail);
4173
+ * ```
4174
+ */
4175
+ splitAt(offset: number): Paragraph {
4176
+ // Create new paragraph with cloned formatting and style
4177
+ const tailPara = new Paragraph(deepClone(this.formatting));
4178
+
4179
+ // Edge case: split at or past end — return empty paragraph
4180
+ const totalLength = this.getText().length;
4181
+ if (offset >= totalLength) {
4182
+ return tailPara;
4183
+ }
4184
+
4185
+ // Edge case: split at or before start — move everything
4186
+ if (offset <= 0) {
4187
+ tailPara.content = this.content;
4188
+ this.content = [];
4189
+ return tailPara;
4190
+ }
4191
+
4192
+ // Walk content to find the split point
4193
+ let charPos = 0;
4194
+ let splitContentIndex = -1;
4195
+
4196
+ for (let i = 0; i < this.content.length; i++) {
4197
+ const item = this.content[i]!;
4198
+
4199
+ if (item instanceof Run) {
4200
+ const runLen = item.getText().length;
4201
+ const runEnd = charPos + runLen;
4202
+
4203
+ if (runEnd <= offset) {
4204
+ // Entire run stays in this paragraph
4205
+ charPos = runEnd;
4206
+ continue;
4207
+ }
4208
+
4209
+ if (charPos >= offset) {
4210
+ // Entire run goes to tail paragraph
4211
+ splitContentIndex = i;
4212
+ break;
4213
+ }
4214
+
4215
+ // Split falls within this run
4216
+ const localOffset = offset - charPos;
4217
+ const tailRun = item.splitAt(localOffset);
4218
+
4219
+ // Insert the tail run and mark everything after it for the new paragraph
4220
+ this.content.splice(i + 1, 0, tailRun);
4221
+ splitContentIndex = i + 1;
4222
+ break;
4223
+ } else {
4224
+ // Non-Run content: if we haven't reached the offset yet, keep it here
4225
+ // Once we've passed the offset, it moves to tail
4226
+ if (charPos >= offset) {
4227
+ splitContentIndex = i;
4228
+ break;
4229
+ }
4230
+ }
4231
+ }
4232
+
4233
+ // If no split point found (shouldn't happen given offset < totalLength),
4234
+ // but guard against it
4235
+ if (splitContentIndex === -1) {
4236
+ return tailPara;
4237
+ }
4238
+
4239
+ // Move content from splitContentIndex onward to the tail paragraph
4240
+ tailPara.content = this.content.splice(splitContentIndex);
4241
+
4242
+ return tailPara;
4243
+ }
4244
+
3792
4245
  /**
3793
4246
  * Sets paragraph borders
3794
4247
  * @param borders - Border definitions for each side
@@ -4066,6 +4519,581 @@ export class Paragraph {
4066
4519
  return replacementCount;
4067
4520
  }
4068
4521
 
4522
+ /**
4523
+ * Replaces all occurrences of a string, searching across run boundaries
4524
+ *
4525
+ * A simplified alias for `replaceTextCrossRun()` with no options — just
4526
+ * find and replace. Case-insensitive. Handles Word-fragmented text.
4527
+ *
4528
+ * @param find - Text to search for
4529
+ * @param replace - Replacement text
4530
+ * @returns Number of replacements made
4531
+ *
4532
+ * @example
4533
+ * ```typescript
4534
+ * para.replaceAll('old', 'new');
4535
+ * para.replaceAll('{{name}}', 'Alice');
4536
+ * ```
4537
+ */
4538
+ replaceAll(find: string, replace: string): number {
4539
+ return this.replaceTextCrossRun(find, replace);
4540
+ }
4541
+
4542
+ /**
4543
+ * Searches for text across run boundaries, returning match positions
4544
+ *
4545
+ * Concatenates text from all runs and searches the combined string,
4546
+ * finding matches that may span multiple runs (e.g., `{{name}}` split
4547
+ * across runs as `{{` + `name` + `}}`). This is the read-only
4548
+ * counterpart to `replaceTextCrossRun()`.
4549
+ *
4550
+ * @param find - Text to search for (plain string)
4551
+ * @param options - Search options
4552
+ * @param options.caseSensitive - Match case exactly (default: false)
4553
+ * @returns Array of match objects with offset and matched text
4554
+ *
4555
+ * @example
4556
+ * ```typescript
4557
+ * // Find all placeholders, even if fragmented across runs
4558
+ * const matches = para.findTextCrossRun('{{name}}');
4559
+ * console.log(`Found ${matches.length} matches`);
4560
+ * for (const m of matches) {
4561
+ * console.log(` at offset ${m.offset}: "${m.text}"`);
4562
+ * }
4563
+ * ```
4564
+ */
4565
+ findTextCrossRun(
4566
+ find: string,
4567
+ options?: { caseSensitive?: boolean }
4568
+ ): { offset: number; text: string }[] {
4569
+ const caseSensitive = options?.caseSensitive ?? false;
4570
+
4571
+ // Build full text from runs only
4572
+ let fullText = '';
4573
+ for (const item of this.content) {
4574
+ if (item instanceof Run) {
4575
+ fullText += item.getText();
4576
+ }
4577
+ }
4578
+
4579
+ if (fullText.length === 0) return [];
4580
+
4581
+ const escaped = find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4582
+ const regex = new RegExp(escaped, caseSensitive ? 'g' : 'gi');
4583
+ const results: { offset: number; text: string }[] = [];
4584
+ let m: RegExpExecArray | null;
4585
+
4586
+ while ((m = regex.exec(fullText)) !== null) {
4587
+ results.push({ offset: m.index, text: m[0]! });
4588
+ }
4589
+
4590
+ return results;
4591
+ }
4592
+
4593
+ /**
4594
+ * Finds and replaces text that may span across multiple runs
4595
+ *
4596
+ * Unlike `replaceText()` which searches each run independently, this method
4597
+ * concatenates text across all runs and searches the combined string. This
4598
+ * handles the common case where Word splits text like `{{name}}` across
4599
+ * multiple runs (e.g., `{{` in one run, `name` in another, `}}` in a third).
4600
+ *
4601
+ * The replacement text inherits formatting from the first run of the match.
4602
+ * Runs that are fully consumed by the match are removed; partially consumed
4603
+ * runs are trimmed.
4604
+ *
4605
+ * @param find - Text to search for (plain string, case-insensitive by default)
4606
+ * @param replace - Replacement text
4607
+ * @param options - Search options
4608
+ * @param options.caseSensitive - Match case exactly (default: false)
4609
+ * @returns Number of replacements made
4610
+ *
4611
+ * @example
4612
+ * ```typescript
4613
+ * // Handle Word-fragmented placeholders
4614
+ * // Paragraph has runs: ["{{", "name", "}}"]
4615
+ * para.replaceTextCrossRun('{{name}}', 'Alice');
4616
+ * // Now has single run: "Alice"
4617
+ * ```
4618
+ *
4619
+ * @example
4620
+ * ```typescript
4621
+ * // Works with normal (non-fragmented) text too
4622
+ * para.replaceTextCrossRun('old text', 'new text');
4623
+ * ```
4624
+ */
4625
+ replaceTextCrossRun(
4626
+ find: string,
4627
+ replace: string,
4628
+ options?: { caseSensitive?: boolean }
4629
+ ): number {
4630
+ const caseSensitive = options?.caseSensitive ?? false;
4631
+
4632
+ // Build run map: [{run, contentIndex, startOffset, endOffset}]
4633
+ const runMap: { run: Run; contentIndex: number; start: number; end: number }[] = [];
4634
+ let charPos = 0;
4635
+ for (let i = 0; i < this.content.length; i++) {
4636
+ const item = this.content[i]!;
4637
+ if (item instanceof Run) {
4638
+ const len = item.getText().length;
4639
+ if (len > 0) {
4640
+ runMap.push({ run: item, contentIndex: i, start: charPos, end: charPos + len });
4641
+ }
4642
+ charPos += len;
4643
+ }
4644
+ }
4645
+
4646
+ if (runMap.length === 0) return 0;
4647
+
4648
+ // Get full concatenated text
4649
+ const fullText = runMap.map((r) => r.run.getText()).join('');
4650
+
4651
+ // Find all match positions
4652
+ const escaped = find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4653
+ const regex = new RegExp(escaped, caseSensitive ? 'g' : 'gi');
4654
+ const matches: { start: number; end: number }[] = [];
4655
+ let m: RegExpExecArray | null;
4656
+ while ((m = regex.exec(fullText)) !== null) {
4657
+ matches.push({ start: m.index, end: m.index + m[0]!.length });
4658
+ }
4659
+
4660
+ if (matches.length === 0) return 0;
4661
+
4662
+ // Process matches in reverse order to preserve earlier offsets
4663
+ for (let mi = matches.length - 1; mi >= 0; mi--) {
4664
+ const match = matches[mi]!;
4665
+
4666
+ // Find all runs that overlap this match
4667
+ const affectedRuns = runMap.filter((r) => r.end > match.start && r.start < match.end);
4668
+
4669
+ if (affectedRuns.length === 0) continue;
4670
+
4671
+ const firstAffected = affectedRuns[0]!;
4672
+ const lastAffected = affectedRuns[affectedRuns.length - 1]!;
4673
+
4674
+ // Calculate what to keep from the first and last runs
4675
+ const keepBefore =
4676
+ match.start > firstAffected.start
4677
+ ? firstAffected.run.getText().slice(0, match.start - firstAffected.start)
4678
+ : '';
4679
+ const keepAfter =
4680
+ match.end < lastAffected.end
4681
+ ? lastAffected.run.getText().slice(match.end - lastAffected.start)
4682
+ : '';
4683
+
4684
+ // Set the first affected run to: kept-before + replacement + kept-after
4685
+ firstAffected.run.setText(keepBefore + replace + keepAfter);
4686
+
4687
+ // Remove any middle/last runs that were fully or partially consumed
4688
+ // (iterate in reverse to preserve indices)
4689
+ for (let r = affectedRuns.length - 1; r >= 1; r--) {
4690
+ const runEntry = affectedRuns[r]!;
4691
+ const idx = this.content.indexOf(runEntry.run);
4692
+ if (idx !== -1) {
4693
+ this.content.splice(idx, 1);
4694
+ }
4695
+ }
4696
+ }
4697
+
4698
+ return matches.length;
4699
+ }
4700
+
4701
+ /**
4702
+ * Deletes a character range from the paragraph
4703
+ *
4704
+ * Removes text in the [start, end) range. Runs fully within the range
4705
+ * are removed entirely; boundary runs are trimmed. Non-Run content is
4706
+ * not affected.
4707
+ *
4708
+ * @param start - Start character offset (0-based, inclusive)
4709
+ * @param end - End character offset (0-based, exclusive)
4710
+ * @returns This paragraph for chaining
4711
+ *
4712
+ * @example
4713
+ * ```typescript
4714
+ * const para = new Paragraph().addText('Hello Beautiful World');
4715
+ * para.deleteRange(5, 15);
4716
+ * para.getText(); // "Hello World"
4717
+ * ```
4718
+ */
4719
+ deleteRange(start: number, end: number): this {
4720
+ if (start >= end) return this;
4721
+
4722
+ // Build run map
4723
+ const runMap: { index: number; start: number; end: number }[] = [];
4724
+ let charPos = 0;
4725
+ for (let i = 0; i < this.content.length; i++) {
4726
+ const item = this.content[i]!;
4727
+ if (item instanceof Run) {
4728
+ const len = item.getText().length;
4729
+ if (len > 0) {
4730
+ runMap.push({ index: i, start: charPos, end: charPos + len });
4731
+ }
4732
+ charPos += len;
4733
+ }
4734
+ }
4735
+
4736
+ // Process in reverse to preserve indices
4737
+ for (let m = runMap.length - 1; m >= 0; m--) {
4738
+ const entry = runMap[m]!;
4739
+ const run = this.content[entry.index] as Run;
4740
+
4741
+ // Skip runs entirely outside the range
4742
+ if (entry.end <= start || entry.start >= end) continue;
4743
+
4744
+ const runStart = entry.start;
4745
+ const runEnd = entry.end;
4746
+
4747
+ // How much of this run is in the delete range?
4748
+ const delStart = Math.max(0, start - runStart);
4749
+ const delEnd = Math.min(runEnd - runStart, end - runStart);
4750
+ const runLen = runEnd - runStart;
4751
+
4752
+ if (delStart === 0 && delEnd === runLen) {
4753
+ // Entire run is deleted
4754
+ this.content.splice(entry.index, 1);
4755
+ } else {
4756
+ // Partial deletion — keep the parts outside the range
4757
+ const textBefore = run.getText().slice(0, delStart);
4758
+ const textAfter = run.getText().slice(delEnd);
4759
+ run.setText(textBefore + textAfter);
4760
+ }
4761
+ }
4762
+
4763
+ return this;
4764
+ }
4765
+
4766
+ /**
4767
+ * Truncates paragraph text to a maximum length
4768
+ *
4769
+ * If the paragraph text exceeds `maxLength`, content beyond that point
4770
+ * is removed and an optional suffix (default `'...'`) is appended.
4771
+ * The suffix counts toward the total length, so the final text is at
4772
+ * most `maxLength` characters.
4773
+ *
4774
+ * @param maxLength - Maximum total character length (including suffix)
4775
+ * @param suffix - Text to append when truncated (default: '...')
4776
+ * @returns This paragraph for chaining
4777
+ *
4778
+ * @example
4779
+ * ```typescript
4780
+ * const para = new Paragraph().addText('The quick brown fox jumps over the lazy dog');
4781
+ * para.truncate(20);
4782
+ * para.getText(); // "The quick brown f..."
4783
+ *
4784
+ * para.truncate(10, ' [more]');
4785
+ * // getText() => "The [more]"
4786
+ * ```
4787
+ */
4788
+ truncate(maxLength: number, suffix = '...'): this {
4789
+ const text = this.getText();
4790
+ if (text.length <= maxLength) return this;
4791
+
4792
+ const cutoff = Math.max(0, maxLength - suffix.length);
4793
+ this.deleteRange(cutoff, text.length);
4794
+
4795
+ // Append suffix as a new run (inherits no formatting — plain text indicator)
4796
+ if (suffix) {
4797
+ this.addText(suffix);
4798
+ }
4799
+
4800
+ return this;
4801
+ }
4802
+
4803
+ /**
4804
+ * Wraps existing paragraph content with prefix and/or suffix text
4805
+ *
4806
+ * Inserts a Run with the prefix text before existing content and
4807
+ * a Run with the suffix text after it. Both inherit no formatting
4808
+ * by default, but optional formatting can be provided.
4809
+ *
4810
+ * @param prefix - Text to prepend (empty string to skip)
4811
+ * @param suffix - Text to append (empty string to skip)
4812
+ * @param formatting - Optional formatting for both prefix and suffix runs
4813
+ * @returns This paragraph for chaining
4814
+ *
4815
+ * @example
4816
+ * ```typescript
4817
+ * const para = new Paragraph().addText('important');
4818
+ * para.wrap('[', ']');
4819
+ * para.getText(); // "[important]"
4820
+ *
4821
+ * // With formatting
4822
+ * para.wrap('NOTE: ', '', { bold: true });
4823
+ * ```
4824
+ */
4825
+ wrap(prefix: string, suffix: string, formatting?: RunFormatting): this {
4826
+ if (prefix) {
4827
+ const prefixRun = new Run(prefix, formatting);
4828
+ this.content.unshift(prefixRun);
4829
+ }
4830
+ if (suffix) {
4831
+ const suffixRun = new Run(suffix, formatting);
4832
+ this.content.push(suffixRun);
4833
+ }
4834
+ return this;
4835
+ }
4836
+
4837
+ /**
4838
+ * Returns the Run that contains the character at the given offset
4839
+ *
4840
+ * Walks through Run elements in the paragraph, counting characters,
4841
+ * and returns the Run that contains the specified offset along with
4842
+ * the local offset within that run. Non-Run content is skipped.
4843
+ *
4844
+ * @param offset - Character position (0-based)
4845
+ * @returns Object with the Run and the local offset within it, or
4846
+ * undefined if the offset is out of range or hits non-Run content
4847
+ *
4848
+ * @example
4849
+ * ```typescript
4850
+ * const para = new Paragraph();
4851
+ * para.addRun(new Run('Hello ', { bold: true }));
4852
+ * para.addRun(new Run('World'));
4853
+ *
4854
+ * const result = para.getRunAtOffset(8);
4855
+ * // result.run.getText() === 'World'
4856
+ * // result.localOffset === 2 (the 'r' in 'World')
4857
+ * ```
4858
+ */
4859
+ getRunAtOffset(offset: number): { run: Run; localOffset: number } | undefined {
4860
+ if (offset < 0) return undefined;
4861
+
4862
+ let charPos = 0;
4863
+ for (const item of this.content) {
4864
+ if (!(item instanceof Run)) continue;
4865
+
4866
+ const len = item.getText().length;
4867
+ if (charPos + len > offset) {
4868
+ return { run: item, localOffset: offset - charPos };
4869
+ }
4870
+ charPos += len;
4871
+ }
4872
+
4873
+ return undefined;
4874
+ }
4875
+
4876
+ /**
4877
+ * Returns the RunFormatting active at a given character position
4878
+ *
4879
+ * Finds the Run containing the character at `offset` and returns its
4880
+ * formatting. Useful for format inspection, format painting, and building
4881
+ * rich text cursors.
4882
+ *
4883
+ * @param offset - Character position (0-based)
4884
+ * @returns RunFormatting at that position, or undefined if out of range
4885
+ *
4886
+ * @example
4887
+ * ```typescript
4888
+ * const para = new Paragraph();
4889
+ * para.addRun(new Run('Bold text', { bold: true, color: 'FF0000' }));
4890
+ * para.addRun(new Run(' plain text'));
4891
+ *
4892
+ * const fmt = para.getFormattingAtOffset(3);
4893
+ * // fmt.bold === true
4894
+ * // fmt.color === 'FF0000'
4895
+ *
4896
+ * const fmt2 = para.getFormattingAtOffset(12);
4897
+ * // fmt2.bold === undefined
4898
+ * ```
4899
+ */
4900
+ getFormattingAtOffset(offset: number): RunFormatting | undefined {
4901
+ const result = this.getRunAtOffset(offset);
4902
+ return result ? result.run.getFormatting() : undefined;
4903
+ }
4904
+
4905
+ /**
4906
+ * Applies formatting to a character range across runs
4907
+ *
4908
+ * Splits runs at the range boundaries as needed, then applies the given
4909
+ * formatting properties to every run that falls within [start, end).
4910
+ * Only modifies Run elements — hyperlinks, revisions, fields, and other
4911
+ * non-Run content are skipped (their characters still count toward offsets).
4912
+ *
4913
+ * Each property in the formatting object is applied via the corresponding
4914
+ * setter method (e.g. `bold` → `setBold()`, `color` → `setColor()`).
4915
+ *
4916
+ * @param start - Start character offset (0-based, inclusive)
4917
+ * @param end - End character offset (0-based, exclusive)
4918
+ * @param formatting - Partial RunFormatting to apply to the range
4919
+ * @returns This paragraph for chaining
4920
+ *
4921
+ * @example
4922
+ * ```typescript
4923
+ * // Bold "World" in "Hello World"
4924
+ * const para = new Paragraph().addText('Hello World');
4925
+ * para.applyFormattingToRange(6, 11, { bold: true });
4926
+ * ```
4927
+ *
4928
+ * @example
4929
+ * ```typescript
4930
+ * // Highlight a middle section
4931
+ * const para = new Paragraph().addText('The quick brown fox');
4932
+ * para.applyFormattingToRange(4, 9, {
4933
+ * bold: true,
4934
+ * color: 'FF0000',
4935
+ * highlight: 'yellow',
4936
+ * });
4937
+ * // Results in 3 runs: "The " | "quick" (bold red highlighted) | " brown fox"
4938
+ * ```
4939
+ */
4940
+ applyFormattingToRange(start: number, end: number, formatting: Partial<RunFormatting>): this {
4941
+ if (start >= end) return this;
4942
+
4943
+ // Build a map of content indices to their character offset ranges,
4944
+ // then work backwards to avoid index shifting during splicing.
4945
+ const contentMap: { index: number; start: number; end: number }[] = [];
4946
+ let charPos = 0;
4947
+ for (let i = 0; i < this.content.length; i++) {
4948
+ const item = this.content[i]!;
4949
+ let len = 0;
4950
+ if (item instanceof Run) {
4951
+ len = item.getText().length;
4952
+ }
4953
+ // Non-Run items don't contribute characters for our purposes
4954
+ // (they're skipped but still occupy content array slots)
4955
+ if (len > 0) {
4956
+ contentMap.push({ index: i, start: charPos, end: charPos + len });
4957
+ }
4958
+ charPos += len;
4959
+ }
4960
+
4961
+ // Process runs in reverse order so splice indices stay valid
4962
+ for (let m = contentMap.length - 1; m >= 0; m--) {
4963
+ const entry = contentMap[m]!;
4964
+ const run = this.content[entry.index] as Run;
4965
+
4966
+ // Skip runs entirely outside the range
4967
+ if (entry.end <= start || entry.start >= end) continue;
4968
+
4969
+ const runStart = entry.start;
4970
+ const runEnd = entry.end;
4971
+
4972
+ // Determine split points relative to this run
4973
+ const splitStart = Math.max(0, start - runStart);
4974
+ const splitEnd = Math.min(runEnd - runStart, end - runStart);
4975
+ const runLen = runEnd - runStart;
4976
+
4977
+ if (splitStart === 0 && splitEnd === runLen) {
4978
+ // Entire run is within range — just apply formatting
4979
+ this.applyFormattingToRun(run, formatting);
4980
+ } else if (splitStart === 0) {
4981
+ // Range covers beginning of run — split off the tail
4982
+ const tail = run.splitAt(splitEnd);
4983
+ this.applyFormattingToRun(run, formatting);
4984
+ this.content.splice(entry.index + 1, 0, tail);
4985
+ } else if (splitEnd === runLen) {
4986
+ // Range covers end of run — split off the head
4987
+ const tail = run.splitAt(splitStart);
4988
+ this.applyFormattingToRun(tail, formatting);
4989
+ this.content.splice(entry.index + 1, 0, tail);
4990
+ } else {
4991
+ // Range is in the middle — three-way split
4992
+ const mid = run.splitAt(splitStart);
4993
+ const tail = mid.splitAt(splitEnd - splitStart);
4994
+ this.applyFormattingToRun(mid, formatting);
4995
+ this.content.splice(entry.index + 1, 0, mid, tail);
4996
+ }
4997
+ }
4998
+
4999
+ return this;
5000
+ }
5001
+
5002
+ /**
5003
+ * Applies partial RunFormatting to a run via setter methods.
5004
+ * @internal
5005
+ */
5006
+ private applyFormattingToRun(run: Run, formatting: Partial<RunFormatting>): void {
5007
+ if (formatting.bold !== undefined) run.setBold(formatting.bold);
5008
+ if (formatting.italic !== undefined) run.setItalic(formatting.italic);
5009
+ if (formatting.underline !== undefined) run.setUnderline(formatting.underline);
5010
+ if (formatting.strike !== undefined) run.setStrike(formatting.strike);
5011
+ if (formatting.subscript !== undefined) run.setSubscript(formatting.subscript);
5012
+ if (formatting.superscript !== undefined) run.setSuperscript(formatting.superscript);
5013
+ if (formatting.smallCaps !== undefined) run.setSmallCaps(formatting.smallCaps);
5014
+ if (formatting.allCaps !== undefined) run.setAllCaps(formatting.allCaps);
5015
+ if (formatting.font !== undefined) run.setFont(formatting.font);
5016
+ if (formatting.size !== undefined) run.setSize(formatting.size);
5017
+ if (formatting.color !== undefined) run.setColor(formatting.color);
5018
+ if (formatting.highlight !== undefined) run.setHighlight(formatting.highlight);
5019
+ if (formatting.characterSpacing !== undefined)
5020
+ run.setCharacterSpacing(formatting.characterSpacing);
5021
+ if (formatting.vanish !== undefined) run.setVanish(formatting.vanish);
5022
+ }
5023
+
5024
+ /**
5025
+ * Merges adjacent runs that have identical formatting
5026
+ *
5027
+ * Walks the content array and combines consecutive Run elements whose
5028
+ * formatting is deeply equal. Non-Run content (hyperlinks, revisions,
5029
+ * fields, etc.) acts as a boundary — runs on opposite sides are never
5030
+ * merged even if their formatting matches.
5031
+ *
5032
+ * This is useful after operations that fragment runs, such as
5033
+ * `applyFormattingToRange()` or `Run.splitAt()`, to reduce file size
5034
+ * and simplify the content model.
5035
+ *
5036
+ * @returns Number of runs that were eliminated by merging
5037
+ *
5038
+ * @example
5039
+ * ```typescript
5040
+ * const para = new Paragraph();
5041
+ * para.addRun(new Run('Hello ', { bold: true }));
5042
+ * para.addRun(new Run('World', { bold: true }));
5043
+ * para.addRun(new Run('!'));
5044
+ *
5045
+ * const merged = para.consolidateRuns();
5046
+ * // merged === 1 (two bold runs became one)
5047
+ * // para now has 2 runs: "Hello World" (bold) and "!"
5048
+ * ```
5049
+ *
5050
+ * @example
5051
+ * ```typescript
5052
+ * // Clean up after applyFormattingToRange
5053
+ * para.applyFormattingToRange(0, 5, { bold: true });
5054
+ * para.applyFormattingToRange(5, 10, { bold: true });
5055
+ * para.consolidateRuns(); // merges the two bold fragments
5056
+ * ```
5057
+ */
5058
+ consolidateRuns(): number {
5059
+ if (this.content.length < 2) return 0;
5060
+
5061
+ const consolidated: ParagraphContent[] = [];
5062
+ let eliminated = 0;
5063
+
5064
+ for (const item of this.content) {
5065
+ if (!(item instanceof Run)) {
5066
+ // Non-run content acts as a merge boundary
5067
+ consolidated.push(item);
5068
+ continue;
5069
+ }
5070
+
5071
+ const prev = consolidated.length > 0 ? consolidated[consolidated.length - 1] : undefined;
5072
+
5073
+ if (
5074
+ prev instanceof Run &&
5075
+ isEqualFormatting(
5076
+ prev.getFormatting() as unknown as Record<string, unknown>,
5077
+ item.getFormatting() as unknown as Record<string, unknown>
5078
+ )
5079
+ ) {
5080
+ // Merge: append item's content to prev run
5081
+ const mergedContent = [...prev.getContent(), ...item.getContent()];
5082
+ const mergedRun = Run.createFromContent(mergedContent, deepClone(prev.getFormatting()));
5083
+ consolidated[consolidated.length - 1] = mergedRun;
5084
+ eliminated++;
5085
+ } else {
5086
+ consolidated.push(item);
5087
+ }
5088
+ }
5089
+
5090
+ if (eliminated > 0) {
5091
+ this.content = consolidated;
5092
+ }
5093
+
5094
+ return eliminated;
5095
+ }
5096
+
4069
5097
  /**
4070
5098
  * Merges another paragraph's content into this one
4071
5099
  *