docxmlater 11.0.10 → 12.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +51 -13
  2. package/dist/core/Document.d.ts +28 -2
  3. package/dist/core/Document.d.ts.map +1 -1
  4. package/dist/core/Document.js +725 -170
  5. package/dist/core/Document.js.map +1 -1
  6. package/dist/core/DocumentGenerator.d.ts +1 -0
  7. package/dist/core/DocumentGenerator.d.ts.map +1 -1
  8. package/dist/core/DocumentGenerator.js +77 -25
  9. package/dist/core/DocumentGenerator.js.map +1 -1
  10. package/dist/core/DocumentParser.d.ts +7 -3
  11. package/dist/core/DocumentParser.d.ts.map +1 -1
  12. package/dist/core/DocumentParser.js +427 -81
  13. package/dist/core/DocumentParser.js.map +1 -1
  14. package/dist/core/RelationshipManager.d.ts.map +1 -1
  15. package/dist/core/RelationshipManager.js +2 -2
  16. package/dist/core/RelationshipManager.js.map +1 -1
  17. package/dist/elements/Comment.d.ts +15 -1
  18. package/dist/elements/Comment.d.ts.map +1 -1
  19. package/dist/elements/Comment.js +67 -12
  20. package/dist/elements/Comment.js.map +1 -1
  21. package/dist/elements/CommentManager.d.ts +4 -0
  22. package/dist/elements/CommentManager.d.ts.map +1 -1
  23. package/dist/elements/CommentManager.js +74 -16
  24. package/dist/elements/CommentManager.js.map +1 -1
  25. package/dist/elements/EndnoteManager.d.ts.map +1 -1
  26. package/dist/elements/EndnoteManager.js.map +1 -1
  27. package/dist/elements/Field.d.ts +10 -1
  28. package/dist/elements/Field.d.ts.map +1 -1
  29. package/dist/elements/Field.js +76 -34
  30. package/dist/elements/Field.js.map +1 -1
  31. package/dist/elements/FieldHelpers.d.ts +1 -0
  32. package/dist/elements/FieldHelpers.d.ts.map +1 -1
  33. package/dist/elements/FieldHelpers.js +8 -4
  34. package/dist/elements/FieldHelpers.js.map +1 -1
  35. package/dist/elements/Footer.d.ts +3 -1
  36. package/dist/elements/Footer.d.ts.map +1 -1
  37. package/dist/elements/Footer.js +39 -53
  38. package/dist/elements/Footer.js.map +1 -1
  39. package/dist/elements/FootnoteManager.d.ts.map +1 -1
  40. package/dist/elements/FootnoteManager.js.map +1 -1
  41. package/dist/elements/Header.d.ts +3 -1
  42. package/dist/elements/Header.d.ts.map +1 -1
  43. package/dist/elements/Header.js +39 -53
  44. package/dist/elements/Header.js.map +1 -1
  45. package/dist/elements/HeaderFooterManager.d.ts +4 -2
  46. package/dist/elements/HeaderFooterManager.d.ts.map +1 -1
  47. package/dist/elements/HeaderFooterManager.js +36 -6
  48. package/dist/elements/HeaderFooterManager.js.map +1 -1
  49. package/dist/elements/Hyperlink.d.ts +6 -1
  50. package/dist/elements/Hyperlink.d.ts.map +1 -1
  51. package/dist/elements/Hyperlink.js +69 -44
  52. package/dist/elements/Hyperlink.js.map +1 -1
  53. package/dist/elements/Image.d.ts.map +1 -1
  54. package/dist/elements/Image.js +33 -6
  55. package/dist/elements/Image.js.map +1 -1
  56. package/dist/elements/ImageManager.d.ts +1 -0
  57. package/dist/elements/ImageManager.d.ts.map +1 -1
  58. package/dist/elements/ImageManager.js +11 -0
  59. package/dist/elements/ImageManager.js.map +1 -1
  60. package/dist/elements/Paragraph.d.ts +3 -0
  61. package/dist/elements/Paragraph.d.ts.map +1 -1
  62. package/dist/elements/Paragraph.js +91 -26
  63. package/dist/elements/Paragraph.js.map +1 -1
  64. package/dist/elements/Revision.d.ts +4 -0
  65. package/dist/elements/Revision.d.ts.map +1 -1
  66. package/dist/elements/Revision.js +369 -29
  67. package/dist/elements/Revision.js.map +1 -1
  68. package/dist/elements/Run.d.ts +1 -0
  69. package/dist/elements/Run.d.ts.map +1 -1
  70. package/dist/elements/Run.js +24 -4
  71. package/dist/elements/Run.js.map +1 -1
  72. package/dist/elements/Section.d.ts.map +1 -1
  73. package/dist/elements/Section.js +17 -7
  74. package/dist/elements/Section.js.map +1 -1
  75. package/dist/elements/Shape.d.ts.map +1 -1
  76. package/dist/elements/Shape.js +50 -42
  77. package/dist/elements/Shape.js.map +1 -1
  78. package/dist/elements/StructuredDocumentTag.d.ts +9 -1
  79. package/dist/elements/StructuredDocumentTag.d.ts.map +1 -1
  80. package/dist/elements/StructuredDocumentTag.js +46 -6
  81. package/dist/elements/StructuredDocumentTag.js.map +1 -1
  82. package/dist/elements/Table.d.ts +2 -0
  83. package/dist/elements/Table.d.ts.map +1 -1
  84. package/dist/elements/Table.js +90 -42
  85. package/dist/elements/Table.js.map +1 -1
  86. package/dist/elements/TableCell.d.ts +1 -0
  87. package/dist/elements/TableCell.d.ts.map +1 -1
  88. package/dist/elements/TableCell.js +32 -13
  89. package/dist/elements/TableCell.js.map +1 -1
  90. package/dist/elements/TableOfContents.d.ts.map +1 -1
  91. package/dist/elements/TableOfContents.js +5 -7
  92. package/dist/elements/TableOfContents.js.map +1 -1
  93. package/dist/elements/TableRow.d.ts +1 -0
  94. package/dist/elements/TableRow.d.ts.map +1 -1
  95. package/dist/elements/TableRow.js +16 -1
  96. package/dist/elements/TableRow.js.map +1 -1
  97. package/dist/elements/TextBox.d.ts.map +1 -1
  98. package/dist/elements/TextBox.js +55 -42
  99. package/dist/elements/TextBox.js.map +1 -1
  100. package/dist/esm/core/Document.js +726 -171
  101. package/dist/esm/core/Document.js.map +1 -1
  102. package/dist/esm/core/DocumentGenerator.js +77 -25
  103. package/dist/esm/core/DocumentGenerator.js.map +1 -1
  104. package/dist/esm/core/DocumentParser.js +427 -81
  105. package/dist/esm/core/DocumentParser.js.map +1 -1
  106. package/dist/esm/core/RelationshipManager.js +2 -2
  107. package/dist/esm/core/RelationshipManager.js.map +1 -1
  108. package/dist/esm/elements/Comment.js +67 -12
  109. package/dist/esm/elements/Comment.js.map +1 -1
  110. package/dist/esm/elements/CommentManager.js +74 -16
  111. package/dist/esm/elements/CommentManager.js.map +1 -1
  112. package/dist/esm/elements/EndnoteManager.js.map +1 -1
  113. package/dist/esm/elements/Field.js +77 -35
  114. package/dist/esm/elements/Field.js.map +1 -1
  115. package/dist/esm/elements/FieldHelpers.js +7 -4
  116. package/dist/esm/elements/FieldHelpers.js.map +1 -1
  117. package/dist/esm/elements/Footer.js +39 -53
  118. package/dist/esm/elements/Footer.js.map +1 -1
  119. package/dist/esm/elements/FootnoteManager.js.map +1 -1
  120. package/dist/esm/elements/Header.js +39 -53
  121. package/dist/esm/elements/Header.js.map +1 -1
  122. package/dist/esm/elements/HeaderFooterManager.js +36 -6
  123. package/dist/esm/elements/HeaderFooterManager.js.map +1 -1
  124. package/dist/esm/elements/Hyperlink.js +69 -44
  125. package/dist/esm/elements/Hyperlink.js.map +1 -1
  126. package/dist/esm/elements/Image.js +33 -6
  127. package/dist/esm/elements/Image.js.map +1 -1
  128. package/dist/esm/elements/ImageManager.js +11 -0
  129. package/dist/esm/elements/ImageManager.js.map +1 -1
  130. package/dist/esm/elements/Paragraph.js +91 -26
  131. package/dist/esm/elements/Paragraph.js.map +1 -1
  132. package/dist/esm/elements/Revision.js +369 -29
  133. package/dist/esm/elements/Revision.js.map +1 -1
  134. package/dist/esm/elements/Run.js +24 -4
  135. package/dist/esm/elements/Run.js.map +1 -1
  136. package/dist/esm/elements/Section.js +17 -7
  137. package/dist/esm/elements/Section.js.map +1 -1
  138. package/dist/esm/elements/Shape.js +50 -42
  139. package/dist/esm/elements/Shape.js.map +1 -1
  140. package/dist/esm/elements/StructuredDocumentTag.js +46 -6
  141. package/dist/esm/elements/StructuredDocumentTag.js.map +1 -1
  142. package/dist/esm/elements/Table.js +90 -42
  143. package/dist/esm/elements/Table.js.map +1 -1
  144. package/dist/esm/elements/TableCell.js +32 -13
  145. package/dist/esm/elements/TableCell.js.map +1 -1
  146. package/dist/esm/elements/TableOfContents.js +5 -7
  147. package/dist/esm/elements/TableOfContents.js.map +1 -1
  148. package/dist/esm/elements/TableRow.js +16 -1
  149. package/dist/esm/elements/TableRow.js.map +1 -1
  150. package/dist/esm/elements/TextBox.js +55 -42
  151. package/dist/esm/elements/TextBox.js.map +1 -1
  152. package/dist/esm/formatting/AbstractNumbering.js +1 -1
  153. package/dist/esm/formatting/AbstractNumbering.js.map +1 -1
  154. package/dist/esm/formatting/NumberingLevel.js +8 -6
  155. package/dist/esm/formatting/NumberingLevel.js.map +1 -1
  156. package/dist/esm/formatting/NumberingManager.js +16 -1
  157. package/dist/esm/formatting/NumberingManager.js.map +1 -1
  158. package/dist/esm/formatting/Style.js +14 -0
  159. package/dist/esm/formatting/Style.js.map +1 -1
  160. package/dist/esm/formatting/StylesManager.js +14 -2
  161. package/dist/esm/formatting/StylesManager.js.map +1 -1
  162. package/dist/esm/helpers/CleanupHelper.js +103 -26
  163. package/dist/esm/helpers/CleanupHelper.js.map +1 -1
  164. package/dist/esm/images/ImageOptimizer.js +52 -4
  165. package/dist/esm/images/ImageOptimizer.js.map +1 -1
  166. package/dist/esm/index.js +1 -0
  167. package/dist/esm/index.js.map +1 -1
  168. package/dist/esm/internal.js +1 -1
  169. package/dist/esm/internal.js.map +1 -1
  170. package/dist/esm/processors/CompatibilityUpgrader.js +7 -1
  171. package/dist/esm/processors/CompatibilityUpgrader.js.map +1 -1
  172. package/dist/esm/processors/InMemoryRevisionAcceptor.js +134 -13
  173. package/dist/esm/processors/InMemoryRevisionAcceptor.js.map +1 -1
  174. package/dist/esm/processors/RevisionWalker.js +58 -8
  175. package/dist/esm/processors/RevisionWalker.js.map +1 -1
  176. package/dist/esm/processors/SelectiveRevisionAcceptor.js +116 -17
  177. package/dist/esm/processors/SelectiveRevisionAcceptor.js.map +1 -1
  178. package/dist/esm/processors/cnfStyleDecoder.js +7 -7
  179. package/dist/esm/processors/cnfStyleDecoder.js.map +1 -1
  180. package/dist/esm/processors/stripTrackedChanges.js +16 -5
  181. package/dist/esm/processors/stripTrackedChanges.js.map +1 -1
  182. package/dist/esm/utils/deepClone.js +24 -11
  183. package/dist/esm/utils/deepClone.js.map +1 -1
  184. package/dist/esm/utils/units.js +1 -1
  185. package/dist/esm/utils/units.js.map +1 -1
  186. package/dist/esm/utils/validation.js +11 -23
  187. package/dist/esm/utils/validation.js.map +1 -1
  188. package/dist/esm/utils/xmlSanitization.js +131 -7
  189. package/dist/esm/utils/xmlSanitization.js.map +1 -1
  190. package/dist/esm/validation/RevisionAutoFixer.js +69 -55
  191. package/dist/esm/validation/RevisionAutoFixer.js.map +1 -1
  192. package/dist/esm/validation/RevisionValidator.js +24 -20
  193. package/dist/esm/validation/RevisionValidator.js.map +1 -1
  194. package/dist/esm/xml/XMLBuilder.js +52 -20
  195. package/dist/esm/xml/XMLBuilder.js.map +1 -1
  196. package/dist/esm/xml/XMLParser.js +55 -13
  197. package/dist/esm/xml/XMLParser.js.map +1 -1
  198. package/dist/esm/zip/ZipHandler.js +37 -13
  199. package/dist/esm/zip/ZipHandler.js.map +1 -1
  200. package/dist/esm/zip/ZipReader.js +53 -6
  201. package/dist/esm/zip/ZipReader.js.map +1 -1
  202. package/dist/esm/zip/errors.js +7 -0
  203. package/dist/esm/zip/errors.js.map +1 -1
  204. package/dist/esm/zip/types.js +4 -0
  205. package/dist/esm/zip/types.js.map +1 -1
  206. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  207. package/dist/formatting/AbstractNumbering.js +1 -1
  208. package/dist/formatting/AbstractNumbering.js.map +1 -1
  209. package/dist/formatting/NumberingLevel.d.ts +1 -1
  210. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  211. package/dist/formatting/NumberingLevel.js +8 -6
  212. package/dist/formatting/NumberingLevel.js.map +1 -1
  213. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  214. package/dist/formatting/NumberingManager.js +16 -1
  215. package/dist/formatting/NumberingManager.js.map +1 -1
  216. package/dist/formatting/Style.d.ts.map +1 -1
  217. package/dist/formatting/Style.js +14 -0
  218. package/dist/formatting/Style.js.map +1 -1
  219. package/dist/formatting/StylesManager.d.ts +2 -0
  220. package/dist/formatting/StylesManager.d.ts.map +1 -1
  221. package/dist/formatting/StylesManager.js +14 -2
  222. package/dist/formatting/StylesManager.js.map +1 -1
  223. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  224. package/dist/helpers/CleanupHelper.js +103 -26
  225. package/dist/helpers/CleanupHelper.js.map +1 -1
  226. package/dist/images/ImageOptimizer.d.ts.map +1 -1
  227. package/dist/images/ImageOptimizer.js +52 -4
  228. package/dist/images/ImageOptimizer.js.map +1 -1
  229. package/dist/index.d.ts +3 -1
  230. package/dist/index.d.ts.map +1 -1
  231. package/dist/index.js +4 -2
  232. package/dist/index.js.map +1 -1
  233. package/dist/internal.d.ts +1 -1
  234. package/dist/internal.d.ts.map +1 -1
  235. package/dist/internal.js +2 -1
  236. package/dist/internal.js.map +1 -1
  237. package/dist/processors/CompatibilityUpgrader.d.ts.map +1 -1
  238. package/dist/processors/CompatibilityUpgrader.js +7 -1
  239. package/dist/processors/CompatibilityUpgrader.js.map +1 -1
  240. package/dist/processors/InMemoryRevisionAcceptor.d.ts +1 -0
  241. package/dist/processors/InMemoryRevisionAcceptor.d.ts.map +1 -1
  242. package/dist/processors/InMemoryRevisionAcceptor.js +135 -13
  243. package/dist/processors/InMemoryRevisionAcceptor.js.map +1 -1
  244. package/dist/processors/RevisionWalker.d.ts +2 -0
  245. package/dist/processors/RevisionWalker.d.ts.map +1 -1
  246. package/dist/processors/RevisionWalker.js +58 -8
  247. package/dist/processors/RevisionWalker.js.map +1 -1
  248. package/dist/processors/SelectiveRevisionAcceptor.d.ts.map +1 -1
  249. package/dist/processors/SelectiveRevisionAcceptor.js +116 -17
  250. package/dist/processors/SelectiveRevisionAcceptor.js.map +1 -1
  251. package/dist/processors/cnfStyleDecoder.d.ts.map +1 -1
  252. package/dist/processors/cnfStyleDecoder.js +7 -7
  253. package/dist/processors/cnfStyleDecoder.js.map +1 -1
  254. package/dist/processors/stripTrackedChanges.d.ts.map +1 -1
  255. package/dist/processors/stripTrackedChanges.js +16 -5
  256. package/dist/processors/stripTrackedChanges.js.map +1 -1
  257. package/dist/utils/deepClone.d.ts.map +1 -1
  258. package/dist/utils/deepClone.js +24 -11
  259. package/dist/utils/deepClone.js.map +1 -1
  260. package/dist/utils/units.d.ts.map +1 -1
  261. package/dist/utils/units.js +1 -1
  262. package/dist/utils/units.js.map +1 -1
  263. package/dist/utils/validation.d.ts.map +1 -1
  264. package/dist/utils/validation.js +11 -23
  265. package/dist/utils/validation.js.map +1 -1
  266. package/dist/utils/xmlSanitization.d.ts +2 -0
  267. package/dist/utils/xmlSanitization.d.ts.map +1 -1
  268. package/dist/utils/xmlSanitization.js +133 -7
  269. package/dist/utils/xmlSanitization.js.map +1 -1
  270. package/dist/validation/RevisionAutoFixer.d.ts +6 -2
  271. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  272. package/dist/validation/RevisionAutoFixer.js +68 -54
  273. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  274. package/dist/validation/RevisionValidator.d.ts +2 -0
  275. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  276. package/dist/validation/RevisionValidator.js +26 -21
  277. package/dist/validation/RevisionValidator.js.map +1 -1
  278. package/dist/xml/XMLBuilder.d.ts +1 -0
  279. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  280. package/dist/xml/XMLBuilder.js +52 -20
  281. package/dist/xml/XMLBuilder.js.map +1 -1
  282. package/dist/xml/XMLParser.d.ts +1 -0
  283. package/dist/xml/XMLParser.d.ts.map +1 -1
  284. package/dist/xml/XMLParser.js +55 -13
  285. package/dist/xml/XMLParser.js.map +1 -1
  286. package/dist/zip/ZipHandler.d.ts.map +1 -1
  287. package/dist/zip/ZipHandler.js +37 -13
  288. package/dist/zip/ZipHandler.js.map +1 -1
  289. package/dist/zip/ZipReader.d.ts.map +1 -1
  290. package/dist/zip/ZipReader.js +52 -5
  291. package/dist/zip/ZipReader.js.map +1 -1
  292. package/dist/zip/errors.d.ts +3 -0
  293. package/dist/zip/errors.d.ts.map +1 -1
  294. package/dist/zip/errors.js +9 -1
  295. package/dist/zip/errors.js.map +1 -1
  296. package/dist/zip/types.d.ts +4 -0
  297. package/dist/zip/types.d.ts.map +1 -1
  298. package/dist/zip/types.js +4 -0
  299. package/dist/zip/types.js.map +1 -1
  300. package/package.json +2 -2
  301. package/src/core/Document.ts +1159 -240
  302. package/src/core/DocumentGenerator.ts +106 -26
  303. package/src/core/DocumentParser.ts +681 -114
  304. package/src/core/RelationshipManager.ts +446 -442
  305. package/src/elements/Comment.ts +123 -18
  306. package/src/elements/CommentManager.ts +121 -22
  307. package/src/elements/EndnoteManager.ts +4 -1
  308. package/src/elements/Field.ts +134 -43
  309. package/src/elements/FieldHelpers.ts +20 -4
  310. package/src/elements/Footer.ts +62 -67
  311. package/src/elements/FootnoteManager.ts +4 -1
  312. package/src/elements/Header.ts +62 -67
  313. package/src/elements/HeaderFooterManager.ts +56 -8
  314. package/src/elements/Hyperlink.ts +150 -55
  315. package/src/elements/Image.ts +51 -7
  316. package/src/elements/ImageManager.ts +24 -0
  317. package/src/elements/Paragraph.ts +161 -40
  318. package/src/elements/Revision.ts +446 -47
  319. package/src/elements/Run.ts +44 -6
  320. package/src/elements/Section.ts +25 -9
  321. package/src/elements/Shape.ts +52 -44
  322. package/src/elements/StructuredDocumentTag.ts +83 -8
  323. package/src/elements/Table.ts +151 -50
  324. package/src/elements/TableCell.ts +57 -17
  325. package/src/elements/TableOfContents.ts +10 -9
  326. package/src/elements/TableRow.ts +35 -3
  327. package/src/elements/TextBox.ts +58 -43
  328. package/src/formatting/AbstractNumbering.ts +4 -2
  329. package/src/formatting/NumberingLevel.ts +50 -12
  330. package/src/formatting/NumberingManager.ts +32 -3
  331. package/src/formatting/Style.ts +14 -0
  332. package/src/formatting/StylesManager.ts +29 -2
  333. package/src/helpers/CleanupHelper.ts +122 -30
  334. package/src/images/ImageOptimizer.ts +64 -5
  335. package/src/index.ts +7 -0
  336. package/src/internal.ts +1 -0
  337. package/src/processors/CompatibilityUpgrader.ts +15 -2
  338. package/src/processors/InMemoryRevisionAcceptor.ts +276 -17
  339. package/src/processors/RevisionWalker.ts +83 -7
  340. package/src/processors/SelectiveRevisionAcceptor.ts +164 -28
  341. package/src/processors/cnfStyleDecoder.ts +18 -12
  342. package/src/processors/stripTrackedChanges.ts +47 -10
  343. package/src/utils/deepClone.ts +48 -14
  344. package/src/utils/units.ts +3 -1
  345. package/src/utils/validation.ts +30 -32
  346. package/src/utils/xmlSanitization.ts +218 -22
  347. package/src/validation/RevisionAutoFixer.ts +109 -65
  348. package/src/validation/RevisionValidator.ts +48 -23
  349. package/src/xml/XMLBuilder.ts +82 -22
  350. package/src/xml/XMLParser.ts +87 -20
  351. package/src/zip/ZipHandler.ts +61 -21
  352. package/src/zip/ZipReader.ts +135 -13
  353. package/src/zip/errors.ts +15 -0
  354. package/src/zip/types.ts +44 -2
@@ -86,12 +86,13 @@ import { acceptAllRevisions, cleanupRevisionMetadata } from '../processors/accep
86
86
  import { acceptRevisionsInMemory } from '../processors/InMemoryRevisionAcceptor.js';
87
87
  import { stripTrackedChanges } from '../processors/stripTrackedChanges.js';
88
88
  import { diffText, diffHasUnchangedParts } from '../utils/textDiff.js';
89
+ import { reorderRunPropertyChildren } from '../utils/xmlSanitization.js';
89
90
  import { XMLBuilder } from '../xml/XMLBuilder.js';
90
91
  import { XMLParser } from '../xml/XMLParser.js';
91
92
  import { DocumentTrackingContext } from '../tracking/DocumentTrackingContext.js';
92
93
  import type { TrackingContext } from '../tracking/TrackingContext.js';
93
94
  import { ZipHandler } from '../zip/ZipHandler.js';
94
- import { DOCX_PATHS } from '../zip/types.js';
95
+ import { DOCX_PATHS, SizeLimitOptions } from '../zip/types.js';
95
96
  import { DocumentGenerator } from './DocumentGenerator.js';
96
97
  import { DocumentIdManager } from './DocumentIdManager.js';
97
98
  import { DocumentParser } from './DocumentParser.js';
@@ -102,7 +103,7 @@ import {
102
103
  type DocumentEventType,
103
104
  } from './DocumentEvents.js';
104
105
  import { RelationshipManager } from './RelationshipManager.js';
105
- import { RelationshipType } from './Relationship.js';
106
+ import { Relationship, RelationshipType } from './Relationship.js';
106
107
  import { BodyElement } from './DocumentContent.js';
107
108
  import { optimizeImage, ImageOptimizationResult } from '../images/ImageOptimizer.js';
108
109
 
@@ -156,7 +157,7 @@ export interface DocumentLoadOptions extends DocumentOptions {
156
157
  *
157
158
  * - 'preserve': Keep tracked changes as-is (may cause corruption if IDs conflict)
158
159
  * - 'accept': Accept all changes - removes revision markup, keeps inserted content, removes deleted content (default)
159
- * - 'strip': Remove all revision markup completely
160
+ * - 'strip': Remove all revision markup, keeping inserted content and removing deleted content (same resulting text as 'accept')
160
161
  *
161
162
  * Default: 'accept' (prevents corruption from revision ID conflicts)
162
163
  *
@@ -209,6 +210,68 @@ export interface DocumentLoadOptions extends DocumentOptions {
209
210
  * ```
210
211
  */
211
212
  acceptRevisions?: boolean;
213
+
214
+ /**
215
+ * Resource limits applied while reading the DOCX archive.
216
+ *
217
+ * Forwarded to the underlying ZIP reader to guard against availability
218
+ * attacks (zip bombs, entry-count amplification) when loading untrusted
219
+ * input. Defaults are intentionally generous so legitimate documents load
220
+ * unchanged; tighten the fields on {@link SizeLimitOptions} for untrusted
221
+ * sources.
222
+ *
223
+ * @example
224
+ * ```typescript
225
+ * const doc = await Document.load('untrusted.docx', {
226
+ * sizeLimits: { maxTotalUncompressedMB: 100, maxEntryCount: 500 },
227
+ * });
228
+ * ```
229
+ */
230
+ sizeLimits?: SizeLimitOptions;
231
+ }
232
+
233
+ /**
234
+ * Options controlling {@link Document.toMarkdown} conversion fidelity.
235
+ *
236
+ * Markdown cannot natively express every Word construct, so the converter
237
+ * falls back to inline HTML (when {@link htmlFallback} is enabled) for things
238
+ * like underline, super/subscript, highlight and color, and collects
239
+ * footnotes/endnotes as GFM-style references. These options let callers tune
240
+ * how aggressively that extra information is preserved.
241
+ */
242
+ export interface MarkdownConversionOptions {
243
+ /**
244
+ * Emit inline HTML for character formatting Markdown cannot represent:
245
+ * underline (`<u>`), superscript (`<sup>`), subscript (`<sub>`), highlight
246
+ * (`<mark>`) and text color (`<span style="color:#…">`). Tracked deletions
247
+ * (in `'preserve'` mode) are wrapped in `<del>`. When `false`, that
248
+ * formatting is dropped but the text itself is still emitted. Default: `true`.
249
+ */
250
+ htmlFallback?: boolean;
251
+ /**
252
+ * Collect footnote/endnote references as GFM footnote markers (`[^fn1]`,
253
+ * `[^en1]`) and append their definitions at the end of the output.
254
+ * Default: `true`.
255
+ */
256
+ footnotes?: boolean;
257
+ /**
258
+ * Emit inline images as `![alt](src)` where `src` is the image's
259
+ * relationship id (or a placeholder for unsaved documents). When `false`,
260
+ * images are skipped. Default: `true`.
261
+ */
262
+ images?: boolean;
263
+ }
264
+
265
+ /**
266
+ * Internal state threaded through a single Markdown conversion pass.
267
+ * @internal
268
+ */
269
+ interface MarkdownContext {
270
+ opts: Required<MarkdownConversionOptions>;
271
+ /** Ordered, de-duplicated footnote/endnote definitions to append at the end. */
272
+ notes: { marker: string; text: string }[];
273
+ /** Markers already emitted, to avoid duplicate definitions. */
274
+ noteMarkers: Set<string>;
212
275
  }
213
276
 
214
277
  /**
@@ -249,6 +312,12 @@ export class Document {
249
312
  () => this.documentIdManager.getNextId(),
250
313
  (existingId) => this.documentIdManager.ensureNextIdAbove(existingId)
251
314
  );
315
+ // Instance-level comment edits (resolve, setAuthor, addRun, ...) must
316
+ // flip the dirty flag, otherwise saveComments() takes the passthrough
317
+ // branch and writes the original comments.xml, silently dropping them
318
+ this.commentManager.setModifiedNotifier(() => {
319
+ this._commentsModified = true;
320
+ });
252
321
 
253
322
  this.trackingContext = new DocumentTrackingContext(this.revisionManager);
254
323
  this.parser = new DocumentParser();
@@ -324,6 +393,7 @@ export class Document {
324
393
  private _originalNumberingXml?: string;
325
394
  private _originalSettingsXml?: string;
326
395
  private _originalAppPropsXml?: string;
396
+ private _originalCorePropsXml?: string;
327
397
  private _originalFootnotesXml?: string;
328
398
  private _originalEndnotesXml?: string;
329
399
  private _originalCommentsXml?: string;
@@ -352,6 +422,9 @@ export class Document {
352
422
  // Track whether app properties have been programmatically modified since load
353
423
  private _appPropsModified = false;
354
424
 
425
+ // Track whether core properties have been programmatically modified since load
426
+ private _corePropsModified = false;
427
+
355
428
  // Track whether footnotes/endnotes have been programmatically modified since load
356
429
  private _footnotesModified = false;
357
430
  private _endnotesModified = false;
@@ -785,7 +858,7 @@ export class Document {
785
858
  logger.info('Loading document from file', { path: filePath });
786
859
 
787
860
  const zipHandler = new ZipHandler();
788
- await zipHandler.load(filePath);
861
+ await zipHandler.load(filePath, { sizeLimits: options?.sizeLimits });
789
862
 
790
863
  const doc = await Document.initializeFromZip(zipHandler, options);
791
864
  doc._events.emit('afterLoad', { source: 'file', path: filePath });
@@ -825,7 +898,7 @@ export class Document {
825
898
  logger.info('Loading document from buffer', { bufferSize: buffer.length });
826
899
 
827
900
  const zipHandler = new ZipHandler();
828
- await zipHandler.loadFromBuffer(buffer);
901
+ await zipHandler.loadFromBuffer(buffer, { sizeLimits: options?.sizeLimits });
829
902
 
830
903
  const doc = await Document.initializeFromZip(zipHandler, options);
831
904
  doc._events.emit('afterLoad', { source: 'buffer' });
@@ -890,7 +963,10 @@ export class Document {
890
963
 
891
964
  const numberingXml = zipHandler.getFileAsString(DOCX_PATHS.NUMBERING);
892
965
  if (numberingXml) {
893
- doc._originalNumberingXml = numberingXml;
966
+ // Numbering definitions are written back verbatim for fidelity, so repair
967
+ // any out-of-order w:rPr children here (e.g. w:b/w:bCs appended after
968
+ // w:color/w:sz by some producers) — Word flags such ordering as corrupt.
969
+ doc._originalNumberingXml = reorderRunPropertyChildren(numberingXml);
894
970
  }
895
971
 
896
972
  // Preserve original settings.xml and parse managed settings into in-memory state
@@ -907,6 +983,15 @@ export class Document {
907
983
  doc._originalAppPropsXml = appPropsXml;
908
984
  }
909
985
 
986
+ // Preserve original core.xml for round-trip fidelity. parseProperties only
987
+ // extracts a fixed subset of OPC core properties; optional elements like
988
+ // cp:lastPrinted, dc:identifier, and cp:version would otherwise be dropped
989
+ // on a plain load→save round-trip.
990
+ const corePropsXml = zipHandler.getFileAsString(DOCX_PATHS.CORE_PROPS);
991
+ if (corePropsXml) {
992
+ doc._originalCorePropsXml = corePropsXml;
993
+ }
994
+
910
995
  // Preserve original webSettings.xml for round-trip fidelity (w:divs, flags, etc.)
911
996
  const webSettingsXml = zipHandler.getFileAsString(DOCX_PATHS.WEB_SETTINGS);
912
997
  if (webSettingsXml) {
@@ -1306,18 +1391,20 @@ export class Document {
1306
1391
 
1307
1392
  // Register headers with HeaderFooterManager (deduplicate by rId)
1308
1393
  // Multiple section property types may reference the same rId
1394
+ // The parsed filename (relationship target) is preserved so saveHeaders()
1395
+ // writes content back to the part the relationship actually points at
1309
1396
  const registeredHeaderRIds = new Set<string>();
1310
- for (const { header, relationshipId } of headersFooters.headers) {
1397
+ for (const { header, relationshipId, filename } of headersFooters.headers) {
1311
1398
  if (registeredHeaderRIds.has(relationshipId)) continue;
1312
- this.headerFooterManager.registerHeader(header, relationshipId);
1399
+ this.headerFooterManager.registerHeader(header, relationshipId, filename);
1313
1400
  registeredHeaderRIds.add(relationshipId);
1314
1401
  }
1315
1402
 
1316
1403
  // Register footers with HeaderFooterManager (deduplicate by rId)
1317
1404
  const registeredFooterRIds = new Set<string>();
1318
- for (const { footer, relationshipId } of headersFooters.footers) {
1405
+ for (const { footer, relationshipId, filename } of headersFooters.footers) {
1319
1406
  if (registeredFooterRIds.has(relationshipId)) continue;
1320
- this.headerFooterManager.registerFooter(footer, relationshipId);
1407
+ this.headerFooterManager.registerFooter(footer, relationshipId, filename);
1321
1408
  registeredFooterRIds.add(relationshipId);
1322
1409
  }
1323
1410
  } catch (headerFooterError) {
@@ -1413,7 +1500,13 @@ export class Document {
1413
1500
  }
1414
1501
 
1415
1502
  const parser = new DocumentParser();
1416
- const comments = parser.parseCommentsXml(commentsXml);
1503
+ // commentsExtended.xml carries the resolved state (w15:done) keyed by
1504
+ // each comment's last-paragraph paraId — CT_Comment itself declares no
1505
+ // done attribute, so isResolved() needs the companion part
1506
+ const comments = parser.parseCommentsXml(
1507
+ commentsXml,
1508
+ this._originalCommentCompanionFiles.get(DOCX_PATHS.COMMENTS_EXTENDED)
1509
+ );
1417
1510
 
1418
1511
  // Register each comment with its existing ID
1419
1512
  for (const comment of comments) {
@@ -1435,7 +1528,12 @@ export class Document {
1435
1528
 
1436
1529
  try {
1437
1530
  const parser = new DocumentParser();
1438
- const footnotes = parser.parseFootnotesXml(footnotesXml);
1531
+ // Hyperlink r:id targets live in the part-scoped rels file per OPC;
1532
+ // resolving them at parse time lets a real footnote edit regenerate
1533
+ // working links instead of dangling r:id references
1534
+ const relsXml = this.zipHandler.getFileAsString('word/_rels/footnotes.xml.rels');
1535
+ const partRels = relsXml ? RelationshipManager.fromXml(relsXml) : undefined;
1536
+ const footnotes = parser.parseFootnotesXml(footnotesXml, partRels);
1439
1537
 
1440
1538
  for (const footnote of footnotes) {
1441
1539
  const id = footnote.getId();
@@ -1461,7 +1559,9 @@ export class Document {
1461
1559
 
1462
1560
  try {
1463
1561
  const parser = new DocumentParser();
1464
- const endnotes = parser.parseEndnotesXml(endnotesXml);
1562
+ const relsXml = this.zipHandler.getFileAsString('word/_rels/endnotes.xml.rels');
1563
+ const partRels = relsXml ? RelationshipManager.fromXml(relsXml) : undefined;
1564
+ const endnotes = parser.parseEndnotesXml(endnotesXml, partRels);
1465
1565
 
1466
1566
  for (const endnote of endnotes) {
1467
1567
  const id = endnote.getId();
@@ -1647,12 +1747,18 @@ export class Document {
1647
1747
  // When tracking enabled, bind context and wrap existing content in w:ins revisions
1648
1748
  if (this.trackChangesEnabled && this.trackingContext.isEnabled()) {
1649
1749
  this.bindTrackingToElement(paragraph);
1650
- const runs = paragraph.getRuns();
1651
- if (runs.length > 0) {
1652
- const author = this.trackingContext.getAuthor();
1653
- const insertion = Revision.createInsertion(author, runs);
1654
- this.trackingContext.getRevisionManager().register(insertion);
1655
- paragraph.addRevision(insertion);
1750
+ const author = this.trackingContext.getAuthor();
1751
+ // Each top-level Run/Hyperlink is REPLACED by its insert revision in
1752
+ // place. Wrapping paragraph.getRuns() and appending the revision would
1753
+ // leave the originals live alongside the w:ins copy (text serialized
1754
+ // twice, unremovable by accept/reject) and would re-wrap runs already
1755
+ // inside existing revisions or hyperlinks.
1756
+ for (const item of paragraph.getContent()) {
1757
+ if (item instanceof Run || item instanceof Hyperlink) {
1758
+ const insertion = Revision.createInsertion(author, item);
1759
+ this.trackingContext.getRevisionManager().register(insertion);
1760
+ paragraph.replaceContent(item, [insertion]);
1761
+ }
1656
1762
  }
1657
1763
  }
1658
1764
 
@@ -2307,6 +2413,7 @@ export class Document {
2307
2413
  */
2308
2414
  setTitle(title: string): this {
2309
2415
  this.properties.title = title;
2416
+ this._corePropsModified = true;
2310
2417
  return this;
2311
2418
  }
2312
2419
 
@@ -2317,6 +2424,7 @@ export class Document {
2317
2424
  */
2318
2425
  setSubject(subject: string): this {
2319
2426
  this.properties.subject = subject;
2427
+ this._corePropsModified = true;
2320
2428
  return this;
2321
2429
  }
2322
2430
 
@@ -2327,6 +2435,7 @@ export class Document {
2327
2435
  */
2328
2436
  setCreator(creator: string): this {
2329
2437
  this.properties.creator = creator;
2438
+ this._corePropsModified = true;
2330
2439
  return this;
2331
2440
  }
2332
2441
 
@@ -2351,6 +2460,7 @@ export class Document {
2351
2460
  */
2352
2461
  setKeywords(keywords: string): this {
2353
2462
  this.properties.keywords = keywords;
2463
+ this._corePropsModified = true;
2354
2464
  return this;
2355
2465
  }
2356
2466
 
@@ -2361,6 +2471,7 @@ export class Document {
2361
2471
  */
2362
2472
  setDescription(description: string): this {
2363
2473
  this.properties.description = description;
2474
+ this._corePropsModified = true;
2364
2475
  return this;
2365
2476
  }
2366
2477
 
@@ -2371,6 +2482,7 @@ export class Document {
2371
2482
  */
2372
2483
  setCategory(category: string): this {
2373
2484
  this.properties.category = category;
2485
+ this._corePropsModified = true;
2374
2486
  return this;
2375
2487
  }
2376
2488
 
@@ -2381,6 +2493,7 @@ export class Document {
2381
2493
  */
2382
2494
  setContentStatus(status: string): this {
2383
2495
  this.properties.contentStatus = status;
2496
+ this._corePropsModified = true;
2384
2497
  return this;
2385
2498
  }
2386
2499
 
@@ -2845,7 +2958,7 @@ export class Document {
2845
2958
  * console.log(doc.toPlainText());
2846
2959
  * ```
2847
2960
  */
2848
- static async loadFromBase64(base64: string, options?: DocumentOptions): Promise<Document> {
2961
+ static async loadFromBase64(base64: string, options?: DocumentLoadOptions): Promise<Document> {
2849
2962
  const buffer = Buffer.from(base64, 'base64');
2850
2963
  return Document.loadFromBuffer(buffer, options);
2851
2964
  }
@@ -3327,6 +3440,7 @@ export class Document {
3327
3440
 
3328
3441
  /**
3329
3442
  * Updates the core properties with current values
3443
+ * Uses preservation strategy to maintain original metadata when unmodified
3330
3444
  */
3331
3445
  private updateCoreProps(): void {
3332
3446
  if (
@@ -3334,8 +3448,65 @@ export class Document {
3334
3448
  this._removedParts.has('docProps/core.xml')
3335
3449
  )
3336
3450
  return;
3337
- const xml = this.generator.generateCoreProps(this.properties);
3338
- this.zipHandler.updateFile(DOCX_PATHS.CORE_PROPS, xml);
3451
+ if (this._originalCorePropsXml && !this._corePropsModified) {
3452
+ // Preserve original as-is — no changes to core properties. Keeps
3453
+ // unparsed elements (cp:lastPrinted, dc:identifier, cp:version) intact.
3454
+ return;
3455
+ }
3456
+ if (this._originalCorePropsXml && this._corePropsModified) {
3457
+ // Merge: update only changed fields in original XML
3458
+ const mergedXml = this.mergeCorePropsWithOriginal();
3459
+ this.zipHandler.updateFile(DOCX_PATHS.CORE_PROPS, mergedXml);
3460
+ } else {
3461
+ // New document — generate from scratch
3462
+ const xml = this.generator.generateCoreProps(this.properties);
3463
+ this.zipHandler.updateFile(DOCX_PATHS.CORE_PROPS, xml);
3464
+ }
3465
+ }
3466
+
3467
+ /**
3468
+ * Merges modified core properties with the original core.xml.
3469
+ * Updates only fields that have been programmatically changed, preserving
3470
+ * unparsed OPC core properties (cp:lastPrinted, dc:identifier, cp:version,
3471
+ * dcterms:created/modified, etc.) that the parser never extracts.
3472
+ */
3473
+ private mergeCorePropsWithOriginal(): string {
3474
+ if (!this._originalCorePropsXml) {
3475
+ return this.generator.generateCoreProps(this.properties);
3476
+ }
3477
+
3478
+ let xml = this._originalCorePropsXml;
3479
+
3480
+ // Each parsed core property maps to a single OOXML element. Replace the
3481
+ // existing element's text in place; if the element is absent, insert it
3482
+ // before the closing tag so the new value is not silently dropped.
3483
+ const mergeField = (tag: string, value: string | undefined): void => {
3484
+ if (value === undefined) return;
3485
+ const escaped = XMLBuilder.sanitizeXmlContent(value);
3486
+ const openClose = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?</${tag}>`);
3487
+ const selfClosing = new RegExp(`<${tag}\\b[^>]*/>`);
3488
+ if (openClose.test(xml)) {
3489
+ xml = xml.replace(openClose, `<${tag}>${escaped}</${tag}>`);
3490
+ } else if (selfClosing.test(xml)) {
3491
+ xml = xml.replace(selfClosing, `<${tag}>${escaped}</${tag}>`);
3492
+ } else {
3493
+ xml = xml.replace(
3494
+ /<\/cp:coreProperties>/,
3495
+ ` <${tag}>${escaped}</${tag}>\n</cp:coreProperties>`
3496
+ );
3497
+ }
3498
+ };
3499
+
3500
+ mergeField('dc:title', this.properties.title);
3501
+ mergeField('dc:subject', this.properties.subject);
3502
+ mergeField('dc:creator', this.properties.creator);
3503
+ mergeField('cp:keywords', this.properties.keywords);
3504
+ mergeField('dc:description', this.properties.description);
3505
+ mergeField('cp:category', this.properties.category);
3506
+ mergeField('cp:contentStatus', this.properties.contentStatus);
3507
+ mergeField('dc:language', this.properties.language);
3508
+
3509
+ return xml;
3339
3510
  }
3340
3511
 
3341
3512
  /**
@@ -3379,6 +3550,9 @@ export class Document {
3379
3550
  xml = xml.replace(/<Company>[^<]*<\/Company>/, `<Company>${escaped}</Company>`);
3380
3551
  } else if (xml.includes('<Company/>')) {
3381
3552
  xml = xml.replace(/<Company\/>/, `<Company>${escaped}</Company>`);
3553
+ } else {
3554
+ // Tag fully absent: insert so the value is not silently dropped on save
3555
+ xml = xml.replace(/<\/Properties>/, ` <Company>${escaped}</Company>\n</Properties>`);
3382
3556
  }
3383
3557
  }
3384
3558
 
@@ -3390,6 +3564,12 @@ export class Document {
3390
3564
  /<Application>[^<]*<\/Application>/,
3391
3565
  `<Application>${escaped}</Application>`
3392
3566
  );
3567
+ } else {
3568
+ // Tag fully absent: insert so the value is not silently dropped on save
3569
+ xml = xml.replace(
3570
+ /<\/Properties>/,
3571
+ ` <Application>${escaped}</Application>\n</Properties>`
3572
+ );
3393
3573
  }
3394
3574
  }
3395
3575
 
@@ -3398,6 +3578,9 @@ export class Document {
3398
3578
  const escaped = XMLBuilder.sanitizeXmlContent(this.properties.appVersion);
3399
3579
  if (xml.includes('<AppVersion>')) {
3400
3580
  xml = xml.replace(/<AppVersion>[^<]*<\/AppVersion>/, `<AppVersion>${escaped}</AppVersion>`);
3581
+ } else {
3582
+ // Tag fully absent: insert so the value is not silently dropped on save
3583
+ xml = xml.replace(/<\/Properties>/, ` <AppVersion>${escaped}</AppVersion>\n</Properties>`);
3401
3584
  }
3402
3585
  }
3403
3586
 
@@ -3406,6 +3589,9 @@ export class Document {
3406
3589
  const escaped = XMLBuilder.sanitizeXmlContent(this.properties.manager);
3407
3590
  if (xml.includes('<Manager>')) {
3408
3591
  xml = xml.replace(/<Manager>[^<]*<\/Manager>/, `<Manager>${escaped}</Manager>`);
3592
+ } else {
3593
+ // Tag fully absent: insert so the value is not silently dropped on save
3594
+ xml = xml.replace(/<\/Properties>/, ` <Manager>${escaped}</Manager>\n</Properties>`);
3409
3595
  }
3410
3596
  }
3411
3597
 
@@ -3592,9 +3778,10 @@ export class Document {
3592
3778
  * Merges modified styles with the original styles.xml
3593
3779
  *
3594
3780
  * This preserves all styles from the original document while only updating
3595
- * styles that have been explicitly modified via addStyle().
3781
+ * styles that have been explicitly modified via addStyle() and stripping
3782
+ * styles that have been removed via removeStyle().
3596
3783
  *
3597
- * @returns Merged XML string with original styles + modified styles
3784
+ * @returns Merged XML string with original styles + modified styles - removed styles
3598
3785
  * @private
3599
3786
  */
3600
3787
  private mergeStylesWithOriginal(): string {
@@ -3603,9 +3790,10 @@ export class Document {
3603
3790
  }
3604
3791
 
3605
3792
  const modifiedStyleIds = this.stylesManager.getModifiedStyleIds();
3793
+ const removedStyleIds = this.stylesManager.getRemovedStyleIds();
3606
3794
 
3607
- // If nothing was modified, return original as-is
3608
- if (modifiedStyleIds.size === 0) {
3795
+ // If nothing was modified or removed, return original as-is
3796
+ if (modifiedStyleIds.size === 0 && removedStyleIds.size === 0) {
3609
3797
  return this._originalStylesXml;
3610
3798
  }
3611
3799
 
@@ -3646,6 +3834,16 @@ export class Document {
3646
3834
  }
3647
3835
  }
3648
3836
 
3837
+ // Strip removed style definitions — without this, removals never reach
3838
+ // the saved styles.xml (mirrors the numbering merge's removed-ID handling)
3839
+ for (const styleId of removedStyleIds) {
3840
+ const escapedStyleId = styleId.replace(/[.*+?^${}()|[\]\\"]/g, '\\$&');
3841
+ const removedPattern = new RegExp(
3842
+ `\\s*<w:style[^>]*\\sw:styleId="${escapedStyleId}"[^>]*>[\\s\\S]*?</w:style>`
3843
+ );
3844
+ resultXml = resultXml.replace(removedPattern, '');
3845
+ }
3846
+
3649
3847
  return resultXml;
3650
3848
  }
3651
3849
 
@@ -4003,9 +4201,22 @@ export class Document {
4003
4201
 
4004
4202
  // w:revisionView (#31 in CT_Settings)
4005
4203
  const view = this.revisionViewSettings;
4006
- // Only emit revisionView if it differs from defaults (all true)
4007
- if (!view.showInsertionsAndDeletions || !view.showFormatting || !view.showInkAnnotations) {
4008
- trackBlock += `\n <w:revisionView w:insDel="${view.showInsertionsAndDeletions ? '1' : '0'}" w:formatting="${view.showFormatting ? '1' : '0'}" w:inkAnnotations="${view.showInkAnnotations ? '1' : '0'}"/>`;
4204
+ // Only emit revisionView if it differs from defaults (all true). showMarkup
4205
+ // and showComments default true when absent, so a parsed w:markup="0" /
4206
+ // w:comments="0" must also trigger emission or it would be dropped here.
4207
+ if (
4208
+ !view.showInsertionsAndDeletions ||
4209
+ !view.showFormatting ||
4210
+ !view.showInkAnnotations ||
4211
+ view.showMarkup === false ||
4212
+ view.showComments === false
4213
+ ) {
4214
+ // CT_TrackChangesView §17.15.1.77: append w:markup / w:comments only when
4215
+ // explicitly false (absent = default true) so the reviewer's default
4216
+ // markup view survives round-trip, mirroring DocumentGenerator.
4217
+ const markupAttr = view.showMarkup === false ? ' w:markup="0"' : '';
4218
+ const commentsAttr = view.showComments === false ? ' w:comments="0"' : '';
4219
+ trackBlock += `\n <w:revisionView w:insDel="${view.showInsertionsAndDeletions ? '1' : '0'}" w:formatting="${view.showFormatting ? '1' : '0'}" w:inkAnnotations="${view.showInkAnnotations ? '1' : '0'}"${markupAttr}${commentsAttr}/>`;
4009
4220
  }
4010
4221
 
4011
4222
  // w:trackRevisions (#32)
@@ -4062,6 +4273,8 @@ export class Document {
4062
4273
  const prot = this.documentProtection;
4063
4274
  const esc = XMLBuilder.escapeXmlAttribute;
4064
4275
  let protXml = `\n <w:documentProtection w:edit="${esc(prot.edit)}" w:enforcement="${prot.enforcement ? '1' : '0'}"`;
4276
+ // w:formatting (CT_DocProtect §17.15.1.29) — tri-state, emit explicit 0/1
4277
+ if (prot.formatting !== undefined) protXml += ` w:formatting="${prot.formatting ? '1' : '0'}"`;
4065
4278
  if (prot.cryptProviderType) protXml += ` w:cryptProviderType="${esc(prot.cryptProviderType)}"`;
4066
4279
  if (prot.cryptAlgorithmClass)
4067
4280
  protXml += ` w:cryptAlgorithmClass="${esc(prot.cryptAlgorithmClass)}"`;
@@ -4072,6 +4285,12 @@ export class Document {
4072
4285
  if (prot.cryptSpinCount) protXml += ` w:cryptSpinCount="${esc(String(prot.cryptSpinCount))}"`;
4073
4286
  if (prot.hash) protXml += ` w:hash="${esc(prot.hash)}"`;
4074
4287
  if (prot.salt) protXml += ` w:salt="${esc(prot.salt)}"`;
4288
+ // Modern Word 2013+ crypto attributes (ISO/IEC 29500-4 §13) — without
4289
+ // these a modern password-protected document loses its password material
4290
+ // whenever any settings-modifying API runs before save.
4291
+ if (prot.algorithmName) protXml += ` w:algorithmName="${esc(prot.algorithmName)}"`;
4292
+ if (prot.hashValue) protXml += ` w:hashValue="${esc(prot.hashValue)}"`;
4293
+ if (prot.saltValue) protXml += ` w:saltValue="${esc(prot.saltValue)}"`;
4075
4294
  protXml += '/>';
4076
4295
 
4077
4296
  // Insert before w:autoFormatOverride (#36) if exists,
@@ -5597,7 +5816,10 @@ export class Document {
5597
5816
  * - numId references inside body-level tracked deletions (w:del blocks
5598
5817
  * that were skipped during parsing)
5599
5818
  * - Header, footer, footnote, and endnote XML files
5600
- * - Raw XML safety net scan of all relevant ZIP entries
5819
+ * - Style definitions (w:numPr in a style's pPr), since styles attach
5820
+ * list numbering without any paragraph carrying a direct numId
5821
+ * - Raw XML safety net scan of all relevant ZIP entries, including
5822
+ * styles.xml and comments.xml
5601
5823
  *
5602
5824
  * A definition is only removed if it appears in NONE of these sources.
5603
5825
  *
@@ -5663,6 +5885,25 @@ export class Document {
5663
5885
  }
5664
5886
  }
5665
5887
 
5888
+ // 7. Scan style definitions for numPr references — Word attaches multilevel
5889
+ // list numbering to paragraph styles (e.g., Headings, list styles), so a
5890
+ // numId can be in use while no paragraph carries a direct w:numPr
5891
+ for (const style of this.stylesManager.getAllStyles()) {
5892
+ const styleNumId = style.getProperties().numPr?.numId;
5893
+ if (styleNumId !== undefined) {
5894
+ usedNumIds.add(styleNumId);
5895
+ }
5896
+ }
5897
+
5898
+ // 8. Raw safety net for styles.xml and comments.xml — preserved XML can
5899
+ // carry numId references not represented in the in-memory model
5900
+ for (const filePath of [DOCX_PATHS.STYLES, 'word/comments.xml']) {
5901
+ const xml = this.zipHandler.getFileAsString(filePath);
5902
+ if (xml) {
5903
+ this.collectNumIdsFromXml(xml, usedNumIds);
5904
+ }
5905
+ }
5906
+
5666
5907
  // Clean up numbering definitions not found in any of the above scans
5667
5908
  this.numberingManager.cleanupUnusedNumbering(usedNumIds);
5668
5909
  }
@@ -5868,7 +6109,69 @@ export class Document {
5868
6109
  }
5869
6110
  const registeredSet = new Set(existingRevisions);
5870
6111
 
5871
- let docPrId = 1;
6112
+ // Preserved raw XML (revision-nested image runs, w:pict/chart passthrough run
6113
+ // content, preserved elements, nested-table passthrough) is emitted verbatim
6114
+ // with its original wp:docPr id, outside the renumbering below. Reassigned ids
6115
+ // must start above every id that survives serialization unchanged, otherwise a
6116
+ // renumbered drawing can duplicate a preserved one (wp:docPr/@id must be
6117
+ // unique per ECMA-376 Part 1 §20.4.2.5).
6118
+ let maxPreservedDocPrId = 0;
6119
+ const collectPreservedDocPrIds = (rawXml: string | undefined): void => {
6120
+ if (!rawXml || !rawXml.includes('wp:docPr')) return;
6121
+ const docPrPattern = /<wp:docPr\b[^>]*\bid="(\d+)"/g;
6122
+ let match: RegExpExecArray | null;
6123
+ while ((match = docPrPattern.exec(rawXml)) !== null) {
6124
+ const id = parseInt(match[1]!, 10);
6125
+ if (id > maxPreservedDocPrId) maxPreservedDocPrId = id;
6126
+ }
6127
+ };
6128
+ const collectFromRun = (run: Run): void => {
6129
+ if (run instanceof ImageRun) {
6130
+ collectPreservedDocPrIds(run.getRawRunXml());
6131
+ return;
6132
+ }
6133
+ for (const contentItem of run.getContent()) {
6134
+ collectPreservedDocPrIds(contentItem.rawXml);
6135
+ }
6136
+ };
6137
+ const collectFromParagraph = (para: Paragraph): void => {
6138
+ for (const item of para.getContent()) {
6139
+ if (item instanceof Revision) {
6140
+ for (const revItem of item.getContent()) {
6141
+ if (revItem instanceof Run) collectFromRun(revItem);
6142
+ }
6143
+ } else if (item instanceof Run) {
6144
+ collectFromRun(item);
6145
+ } else if (item instanceof PreservedElement) {
6146
+ collectPreservedDocPrIds(item.getRawXml());
6147
+ }
6148
+ }
6149
+ };
6150
+ for (const element of this.bodyElements) {
6151
+ if (element instanceof Paragraph) {
6152
+ collectFromParagraph(element);
6153
+ } else if (element instanceof Table) {
6154
+ for (const row of element.getRows()) {
6155
+ for (const cell of row.getCells()) {
6156
+ for (const para of cell.getParagraphs()) {
6157
+ collectFromParagraph(para);
6158
+ }
6159
+ for (const nested of cell.getRawNestedContent()) {
6160
+ collectPreservedDocPrIds(nested.xml);
6161
+ }
6162
+ }
6163
+ }
6164
+ } else {
6165
+ // Raw-XML passthrough body elements (AlternateContent, MathParagraph,
6166
+ // CustomXmlBlock, PreservedElement, registered elements) expose getRawXml().
6167
+ const passthrough = element as { getRawXml?: () => string };
6168
+ if (typeof passthrough.getRawXml === 'function') {
6169
+ collectPreservedDocPrIds(passthrough.getRawXml());
6170
+ }
6171
+ }
6172
+ }
6173
+
6174
+ let docPrId = maxPreservedDocPrId + 1;
5872
6175
 
5873
6176
  // Collect existing paraIds to avoid collisions when generating new ones
5874
6177
  const existingParaIds = new Set<string>();
@@ -5906,6 +6209,16 @@ export class Document {
5906
6209
  item.setDocPrId(docPrId++);
5907
6210
  } else if (item instanceof ImageRun) {
5908
6211
  item.getImageElement().setDocPrId(docPrId++);
6212
+ } else if (item instanceof Revision) {
6213
+ // Revision-nested images serialize from the model when no raw run XML
6214
+ // was captured (programmatic) or after a mutation splices a regenerated
6215
+ // w:drawing — give them fresh ids so they cannot collide with the
6216
+ // renumbered top-level drawings. Unmutated raw runs ignore the model id.
6217
+ for (const revItem of item.getContent()) {
6218
+ if (revItem instanceof ImageRun) {
6219
+ revItem.getImageElement().setDocPrId(docPrId++);
6220
+ }
6221
+ }
5909
6222
  }
5910
6223
  }
5911
6224
 
@@ -6631,6 +6944,10 @@ export class Document {
6631
6944
  numLevel.setHangingIndent(360);
6632
6945
  }
6633
6946
 
6947
+ // Level setters don't propagate dirty state; without this the save
6948
+ // pipeline writes the original numbering.xml verbatim for loaded docs
6949
+ this.numberingManager.markAbstractNumberingModified(abstractNumId);
6950
+
6634
6951
  // Apply paragraph formatting to all paragraphs using this list
6635
6952
  this.applyFormattingToListParagraphs(instance.getNumId());
6636
6953
  count++;
@@ -6697,6 +7014,10 @@ export class Document {
6697
7014
  numLevel.setAlignment('left');
6698
7015
  }
6699
7016
 
7017
+ // Level setters don't propagate dirty state; without this the save
7018
+ // pipeline writes the original numbering.xml verbatim for loaded docs
7019
+ this.numberingManager.markAbstractNumberingModified(abstractNumId);
7020
+
6700
7021
  // Apply paragraph formatting to all paragraphs using this list
6701
7022
  this.applyFormattingToListParagraphs(instance.getNumId());
6702
7023
  count++;
@@ -9778,8 +10099,11 @@ export class Document {
9778
10099
  * @returns This document for chaining
9779
10100
  */
9780
10101
  setHeader(header: Header): this {
9781
- // Generate relationship for header
9782
- const relationship = this.relationshipManager.addHeader(`${header.getFilename(1)}`);
10102
+ // The relationship must target the part name the manager assigns,
10103
+ // otherwise saveHeaders() writes content to a part nothing references
10104
+ const relationship = this.relationshipManager.addHeader(
10105
+ this.headerFooterManager.peekHeaderFilename(header)
10106
+ );
9783
10107
 
9784
10108
  // Register with manager
9785
10109
  this.headerFooterManager.registerHeader(header, relationship.getId());
@@ -9799,9 +10123,9 @@ export class Document {
9799
10123
  // Enable title page
9800
10124
  this.section.setTitlePage(true);
9801
10125
 
9802
- // Generate relationship for header
10126
+ // Target the part name the manager will assign on registration
9803
10127
  const relationship = this.relationshipManager.addHeader(
9804
- `${header.getFilename(this.headerFooterManager.getHeaderCount() + 1)}`
10128
+ this.headerFooterManager.peekHeaderFilename(header)
9805
10129
  );
9806
10130
 
9807
10131
  // Register with manager
@@ -9819,9 +10143,9 @@ export class Document {
9819
10143
  * @returns This document for chaining
9820
10144
  */
9821
10145
  setEvenPageHeader(header: Header): this {
9822
- // Generate relationship for header
10146
+ // Target the part name the manager will assign on registration
9823
10147
  const relationship = this.relationshipManager.addHeader(
9824
- `${header.getFilename(this.headerFooterManager.getHeaderCount() + 1)}`
10148
+ this.headerFooterManager.peekHeaderFilename(header)
9825
10149
  );
9826
10150
 
9827
10151
  // Register with manager
@@ -9839,8 +10163,11 @@ export class Document {
9839
10163
  * @returns This document for chaining
9840
10164
  */
9841
10165
  setFooter(footer: Footer): this {
9842
- // Generate relationship for footer
9843
- const relationship = this.relationshipManager.addFooter(`${footer.getFilename(1)}`);
10166
+ // The relationship must target the part name the manager assigns,
10167
+ // otherwise saveFooters() writes content to a part nothing references
10168
+ const relationship = this.relationshipManager.addFooter(
10169
+ this.headerFooterManager.peekFooterFilename(footer)
10170
+ );
9844
10171
 
9845
10172
  // Register with manager
9846
10173
  this.headerFooterManager.registerFooter(footer, relationship.getId());
@@ -9860,9 +10187,9 @@ export class Document {
9860
10187
  // Enable title page
9861
10188
  this.section.setTitlePage(true);
9862
10189
 
9863
- // Generate relationship for footer
10190
+ // Target the part name the manager will assign on registration
9864
10191
  const relationship = this.relationshipManager.addFooter(
9865
- `${footer.getFilename(this.headerFooterManager.getFooterCount() + 1)}`
10192
+ this.headerFooterManager.peekFooterFilename(footer)
9866
10193
  );
9867
10194
 
9868
10195
  // Register with manager
@@ -9880,9 +10207,9 @@ export class Document {
9880
10207
  * @returns This document for chaining
9881
10208
  */
9882
10209
  setEvenPageFooter(footer: Footer): this {
9883
- // Generate relationship for footer
10210
+ // Target the part name the manager will assign on registration
9884
10211
  const relationship = this.relationshipManager.addFooter(
9885
- `${footer.getFilename(this.headerFooterManager.getFooterCount() + 1)}`
10212
+ this.headerFooterManager.peekFooterFilename(footer)
9886
10213
  );
9887
10214
 
9888
10215
  // Register with manager
@@ -10188,6 +10515,26 @@ export class Document {
10188
10515
  }
10189
10516
  // Remove companion files since regenerated comments lack w14:paraId
10190
10517
  this.removeCommentCompanionFiles();
10518
+
10519
+ // Resolved state lives in commentsExtended.xml (w15:commentEx
10520
+ // w15:done) — CT_Comment declares no done attribute, so the part is
10521
+ // regenerated alongside comments.xml whenever a comment is resolved
10522
+ const extendedXml = this.commentManager.generateCommentsExtendedXml();
10523
+ if (extendedXml) {
10524
+ this.zipHandler.addFile(DOCX_PATHS.COMMENTS_EXTENDED, extendedXml);
10525
+ const hasExtendedRel = this.relationshipManager
10526
+ .getAllRelationships()
10527
+ .some((rel) => rel.getTarget() === 'commentsExtended.xml');
10528
+ if (!hasExtendedRel) {
10529
+ this.relationshipManager.addRelationship(
10530
+ Relationship.create({
10531
+ id: this.relationshipManager.generateId(),
10532
+ type: 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended',
10533
+ target: 'commentsExtended.xml',
10534
+ })
10535
+ );
10536
+ }
10537
+ }
10191
10538
  } else if (this._originalCommentsXml) {
10192
10539
  // Passthrough — preserve original comments.xml exactly
10193
10540
  this.zipHandler.addFile('word/comments.xml', this._originalCommentsXml);
@@ -10236,7 +10583,14 @@ export class Document {
10236
10583
  }
10237
10584
 
10238
10585
  private saveFootnotes(): void {
10239
- if (this._footnotesModified || this.footnoteManager.getCount() > 0) {
10586
+ // Dirty flag gates regeneration (mirrors saveComments) so an unmodified
10587
+ // loaded footnotes.xml round-trips via passthrough; the count check only
10588
+ // covers footnotes registered directly on the manager for documents that
10589
+ // never had a footnotes part.
10590
+ if (
10591
+ this._footnotesModified ||
10592
+ (!this._originalFootnotesXml && this.footnoteManager.getCount() > 0)
10593
+ ) {
10240
10594
  const xml = this.footnoteManager.generateFootnotesXml();
10241
10595
  this.zipHandler.addFile(DOCX_PATHS.FOOTNOTES, xml);
10242
10596
 
@@ -10267,7 +10621,11 @@ export class Document {
10267
10621
  }
10268
10622
 
10269
10623
  private saveEndnotes(): void {
10270
- if (this._endnotesModified || this.endnoteManager.getCount() > 0) {
10624
+ // Same passthrough gating as saveFootnotes
10625
+ if (
10626
+ this._endnotesModified ||
10627
+ (!this._originalEndnotesXml && this.endnoteManager.getCount() > 0)
10628
+ ) {
10271
10629
  const xml = this.endnoteManager.generateEndnotesXml();
10272
10630
  this.zipHandler.addFile(DOCX_PATHS.ENDNOTES, xml);
10273
10631
 
@@ -10364,10 +10722,21 @@ export class Document {
10364
10722
 
10365
10723
  this.zipHandler.addFile(relsPath, xml);
10366
10724
 
10367
- // Remove these relationships from the main RelationshipManager
10368
- // so they don't appear in document.xml.rels
10725
+ // Remove these relationships from the main RelationshipManager so they
10726
+ // don't appear in document.xml.rels. Per OPC, relationship IDs are
10727
+ // part-scoped: a loaded header/footnote hyperlink keeps an ID from its
10728
+ // own .rels file that may collide with an unrelated document.xml.rels
10729
+ // entry (styles, settings, an image). Only remove when the main entry
10730
+ // is genuinely the same hyperlink (type and target match).
10369
10731
  for (const rel of hyperlinkRels) {
10370
- this.relationshipManager.removeRelationship(rel.id);
10732
+ const mainRel = this.relationshipManager.getRelationship(rel.id);
10733
+ if (
10734
+ mainRel &&
10735
+ mainRel.getType() === RelationshipType.HYPERLINK &&
10736
+ mainRel.getTarget() === rel.url
10737
+ ) {
10738
+ this.relationshipManager.removeRelationship(rel.id);
10739
+ }
10371
10740
  }
10372
10741
  }
10373
10742
  }
@@ -10531,20 +10900,32 @@ export class Document {
10531
10900
  const defaults = new Set<string>();
10532
10901
  const overrides = new Set<string>();
10533
10902
 
10534
- // Extract all <Default Extension="..." ContentType="..."/> entries
10535
- const defaultMatches = xml.matchAll(
10536
- /<Default\s+Extension="([^"]+)"\s+ContentType="([^"]+)"\s*\/>/g
10537
- );
10538
- for (const match of defaultMatches) {
10539
- defaults.add(`${match[1]}|${match[2]}`); // Store as "ext|mimetype"
10903
+ // Per XML 1.0, attribute order is insignificant, values may use single or
10904
+ // double quotes, and empty elements may be expanded (<Override ...></Override>).
10905
+ // Match each opening tag and pull attributes out individually so spec-valid
10906
+ // packages from non-Word producers keep their entries on save (the generator
10907
+ // regenerates [Content_Types].xml; unmatched entries would be silently lost).
10908
+ const readAttr = (tag: string, name: string): string | undefined => {
10909
+ const match = tag.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*("([^"]*)"|'([^']*)')`));
10910
+ if (!match) return undefined;
10911
+ // Unescape so re-serialization (which escapes) doesn't double-escape
10912
+ return XMLBuilder.unescapeXml(match[2] ?? match[3] ?? '');
10913
+ };
10914
+
10915
+ for (const tagMatch of xml.matchAll(/<Default\b[^>]*>/g)) {
10916
+ const ext = readAttr(tagMatch[0], 'Extension');
10917
+ const contentType = readAttr(tagMatch[0], 'ContentType');
10918
+ if (ext && contentType) {
10919
+ defaults.add(`${ext}|${contentType}`); // Store as "ext|mimetype"
10920
+ }
10540
10921
  }
10541
10922
 
10542
- // Extract all <Override PartName="..." ContentType="..."/> entries
10543
- const overrideMatches = xml.matchAll(
10544
- /<Override\s+PartName="([^"]+)"\s+ContentType="([^"]+)"\s*\/>/g
10545
- );
10546
- for (const match of overrideMatches) {
10547
- overrides.add(`${match[1]}|${match[2]}`); // Store as "path|mimetype"
10923
+ for (const tagMatch of xml.matchAll(/<Override\b[^>]*>/g)) {
10924
+ const partName = readAttr(tagMatch[0], 'PartName');
10925
+ const contentType = readAttr(tagMatch[0], 'ContentType');
10926
+ if (partName && contentType) {
10927
+ overrides.add(`${partName}|${contentType}`); // Store as "path|mimetype"
10928
+ }
10548
10929
  }
10549
10930
 
10550
10931
  return { defaults, overrides };
@@ -11509,6 +11890,12 @@ export class Document {
11509
11890
  */
11510
11891
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
11511
11892
  private bindTrackingToElement(element: any): void {
11893
+ // Two-arm coverage: the getContent() arm below binds any paragraph-content
11894
+ // item exposing _setTrackingContext (Hyperlink, ComplexField, etc.) so their
11895
+ // own setText() emits tracked markup; the getRuns() arm binds the inner Runs
11896
+ // that actually carry the text. Both arms are required — neither subsumes the
11897
+ // other — to fully wire a paragraph's tracking context.
11898
+
11512
11899
  // Set tracking context on element if it supports it
11513
11900
  if (element && typeof element._setTrackingContext === 'function') {
11514
11901
  element._setTrackingContext(this.trackingContext);
@@ -11528,14 +11915,13 @@ export class Document {
11528
11915
  }
11529
11916
  }
11530
11917
 
11531
- // Bind to ComplexField instances in paragraph content
11918
+ // Bind to content items that accept a tracking context (Hyperlink,
11919
+ // ComplexField, etc.). Hyperlink.setText() consults its own
11920
+ // trackingContext, so it must be bound here too; re-binding the inner
11921
+ // Runs handled above is idempotent.
11532
11922
  if (element && typeof element.getContent === 'function') {
11533
11923
  for (const item of element.getContent()) {
11534
- if (
11535
- item &&
11536
- typeof item._setTrackingContext === 'function' &&
11537
- typeof item.getInstruction === 'function'
11538
- ) {
11924
+ if (item && typeof item._setTrackingContext === 'function') {
11539
11925
  item._setTrackingContext(this.trackingContext);
11540
11926
  }
11541
11927
  }
@@ -11876,6 +12262,10 @@ export class Document {
11876
12262
  updated.size = sizeInPoints;
11877
12263
  }
11878
12264
  normalStyle.setRunFormatting(updated);
12265
+ // Re-register so the style lands in _modifiedStyleIds — otherwise
12266
+ // mergeStylesWithOriginal() returns the original styles.xml verbatim
12267
+ // for loaded documents and the change is silently dropped
12268
+ this.stylesManager.addStyle(normalStyle);
11879
12269
  return this;
11880
12270
  }
11881
12271
 
@@ -11904,6 +12294,10 @@ export class Document {
11904
12294
 
11905
12295
  const existing = normalStyle.getRunFormatting() ?? {};
11906
12296
  normalStyle.setRunFormatting({ ...existing, size: sizeInPoints });
12297
+ // Re-register so the style lands in _modifiedStyleIds — otherwise
12298
+ // mergeStylesWithOriginal() returns the original styles.xml verbatim
12299
+ // for loaded documents and the change is silently dropped
12300
+ this.stylesManager.addStyle(normalStyle);
11907
12301
  return this;
11908
12302
  }
11909
12303
 
@@ -12438,6 +12832,70 @@ export class Document {
12438
12832
  this._endnotesModified = true;
12439
12833
  }
12440
12834
 
12835
+ /**
12836
+ * Removes a footnote definition and every w:footnoteReference run pointing
12837
+ * at it in the body. Per ECMA-376 §17.11.14 each reference must resolve to
12838
+ * a footnote definition, so the references are stripped together with the
12839
+ * definition; the dirty flag ensures footnotes.xml is regenerated instead
12840
+ * of being restored from the original passthrough XML.
12841
+ * @param id - Footnote ID
12842
+ * @returns True if removed, false if not found or a special separator type
12843
+ */
12844
+ removeFootnote(id: number): boolean {
12845
+ const removed = this.footnoteManager.removeFootnote(id);
12846
+ if (removed) {
12847
+ this._footnotesModified = true;
12848
+ this.removeNoteReferenceRuns('footnoteReference', id);
12849
+ }
12850
+ return removed;
12851
+ }
12852
+
12853
+ /**
12854
+ * Removes an endnote definition and every w:endnoteReference run pointing
12855
+ * at it in the body. Same reference-consistency contract as
12856
+ * {@link removeFootnote} (ECMA-376 §17.11.3).
12857
+ * @param id - Endnote ID
12858
+ * @returns True if removed, false if not found or a special separator type
12859
+ */
12860
+ removeEndnote(id: number): boolean {
12861
+ const removed = this.endnoteManager.removeEndnote(id);
12862
+ if (removed) {
12863
+ this._endnotesModified = true;
12864
+ this.removeNoteReferenceRuns('endnoteReference', id);
12865
+ }
12866
+ return removed;
12867
+ }
12868
+
12869
+ /**
12870
+ * Strips body runs containing a footnote/endnote reference to the given
12871
+ * note ID so the saved document.xml never references a deleted definition.
12872
+ */
12873
+ private removeNoteReferenceRuns(
12874
+ refType: 'footnoteReference' | 'endnoteReference',
12875
+ id: number
12876
+ ): void {
12877
+ for (const para of this.getAllParagraphs()) {
12878
+ const content = para.getContent();
12879
+ // Iterate backwards so removals don't shift pending indices
12880
+ for (let i = content.length - 1; i >= 0; i--) {
12881
+ const item = content[i];
12882
+ if (!(item instanceof Run)) {
12883
+ continue;
12884
+ }
12885
+ const matches = item
12886
+ .getContent()
12887
+ .some(
12888
+ (c) =>
12889
+ c.type === refType &&
12890
+ (refType === 'footnoteReference' ? c.footnoteId === id : c.endnoteId === id)
12891
+ );
12892
+ if (matches) {
12893
+ para.removeContentAt(i);
12894
+ }
12895
+ }
12896
+ }
12897
+ }
12898
+
12441
12899
  /**
12442
12900
  * Adds a comment to a paragraph (wraps the entire paragraph)
12443
12901
  * Creates the comment if text is provided, or uses an existing comment object
@@ -12594,13 +13052,70 @@ export class Document {
12594
13052
  * @returns True if the comment was removed
12595
13053
  */
12596
13054
  removeComment(id: number): boolean {
13055
+ // Capture reply IDs before removal — CommentManager.removeComment deletes
13056
+ // them too, and their body anchors must be scrubbed along with the parent's
13057
+ const replyIds = this.commentManager.getReplies(id).map((reply) => reply.getId());
12597
13058
  const removed = this.commentManager.removeComment(id);
12598
13059
  if (removed) {
13060
+ this.removeCommentAnchors([id, ...replyIds]);
12599
13061
  this._commentsModified = true;
12600
13062
  }
12601
13063
  return removed;
12602
13064
  }
12603
13065
 
13066
+ /**
13067
+ * Strips comment anchors (range markers and reference runs) for the given
13068
+ * comment IDs from every paragraph in the body, headers, footers, footnotes,
13069
+ * and endnotes. Without this, deleting a comment definition leaves
13070
+ * w:commentRangeStart/End and w:commentReference pointing at a comment that
13071
+ * no longer exists in comments.xml — Word reports such documents as
13072
+ * containing unreadable content.
13073
+ */
13074
+ private removeCommentAnchors(commentIds: number[]): void {
13075
+ const strip = (paragraphs: Paragraph[]): boolean => {
13076
+ let removed = false;
13077
+ for (const paragraph of paragraphs) {
13078
+ for (const commentId of commentIds) {
13079
+ if (paragraph.removeCommentAnchor(commentId)) {
13080
+ removed = true;
13081
+ }
13082
+ }
13083
+ }
13084
+ return removed;
13085
+ };
13086
+
13087
+ strip(this.getAllParagraphs());
13088
+
13089
+ for (const entry of this.headerFooterManager.getAllHeaders()) {
13090
+ strip(this.extractParagraphsFromElements(entry.header.getElements()));
13091
+ }
13092
+ for (const entry of this.headerFooterManager.getAllFooters()) {
13093
+ strip(this.extractParagraphsFromElements(entry.footer.getElements()));
13094
+ }
13095
+
13096
+ // Footnote/endnote parts are raw-XML passthrough unless flagged modified,
13097
+ // so anchor removal must mark them dirty to take effect on save
13098
+ let footnotesChanged = false;
13099
+ for (const footnote of this.footnoteManager.getAllFootnotes()) {
13100
+ if (strip(footnote.getParagraphs())) {
13101
+ footnotesChanged = true;
13102
+ }
13103
+ }
13104
+ if (footnotesChanged) {
13105
+ this._footnotesModified = true;
13106
+ }
13107
+
13108
+ let endnotesChanged = false;
13109
+ for (const endnote of this.endnoteManager.getAllEndnotes()) {
13110
+ if (strip(endnote.getParagraphs())) {
13111
+ endnotesChanged = true;
13112
+ }
13113
+ }
13114
+ if (endnotesChanged) {
13115
+ this._endnotesModified = true;
13116
+ }
13117
+ }
13118
+
12604
13119
  /**
12605
13120
  * Checks if there are any revisions in the document
12606
13121
  * @returns True if there are no revisions
@@ -13342,8 +13857,10 @@ export class Document {
13342
13857
  // Clear all managers using their clear() methods
13343
13858
  this.stylesManager.clear();
13344
13859
  this.numberingManager.clear();
13345
- this.imageManager.clear();
13860
+ // Release buffers before clear() empties the images map; the reverse order
13861
+ // would leave releaseAllImageData() iterating zero entries.
13346
13862
  this.imageManager.releaseAllImageData();
13863
+ this.imageManager.clear();
13347
13864
  this.relationshipManager.clear();
13348
13865
  this.headerFooterManager.clear();
13349
13866
  this.bookmarkManager.clear();
@@ -13364,6 +13881,7 @@ export class Document {
13364
13881
  this._originalNumberingXml = undefined;
13365
13882
  this._originalSettingsXml = undefined;
13366
13883
  this._originalAppPropsXml = undefined;
13884
+ this._originalCorePropsXml = undefined;
13367
13885
  this._originalFootnotesXml = undefined;
13368
13886
  this._originalEndnotesXml = undefined;
13369
13887
  this._originalCommentsXml = undefined;
@@ -13372,6 +13890,7 @@ export class Document {
13372
13890
  this._originalContentTypes = undefined;
13373
13891
  this._settingsModified = false;
13374
13892
  this._appPropsModified = false;
13893
+ this._corePropsModified = false;
13375
13894
  this._footnotesModified = false;
13376
13895
  this._endnotesModified = false;
13377
13896
  this._originalWebSettingsXml = undefined;
@@ -14614,7 +15133,9 @@ export class Document {
14614
15133
  const matches = originalText.match(wordPattern);
14615
15134
  if (matches) {
14616
15135
  replacementCount += matches.length;
14617
- newText = originalText.replace(wordPattern, replace);
15136
+ // Replacer function inserts `replace` literally; passing the raw
15137
+ // string would expand $-substitution tokens ($&, $', $`, $$).
15138
+ newText = originalText.replace(wordPattern, () => replace);
14618
15139
  }
14619
15140
  } else {
14620
15141
  // Simple substring replacement
@@ -14625,7 +15146,9 @@ export class Document {
14625
15146
  const matches = originalText.match(searchPattern);
14626
15147
  if (matches) {
14627
15148
  replacementCount += matches.length;
14628
- newText = originalText.replace(searchPattern, replace);
15149
+ // Replacer function inserts `replace` literally; passing the raw
15150
+ // string would expand $-substitution tokens ($&, $', $`, $$).
15151
+ newText = originalText.replace(searchPattern, () => replace);
14629
15152
  }
14630
15153
  }
14631
15154
 
@@ -15253,6 +15776,13 @@ export class Document {
15253
15776
  * @param separator - String to insert between paragraphs (default: '\n')
15254
15777
  * @returns Plain text content of the entire document
15255
15778
  *
15779
+ * @remarks
15780
+ * The output depends on the `revisionHandling` mode the document was loaded with, and reflects any tracked changes applied to the document after loading.
15781
+ * In `'preserve'` mode the result now includes BOTH tracked-inserted (`w:ins`) and
15782
+ * tracked-deleted (`w:delText`) text, interleaved in document order. In
15783
+ * `'accept'`/`'strip'` modes (the default) the output is the post-acceptance plain
15784
+ * text — inserted content kept, deleted content dropped.
15785
+ *
15256
15786
  * @example
15257
15787
  * ```typescript
15258
15788
  * const text = doc.toPlainText();
@@ -15264,23 +15794,37 @@ export class Document {
15264
15794
  */
15265
15795
  toPlainText(separator = '\n'): string {
15266
15796
  const paragraphs = this.getAllParagraphs();
15267
- return paragraphs.map((p) => p.getText()).join(separator);
15797
+ // Use the revision-aware path so preserve-mode documents retain the text
15798
+ // carried by tracked changes (inserted w:t and deleted w:delText) instead
15799
+ // of silently dropping it.
15800
+ return paragraphs.map((p) => p.getTextIncludingRevisions()).join(separator);
15268
15801
  }
15269
15802
 
15270
15803
  /**
15271
15804
  * Converts the document to Markdown format
15272
15805
  *
15273
- * Iterates body elements in order and converts them to Markdown syntax:
15274
- * - Headings `#` / `##` / `###` etc.
15275
- * - Bold/italic runs `**bold**` / `*italic*`
15276
- * - Hyperlinks`[text](url)`
15277
- * - Tablespipe-delimited Markdown tables with alignment row
15278
- * - Numbered/bulleted lists `1.` / `-` prefixes
15279
- * - Regular paragraphs plain text with blank lines between
15280
- *
15281
- * Useful for AI/LLM pipelines, content migration, documentation
15806
+ * Walks the body in order and renders every information-bearing construct,
15807
+ * falling back to inline HTML where Markdown has no native equivalent so
15808
+ * that no content is silently dropped:
15809
+ * - Headings`#` … `######`
15810
+ * - Bold/italic/strikethrough`**` / `*` / `~~`; monospace fonts → `` `code` ``
15811
+ * - Underline/super/subscript/highlight/colorinline HTML (`<u>`, `<sup>`,
15812
+ * `<sub>`, `<mark>`, `<span style="color:#…">`) when {@link MarkdownConversionOptions.htmlFallback}
15813
+ * - Hyperlinks → `[text](url)` (internal links resolve to `#anchor`)
15814
+ * - Inline images `![alt](src)`
15815
+ * - Footnote/endnote references → `[^fn1]` / `[^en1]` with definitions appended
15816
+ * - Line breaks → `<br>` (or newline), tabs preserved
15817
+ * - Numbered/bulleted lists → `1.` / `-` with two-space indentation per nesting level
15818
+ * - Block quotes (Quote styles) → `>` prefixes
15819
+ * - Tables → GFM pipe tables; tables with merged or nested cells → inline HTML
15820
+ * - Field results, shapes, text boxes, structured document tags and preserved
15821
+ * elements → their textual content
15822
+ * - Tracked insertions (preserve mode) render inline; deletions wrap in `~~`/`<del>`
15823
+ *
15824
+ * Useful for document processing pipelines, content migration, documentation
15282
15825
  * generation, and plain-text extraction with structure preserved.
15283
15826
  *
15827
+ * @param options - Conversion fidelity options (see {@link MarkdownConversionOptions})
15284
15828
  * @returns Markdown string representation of the document
15285
15829
  *
15286
15830
  * @example
@@ -15298,139 +15842,495 @@ export class Document {
15298
15842
  * // | Alice | 30 |
15299
15843
  * ```
15300
15844
  */
15301
- toMarkdown(): string {
15302
- const lines: string[] = [];
15845
+ toMarkdown(options?: MarkdownConversionOptions): string {
15846
+ const ctx: MarkdownContext = {
15847
+ opts: {
15848
+ htmlFallback: options?.htmlFallback ?? true,
15849
+ footnotes: options?.footnotes ?? true,
15850
+ images: options?.images ?? true,
15851
+ },
15852
+ notes: [],
15853
+ noteMarkers: new Set<string>(),
15854
+ };
15303
15855
 
15856
+ const lines: string[] = [];
15304
15857
  for (const element of this.bodyElements) {
15305
- if (element instanceof Paragraph) {
15306
- const mdLine = this.paragraphToMarkdown(element);
15307
- if (mdLine !== null) {
15308
- lines.push(mdLine);
15309
- lines.push('');
15310
- }
15311
- } else if (element instanceof Table) {
15312
- lines.push(...this.tableToMarkdown(element));
15313
- lines.push('');
15314
- }
15315
- // Other element types (SDT, AlternateContent, etc.) are skipped
15858
+ this.bodyElementToMarkdown(element, ctx, lines);
15316
15859
  }
15317
15860
 
15318
- // Remove trailing blank line
15861
+ // Remove trailing blank line(s)
15319
15862
  while (lines.length > 0 && lines[lines.length - 1] === '') {
15320
15863
  lines.pop();
15321
15864
  }
15322
15865
 
15323
- return lines.join('\n');
15866
+ let out = lines.join('\n');
15867
+
15868
+ // Append footnote/endnote definitions in first-reference order.
15869
+ if (ctx.opts.footnotes && ctx.notes.length > 0) {
15870
+ const defs = ctx.notes.map((n) => `[^${n.marker}]: ${n.text}`).join('\n');
15871
+ out = out ? `${out}\n\n${defs}` : defs;
15872
+ }
15873
+
15874
+ return out;
15875
+ }
15876
+
15877
+ /**
15878
+ * Renders a single body element into the running Markdown line buffer.
15879
+ * Recurses into block-level structured document tags.
15880
+ * @internal
15881
+ */
15882
+ private bodyElementToMarkdown(element: BodyElement, ctx: MarkdownContext, lines: string[]): void {
15883
+ if (element instanceof Paragraph) {
15884
+ const md = this.paragraphToMarkdown(element, ctx);
15885
+ if (md !== null) {
15886
+ lines.push(md);
15887
+ lines.push('');
15888
+ }
15889
+ } else if (element instanceof Table) {
15890
+ const tableLines = this.tableToMarkdown(element, ctx);
15891
+ if (tableLines.length > 0) {
15892
+ lines.push(...tableLines);
15893
+ lines.push('');
15894
+ }
15895
+ } else if (element instanceof StructuredDocumentTag) {
15896
+ // Block SDT: render its inner content (paragraphs/tables/nested SDTs).
15897
+ for (const child of element.getContent()) {
15898
+ this.bodyElementToMarkdown(child, ctx, lines);
15899
+ }
15900
+ } else if (element instanceof PreservedElement) {
15901
+ // Best-effort: surface any text so round-trip-preserved blocks are not lost.
15902
+ const text = this.extractTextFromRawXml(element.getRawXml());
15903
+ if (text) {
15904
+ lines.push(this.escapeMarkdown(text));
15905
+ lines.push('');
15906
+ }
15907
+ }
15908
+ // TableOfContentsElement is intentionally skipped: its content is generated
15909
+ // from the headings, which are already represented in the Markdown output.
15324
15910
  }
15325
15911
 
15326
15912
  /**
15327
- * Converts a paragraph to a Markdown line.
15913
+ * Converts a paragraph to a Markdown block (heading, list item, block quote,
15914
+ * or plain paragraph). Returns null for empty, non-list paragraphs.
15328
15915
  * @internal
15329
15916
  */
15330
- private paragraphToMarkdown(para: Paragraph): string | null {
15331
- const text = this.paragraphContentToMarkdown(para);
15332
- if (!text && !para.hasNumbering()) return null;
15917
+ private paragraphToMarkdown(para: Paragraph, ctx: MarkdownContext): string | null {
15918
+ const inline = this.paragraphContentToMarkdown(para, ctx);
15919
+ const hasNumbering = para.hasNumbering();
15920
+ if (!inline && !hasNumbering) return null;
15333
15921
 
15334
- // Headings
15922
+ // Headings take precedence over every other block style.
15335
15923
  const headingLevel = para.detectHeadingLevel();
15336
15924
  if (headingLevel !== null && headingLevel >= 1 && headingLevel <= 6) {
15337
- return '#'.repeat(headingLevel) + ' ' + text;
15925
+ return '#'.repeat(headingLevel) + ' ' + this.toSingleLine(inline);
15338
15926
  }
15339
15927
 
15340
- // Numbered/bulleted lists
15341
- if (para.hasNumbering()) {
15342
- const style = para.getStyle();
15343
- const isBullet =
15344
- style?.toLowerCase().includes('bullet') || style?.toLowerCase().includes('list bullet');
15345
- return isBullet ? `- ${text}` : `1. ${text}`;
15928
+ const style = (para.getStyle() ?? '').toLowerCase();
15929
+ const isListStyle =
15930
+ style.includes('listbullet') ||
15931
+ style.includes('list bullet') ||
15932
+ style.includes('listnumber') ||
15933
+ style.includes('list number');
15934
+
15935
+ if (hasNumbering || isListStyle) {
15936
+ const level = para.getNumbering()?.level ?? 0;
15937
+ const indent = ' '.repeat(Math.max(0, level));
15938
+ const marker = this.isOrderedList(para) ? '1.' : '-';
15939
+ return `${indent}${marker} ${this.toSingleLine(inline)}`;
15346
15940
  }
15347
15941
 
15348
- return text;
15942
+ // Block quote (Quote / IntenseQuote styles).
15943
+ if (style.includes('quote')) {
15944
+ return this.toSingleLine(inline)
15945
+ .split('\n')
15946
+ .map((l) => `> ${l}`)
15947
+ .join('\n');
15948
+ }
15949
+
15950
+ return inline;
15951
+ }
15952
+
15953
+ /**
15954
+ * Determines whether a numbered paragraph uses an ordered (decimal/letter/
15955
+ * roman) format rather than a bullet. Resolves the numbering definition when
15956
+ * available, falling back to the paragraph's style name.
15957
+ * @internal
15958
+ */
15959
+ private isOrderedList(para: Paragraph): boolean {
15960
+ const numbering = para.getNumbering();
15961
+ if (numbering) {
15962
+ const instance = this.numberingManager.getInstance(numbering.numId);
15963
+ if (instance) {
15964
+ const abstract = this.numberingManager.getAbstractNumbering(instance.getAbstractNumId());
15965
+ const levelDef = abstract?.getLevel(numbering.level);
15966
+ const format = levelDef?.getFormat();
15967
+ if (format) return format !== 'bullet';
15968
+ }
15969
+ }
15970
+ const style = (para.getStyle() ?? '').toLowerCase();
15971
+ if (style.includes('bullet')) return false;
15972
+ if (style.includes('number')) return true;
15973
+ return true;
15349
15974
  }
15350
15975
 
15351
15976
  /**
15352
15977
  * Converts paragraph inline content to Markdown with formatting.
15353
15978
  * @internal
15354
15979
  */
15355
- private paragraphContentToMarkdown(para: Paragraph): string {
15356
- const parts: string[] = [];
15980
+ private paragraphContentToMarkdown(para: Paragraph, ctx: MarkdownContext): string {
15981
+ return this.inlineContentToMarkdown(para.getContent(), ctx);
15982
+ }
15357
15983
 
15358
- for (const item of para.getContent()) {
15359
- if (item instanceof Run) {
15360
- const runText = item.getText();
15361
- if (!runText) continue;
15984
+ /**
15985
+ * Converts a sequence of inline content items (runs, hyperlinks, fields,
15986
+ * revisions, shapes, text boxes, preserved elements) to Markdown.
15987
+ * @internal
15988
+ */
15989
+ private inlineContentToMarkdown(items: ParagraphContent[], ctx: MarkdownContext): string {
15990
+ const parts: string[] = [];
15362
15991
 
15363
- const fmt = item.getFormatting();
15364
- let md = runText;
15365
-
15366
- // Apply inline formatting (bold + italic combined)
15367
- if (fmt.bold && fmt.italic) {
15368
- md = `***${md}***`;
15369
- } else if (fmt.bold) {
15370
- md = `**${md}**`;
15371
- } else if (fmt.italic) {
15372
- md = `*${md}*`;
15992
+ for (const item of items) {
15993
+ // ImageRun extends Run, so it must be checked first.
15994
+ if (item instanceof ImageRun) {
15995
+ if (ctx.opts.images) {
15996
+ const image = item.getImageElement();
15997
+ const alt = this.escapeMarkdown(image.getAltText() || 'image');
15998
+ const src = image.getRelationshipId() ?? 'image';
15999
+ parts.push(`![${alt}](${src})`);
15373
16000
  }
15374
-
15375
- if (fmt.strike) {
15376
- md = `~~${md}~~`;
16001
+ } else if (item instanceof Run) {
16002
+ parts.push(this.runToMarkdown(item, ctx));
16003
+ } else if (item instanceof Hyperlink) {
16004
+ const runs = item.getRuns();
16005
+ let label =
16006
+ runs.length > 0
16007
+ ? runs.map((r) => this.runToMarkdown(r, ctx, true)).join('')
16008
+ : this.escapeMarkdown(item.getText());
16009
+ if (!label) label = this.escapeMarkdown(item.getText());
16010
+ let url = item.getUrl() ?? '';
16011
+ const anchor = item.getAnchor();
16012
+ if (anchor) url = url ? `${url}#${anchor}` : `#${anchor}`;
16013
+ parts.push(`[${label}](${url})`);
16014
+ } else if (item instanceof Revision) {
16015
+ const inner = this.inlineContentToMarkdown(item.getContent(), ctx);
16016
+ if (!inner) continue;
16017
+ const type = item.getType();
16018
+ if (type === 'delete' || type === 'moveFrom') {
16019
+ // Deleted/moved-away content (only present in 'preserve' mode): mark it
16020
+ // so the information survives without claiming it as final body text.
16021
+ parts.push(ctx.opts.htmlFallback ? `<del>${inner}</del>` : `~~${inner}~~`);
16022
+ } else {
16023
+ parts.push(inner);
15377
16024
  }
15378
-
15379
- // Inline code (monospace font detection)
15380
- if (
15381
- fmt.font &&
15382
- /^(courier|consolas|monaco|menlo|source code|fira code|jetbrains mono)/i.test(fmt.font)
15383
- ) {
15384
- md = `\`${runText}\``;
16025
+ } else if (item instanceof ComplexField) {
16026
+ const result = item.getResult();
16027
+ if (result) parts.push(this.escapeMarkdown(result));
16028
+ } else if (item instanceof Field) {
16029
+ const result = item.getCachedResult();
16030
+ if (result) parts.push(this.escapeMarkdown(result));
16031
+ } else if (item instanceof TextBox) {
16032
+ const text = item
16033
+ .getParagraphs()
16034
+ .map((p) => this.paragraphContentToMarkdown(p, ctx))
16035
+ .filter((s) => s !== '')
16036
+ .join(' ');
16037
+ if (text) parts.push(text);
16038
+ } else if (item instanceof Shape) {
16039
+ const text = item.getText();
16040
+ if (text) parts.push(this.escapeMarkdown(text));
16041
+ } else if (item instanceof PreservedElement) {
16042
+ const type = item.getElementType();
16043
+ // Comment range/reference markers carry no inline body text.
16044
+ if (!type.startsWith('w:comment')) {
16045
+ const text = this.extractTextFromRawXml(item.getRawXml());
16046
+ if (text) parts.push(this.escapeMarkdown(text));
15385
16047
  }
15386
-
15387
- parts.push(md);
15388
- } else if (item instanceof Hyperlink) {
15389
- const url = item.getUrl() || '';
15390
- const linkText = item.getText() || url;
15391
- parts.push(`[${linkText}](${url})`);
15392
16048
  }
15393
- // Revisions, fields, shapes, etc. extract text if possible
16049
+ // RangeMarker (bookmarks) carry no display text and are skipped.
15394
16050
  }
15395
16051
 
15396
16052
  return parts.join('');
15397
16053
  }
15398
16054
 
15399
16055
  /**
15400
- * Converts a table to Markdown table lines.
16056
+ * Converts a single run to Markdown, applying inline formatting and emitting
16057
+ * footnote/endnote markers. Tabs, breaks and symbols are preserved.
15401
16058
  * @internal
15402
16059
  */
15403
- private tableToMarkdown(table: Table): string[] {
15404
- const data = table.toArray();
15405
- if (data.length === 0) return [];
16060
+ private runToMarkdown(run: Run, ctx: MarkdownContext, inLink = false): string {
16061
+ const fmt = run.getFormatting();
16062
+ const isMono = !!(
16063
+ fmt.font &&
16064
+ /^(courier|consolas|monaco|menlo|source code|fira code|jetbrains mono)/i.test(fmt.font)
16065
+ );
16066
+ const escape = (s: string): string => (isMono ? s : this.escapeMarkdown(s));
15406
16067
 
15407
- const colCount = Math.max(...data.map((row) => row.length));
16068
+ const segments: string[] = [];
16069
+ const markers: string[] = [];
16070
+
16071
+ for (const content of run.getContent()) {
16072
+ switch (content.type) {
16073
+ case 'text':
16074
+ case 'instructionText':
16075
+ segments.push(escape(content.value ?? ''));
16076
+ break;
16077
+ case 'tab':
16078
+ segments.push('\t');
16079
+ break;
16080
+ case 'carriageReturn':
16081
+ segments.push('\n');
16082
+ break;
16083
+ case 'break':
16084
+ // Page/column breaks are layout-only; line breaks become hard breaks.
16085
+ if (content.breakType !== 'page' && content.breakType !== 'column') {
16086
+ segments.push(ctx.opts.htmlFallback ? '<br>' : '\n');
16087
+ }
16088
+ break;
16089
+ case 'noBreakHyphen':
16090
+ segments.push('‑');
16091
+ break;
16092
+ case 'symbol':
16093
+ if (content.symbolChar) {
16094
+ const code = parseInt(content.symbolChar, 16);
16095
+ if (!Number.isNaN(code)) segments.push(escape(String.fromCharCode(code)));
16096
+ }
16097
+ break;
16098
+ case 'footnoteReference':
16099
+ if (ctx.opts.footnotes && content.footnoteId !== undefined) {
16100
+ markers.push(this.noteMarker(content.footnoteId, 'fn', ctx));
16101
+ }
16102
+ break;
16103
+ case 'endnoteReference':
16104
+ if (ctx.opts.footnotes && content.endnoteId !== undefined) {
16105
+ markers.push(this.noteMarker(content.endnoteId, 'en', ctx));
16106
+ }
16107
+ break;
16108
+ // Field chars, VML, soft hyphens and other layout markers contribute no text.
16109
+ default:
16110
+ break;
16111
+ }
16112
+ }
16113
+
16114
+ let text = segments.join('');
16115
+ if (text) {
16116
+ if (isMono) {
16117
+ text = `\`${text}\``;
16118
+ }
16119
+ // Emphasis (bold/italic innermost, strikethrough outermost).
16120
+ if (fmt.bold && fmt.italic) text = `***${text}***`;
16121
+ else if (fmt.bold) text = `**${text}**`;
16122
+ else if (fmt.italic) text = `*${text}*`;
16123
+ if (fmt.strike || fmt.dstrike) text = `~~${text}~~`;
16124
+
16125
+ if (ctx.opts.htmlFallback) {
16126
+ if (fmt.superscript) text = `<sup>${text}</sup>`;
16127
+ else if (fmt.subscript) text = `<sub>${text}</sub>`;
16128
+ if (fmt.highlight && fmt.highlight !== 'none') text = `<mark>${text}</mark>`;
16129
+ // Underline and color are the conventional appearance of a hyperlink, so
16130
+ // suppress them inside link labels to avoid redundant markup; elsewhere
16131
+ // they carry real information and are preserved as inline HTML.
16132
+ if (!inLink) {
16133
+ if (fmt.underline && fmt.underline !== 'none') text = `<u>${text}</u>`;
16134
+ if (fmt.color && fmt.color !== 'auto' && /^[0-9a-fA-F]{6}$/.test(fmt.color)) {
16135
+ text = `<span style="color:#${fmt.color}">${text}</span>`;
16136
+ }
16137
+ }
16138
+ }
16139
+ }
16140
+
16141
+ // Footnote/endnote markers follow the text, unaffected by run formatting.
16142
+ return text + markers.join('');
16143
+ }
16144
+
16145
+ /**
16146
+ * Resolves a footnote/endnote reference to a GFM marker and records its
16147
+ * definition (once) for later emission.
16148
+ * @internal
16149
+ */
16150
+ private noteMarker(id: number, kind: 'fn' | 'en', ctx: MarkdownContext): string {
16151
+ const marker = `${kind}${id}`;
16152
+ if (!ctx.noteMarkers.has(marker)) {
16153
+ ctx.noteMarkers.add(marker);
16154
+ const note =
16155
+ kind === 'fn' ? this.footnoteManager.getFootnote(id) : this.endnoteManager.getEndnote(id);
16156
+ let text = '';
16157
+ if (note) {
16158
+ text = note
16159
+ .getParagraphs()
16160
+ .map((p) => this.paragraphContentToMarkdown(p, ctx))
16161
+ .filter((s) => s !== '')
16162
+ .join(' ')
16163
+ .replace(/\s*\n\s*/g, ' ')
16164
+ .trim();
16165
+ }
16166
+ ctx.notes.push({ marker, text });
16167
+ }
16168
+ return `[^${marker}]`;
16169
+ }
16170
+
16171
+ /**
16172
+ * Converts a table to Markdown. Simple grid tables become GFM pipe tables;
16173
+ * tables with merged or nested cells fall back to inline HTML (when enabled)
16174
+ * so structural information is preserved.
16175
+ * @internal
16176
+ */
16177
+ private tableToMarkdown(table: Table, ctx: MarkdownContext): string[] {
16178
+ const rows = table.getRows();
16179
+ if (rows.length === 0) return [];
16180
+
16181
+ const isComplex = rows.some((row) =>
16182
+ row
16183
+ .getCells()
16184
+ .some(
16185
+ (cell) =>
16186
+ (cell.getColumnSpan() ?? 1) > 1 ||
16187
+ cell.getVerticalMerge() !== undefined ||
16188
+ cell.hasNestedTables()
16189
+ )
16190
+ );
16191
+
16192
+ if (isComplex && ctx.opts.htmlFallback) {
16193
+ return this.tableToHtmlBlock(table);
16194
+ }
16195
+
16196
+ const matrix = rows.map((row) => row.getCells().map((cell) => this.cellToMarkdown(cell, ctx)));
16197
+ const colCount = Math.max(...matrix.map((row) => row.length));
15408
16198
  if (colCount === 0) return [];
15409
16199
 
15410
- // Normalize all rows to same column count
15411
- const normalized = data.map((row) => {
16200
+ const normalized = matrix.map((row) => {
15412
16201
  const padded = [...row];
15413
16202
  while (padded.length < colCount) padded.push('');
15414
- // Escape pipes and normalize whitespace in cell text
15415
- return padded.map((cell) => cell.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim());
16203
+ return padded;
15416
16204
  });
15417
16205
 
15418
16206
  const lines: string[] = [];
15419
-
15420
- // Header row
15421
16207
  lines.push('| ' + normalized[0]!.join(' | ') + ' |');
15422
-
15423
- // Separator row
15424
16208
  lines.push('| ' + normalized[0]!.map(() => '---').join(' | ') + ' |');
15425
-
15426
- // Data rows
15427
16209
  for (let i = 1; i < normalized.length; i++) {
15428
16210
  lines.push('| ' + normalized[i]!.join(' | ') + ' |');
15429
16211
  }
15430
-
15431
16212
  return lines;
15432
16213
  }
15433
16214
 
16215
+ /**
16216
+ * Renders a single table cell as inline Markdown text (formatting preserved),
16217
+ * with pipes escaped and line breaks collapsed for pipe-table compatibility.
16218
+ * @internal
16219
+ */
16220
+ private cellToMarkdown(cell: TableCell, ctx: MarkdownContext): string {
16221
+ const text = cell
16222
+ .getParagraphs()
16223
+ .map((p) => this.paragraphContentToMarkdown(p, ctx))
16224
+ .filter((s) => s !== '')
16225
+ .join(' ');
16226
+ return text.replace(/\|/g, '\\|').replace(/<br>/g, ' ').replace(/\n/g, ' ').trim();
16227
+ }
16228
+
16229
+ /**
16230
+ * Renders a table with merged or nested cells as an inline HTML table,
16231
+ * honoring `colspan`/`rowspan` from grid and vertical-merge information.
16232
+ * @internal
16233
+ */
16234
+ private tableToHtmlBlock(table: Table): string[] {
16235
+ const rows = table.getRows();
16236
+
16237
+ // Precompute each cell's starting grid column (accounting for column spans).
16238
+ const rowCells = rows.map((row) => {
16239
+ let gridStart = 0;
16240
+ return row.getCells().map((cell) => {
16241
+ const span = cell.getColumnSpan() ?? 1;
16242
+ const entry = { cell, gridStart, span };
16243
+ gridStart += span;
16244
+ return entry;
16245
+ });
16246
+ });
16247
+
16248
+ const html: string[] = ['<table>'];
16249
+ for (let ri = 0; ri < rowCells.length; ri++) {
16250
+ html.push('<tr>');
16251
+ for (const { cell, gridStart, span } of rowCells[ri]!) {
16252
+ // Cells continuing a vertical merge are covered by the rowspan above.
16253
+ if (cell.getVerticalMerge() === 'continue') continue;
16254
+
16255
+ let rowspan = 1;
16256
+ if (cell.getVerticalMerge() === 'restart') {
16257
+ for (let rj = ri + 1; rj < rowCells.length; rj++) {
16258
+ const below = rowCells[rj]!.find((e) => e.gridStart === gridStart);
16259
+ if (below?.cell.getVerticalMerge() === 'continue') rowspan++;
16260
+ else break;
16261
+ }
16262
+ }
16263
+
16264
+ const tag = ri === 0 ? 'th' : 'td';
16265
+ const attrs =
16266
+ (span > 1 ? ` colspan="${span}"` : '') + (rowspan > 1 ? ` rowspan="${rowspan}"` : '');
16267
+
16268
+ let content =
16269
+ cell
16270
+ .getParagraphs()
16271
+ .map((p) => this.paragraphContentToHTML(p))
16272
+ .filter((s) => s !== '')
16273
+ .join('<br>') || this.escapeHTML(cell.getText());
16274
+
16275
+ if (cell.hasNestedTables()) {
16276
+ const nestedText = cell
16277
+ .getRawNestedContent()
16278
+ .map((n) => this.extractTextFromRawXml(n.xml))
16279
+ .filter((s) => s)
16280
+ .join(' ');
16281
+ if (nestedText) content += `<br>${this.escapeHTML(nestedText)}`;
16282
+ }
16283
+
16284
+ html.push(`<${tag}${attrs}>${content}</${tag}>`);
16285
+ }
16286
+ html.push('</tr>');
16287
+ }
16288
+ html.push('</table>');
16289
+ return html;
16290
+ }
16291
+
16292
+ /**
16293
+ * Escapes Markdown-significant characters in literal text so they render
16294
+ * verbatim rather than as formatting.
16295
+ * @internal
16296
+ */
16297
+ private escapeMarkdown(text: string): string {
16298
+ return text.replace(/([\\`*_[\]<>])/g, '\\$1');
16299
+ }
16300
+
16301
+ /**
16302
+ * Collapses hard/soft line breaks to single spaces for contexts that must
16303
+ * stay on one line (headings, list items, table cells).
16304
+ * @internal
16305
+ */
16306
+ private toSingleLine(text: string): string {
16307
+ return text.replace(/<br>/g, ' ').replace(/\s*\n\s*/g, ' ');
16308
+ }
16309
+
16310
+ /**
16311
+ * Extracts visible text from a raw OOXML fragment by concatenating the
16312
+ * contents of its `w:t` elements and decoding XML entities. Used as a
16313
+ * last-resort so preserved/round-trip elements never silently lose text.
16314
+ * @internal
16315
+ */
16316
+ private extractTextFromRawXml(xml: string): string {
16317
+ if (!xml.includes('<w:t')) return '';
16318
+ const parts: string[] = [];
16319
+ const regex = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g;
16320
+ let match: RegExpExecArray | null;
16321
+ while ((match = regex.exec(xml)) !== null) {
16322
+ parts.push(match[1] ?? '');
16323
+ }
16324
+ return parts
16325
+ .join('')
16326
+ .replace(/&lt;/g, '<')
16327
+ .replace(/&gt;/g, '>')
16328
+ .replace(/&quot;/g, '"')
16329
+ .replace(/&apos;/g, "'")
16330
+ .replace(/&amp;/g, '&')
16331
+ .trim();
16332
+ }
16333
+
15434
16334
  /**
15435
16335
  * Converts the document to an HTML string
15436
16336
  *
@@ -15567,8 +16467,13 @@ export class Document {
15567
16467
 
15568
16468
  parts.push(html);
15569
16469
  } else if (item instanceof Hyperlink) {
15570
- const url = this.escapeHTML(item.getUrl() || '');
15571
- const linkText = this.escapeHTML(item.getText() || url);
16470
+ // Word stores the fragment/anchor (e.g. "!/view?docid=…") separately
16471
+ // from the base URL; recombine them so deep links are not truncated.
16472
+ let rawUrl = item.getUrl() ?? '';
16473
+ const anchor = item.getAnchor();
16474
+ if (anchor) rawUrl = rawUrl ? `${rawUrl}#${anchor}` : `#${anchor}`;
16475
+ const url = this.escapeHTML(rawUrl);
16476
+ const linkText = this.escapeHTML(item.getText() || rawUrl);
15572
16477
  parts.push(`<a href="${url}">${linkText}</a>`);
15573
16478
  }
15574
16479
  }
@@ -15871,7 +16776,12 @@ export class Document {
15871
16776
  let index: number;
15872
16777
 
15873
16778
  if (typeof paragraphOrIndex === 'number') {
15874
- index = paragraphOrIndex;
16779
+ // Numeric index is paragraph-ordinal (matching getParagraphAt /
16780
+ // getParagraphIndex), not a bodyElements index — tables or SDTs before
16781
+ // the target would otherwise shift it onto the wrong paragraph.
16782
+ const para = this.getParagraphAt(paragraphOrIndex);
16783
+ if (!para) return false;
16784
+ index = this.bodyElements.indexOf(para);
15875
16785
  } else {
15876
16786
  // Find the index of the paragraph
15877
16787
  index = this.bodyElements.indexOf(paragraphOrIndex);
@@ -15884,13 +16794,12 @@ export class Document {
15884
16794
  // No paragraphRemoved event in this branch — the paragraph is still
15885
16795
  // structurally present, just marked deleted via revision.
15886
16796
  if (this.trackChangesEnabled && this.trackingContext.isEnabled()) {
15887
- const runs = element.getRuns();
15888
- if (runs.length > 0) {
15889
- const author = this.trackingContext.getAuthor();
15890
- const deletion = Revision.createDeletion(author, runs);
15891
- this.trackingContext.getRevisionManager().register(deletion);
15892
- element.addRevision(deletion);
15893
- }
16797
+ // clearContent with tracking bound REPLACES each Run/Hyperlink with
16798
+ // its delete revision in place. Appending a revision built from
16799
+ // getRuns() would leave the originals live alongside the w:del copy,
16800
+ // so the text would serialize twice and survive an accept-all.
16801
+ this.bindTrackingToElement(element);
16802
+ element.clearContent();
15894
16803
  return true;
15895
16804
  }
15896
16805
  this.bodyElements.splice(index, 1);
@@ -16511,23 +17420,12 @@ export class Document {
16511
17420
  }
16512
17421
  };
16513
17422
 
17423
+ // getAllParagraphs() already walks table-cell paragraphs, so a separate
17424
+ // table pass would report each table hyperlink twice.
16514
17425
  for (const paragraph of this.getAllParagraphs()) {
16515
17426
  extractHyperlinksFromParagraph(paragraph);
16516
17427
  }
16517
17428
 
16518
- // Also check in tables
16519
- for (const table of this.getTables()) {
16520
- for (const row of table.getRows()) {
16521
- for (const cell of row.getCells()) {
16522
- // TableCell has getParagraphs method
16523
- const cellParagraphs = cell instanceof TableCell ? cell.getParagraphs() : [];
16524
- for (const para of cellParagraphs) {
16525
- extractHyperlinksFromParagraph(para);
16526
- }
16527
- }
16528
- }
16529
- }
16530
-
16531
17429
  return hyperlinks;
16532
17430
  }
16533
17431
 
@@ -16721,6 +17619,8 @@ export class Document {
16721
17619
  getBookmarks(): { bookmark: Bookmark; paragraph: Paragraph }[] {
16722
17620
  const bookmarks: { bookmark: Bookmark; paragraph: Paragraph }[] = [];
16723
17621
 
17622
+ // getAllParagraphs() already walks table-cell paragraphs, so a separate
17623
+ // table pass would report each table bookmark twice.
16724
17624
  for (const paragraph of this.getAllParagraphs()) {
16725
17625
  // Get bookmarks that start in this paragraph
16726
17626
  for (const bookmark of paragraph.getBookmarksStart()) {
@@ -16728,19 +17628,6 @@ export class Document {
16728
17628
  }
16729
17629
  }
16730
17630
 
16731
- // Also check in tables
16732
- for (const table of this.getTables()) {
16733
- for (const row of table.getRows()) {
16734
- for (const cell of row.getCells()) {
16735
- for (const para of cell.getParagraphs()) {
16736
- for (const bookmark of para.getBookmarksStart()) {
16737
- bookmarks.push({ bookmark, paragraph: para });
16738
- }
16739
- }
16740
- }
16741
- }
16742
- }
16743
-
16744
17631
  return bookmarks;
16745
17632
  }
16746
17633
 
@@ -16780,27 +17667,14 @@ export class Document {
16780
17667
  getFields(): { field: FieldLike; paragraph: Paragraph; table?: Table }[] {
16781
17668
  const results: { field: FieldLike; paragraph: Paragraph; table?: Table }[] = [];
16782
17669
 
16783
- // Get fields from all body paragraphs
17670
+ // getAllParagraphs() already walks table-cell paragraphs, so a separate
17671
+ // table pass would report each table field twice.
16784
17672
  for (const paragraph of this.getAllParagraphs()) {
16785
17673
  for (const field of paragraph.getFields()) {
16786
17674
  results.push({ field, paragraph });
16787
17675
  }
16788
17676
  }
16789
17677
 
16790
- // Get fields from paragraphs inside table cells
16791
- for (const table of this.getTables()) {
16792
- for (const row of table.getRows()) {
16793
- for (const cell of row.getCells()) {
16794
- const cellParagraphs = cell instanceof TableCell ? cell.getParagraphs() : [];
16795
- for (const para of cellParagraphs) {
16796
- for (const field of para.getFields()) {
16797
- results.push({ field, paragraph: para, table });
16798
- }
16799
- }
16800
- }
16801
- }
16802
- }
16803
-
16804
17678
  return results;
16805
17679
  }
16806
17680
 
@@ -16892,7 +17766,14 @@ export class Document {
16892
17766
 
16893
17767
  // 5. If format changed (e.g., bmp → png), update filename + relationships
16894
17768
  if (result.newExtension !== extension) {
16895
- const newFilename = filename.replace(/\.[^.]+$/, `.${result.newExtension}`);
17769
+ // Swapping the extension can collide with a distinct existing media
17770
+ // part (e.g. image1.bmp → image1.png when image1.png already exists;
17771
+ // non-Word producers don't share Word's global numbering). Resolve to
17772
+ // a fresh, unused name before renaming so neither image is clobbered.
17773
+ const newFilename = this.resolveUniqueMediaFilename(
17774
+ filename.replace(/\.[^.]+$/, `.${result.newExtension}`),
17775
+ filename
17776
+ );
16896
17777
  this.imageManager.updateEntryFilename(image, newFilename);
16897
17778
 
16898
17779
  // Update relationship targets across all relationship managers
@@ -16908,6 +17789,33 @@ export class Document {
16908
17789
  return { optimizedCount, totalSavedBytes: totalSaved };
16909
17790
  }
16910
17791
 
17792
+ /**
17793
+ * Resolves a media filename that does not collide with an existing ZIP media
17794
+ * part or another registered image entry. If the candidate is free, it is
17795
+ * returned unchanged; otherwise a numeric suffix is appended to the base name
17796
+ * (image1.png → image1-1.png → image1-2.png …) until a unique name is found.
17797
+ * @param candidate The desired filename (base name + extension, no directory)
17798
+ * @param currentFilename The filename being renamed away from (excluded from collision check)
17799
+ * @private
17800
+ */
17801
+ private resolveUniqueMediaFilename(candidate: string, currentFilename: string): string {
17802
+ const dotIndex = candidate.lastIndexOf('.');
17803
+ const base = dotIndex === -1 ? candidate : candidate.slice(0, dotIndex);
17804
+ const ext = dotIndex === -1 ? '' : candidate.slice(dotIndex);
17805
+
17806
+ const isTaken = (name: string): boolean =>
17807
+ this.zipHandler.hasFile(`word/media/${name}`) ||
17808
+ this.imageManager.isFilenameOwnedByOther(name, currentFilename);
17809
+
17810
+ let name = candidate;
17811
+ let counter = 1;
17812
+ while (isTaken(name)) {
17813
+ name = `${base}-${counter}${ext}`;
17814
+ counter++;
17815
+ }
17816
+ return name;
17817
+ }
17818
+
16911
17819
  /**
16912
17820
  * Updates an image relationship target when the image format changes.
16913
17821
  * Handles both document body and header/footer relationships.
@@ -17296,7 +18204,7 @@ export class Document {
17296
18204
 
17297
18205
  this.bodyElements.forEach((element, index) => {
17298
18206
  if (element instanceof Paragraph) {
17299
- const isEmpty = element.getText().trim() === '';
18207
+ const isEmpty = this.isParagraphRemovableBlank(element);
17300
18208
  if (isEmpty && lastWasEmpty) {
17301
18209
  toRemove.push(index);
17302
18210
  }
@@ -17392,6 +18300,44 @@ export class Document {
17392
18300
  return { removed, normalized };
17393
18301
  }
17394
18302
 
18303
+ /**
18304
+ * Determines whether a body paragraph is safely removable as a duplicate
18305
+ * empty paragraph. Text emptiness alone is insufficient: a paragraph with no
18306
+ * visible text can still carry a bookmark/comment anchor, an inline image or
18307
+ * shape, a range marker, or a section break (sectPr) in its formatting.
18308
+ * Removing such a paragraph silently destroys that content. Mirrors the
18309
+ * richer blankness standard used by TableCell.isParaBlank.
18310
+ * @private
18311
+ */
18312
+ private isParagraphRemovableBlank(para: Paragraph): boolean {
18313
+ if (para.getText().trim() !== '') return false;
18314
+
18315
+ // Inline section break — removing it merges sections and drops that
18316
+ // section's page setup / headers / margins.
18317
+ if (para.formatting.sectPr !== undefined) return false;
18318
+
18319
+ // Bookmark anchors — destroying them breaks REF fields / internal hyperlinks
18320
+ // targeting them ("Error! Bookmark not defined.").
18321
+ if (para.getBookmarksStart().length > 0 || para.getBookmarksEnd().length > 0) {
18322
+ return false;
18323
+ }
18324
+
18325
+ // Comment anchors.
18326
+ if (para.getCommentsStart().length > 0 || para.getCommentsEnd().length > 0) {
18327
+ return false;
18328
+ }
18329
+
18330
+ // Inline images, shapes, and range markers (bookmark/comment range markers)
18331
+ // are invisible to getText() but are real content.
18332
+ for (const item of para.getContent()) {
18333
+ if (item instanceof ImageRun || item instanceof Shape || item instanceof RangeMarker) {
18334
+ return false;
18335
+ }
18336
+ }
18337
+
18338
+ return true;
18339
+ }
18340
+
17395
18341
  /**
17396
18342
  * Sets the document language
17397
18343
  * @param language - Language code (e.g., 'en-US', 'es-ES', 'fr-FR')
@@ -17403,6 +18349,7 @@ export class Document {
17403
18349
  this.properties = {};
17404
18350
  }
17405
18351
  this.properties.language = language;
18352
+ this._corePropsModified = true;
17406
18353
 
17407
18354
  return this;
17408
18355
  }
@@ -17701,7 +18648,9 @@ export class Document {
17701
18648
  ? new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
17702
18649
  : new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
17703
18650
 
17704
- // Process body paragraphs
18651
+ // getAllParagraphs() walks recursively and already includes every
18652
+ // table-cell paragraph, so a separate table loop would re-apply the
18653
+ // replacement to the same Run instances (e.g. 'cat'->'cats' => 'catss')
17705
18654
  for (const para of this.getAllParagraphs()) {
17706
18655
  for (const run of para.getRuns()) {
17707
18656
  const text = run.getText();
@@ -17725,36 +18674,6 @@ export class Document {
17725
18674
  }
17726
18675
  }
17727
18676
 
17728
- // Process paragraphs inside table cells
17729
- for (const table of this.getTables()) {
17730
- for (const row of table.getRows()) {
17731
- for (const cell of row.getCells()) {
17732
- for (const para of cell.getParagraphs()) {
17733
- for (const run of para.getRuns()) {
17734
- const text = run.getText();
17735
- if (!text) continue;
17736
-
17737
- // Check formatting constraints
17738
- const runFormatting = run.getFormatting();
17739
- if (matchBold && !runFormatting.bold) continue;
17740
- if (matchItalic && !runFormatting.italic) continue;
17741
-
17742
- // Perform replacement
17743
- if (pattern.test(text)) {
17744
- const newText = text.replace(pattern, replace);
17745
- if (newText !== text) {
17746
- run.setText(newText);
17747
- replacedCount++;
17748
- }
17749
- }
17750
- // Reset regex lastIndex for next iteration
17751
- pattern.lastIndex = 0;
17752
- }
17753
- }
17754
- }
17755
- }
17756
- }
17757
-
17758
18677
  return replacedCount;
17759
18678
  }
17760
18679