docxmlater 11.0.10 → 12.1.0

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 (361) hide show
  1. package/README.md +53 -13
  2. package/dist/core/Document.d.ts +29 -3
  3. package/dist/core/Document.d.ts.map +1 -1
  4. package/dist/core/Document.js +733 -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 +735 -172
  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 +223 -39
  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/acceptRevisions.js +20 -11
  179. package/dist/esm/processors/acceptRevisions.js.map +1 -1
  180. package/dist/esm/processors/cnfStyleDecoder.js +7 -7
  181. package/dist/esm/processors/cnfStyleDecoder.js.map +1 -1
  182. package/dist/esm/processors/stripTrackedChanges.js +16 -5
  183. package/dist/esm/processors/stripTrackedChanges.js.map +1 -1
  184. package/dist/esm/utils/deepClone.js +24 -11
  185. package/dist/esm/utils/deepClone.js.map +1 -1
  186. package/dist/esm/utils/units.js +1 -1
  187. package/dist/esm/utils/units.js.map +1 -1
  188. package/dist/esm/utils/validation.js +11 -23
  189. package/dist/esm/utils/validation.js.map +1 -1
  190. package/dist/esm/utils/xmlSanitization.js +131 -7
  191. package/dist/esm/utils/xmlSanitization.js.map +1 -1
  192. package/dist/esm/validation/RevisionAutoFixer.js +69 -55
  193. package/dist/esm/validation/RevisionAutoFixer.js.map +1 -1
  194. package/dist/esm/validation/RevisionValidator.js +24 -20
  195. package/dist/esm/validation/RevisionValidator.js.map +1 -1
  196. package/dist/esm/xml/XMLBuilder.js +52 -20
  197. package/dist/esm/xml/XMLBuilder.js.map +1 -1
  198. package/dist/esm/xml/XMLParser.js +55 -13
  199. package/dist/esm/xml/XMLParser.js.map +1 -1
  200. package/dist/esm/zip/ZipHandler.js +37 -13
  201. package/dist/esm/zip/ZipHandler.js.map +1 -1
  202. package/dist/esm/zip/ZipReader.js +53 -6
  203. package/dist/esm/zip/ZipReader.js.map +1 -1
  204. package/dist/esm/zip/errors.js +7 -0
  205. package/dist/esm/zip/errors.js.map +1 -1
  206. package/dist/esm/zip/types.js +4 -0
  207. package/dist/esm/zip/types.js.map +1 -1
  208. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  209. package/dist/formatting/AbstractNumbering.js +1 -1
  210. package/dist/formatting/AbstractNumbering.js.map +1 -1
  211. package/dist/formatting/NumberingLevel.d.ts +1 -1
  212. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  213. package/dist/formatting/NumberingLevel.js +8 -6
  214. package/dist/formatting/NumberingLevel.js.map +1 -1
  215. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  216. package/dist/formatting/NumberingManager.js +16 -1
  217. package/dist/formatting/NumberingManager.js.map +1 -1
  218. package/dist/formatting/Style.d.ts.map +1 -1
  219. package/dist/formatting/Style.js +14 -0
  220. package/dist/formatting/Style.js.map +1 -1
  221. package/dist/formatting/StylesManager.d.ts +2 -0
  222. package/dist/formatting/StylesManager.d.ts.map +1 -1
  223. package/dist/formatting/StylesManager.js +14 -2
  224. package/dist/formatting/StylesManager.js.map +1 -1
  225. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  226. package/dist/helpers/CleanupHelper.js +103 -26
  227. package/dist/helpers/CleanupHelper.js.map +1 -1
  228. package/dist/images/ImageOptimizer.d.ts.map +1 -1
  229. package/dist/images/ImageOptimizer.js +52 -4
  230. package/dist/images/ImageOptimizer.js.map +1 -1
  231. package/dist/index.d.ts +3 -1
  232. package/dist/index.d.ts.map +1 -1
  233. package/dist/index.js +4 -2
  234. package/dist/index.js.map +1 -1
  235. package/dist/internal.d.ts +1 -1
  236. package/dist/internal.d.ts.map +1 -1
  237. package/dist/internal.js +2 -1
  238. package/dist/internal.js.map +1 -1
  239. package/dist/processors/CompatibilityUpgrader.d.ts.map +1 -1
  240. package/dist/processors/CompatibilityUpgrader.js +7 -1
  241. package/dist/processors/CompatibilityUpgrader.js.map +1 -1
  242. package/dist/processors/InMemoryRevisionAcceptor.d.ts +1 -0
  243. package/dist/processors/InMemoryRevisionAcceptor.d.ts.map +1 -1
  244. package/dist/processors/InMemoryRevisionAcceptor.js +135 -13
  245. package/dist/processors/InMemoryRevisionAcceptor.js.map +1 -1
  246. package/dist/processors/RevisionWalker.d.ts +9 -3
  247. package/dist/processors/RevisionWalker.d.ts.map +1 -1
  248. package/dist/processors/RevisionWalker.js +223 -39
  249. package/dist/processors/RevisionWalker.js.map +1 -1
  250. package/dist/processors/SelectiveRevisionAcceptor.d.ts.map +1 -1
  251. package/dist/processors/SelectiveRevisionAcceptor.js +116 -17
  252. package/dist/processors/SelectiveRevisionAcceptor.js.map +1 -1
  253. package/dist/processors/acceptRevisions.d.ts +1 -0
  254. package/dist/processors/acceptRevisions.d.ts.map +1 -1
  255. package/dist/processors/acceptRevisions.js +21 -11
  256. package/dist/processors/acceptRevisions.js.map +1 -1
  257. package/dist/processors/cnfStyleDecoder.d.ts.map +1 -1
  258. package/dist/processors/cnfStyleDecoder.js +7 -7
  259. package/dist/processors/cnfStyleDecoder.js.map +1 -1
  260. package/dist/processors/stripTrackedChanges.d.ts.map +1 -1
  261. package/dist/processors/stripTrackedChanges.js +16 -5
  262. package/dist/processors/stripTrackedChanges.js.map +1 -1
  263. package/dist/utils/deepClone.d.ts.map +1 -1
  264. package/dist/utils/deepClone.js +24 -11
  265. package/dist/utils/deepClone.js.map +1 -1
  266. package/dist/utils/units.d.ts.map +1 -1
  267. package/dist/utils/units.js +1 -1
  268. package/dist/utils/units.js.map +1 -1
  269. package/dist/utils/validation.d.ts.map +1 -1
  270. package/dist/utils/validation.js +11 -23
  271. package/dist/utils/validation.js.map +1 -1
  272. package/dist/utils/xmlSanitization.d.ts +2 -0
  273. package/dist/utils/xmlSanitization.d.ts.map +1 -1
  274. package/dist/utils/xmlSanitization.js +133 -7
  275. package/dist/utils/xmlSanitization.js.map +1 -1
  276. package/dist/validation/RevisionAutoFixer.d.ts +6 -2
  277. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  278. package/dist/validation/RevisionAutoFixer.js +68 -54
  279. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  280. package/dist/validation/RevisionValidator.d.ts +2 -0
  281. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  282. package/dist/validation/RevisionValidator.js +26 -21
  283. package/dist/validation/RevisionValidator.js.map +1 -1
  284. package/dist/xml/XMLBuilder.d.ts +1 -0
  285. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  286. package/dist/xml/XMLBuilder.js +52 -20
  287. package/dist/xml/XMLBuilder.js.map +1 -1
  288. package/dist/xml/XMLParser.d.ts +1 -0
  289. package/dist/xml/XMLParser.d.ts.map +1 -1
  290. package/dist/xml/XMLParser.js +55 -13
  291. package/dist/xml/XMLParser.js.map +1 -1
  292. package/dist/zip/ZipHandler.d.ts.map +1 -1
  293. package/dist/zip/ZipHandler.js +37 -13
  294. package/dist/zip/ZipHandler.js.map +1 -1
  295. package/dist/zip/ZipReader.d.ts.map +1 -1
  296. package/dist/zip/ZipReader.js +52 -5
  297. package/dist/zip/ZipReader.js.map +1 -1
  298. package/dist/zip/errors.d.ts +3 -0
  299. package/dist/zip/errors.d.ts.map +1 -1
  300. package/dist/zip/errors.js +9 -1
  301. package/dist/zip/errors.js.map +1 -1
  302. package/dist/zip/types.d.ts +4 -0
  303. package/dist/zip/types.d.ts.map +1 -1
  304. package/dist/zip/types.js +4 -0
  305. package/dist/zip/types.js.map +1 -1
  306. package/package.json +2 -2
  307. package/src/core/Document.ts +1184 -242
  308. package/src/core/DocumentGenerator.ts +106 -26
  309. package/src/core/DocumentParser.ts +681 -114
  310. package/src/core/RelationshipManager.ts +446 -442
  311. package/src/elements/Comment.ts +123 -18
  312. package/src/elements/CommentManager.ts +121 -22
  313. package/src/elements/EndnoteManager.ts +4 -1
  314. package/src/elements/Field.ts +134 -43
  315. package/src/elements/FieldHelpers.ts +20 -4
  316. package/src/elements/Footer.ts +62 -67
  317. package/src/elements/FootnoteManager.ts +4 -1
  318. package/src/elements/Header.ts +62 -67
  319. package/src/elements/HeaderFooterManager.ts +56 -8
  320. package/src/elements/Hyperlink.ts +150 -55
  321. package/src/elements/Image.ts +51 -7
  322. package/src/elements/ImageManager.ts +24 -0
  323. package/src/elements/Paragraph.ts +161 -40
  324. package/src/elements/Revision.ts +446 -47
  325. package/src/elements/Run.ts +44 -6
  326. package/src/elements/Section.ts +25 -9
  327. package/src/elements/Shape.ts +52 -44
  328. package/src/elements/StructuredDocumentTag.ts +83 -8
  329. package/src/elements/Table.ts +151 -50
  330. package/src/elements/TableCell.ts +57 -17
  331. package/src/elements/TableOfContents.ts +10 -9
  332. package/src/elements/TableRow.ts +35 -3
  333. package/src/elements/TextBox.ts +58 -43
  334. package/src/formatting/AbstractNumbering.ts +4 -2
  335. package/src/formatting/NumberingLevel.ts +50 -12
  336. package/src/formatting/NumberingManager.ts +32 -3
  337. package/src/formatting/Style.ts +14 -0
  338. package/src/formatting/StylesManager.ts +29 -2
  339. package/src/helpers/CleanupHelper.ts +122 -30
  340. package/src/images/ImageOptimizer.ts +64 -5
  341. package/src/index.ts +7 -0
  342. package/src/internal.ts +1 -0
  343. package/src/processors/CompatibilityUpgrader.ts +15 -2
  344. package/src/processors/InMemoryRevisionAcceptor.ts +276 -17
  345. package/src/processors/RevisionWalker.ts +390 -46
  346. package/src/processors/SelectiveRevisionAcceptor.ts +164 -28
  347. package/src/processors/acceptRevisions.ts +67 -17
  348. package/src/processors/cnfStyleDecoder.ts +18 -12
  349. package/src/processors/stripTrackedChanges.ts +47 -10
  350. package/src/utils/deepClone.ts +48 -14
  351. package/src/utils/units.ts +3 -1
  352. package/src/utils/validation.ts +30 -32
  353. package/src/utils/xmlSanitization.ts +218 -22
  354. package/src/validation/RevisionAutoFixer.ts +109 -65
  355. package/src/validation/RevisionValidator.ts +48 -23
  356. package/src/xml/XMLBuilder.ts +82 -22
  357. package/src/xml/XMLParser.ts +87 -20
  358. package/src/zip/ZipHandler.ts +61 -21
  359. package/src/zip/ZipReader.ts +135 -13
  360. package/src/zip/errors.ts +15 -0
  361. package/src/zip/types.ts +44 -2
@@ -81,17 +81,22 @@ function getLogger(): ILogger {
81
81
  }
82
82
  // Raw XML revision acceptance - used at load time BEFORE parsing
83
83
  // cleanupRevisionMetadata - cleanup metadata files after in-memory acceptance
84
- import { acceptAllRevisions, cleanupRevisionMetadata } from '../processors/acceptRevisions.js';
84
+ import {
85
+ acceptAllRevisions,
86
+ rejectAllRevisions,
87
+ cleanupRevisionMetadata,
88
+ } from '../processors/acceptRevisions.js';
85
89
  // In-memory revision acceptance - used AFTER parsing, allows subsequent modifications
86
90
  import { acceptRevisionsInMemory } from '../processors/InMemoryRevisionAcceptor.js';
87
91
  import { stripTrackedChanges } from '../processors/stripTrackedChanges.js';
88
92
  import { diffText, diffHasUnchangedParts } from '../utils/textDiff.js';
93
+ import { reorderRunPropertyChildren } from '../utils/xmlSanitization.js';
89
94
  import { XMLBuilder } from '../xml/XMLBuilder.js';
90
95
  import { XMLParser } from '../xml/XMLParser.js';
91
96
  import { DocumentTrackingContext } from '../tracking/DocumentTrackingContext.js';
92
97
  import type { TrackingContext } from '../tracking/TrackingContext.js';
93
98
  import { ZipHandler } from '../zip/ZipHandler.js';
94
- import { DOCX_PATHS } from '../zip/types.js';
99
+ import { DOCX_PATHS, SizeLimitOptions } from '../zip/types.js';
95
100
  import { DocumentGenerator } from './DocumentGenerator.js';
96
101
  import { DocumentIdManager } from './DocumentIdManager.js';
97
102
  import { DocumentParser } from './DocumentParser.js';
@@ -102,7 +107,7 @@ import {
102
107
  type DocumentEventType,
103
108
  } from './DocumentEvents.js';
104
109
  import { RelationshipManager } from './RelationshipManager.js';
105
- import { RelationshipType } from './Relationship.js';
110
+ import { Relationship, RelationshipType } from './Relationship.js';
106
111
  import { BodyElement } from './DocumentContent.js';
107
112
  import { optimizeImage, ImageOptimizationResult } from '../images/ImageOptimizer.js';
108
113
 
@@ -156,7 +161,8 @@ export interface DocumentLoadOptions extends DocumentOptions {
156
161
  *
157
162
  * - 'preserve': Keep tracked changes as-is (may cause corruption if IDs conflict)
158
163
  * - 'accept': Accept all changes - removes revision markup, keeps inserted content, removes deleted content (default)
159
- * - 'strip': Remove all revision markup completely
164
+ * - 'reject': Reject all changes - reverts to the original pre-edit document: removes inserted content, restores deleted content, undoes moves, and restores previous formatting from property-change markers (the exact inverse of 'accept')
165
+ * - 'strip': Remove all revision markup, keeping inserted content and removing deleted content (same resulting text as 'accept')
160
166
  *
161
167
  * Default: 'accept' (prevents corruption from revision ID conflicts)
162
168
  *
@@ -170,6 +176,9 @@ export interface DocumentLoadOptions extends DocumentOptions {
170
176
  * // Explicit accept
171
177
  * const doc = await Document.load('file.docx', { revisionHandling: 'accept' });
172
178
  *
179
+ * // Reject all changes - revert to the original pre-edit document
180
+ * const doc = await Document.load('file.docx', { revisionHandling: 'reject' });
181
+ *
173
182
  * // Strip all tracked changes
174
183
  * const doc = await Document.load('file.docx', { revisionHandling: 'strip' });
175
184
  *
@@ -177,7 +186,7 @@ export interface DocumentLoadOptions extends DocumentOptions {
177
186
  * const doc = await Document.load('file.docx', { revisionHandling: 'preserve' });
178
187
  * ```
179
188
  */
180
- revisionHandling?: 'preserve' | 'accept' | 'strip';
189
+ revisionHandling?: 'preserve' | 'accept' | 'reject' | 'strip';
181
190
 
182
191
  /**
183
192
  * Accept all tracked changes after parsing using in-memory transformation.
@@ -209,6 +218,68 @@ export interface DocumentLoadOptions extends DocumentOptions {
209
218
  * ```
210
219
  */
211
220
  acceptRevisions?: boolean;
221
+
222
+ /**
223
+ * Resource limits applied while reading the DOCX archive.
224
+ *
225
+ * Forwarded to the underlying ZIP reader to guard against availability
226
+ * attacks (zip bombs, entry-count amplification) when loading untrusted
227
+ * input. Defaults are intentionally generous so legitimate documents load
228
+ * unchanged; tighten the fields on {@link SizeLimitOptions} for untrusted
229
+ * sources.
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * const doc = await Document.load('untrusted.docx', {
234
+ * sizeLimits: { maxTotalUncompressedMB: 100, maxEntryCount: 500 },
235
+ * });
236
+ * ```
237
+ */
238
+ sizeLimits?: SizeLimitOptions;
239
+ }
240
+
241
+ /**
242
+ * Options controlling {@link Document.toMarkdown} conversion fidelity.
243
+ *
244
+ * Markdown cannot natively express every Word construct, so the converter
245
+ * falls back to inline HTML (when {@link htmlFallback} is enabled) for things
246
+ * like underline, super/subscript, highlight and color, and collects
247
+ * footnotes/endnotes as GFM-style references. These options let callers tune
248
+ * how aggressively that extra information is preserved.
249
+ */
250
+ export interface MarkdownConversionOptions {
251
+ /**
252
+ * Emit inline HTML for character formatting Markdown cannot represent:
253
+ * underline (`<u>`), superscript (`<sup>`), subscript (`<sub>`), highlight
254
+ * (`<mark>`) and text color (`<span style="color:#…">`). Tracked deletions
255
+ * (in `'preserve'` mode) are wrapped in `<del>`. When `false`, that
256
+ * formatting is dropped but the text itself is still emitted. Default: `true`.
257
+ */
258
+ htmlFallback?: boolean;
259
+ /**
260
+ * Collect footnote/endnote references as GFM footnote markers (`[^fn1]`,
261
+ * `[^en1]`) and append their definitions at the end of the output.
262
+ * Default: `true`.
263
+ */
264
+ footnotes?: boolean;
265
+ /**
266
+ * Emit inline images as `![alt](src)` where `src` is the image's
267
+ * relationship id (or a placeholder for unsaved documents). When `false`,
268
+ * images are skipped. Default: `true`.
269
+ */
270
+ images?: boolean;
271
+ }
272
+
273
+ /**
274
+ * Internal state threaded through a single Markdown conversion pass.
275
+ * @internal
276
+ */
277
+ interface MarkdownContext {
278
+ opts: Required<MarkdownConversionOptions>;
279
+ /** Ordered, de-duplicated footnote/endnote definitions to append at the end. */
280
+ notes: { marker: string; text: string }[];
281
+ /** Markers already emitted, to avoid duplicate definitions. */
282
+ noteMarkers: Set<string>;
212
283
  }
213
284
 
214
285
  /**
@@ -249,6 +320,12 @@ export class Document {
249
320
  () => this.documentIdManager.getNextId(),
250
321
  (existingId) => this.documentIdManager.ensureNextIdAbove(existingId)
251
322
  );
323
+ // Instance-level comment edits (resolve, setAuthor, addRun, ...) must
324
+ // flip the dirty flag, otherwise saveComments() takes the passthrough
325
+ // branch and writes the original comments.xml, silently dropping them
326
+ this.commentManager.setModifiedNotifier(() => {
327
+ this._commentsModified = true;
328
+ });
252
329
 
253
330
  this.trackingContext = new DocumentTrackingContext(this.revisionManager);
254
331
  this.parser = new DocumentParser();
@@ -324,6 +401,7 @@ export class Document {
324
401
  private _originalNumberingXml?: string;
325
402
  private _originalSettingsXml?: string;
326
403
  private _originalAppPropsXml?: string;
404
+ private _originalCorePropsXml?: string;
327
405
  private _originalFootnotesXml?: string;
328
406
  private _originalEndnotesXml?: string;
329
407
  private _originalCommentsXml?: string;
@@ -352,6 +430,9 @@ export class Document {
352
430
  // Track whether app properties have been programmatically modified since load
353
431
  private _appPropsModified = false;
354
432
 
433
+ // Track whether core properties have been programmatically modified since load
434
+ private _corePropsModified = false;
435
+
355
436
  // Track whether footnotes/endnotes have been programmatically modified since load
356
437
  private _footnotesModified = false;
357
438
  private _endnotesModified = false;
@@ -785,7 +866,7 @@ export class Document {
785
866
  logger.info('Loading document from file', { path: filePath });
786
867
 
787
868
  const zipHandler = new ZipHandler();
788
- await zipHandler.load(filePath);
869
+ await zipHandler.load(filePath, { sizeLimits: options?.sizeLimits });
789
870
 
790
871
  const doc = await Document.initializeFromZip(zipHandler, options);
791
872
  doc._events.emit('afterLoad', { source: 'file', path: filePath });
@@ -825,7 +906,7 @@ export class Document {
825
906
  logger.info('Loading document from buffer', { bufferSize: buffer.length });
826
907
 
827
908
  const zipHandler = new ZipHandler();
828
- await zipHandler.loadFromBuffer(buffer);
909
+ await zipHandler.loadFromBuffer(buffer, { sizeLimits: options?.sizeLimits });
829
910
 
830
911
  const doc = await Document.initializeFromZip(zipHandler, options);
831
912
  doc._events.emit('afterLoad', { source: 'buffer' });
@@ -846,6 +927,17 @@ export class Document {
846
927
  // If acceptRevisions is true, we need to preserve revisions during parsing
847
928
  // so they can be accepted using in-memory transformation after parsing
848
929
  const useInMemoryAccept = options?.acceptRevisions === true;
930
+ // `acceptRevisions: true` performs an in-memory ACCEPT, which is the exact
931
+ // opposite of `revisionHandling: 'reject'`. Honoring the in-memory accept
932
+ // gate would silently produce the accepted document when the caller asked
933
+ // to revert — surface the conflict instead of doing the opposite.
934
+ if (useInMemoryAccept && options?.revisionHandling === 'reject') {
935
+ throw new Error(
936
+ "Conflicting load options: 'acceptRevisions: true' (in-memory accept) cannot be combined " +
937
+ 'with \'revisionHandling: "reject"\'. Use \'revisionHandling: "reject"\' on its own to ' +
938
+ 'revert the document to its original pre-edit state.'
939
+ );
940
+ }
849
941
  const revisionHandling = useInMemoryAccept
850
942
  ? 'preserve' // Force preserve so revisions are parsed into model
851
943
  : (options?.revisionHandling ?? 'accept'); // Default to accept
@@ -854,6 +946,10 @@ export class Document {
854
946
  if (revisionHandling === 'accept') {
855
947
  // Accept all tracked changes to prevent corruption (raw XML approach)
856
948
  await acceptAllRevisions(zipHandler);
949
+ } else if (revisionHandling === 'reject') {
950
+ // Reject all tracked changes - revert to the original pre-edit document
951
+ // (inverse of accept: drop insertions, restore deletions/moves/formatting)
952
+ await rejectAllRevisions(zipHandler);
857
953
  } else if (revisionHandling === 'strip') {
858
954
  // Strip all tracked changes completely
859
955
  await stripTrackedChanges(zipHandler);
@@ -890,7 +986,10 @@ export class Document {
890
986
 
891
987
  const numberingXml = zipHandler.getFileAsString(DOCX_PATHS.NUMBERING);
892
988
  if (numberingXml) {
893
- doc._originalNumberingXml = numberingXml;
989
+ // Numbering definitions are written back verbatim for fidelity, so repair
990
+ // any out-of-order w:rPr children here (e.g. w:b/w:bCs appended after
991
+ // w:color/w:sz by some producers) — Word flags such ordering as corrupt.
992
+ doc._originalNumberingXml = reorderRunPropertyChildren(numberingXml);
894
993
  }
895
994
 
896
995
  // Preserve original settings.xml and parse managed settings into in-memory state
@@ -907,6 +1006,15 @@ export class Document {
907
1006
  doc._originalAppPropsXml = appPropsXml;
908
1007
  }
909
1008
 
1009
+ // Preserve original core.xml for round-trip fidelity. parseProperties only
1010
+ // extracts a fixed subset of OPC core properties; optional elements like
1011
+ // cp:lastPrinted, dc:identifier, and cp:version would otherwise be dropped
1012
+ // on a plain load→save round-trip.
1013
+ const corePropsXml = zipHandler.getFileAsString(DOCX_PATHS.CORE_PROPS);
1014
+ if (corePropsXml) {
1015
+ doc._originalCorePropsXml = corePropsXml;
1016
+ }
1017
+
910
1018
  // Preserve original webSettings.xml for round-trip fidelity (w:divs, flags, etc.)
911
1019
  const webSettingsXml = zipHandler.getFileAsString(DOCX_PATHS.WEB_SETTINGS);
912
1020
  if (webSettingsXml) {
@@ -1306,18 +1414,20 @@ export class Document {
1306
1414
 
1307
1415
  // Register headers with HeaderFooterManager (deduplicate by rId)
1308
1416
  // Multiple section property types may reference the same rId
1417
+ // The parsed filename (relationship target) is preserved so saveHeaders()
1418
+ // writes content back to the part the relationship actually points at
1309
1419
  const registeredHeaderRIds = new Set<string>();
1310
- for (const { header, relationshipId } of headersFooters.headers) {
1420
+ for (const { header, relationshipId, filename } of headersFooters.headers) {
1311
1421
  if (registeredHeaderRIds.has(relationshipId)) continue;
1312
- this.headerFooterManager.registerHeader(header, relationshipId);
1422
+ this.headerFooterManager.registerHeader(header, relationshipId, filename);
1313
1423
  registeredHeaderRIds.add(relationshipId);
1314
1424
  }
1315
1425
 
1316
1426
  // Register footers with HeaderFooterManager (deduplicate by rId)
1317
1427
  const registeredFooterRIds = new Set<string>();
1318
- for (const { footer, relationshipId } of headersFooters.footers) {
1428
+ for (const { footer, relationshipId, filename } of headersFooters.footers) {
1319
1429
  if (registeredFooterRIds.has(relationshipId)) continue;
1320
- this.headerFooterManager.registerFooter(footer, relationshipId);
1430
+ this.headerFooterManager.registerFooter(footer, relationshipId, filename);
1321
1431
  registeredFooterRIds.add(relationshipId);
1322
1432
  }
1323
1433
  } catch (headerFooterError) {
@@ -1413,7 +1523,13 @@ export class Document {
1413
1523
  }
1414
1524
 
1415
1525
  const parser = new DocumentParser();
1416
- const comments = parser.parseCommentsXml(commentsXml);
1526
+ // commentsExtended.xml carries the resolved state (w15:done) keyed by
1527
+ // each comment's last-paragraph paraId — CT_Comment itself declares no
1528
+ // done attribute, so isResolved() needs the companion part
1529
+ const comments = parser.parseCommentsXml(
1530
+ commentsXml,
1531
+ this._originalCommentCompanionFiles.get(DOCX_PATHS.COMMENTS_EXTENDED)
1532
+ );
1417
1533
 
1418
1534
  // Register each comment with its existing ID
1419
1535
  for (const comment of comments) {
@@ -1435,7 +1551,12 @@ export class Document {
1435
1551
 
1436
1552
  try {
1437
1553
  const parser = new DocumentParser();
1438
- const footnotes = parser.parseFootnotesXml(footnotesXml);
1554
+ // Hyperlink r:id targets live in the part-scoped rels file per OPC;
1555
+ // resolving them at parse time lets a real footnote edit regenerate
1556
+ // working links instead of dangling r:id references
1557
+ const relsXml = this.zipHandler.getFileAsString('word/_rels/footnotes.xml.rels');
1558
+ const partRels = relsXml ? RelationshipManager.fromXml(relsXml) : undefined;
1559
+ const footnotes = parser.parseFootnotesXml(footnotesXml, partRels);
1439
1560
 
1440
1561
  for (const footnote of footnotes) {
1441
1562
  const id = footnote.getId();
@@ -1461,7 +1582,9 @@ export class Document {
1461
1582
 
1462
1583
  try {
1463
1584
  const parser = new DocumentParser();
1464
- const endnotes = parser.parseEndnotesXml(endnotesXml);
1585
+ const relsXml = this.zipHandler.getFileAsString('word/_rels/endnotes.xml.rels');
1586
+ const partRels = relsXml ? RelationshipManager.fromXml(relsXml) : undefined;
1587
+ const endnotes = parser.parseEndnotesXml(endnotesXml, partRels);
1465
1588
 
1466
1589
  for (const endnote of endnotes) {
1467
1590
  const id = endnote.getId();
@@ -1647,12 +1770,18 @@ export class Document {
1647
1770
  // When tracking enabled, bind context and wrap existing content in w:ins revisions
1648
1771
  if (this.trackChangesEnabled && this.trackingContext.isEnabled()) {
1649
1772
  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);
1773
+ const author = this.trackingContext.getAuthor();
1774
+ // Each top-level Run/Hyperlink is REPLACED by its insert revision in
1775
+ // place. Wrapping paragraph.getRuns() and appending the revision would
1776
+ // leave the originals live alongside the w:ins copy (text serialized
1777
+ // twice, unremovable by accept/reject) and would re-wrap runs already
1778
+ // inside existing revisions or hyperlinks.
1779
+ for (const item of paragraph.getContent()) {
1780
+ if (item instanceof Run || item instanceof Hyperlink) {
1781
+ const insertion = Revision.createInsertion(author, item);
1782
+ this.trackingContext.getRevisionManager().register(insertion);
1783
+ paragraph.replaceContent(item, [insertion]);
1784
+ }
1656
1785
  }
1657
1786
  }
1658
1787
 
@@ -2307,6 +2436,7 @@ export class Document {
2307
2436
  */
2308
2437
  setTitle(title: string): this {
2309
2438
  this.properties.title = title;
2439
+ this._corePropsModified = true;
2310
2440
  return this;
2311
2441
  }
2312
2442
 
@@ -2317,6 +2447,7 @@ export class Document {
2317
2447
  */
2318
2448
  setSubject(subject: string): this {
2319
2449
  this.properties.subject = subject;
2450
+ this._corePropsModified = true;
2320
2451
  return this;
2321
2452
  }
2322
2453
 
@@ -2327,6 +2458,7 @@ export class Document {
2327
2458
  */
2328
2459
  setCreator(creator: string): this {
2329
2460
  this.properties.creator = creator;
2461
+ this._corePropsModified = true;
2330
2462
  return this;
2331
2463
  }
2332
2464
 
@@ -2351,6 +2483,7 @@ export class Document {
2351
2483
  */
2352
2484
  setKeywords(keywords: string): this {
2353
2485
  this.properties.keywords = keywords;
2486
+ this._corePropsModified = true;
2354
2487
  return this;
2355
2488
  }
2356
2489
 
@@ -2361,6 +2494,7 @@ export class Document {
2361
2494
  */
2362
2495
  setDescription(description: string): this {
2363
2496
  this.properties.description = description;
2497
+ this._corePropsModified = true;
2364
2498
  return this;
2365
2499
  }
2366
2500
 
@@ -2371,6 +2505,7 @@ export class Document {
2371
2505
  */
2372
2506
  setCategory(category: string): this {
2373
2507
  this.properties.category = category;
2508
+ this._corePropsModified = true;
2374
2509
  return this;
2375
2510
  }
2376
2511
 
@@ -2381,6 +2516,7 @@ export class Document {
2381
2516
  */
2382
2517
  setContentStatus(status: string): this {
2383
2518
  this.properties.contentStatus = status;
2519
+ this._corePropsModified = true;
2384
2520
  return this;
2385
2521
  }
2386
2522
 
@@ -2845,7 +2981,7 @@ export class Document {
2845
2981
  * console.log(doc.toPlainText());
2846
2982
  * ```
2847
2983
  */
2848
- static async loadFromBase64(base64: string, options?: DocumentOptions): Promise<Document> {
2984
+ static async loadFromBase64(base64: string, options?: DocumentLoadOptions): Promise<Document> {
2849
2985
  const buffer = Buffer.from(base64, 'base64');
2850
2986
  return Document.loadFromBuffer(buffer, options);
2851
2987
  }
@@ -3327,6 +3463,7 @@ export class Document {
3327
3463
 
3328
3464
  /**
3329
3465
  * Updates the core properties with current values
3466
+ * Uses preservation strategy to maintain original metadata when unmodified
3330
3467
  */
3331
3468
  private updateCoreProps(): void {
3332
3469
  if (
@@ -3334,8 +3471,65 @@ export class Document {
3334
3471
  this._removedParts.has('docProps/core.xml')
3335
3472
  )
3336
3473
  return;
3337
- const xml = this.generator.generateCoreProps(this.properties);
3338
- this.zipHandler.updateFile(DOCX_PATHS.CORE_PROPS, xml);
3474
+ if (this._originalCorePropsXml && !this._corePropsModified) {
3475
+ // Preserve original as-is — no changes to core properties. Keeps
3476
+ // unparsed elements (cp:lastPrinted, dc:identifier, cp:version) intact.
3477
+ return;
3478
+ }
3479
+ if (this._originalCorePropsXml && this._corePropsModified) {
3480
+ // Merge: update only changed fields in original XML
3481
+ const mergedXml = this.mergeCorePropsWithOriginal();
3482
+ this.zipHandler.updateFile(DOCX_PATHS.CORE_PROPS, mergedXml);
3483
+ } else {
3484
+ // New document — generate from scratch
3485
+ const xml = this.generator.generateCoreProps(this.properties);
3486
+ this.zipHandler.updateFile(DOCX_PATHS.CORE_PROPS, xml);
3487
+ }
3488
+ }
3489
+
3490
+ /**
3491
+ * Merges modified core properties with the original core.xml.
3492
+ * Updates only fields that have been programmatically changed, preserving
3493
+ * unparsed OPC core properties (cp:lastPrinted, dc:identifier, cp:version,
3494
+ * dcterms:created/modified, etc.) that the parser never extracts.
3495
+ */
3496
+ private mergeCorePropsWithOriginal(): string {
3497
+ if (!this._originalCorePropsXml) {
3498
+ return this.generator.generateCoreProps(this.properties);
3499
+ }
3500
+
3501
+ let xml = this._originalCorePropsXml;
3502
+
3503
+ // Each parsed core property maps to a single OOXML element. Replace the
3504
+ // existing element's text in place; if the element is absent, insert it
3505
+ // before the closing tag so the new value is not silently dropped.
3506
+ const mergeField = (tag: string, value: string | undefined): void => {
3507
+ if (value === undefined) return;
3508
+ const escaped = XMLBuilder.sanitizeXmlContent(value);
3509
+ const openClose = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?</${tag}>`);
3510
+ const selfClosing = new RegExp(`<${tag}\\b[^>]*/>`);
3511
+ if (openClose.test(xml)) {
3512
+ xml = xml.replace(openClose, `<${tag}>${escaped}</${tag}>`);
3513
+ } else if (selfClosing.test(xml)) {
3514
+ xml = xml.replace(selfClosing, `<${tag}>${escaped}</${tag}>`);
3515
+ } else {
3516
+ xml = xml.replace(
3517
+ /<\/cp:coreProperties>/,
3518
+ ` <${tag}>${escaped}</${tag}>\n</cp:coreProperties>`
3519
+ );
3520
+ }
3521
+ };
3522
+
3523
+ mergeField('dc:title', this.properties.title);
3524
+ mergeField('dc:subject', this.properties.subject);
3525
+ mergeField('dc:creator', this.properties.creator);
3526
+ mergeField('cp:keywords', this.properties.keywords);
3527
+ mergeField('dc:description', this.properties.description);
3528
+ mergeField('cp:category', this.properties.category);
3529
+ mergeField('cp:contentStatus', this.properties.contentStatus);
3530
+ mergeField('dc:language', this.properties.language);
3531
+
3532
+ return xml;
3339
3533
  }
3340
3534
 
3341
3535
  /**
@@ -3379,6 +3573,9 @@ export class Document {
3379
3573
  xml = xml.replace(/<Company>[^<]*<\/Company>/, `<Company>${escaped}</Company>`);
3380
3574
  } else if (xml.includes('<Company/>')) {
3381
3575
  xml = xml.replace(/<Company\/>/, `<Company>${escaped}</Company>`);
3576
+ } else {
3577
+ // Tag fully absent: insert so the value is not silently dropped on save
3578
+ xml = xml.replace(/<\/Properties>/, ` <Company>${escaped}</Company>\n</Properties>`);
3382
3579
  }
3383
3580
  }
3384
3581
 
@@ -3390,6 +3587,12 @@ export class Document {
3390
3587
  /<Application>[^<]*<\/Application>/,
3391
3588
  `<Application>${escaped}</Application>`
3392
3589
  );
3590
+ } else {
3591
+ // Tag fully absent: insert so the value is not silently dropped on save
3592
+ xml = xml.replace(
3593
+ /<\/Properties>/,
3594
+ ` <Application>${escaped}</Application>\n</Properties>`
3595
+ );
3393
3596
  }
3394
3597
  }
3395
3598
 
@@ -3398,6 +3601,9 @@ export class Document {
3398
3601
  const escaped = XMLBuilder.sanitizeXmlContent(this.properties.appVersion);
3399
3602
  if (xml.includes('<AppVersion>')) {
3400
3603
  xml = xml.replace(/<AppVersion>[^<]*<\/AppVersion>/, `<AppVersion>${escaped}</AppVersion>`);
3604
+ } else {
3605
+ // Tag fully absent: insert so the value is not silently dropped on save
3606
+ xml = xml.replace(/<\/Properties>/, ` <AppVersion>${escaped}</AppVersion>\n</Properties>`);
3401
3607
  }
3402
3608
  }
3403
3609
 
@@ -3406,6 +3612,9 @@ export class Document {
3406
3612
  const escaped = XMLBuilder.sanitizeXmlContent(this.properties.manager);
3407
3613
  if (xml.includes('<Manager>')) {
3408
3614
  xml = xml.replace(/<Manager>[^<]*<\/Manager>/, `<Manager>${escaped}</Manager>`);
3615
+ } else {
3616
+ // Tag fully absent: insert so the value is not silently dropped on save
3617
+ xml = xml.replace(/<\/Properties>/, ` <Manager>${escaped}</Manager>\n</Properties>`);
3409
3618
  }
3410
3619
  }
3411
3620
 
@@ -3592,9 +3801,10 @@ export class Document {
3592
3801
  * Merges modified styles with the original styles.xml
3593
3802
  *
3594
3803
  * This preserves all styles from the original document while only updating
3595
- * styles that have been explicitly modified via addStyle().
3804
+ * styles that have been explicitly modified via addStyle() and stripping
3805
+ * styles that have been removed via removeStyle().
3596
3806
  *
3597
- * @returns Merged XML string with original styles + modified styles
3807
+ * @returns Merged XML string with original styles + modified styles - removed styles
3598
3808
  * @private
3599
3809
  */
3600
3810
  private mergeStylesWithOriginal(): string {
@@ -3603,9 +3813,10 @@ export class Document {
3603
3813
  }
3604
3814
 
3605
3815
  const modifiedStyleIds = this.stylesManager.getModifiedStyleIds();
3816
+ const removedStyleIds = this.stylesManager.getRemovedStyleIds();
3606
3817
 
3607
- // If nothing was modified, return original as-is
3608
- if (modifiedStyleIds.size === 0) {
3818
+ // If nothing was modified or removed, return original as-is
3819
+ if (modifiedStyleIds.size === 0 && removedStyleIds.size === 0) {
3609
3820
  return this._originalStylesXml;
3610
3821
  }
3611
3822
 
@@ -3646,6 +3857,16 @@ export class Document {
3646
3857
  }
3647
3858
  }
3648
3859
 
3860
+ // Strip removed style definitions — without this, removals never reach
3861
+ // the saved styles.xml (mirrors the numbering merge's removed-ID handling)
3862
+ for (const styleId of removedStyleIds) {
3863
+ const escapedStyleId = styleId.replace(/[.*+?^${}()|[\]\\"]/g, '\\$&');
3864
+ const removedPattern = new RegExp(
3865
+ `\\s*<w:style[^>]*\\sw:styleId="${escapedStyleId}"[^>]*>[\\s\\S]*?</w:style>`
3866
+ );
3867
+ resultXml = resultXml.replace(removedPattern, '');
3868
+ }
3869
+
3649
3870
  return resultXml;
3650
3871
  }
3651
3872
 
@@ -4003,9 +4224,22 @@ export class Document {
4003
4224
 
4004
4225
  // w:revisionView (#31 in CT_Settings)
4005
4226
  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'}"/>`;
4227
+ // Only emit revisionView if it differs from defaults (all true). showMarkup
4228
+ // and showComments default true when absent, so a parsed w:markup="0" /
4229
+ // w:comments="0" must also trigger emission or it would be dropped here.
4230
+ if (
4231
+ !view.showInsertionsAndDeletions ||
4232
+ !view.showFormatting ||
4233
+ !view.showInkAnnotations ||
4234
+ view.showMarkup === false ||
4235
+ view.showComments === false
4236
+ ) {
4237
+ // CT_TrackChangesView §17.15.1.77: append w:markup / w:comments only when
4238
+ // explicitly false (absent = default true) so the reviewer's default
4239
+ // markup view survives round-trip, mirroring DocumentGenerator.
4240
+ const markupAttr = view.showMarkup === false ? ' w:markup="0"' : '';
4241
+ const commentsAttr = view.showComments === false ? ' w:comments="0"' : '';
4242
+ 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
4243
  }
4010
4244
 
4011
4245
  // w:trackRevisions (#32)
@@ -4062,6 +4296,8 @@ export class Document {
4062
4296
  const prot = this.documentProtection;
4063
4297
  const esc = XMLBuilder.escapeXmlAttribute;
4064
4298
  let protXml = `\n <w:documentProtection w:edit="${esc(prot.edit)}" w:enforcement="${prot.enforcement ? '1' : '0'}"`;
4299
+ // w:formatting (CT_DocProtect §17.15.1.29) — tri-state, emit explicit 0/1
4300
+ if (prot.formatting !== undefined) protXml += ` w:formatting="${prot.formatting ? '1' : '0'}"`;
4065
4301
  if (prot.cryptProviderType) protXml += ` w:cryptProviderType="${esc(prot.cryptProviderType)}"`;
4066
4302
  if (prot.cryptAlgorithmClass)
4067
4303
  protXml += ` w:cryptAlgorithmClass="${esc(prot.cryptAlgorithmClass)}"`;
@@ -4072,6 +4308,12 @@ export class Document {
4072
4308
  if (prot.cryptSpinCount) protXml += ` w:cryptSpinCount="${esc(String(prot.cryptSpinCount))}"`;
4073
4309
  if (prot.hash) protXml += ` w:hash="${esc(prot.hash)}"`;
4074
4310
  if (prot.salt) protXml += ` w:salt="${esc(prot.salt)}"`;
4311
+ // Modern Word 2013+ crypto attributes (ISO/IEC 29500-4 §13) — without
4312
+ // these a modern password-protected document loses its password material
4313
+ // whenever any settings-modifying API runs before save.
4314
+ if (prot.algorithmName) protXml += ` w:algorithmName="${esc(prot.algorithmName)}"`;
4315
+ if (prot.hashValue) protXml += ` w:hashValue="${esc(prot.hashValue)}"`;
4316
+ if (prot.saltValue) protXml += ` w:saltValue="${esc(prot.saltValue)}"`;
4075
4317
  protXml += '/>';
4076
4318
 
4077
4319
  // Insert before w:autoFormatOverride (#36) if exists,
@@ -5597,7 +5839,10 @@ export class Document {
5597
5839
  * - numId references inside body-level tracked deletions (w:del blocks
5598
5840
  * that were skipped during parsing)
5599
5841
  * - Header, footer, footnote, and endnote XML files
5600
- * - Raw XML safety net scan of all relevant ZIP entries
5842
+ * - Style definitions (w:numPr in a style's pPr), since styles attach
5843
+ * list numbering without any paragraph carrying a direct numId
5844
+ * - Raw XML safety net scan of all relevant ZIP entries, including
5845
+ * styles.xml and comments.xml
5601
5846
  *
5602
5847
  * A definition is only removed if it appears in NONE of these sources.
5603
5848
  *
@@ -5663,6 +5908,25 @@ export class Document {
5663
5908
  }
5664
5909
  }
5665
5910
 
5911
+ // 7. Scan style definitions for numPr references — Word attaches multilevel
5912
+ // list numbering to paragraph styles (e.g., Headings, list styles), so a
5913
+ // numId can be in use while no paragraph carries a direct w:numPr
5914
+ for (const style of this.stylesManager.getAllStyles()) {
5915
+ const styleNumId = style.getProperties().numPr?.numId;
5916
+ if (styleNumId !== undefined) {
5917
+ usedNumIds.add(styleNumId);
5918
+ }
5919
+ }
5920
+
5921
+ // 8. Raw safety net for styles.xml and comments.xml — preserved XML can
5922
+ // carry numId references not represented in the in-memory model
5923
+ for (const filePath of [DOCX_PATHS.STYLES, 'word/comments.xml']) {
5924
+ const xml = this.zipHandler.getFileAsString(filePath);
5925
+ if (xml) {
5926
+ this.collectNumIdsFromXml(xml, usedNumIds);
5927
+ }
5928
+ }
5929
+
5666
5930
  // Clean up numbering definitions not found in any of the above scans
5667
5931
  this.numberingManager.cleanupUnusedNumbering(usedNumIds);
5668
5932
  }
@@ -5868,7 +6132,69 @@ export class Document {
5868
6132
  }
5869
6133
  const registeredSet = new Set(existingRevisions);
5870
6134
 
5871
- let docPrId = 1;
6135
+ // Preserved raw XML (revision-nested image runs, w:pict/chart passthrough run
6136
+ // content, preserved elements, nested-table passthrough) is emitted verbatim
6137
+ // with its original wp:docPr id, outside the renumbering below. Reassigned ids
6138
+ // must start above every id that survives serialization unchanged, otherwise a
6139
+ // renumbered drawing can duplicate a preserved one (wp:docPr/@id must be
6140
+ // unique per ECMA-376 Part 1 §20.4.2.5).
6141
+ let maxPreservedDocPrId = 0;
6142
+ const collectPreservedDocPrIds = (rawXml: string | undefined): void => {
6143
+ if (!rawXml || !rawXml.includes('wp:docPr')) return;
6144
+ const docPrPattern = /<wp:docPr\b[^>]*\bid="(\d+)"/g;
6145
+ let match: RegExpExecArray | null;
6146
+ while ((match = docPrPattern.exec(rawXml)) !== null) {
6147
+ const id = parseInt(match[1]!, 10);
6148
+ if (id > maxPreservedDocPrId) maxPreservedDocPrId = id;
6149
+ }
6150
+ };
6151
+ const collectFromRun = (run: Run): void => {
6152
+ if (run instanceof ImageRun) {
6153
+ collectPreservedDocPrIds(run.getRawRunXml());
6154
+ return;
6155
+ }
6156
+ for (const contentItem of run.getContent()) {
6157
+ collectPreservedDocPrIds(contentItem.rawXml);
6158
+ }
6159
+ };
6160
+ const collectFromParagraph = (para: Paragraph): void => {
6161
+ for (const item of para.getContent()) {
6162
+ if (item instanceof Revision) {
6163
+ for (const revItem of item.getContent()) {
6164
+ if (revItem instanceof Run) collectFromRun(revItem);
6165
+ }
6166
+ } else if (item instanceof Run) {
6167
+ collectFromRun(item);
6168
+ } else if (item instanceof PreservedElement) {
6169
+ collectPreservedDocPrIds(item.getRawXml());
6170
+ }
6171
+ }
6172
+ };
6173
+ for (const element of this.bodyElements) {
6174
+ if (element instanceof Paragraph) {
6175
+ collectFromParagraph(element);
6176
+ } else if (element instanceof Table) {
6177
+ for (const row of element.getRows()) {
6178
+ for (const cell of row.getCells()) {
6179
+ for (const para of cell.getParagraphs()) {
6180
+ collectFromParagraph(para);
6181
+ }
6182
+ for (const nested of cell.getRawNestedContent()) {
6183
+ collectPreservedDocPrIds(nested.xml);
6184
+ }
6185
+ }
6186
+ }
6187
+ } else {
6188
+ // Raw-XML passthrough body elements (AlternateContent, MathParagraph,
6189
+ // CustomXmlBlock, PreservedElement, registered elements) expose getRawXml().
6190
+ const passthrough = element as { getRawXml?: () => string };
6191
+ if (typeof passthrough.getRawXml === 'function') {
6192
+ collectPreservedDocPrIds(passthrough.getRawXml());
6193
+ }
6194
+ }
6195
+ }
6196
+
6197
+ let docPrId = maxPreservedDocPrId + 1;
5872
6198
 
5873
6199
  // Collect existing paraIds to avoid collisions when generating new ones
5874
6200
  const existingParaIds = new Set<string>();
@@ -5906,6 +6232,16 @@ export class Document {
5906
6232
  item.setDocPrId(docPrId++);
5907
6233
  } else if (item instanceof ImageRun) {
5908
6234
  item.getImageElement().setDocPrId(docPrId++);
6235
+ } else if (item instanceof Revision) {
6236
+ // Revision-nested images serialize from the model when no raw run XML
6237
+ // was captured (programmatic) or after a mutation splices a regenerated
6238
+ // w:drawing — give them fresh ids so they cannot collide with the
6239
+ // renumbered top-level drawings. Unmutated raw runs ignore the model id.
6240
+ for (const revItem of item.getContent()) {
6241
+ if (revItem instanceof ImageRun) {
6242
+ revItem.getImageElement().setDocPrId(docPrId++);
6243
+ }
6244
+ }
5909
6245
  }
5910
6246
  }
5911
6247
 
@@ -6631,6 +6967,10 @@ export class Document {
6631
6967
  numLevel.setHangingIndent(360);
6632
6968
  }
6633
6969
 
6970
+ // Level setters don't propagate dirty state; without this the save
6971
+ // pipeline writes the original numbering.xml verbatim for loaded docs
6972
+ this.numberingManager.markAbstractNumberingModified(abstractNumId);
6973
+
6634
6974
  // Apply paragraph formatting to all paragraphs using this list
6635
6975
  this.applyFormattingToListParagraphs(instance.getNumId());
6636
6976
  count++;
@@ -6697,6 +7037,10 @@ export class Document {
6697
7037
  numLevel.setAlignment('left');
6698
7038
  }
6699
7039
 
7040
+ // Level setters don't propagate dirty state; without this the save
7041
+ // pipeline writes the original numbering.xml verbatim for loaded docs
7042
+ this.numberingManager.markAbstractNumberingModified(abstractNumId);
7043
+
6700
7044
  // Apply paragraph formatting to all paragraphs using this list
6701
7045
  this.applyFormattingToListParagraphs(instance.getNumId());
6702
7046
  count++;
@@ -9778,8 +10122,11 @@ export class Document {
9778
10122
  * @returns This document for chaining
9779
10123
  */
9780
10124
  setHeader(header: Header): this {
9781
- // Generate relationship for header
9782
- const relationship = this.relationshipManager.addHeader(`${header.getFilename(1)}`);
10125
+ // The relationship must target the part name the manager assigns,
10126
+ // otherwise saveHeaders() writes content to a part nothing references
10127
+ const relationship = this.relationshipManager.addHeader(
10128
+ this.headerFooterManager.peekHeaderFilename(header)
10129
+ );
9783
10130
 
9784
10131
  // Register with manager
9785
10132
  this.headerFooterManager.registerHeader(header, relationship.getId());
@@ -9799,9 +10146,9 @@ export class Document {
9799
10146
  // Enable title page
9800
10147
  this.section.setTitlePage(true);
9801
10148
 
9802
- // Generate relationship for header
10149
+ // Target the part name the manager will assign on registration
9803
10150
  const relationship = this.relationshipManager.addHeader(
9804
- `${header.getFilename(this.headerFooterManager.getHeaderCount() + 1)}`
10151
+ this.headerFooterManager.peekHeaderFilename(header)
9805
10152
  );
9806
10153
 
9807
10154
  // Register with manager
@@ -9819,9 +10166,9 @@ export class Document {
9819
10166
  * @returns This document for chaining
9820
10167
  */
9821
10168
  setEvenPageHeader(header: Header): this {
9822
- // Generate relationship for header
10169
+ // Target the part name the manager will assign on registration
9823
10170
  const relationship = this.relationshipManager.addHeader(
9824
- `${header.getFilename(this.headerFooterManager.getHeaderCount() + 1)}`
10171
+ this.headerFooterManager.peekHeaderFilename(header)
9825
10172
  );
9826
10173
 
9827
10174
  // Register with manager
@@ -9839,8 +10186,11 @@ export class Document {
9839
10186
  * @returns This document for chaining
9840
10187
  */
9841
10188
  setFooter(footer: Footer): this {
9842
- // Generate relationship for footer
9843
- const relationship = this.relationshipManager.addFooter(`${footer.getFilename(1)}`);
10189
+ // The relationship must target the part name the manager assigns,
10190
+ // otherwise saveFooters() writes content to a part nothing references
10191
+ const relationship = this.relationshipManager.addFooter(
10192
+ this.headerFooterManager.peekFooterFilename(footer)
10193
+ );
9844
10194
 
9845
10195
  // Register with manager
9846
10196
  this.headerFooterManager.registerFooter(footer, relationship.getId());
@@ -9860,9 +10210,9 @@ export class Document {
9860
10210
  // Enable title page
9861
10211
  this.section.setTitlePage(true);
9862
10212
 
9863
- // Generate relationship for footer
10213
+ // Target the part name the manager will assign on registration
9864
10214
  const relationship = this.relationshipManager.addFooter(
9865
- `${footer.getFilename(this.headerFooterManager.getFooterCount() + 1)}`
10215
+ this.headerFooterManager.peekFooterFilename(footer)
9866
10216
  );
9867
10217
 
9868
10218
  // Register with manager
@@ -9880,9 +10230,9 @@ export class Document {
9880
10230
  * @returns This document for chaining
9881
10231
  */
9882
10232
  setEvenPageFooter(footer: Footer): this {
9883
- // Generate relationship for footer
10233
+ // Target the part name the manager will assign on registration
9884
10234
  const relationship = this.relationshipManager.addFooter(
9885
- `${footer.getFilename(this.headerFooterManager.getFooterCount() + 1)}`
10235
+ this.headerFooterManager.peekFooterFilename(footer)
9886
10236
  );
9887
10237
 
9888
10238
  // Register with manager
@@ -10188,6 +10538,26 @@ export class Document {
10188
10538
  }
10189
10539
  // Remove companion files since regenerated comments lack w14:paraId
10190
10540
  this.removeCommentCompanionFiles();
10541
+
10542
+ // Resolved state lives in commentsExtended.xml (w15:commentEx
10543
+ // w15:done) — CT_Comment declares no done attribute, so the part is
10544
+ // regenerated alongside comments.xml whenever a comment is resolved
10545
+ const extendedXml = this.commentManager.generateCommentsExtendedXml();
10546
+ if (extendedXml) {
10547
+ this.zipHandler.addFile(DOCX_PATHS.COMMENTS_EXTENDED, extendedXml);
10548
+ const hasExtendedRel = this.relationshipManager
10549
+ .getAllRelationships()
10550
+ .some((rel) => rel.getTarget() === 'commentsExtended.xml');
10551
+ if (!hasExtendedRel) {
10552
+ this.relationshipManager.addRelationship(
10553
+ Relationship.create({
10554
+ id: this.relationshipManager.generateId(),
10555
+ type: 'http://schemas.microsoft.com/office/2011/relationships/commentsExtended',
10556
+ target: 'commentsExtended.xml',
10557
+ })
10558
+ );
10559
+ }
10560
+ }
10191
10561
  } else if (this._originalCommentsXml) {
10192
10562
  // Passthrough — preserve original comments.xml exactly
10193
10563
  this.zipHandler.addFile('word/comments.xml', this._originalCommentsXml);
@@ -10236,7 +10606,14 @@ export class Document {
10236
10606
  }
10237
10607
 
10238
10608
  private saveFootnotes(): void {
10239
- if (this._footnotesModified || this.footnoteManager.getCount() > 0) {
10609
+ // Dirty flag gates regeneration (mirrors saveComments) so an unmodified
10610
+ // loaded footnotes.xml round-trips via passthrough; the count check only
10611
+ // covers footnotes registered directly on the manager for documents that
10612
+ // never had a footnotes part.
10613
+ if (
10614
+ this._footnotesModified ||
10615
+ (!this._originalFootnotesXml && this.footnoteManager.getCount() > 0)
10616
+ ) {
10240
10617
  const xml = this.footnoteManager.generateFootnotesXml();
10241
10618
  this.zipHandler.addFile(DOCX_PATHS.FOOTNOTES, xml);
10242
10619
 
@@ -10267,7 +10644,11 @@ export class Document {
10267
10644
  }
10268
10645
 
10269
10646
  private saveEndnotes(): void {
10270
- if (this._endnotesModified || this.endnoteManager.getCount() > 0) {
10647
+ // Same passthrough gating as saveFootnotes
10648
+ if (
10649
+ this._endnotesModified ||
10650
+ (!this._originalEndnotesXml && this.endnoteManager.getCount() > 0)
10651
+ ) {
10271
10652
  const xml = this.endnoteManager.generateEndnotesXml();
10272
10653
  this.zipHandler.addFile(DOCX_PATHS.ENDNOTES, xml);
10273
10654
 
@@ -10364,10 +10745,21 @@ export class Document {
10364
10745
 
10365
10746
  this.zipHandler.addFile(relsPath, xml);
10366
10747
 
10367
- // Remove these relationships from the main RelationshipManager
10368
- // so they don't appear in document.xml.rels
10748
+ // Remove these relationships from the main RelationshipManager so they
10749
+ // don't appear in document.xml.rels. Per OPC, relationship IDs are
10750
+ // part-scoped: a loaded header/footnote hyperlink keeps an ID from its
10751
+ // own .rels file that may collide with an unrelated document.xml.rels
10752
+ // entry (styles, settings, an image). Only remove when the main entry
10753
+ // is genuinely the same hyperlink (type and target match).
10369
10754
  for (const rel of hyperlinkRels) {
10370
- this.relationshipManager.removeRelationship(rel.id);
10755
+ const mainRel = this.relationshipManager.getRelationship(rel.id);
10756
+ if (
10757
+ mainRel &&
10758
+ mainRel.getType() === RelationshipType.HYPERLINK &&
10759
+ mainRel.getTarget() === rel.url
10760
+ ) {
10761
+ this.relationshipManager.removeRelationship(rel.id);
10762
+ }
10371
10763
  }
10372
10764
  }
10373
10765
  }
@@ -10531,20 +10923,32 @@ export class Document {
10531
10923
  const defaults = new Set<string>();
10532
10924
  const overrides = new Set<string>();
10533
10925
 
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"
10926
+ // Per XML 1.0, attribute order is insignificant, values may use single or
10927
+ // double quotes, and empty elements may be expanded (<Override ...></Override>).
10928
+ // Match each opening tag and pull attributes out individually so spec-valid
10929
+ // packages from non-Word producers keep their entries on save (the generator
10930
+ // regenerates [Content_Types].xml; unmatched entries would be silently lost).
10931
+ const readAttr = (tag: string, name: string): string | undefined => {
10932
+ const match = tag.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*("([^"]*)"|'([^']*)')`));
10933
+ if (!match) return undefined;
10934
+ // Unescape so re-serialization (which escapes) doesn't double-escape
10935
+ return XMLBuilder.unescapeXml(match[2] ?? match[3] ?? '');
10936
+ };
10937
+
10938
+ for (const tagMatch of xml.matchAll(/<Default\b[^>]*>/g)) {
10939
+ const ext = readAttr(tagMatch[0], 'Extension');
10940
+ const contentType = readAttr(tagMatch[0], 'ContentType');
10941
+ if (ext && contentType) {
10942
+ defaults.add(`${ext}|${contentType}`); // Store as "ext|mimetype"
10943
+ }
10540
10944
  }
10541
10945
 
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"
10946
+ for (const tagMatch of xml.matchAll(/<Override\b[^>]*>/g)) {
10947
+ const partName = readAttr(tagMatch[0], 'PartName');
10948
+ const contentType = readAttr(tagMatch[0], 'ContentType');
10949
+ if (partName && contentType) {
10950
+ overrides.add(`${partName}|${contentType}`); // Store as "path|mimetype"
10951
+ }
10548
10952
  }
10549
10953
 
10550
10954
  return { defaults, overrides };
@@ -11509,6 +11913,12 @@ export class Document {
11509
11913
  */
11510
11914
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
11511
11915
  private bindTrackingToElement(element: any): void {
11916
+ // Two-arm coverage: the getContent() arm below binds any paragraph-content
11917
+ // item exposing _setTrackingContext (Hyperlink, ComplexField, etc.) so their
11918
+ // own setText() emits tracked markup; the getRuns() arm binds the inner Runs
11919
+ // that actually carry the text. Both arms are required — neither subsumes the
11920
+ // other — to fully wire a paragraph's tracking context.
11921
+
11512
11922
  // Set tracking context on element if it supports it
11513
11923
  if (element && typeof element._setTrackingContext === 'function') {
11514
11924
  element._setTrackingContext(this.trackingContext);
@@ -11528,14 +11938,13 @@ export class Document {
11528
11938
  }
11529
11939
  }
11530
11940
 
11531
- // Bind to ComplexField instances in paragraph content
11941
+ // Bind to content items that accept a tracking context (Hyperlink,
11942
+ // ComplexField, etc.). Hyperlink.setText() consults its own
11943
+ // trackingContext, so it must be bound here too; re-binding the inner
11944
+ // Runs handled above is idempotent.
11532
11945
  if (element && typeof element.getContent === 'function') {
11533
11946
  for (const item of element.getContent()) {
11534
- if (
11535
- item &&
11536
- typeof item._setTrackingContext === 'function' &&
11537
- typeof item.getInstruction === 'function'
11538
- ) {
11947
+ if (item && typeof item._setTrackingContext === 'function') {
11539
11948
  item._setTrackingContext(this.trackingContext);
11540
11949
  }
11541
11950
  }
@@ -11876,6 +12285,10 @@ export class Document {
11876
12285
  updated.size = sizeInPoints;
11877
12286
  }
11878
12287
  normalStyle.setRunFormatting(updated);
12288
+ // Re-register so the style lands in _modifiedStyleIds — otherwise
12289
+ // mergeStylesWithOriginal() returns the original styles.xml verbatim
12290
+ // for loaded documents and the change is silently dropped
12291
+ this.stylesManager.addStyle(normalStyle);
11879
12292
  return this;
11880
12293
  }
11881
12294
 
@@ -11904,6 +12317,10 @@ export class Document {
11904
12317
 
11905
12318
  const existing = normalStyle.getRunFormatting() ?? {};
11906
12319
  normalStyle.setRunFormatting({ ...existing, size: sizeInPoints });
12320
+ // Re-register so the style lands in _modifiedStyleIds — otherwise
12321
+ // mergeStylesWithOriginal() returns the original styles.xml verbatim
12322
+ // for loaded documents and the change is silently dropped
12323
+ this.stylesManager.addStyle(normalStyle);
11907
12324
  return this;
11908
12325
  }
11909
12326
 
@@ -12438,6 +12855,70 @@ export class Document {
12438
12855
  this._endnotesModified = true;
12439
12856
  }
12440
12857
 
12858
+ /**
12859
+ * Removes a footnote definition and every w:footnoteReference run pointing
12860
+ * at it in the body. Per ECMA-376 §17.11.14 each reference must resolve to
12861
+ * a footnote definition, so the references are stripped together with the
12862
+ * definition; the dirty flag ensures footnotes.xml is regenerated instead
12863
+ * of being restored from the original passthrough XML.
12864
+ * @param id - Footnote ID
12865
+ * @returns True if removed, false if not found or a special separator type
12866
+ */
12867
+ removeFootnote(id: number): boolean {
12868
+ const removed = this.footnoteManager.removeFootnote(id);
12869
+ if (removed) {
12870
+ this._footnotesModified = true;
12871
+ this.removeNoteReferenceRuns('footnoteReference', id);
12872
+ }
12873
+ return removed;
12874
+ }
12875
+
12876
+ /**
12877
+ * Removes an endnote definition and every w:endnoteReference run pointing
12878
+ * at it in the body. Same reference-consistency contract as
12879
+ * {@link removeFootnote} (ECMA-376 §17.11.3).
12880
+ * @param id - Endnote ID
12881
+ * @returns True if removed, false if not found or a special separator type
12882
+ */
12883
+ removeEndnote(id: number): boolean {
12884
+ const removed = this.endnoteManager.removeEndnote(id);
12885
+ if (removed) {
12886
+ this._endnotesModified = true;
12887
+ this.removeNoteReferenceRuns('endnoteReference', id);
12888
+ }
12889
+ return removed;
12890
+ }
12891
+
12892
+ /**
12893
+ * Strips body runs containing a footnote/endnote reference to the given
12894
+ * note ID so the saved document.xml never references a deleted definition.
12895
+ */
12896
+ private removeNoteReferenceRuns(
12897
+ refType: 'footnoteReference' | 'endnoteReference',
12898
+ id: number
12899
+ ): void {
12900
+ for (const para of this.getAllParagraphs()) {
12901
+ const content = para.getContent();
12902
+ // Iterate backwards so removals don't shift pending indices
12903
+ for (let i = content.length - 1; i >= 0; i--) {
12904
+ const item = content[i];
12905
+ if (!(item instanceof Run)) {
12906
+ continue;
12907
+ }
12908
+ const matches = item
12909
+ .getContent()
12910
+ .some(
12911
+ (c) =>
12912
+ c.type === refType &&
12913
+ (refType === 'footnoteReference' ? c.footnoteId === id : c.endnoteId === id)
12914
+ );
12915
+ if (matches) {
12916
+ para.removeContentAt(i);
12917
+ }
12918
+ }
12919
+ }
12920
+ }
12921
+
12441
12922
  /**
12442
12923
  * Adds a comment to a paragraph (wraps the entire paragraph)
12443
12924
  * Creates the comment if text is provided, or uses an existing comment object
@@ -12594,13 +13075,70 @@ export class Document {
12594
13075
  * @returns True if the comment was removed
12595
13076
  */
12596
13077
  removeComment(id: number): boolean {
13078
+ // Capture reply IDs before removal — CommentManager.removeComment deletes
13079
+ // them too, and their body anchors must be scrubbed along with the parent's
13080
+ const replyIds = this.commentManager.getReplies(id).map((reply) => reply.getId());
12597
13081
  const removed = this.commentManager.removeComment(id);
12598
13082
  if (removed) {
13083
+ this.removeCommentAnchors([id, ...replyIds]);
12599
13084
  this._commentsModified = true;
12600
13085
  }
12601
13086
  return removed;
12602
13087
  }
12603
13088
 
13089
+ /**
13090
+ * Strips comment anchors (range markers and reference runs) for the given
13091
+ * comment IDs from every paragraph in the body, headers, footers, footnotes,
13092
+ * and endnotes. Without this, deleting a comment definition leaves
13093
+ * w:commentRangeStart/End and w:commentReference pointing at a comment that
13094
+ * no longer exists in comments.xml — Word reports such documents as
13095
+ * containing unreadable content.
13096
+ */
13097
+ private removeCommentAnchors(commentIds: number[]): void {
13098
+ const strip = (paragraphs: Paragraph[]): boolean => {
13099
+ let removed = false;
13100
+ for (const paragraph of paragraphs) {
13101
+ for (const commentId of commentIds) {
13102
+ if (paragraph.removeCommentAnchor(commentId)) {
13103
+ removed = true;
13104
+ }
13105
+ }
13106
+ }
13107
+ return removed;
13108
+ };
13109
+
13110
+ strip(this.getAllParagraphs());
13111
+
13112
+ for (const entry of this.headerFooterManager.getAllHeaders()) {
13113
+ strip(this.extractParagraphsFromElements(entry.header.getElements()));
13114
+ }
13115
+ for (const entry of this.headerFooterManager.getAllFooters()) {
13116
+ strip(this.extractParagraphsFromElements(entry.footer.getElements()));
13117
+ }
13118
+
13119
+ // Footnote/endnote parts are raw-XML passthrough unless flagged modified,
13120
+ // so anchor removal must mark them dirty to take effect on save
13121
+ let footnotesChanged = false;
13122
+ for (const footnote of this.footnoteManager.getAllFootnotes()) {
13123
+ if (strip(footnote.getParagraphs())) {
13124
+ footnotesChanged = true;
13125
+ }
13126
+ }
13127
+ if (footnotesChanged) {
13128
+ this._footnotesModified = true;
13129
+ }
13130
+
13131
+ let endnotesChanged = false;
13132
+ for (const endnote of this.endnoteManager.getAllEndnotes()) {
13133
+ if (strip(endnote.getParagraphs())) {
13134
+ endnotesChanged = true;
13135
+ }
13136
+ }
13137
+ if (endnotesChanged) {
13138
+ this._endnotesModified = true;
13139
+ }
13140
+ }
13141
+
12604
13142
  /**
12605
13143
  * Checks if there are any revisions in the document
12606
13144
  * @returns True if there are no revisions
@@ -13342,8 +13880,10 @@ export class Document {
13342
13880
  // Clear all managers using their clear() methods
13343
13881
  this.stylesManager.clear();
13344
13882
  this.numberingManager.clear();
13345
- this.imageManager.clear();
13883
+ // Release buffers before clear() empties the images map; the reverse order
13884
+ // would leave releaseAllImageData() iterating zero entries.
13346
13885
  this.imageManager.releaseAllImageData();
13886
+ this.imageManager.clear();
13347
13887
  this.relationshipManager.clear();
13348
13888
  this.headerFooterManager.clear();
13349
13889
  this.bookmarkManager.clear();
@@ -13364,6 +13904,7 @@ export class Document {
13364
13904
  this._originalNumberingXml = undefined;
13365
13905
  this._originalSettingsXml = undefined;
13366
13906
  this._originalAppPropsXml = undefined;
13907
+ this._originalCorePropsXml = undefined;
13367
13908
  this._originalFootnotesXml = undefined;
13368
13909
  this._originalEndnotesXml = undefined;
13369
13910
  this._originalCommentsXml = undefined;
@@ -13372,6 +13913,7 @@ export class Document {
13372
13913
  this._originalContentTypes = undefined;
13373
13914
  this._settingsModified = false;
13374
13915
  this._appPropsModified = false;
13916
+ this._corePropsModified = false;
13375
13917
  this._footnotesModified = false;
13376
13918
  this._endnotesModified = false;
13377
13919
  this._originalWebSettingsXml = undefined;
@@ -14614,7 +15156,9 @@ export class Document {
14614
15156
  const matches = originalText.match(wordPattern);
14615
15157
  if (matches) {
14616
15158
  replacementCount += matches.length;
14617
- newText = originalText.replace(wordPattern, replace);
15159
+ // Replacer function inserts `replace` literally; passing the raw
15160
+ // string would expand $-substitution tokens ($&, $', $`, $$).
15161
+ newText = originalText.replace(wordPattern, () => replace);
14618
15162
  }
14619
15163
  } else {
14620
15164
  // Simple substring replacement
@@ -14625,7 +15169,9 @@ export class Document {
14625
15169
  const matches = originalText.match(searchPattern);
14626
15170
  if (matches) {
14627
15171
  replacementCount += matches.length;
14628
- newText = originalText.replace(searchPattern, replace);
15172
+ // Replacer function inserts `replace` literally; passing the raw
15173
+ // string would expand $-substitution tokens ($&, $', $`, $$).
15174
+ newText = originalText.replace(searchPattern, () => replace);
14629
15175
  }
14630
15176
  }
14631
15177
 
@@ -15253,6 +15799,13 @@ export class Document {
15253
15799
  * @param separator - String to insert between paragraphs (default: '\n')
15254
15800
  * @returns Plain text content of the entire document
15255
15801
  *
15802
+ * @remarks
15803
+ * The output depends on the `revisionHandling` mode the document was loaded with, and reflects any tracked changes applied to the document after loading.
15804
+ * In `'preserve'` mode the result now includes BOTH tracked-inserted (`w:ins`) and
15805
+ * tracked-deleted (`w:delText`) text, interleaved in document order. In
15806
+ * `'accept'`/`'strip'` modes (the default) the output is the post-acceptance plain
15807
+ * text — inserted content kept, deleted content dropped.
15808
+ *
15256
15809
  * @example
15257
15810
  * ```typescript
15258
15811
  * const text = doc.toPlainText();
@@ -15264,23 +15817,37 @@ export class Document {
15264
15817
  */
15265
15818
  toPlainText(separator = '\n'): string {
15266
15819
  const paragraphs = this.getAllParagraphs();
15267
- return paragraphs.map((p) => p.getText()).join(separator);
15820
+ // Use the revision-aware path so preserve-mode documents retain the text
15821
+ // carried by tracked changes (inserted w:t and deleted w:delText) instead
15822
+ // of silently dropping it.
15823
+ return paragraphs.map((p) => p.getTextIncludingRevisions()).join(separator);
15268
15824
  }
15269
15825
 
15270
15826
  /**
15271
15827
  * Converts the document to Markdown format
15272
15828
  *
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
15829
+ * Walks the body in order and renders every information-bearing construct,
15830
+ * falling back to inline HTML where Markdown has no native equivalent so
15831
+ * that no content is silently dropped:
15832
+ * - Headings`#` … `######`
15833
+ * - Bold/italic/strikethrough`**` / `*` / `~~`; monospace fonts → `` `code` ``
15834
+ * - Underline/super/subscript/highlight/colorinline HTML (`<u>`, `<sup>`,
15835
+ * `<sub>`, `<mark>`, `<span style="color:#…">`) when {@link MarkdownConversionOptions.htmlFallback}
15836
+ * - Hyperlinks → `[text](url)` (internal links resolve to `#anchor`)
15837
+ * - Inline images `![alt](src)`
15838
+ * - Footnote/endnote references → `[^fn1]` / `[^en1]` with definitions appended
15839
+ * - Line breaks → `<br>` (or newline), tabs preserved
15840
+ * - Numbered/bulleted lists → `1.` / `-` with two-space indentation per nesting level
15841
+ * - Block quotes (Quote styles) → `>` prefixes
15842
+ * - Tables → GFM pipe tables; tables with merged or nested cells → inline HTML
15843
+ * - Field results, shapes, text boxes, structured document tags and preserved
15844
+ * elements → their textual content
15845
+ * - Tracked insertions (preserve mode) render inline; deletions wrap in `~~`/`<del>`
15846
+ *
15847
+ * Useful for document processing pipelines, content migration, documentation
15282
15848
  * generation, and plain-text extraction with structure preserved.
15283
15849
  *
15850
+ * @param options - Conversion fidelity options (see {@link MarkdownConversionOptions})
15284
15851
  * @returns Markdown string representation of the document
15285
15852
  *
15286
15853
  * @example
@@ -15298,139 +15865,495 @@ export class Document {
15298
15865
  * // | Alice | 30 |
15299
15866
  * ```
15300
15867
  */
15301
- toMarkdown(): string {
15302
- const lines: string[] = [];
15868
+ toMarkdown(options?: MarkdownConversionOptions): string {
15869
+ const ctx: MarkdownContext = {
15870
+ opts: {
15871
+ htmlFallback: options?.htmlFallback ?? true,
15872
+ footnotes: options?.footnotes ?? true,
15873
+ images: options?.images ?? true,
15874
+ },
15875
+ notes: [],
15876
+ noteMarkers: new Set<string>(),
15877
+ };
15303
15878
 
15879
+ const lines: string[] = [];
15304
15880
  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
15881
+ this.bodyElementToMarkdown(element, ctx, lines);
15316
15882
  }
15317
15883
 
15318
- // Remove trailing blank line
15884
+ // Remove trailing blank line(s)
15319
15885
  while (lines.length > 0 && lines[lines.length - 1] === '') {
15320
15886
  lines.pop();
15321
15887
  }
15322
15888
 
15323
- return lines.join('\n');
15889
+ let out = lines.join('\n');
15890
+
15891
+ // Append footnote/endnote definitions in first-reference order.
15892
+ if (ctx.opts.footnotes && ctx.notes.length > 0) {
15893
+ const defs = ctx.notes.map((n) => `[^${n.marker}]: ${n.text}`).join('\n');
15894
+ out = out ? `${out}\n\n${defs}` : defs;
15895
+ }
15896
+
15897
+ return out;
15898
+ }
15899
+
15900
+ /**
15901
+ * Renders a single body element into the running Markdown line buffer.
15902
+ * Recurses into block-level structured document tags.
15903
+ * @internal
15904
+ */
15905
+ private bodyElementToMarkdown(element: BodyElement, ctx: MarkdownContext, lines: string[]): void {
15906
+ if (element instanceof Paragraph) {
15907
+ const md = this.paragraphToMarkdown(element, ctx);
15908
+ if (md !== null) {
15909
+ lines.push(md);
15910
+ lines.push('');
15911
+ }
15912
+ } else if (element instanceof Table) {
15913
+ const tableLines = this.tableToMarkdown(element, ctx);
15914
+ if (tableLines.length > 0) {
15915
+ lines.push(...tableLines);
15916
+ lines.push('');
15917
+ }
15918
+ } else if (element instanceof StructuredDocumentTag) {
15919
+ // Block SDT: render its inner content (paragraphs/tables/nested SDTs).
15920
+ for (const child of element.getContent()) {
15921
+ this.bodyElementToMarkdown(child, ctx, lines);
15922
+ }
15923
+ } else if (element instanceof PreservedElement) {
15924
+ // Best-effort: surface any text so round-trip-preserved blocks are not lost.
15925
+ const text = this.extractTextFromRawXml(element.getRawXml());
15926
+ if (text) {
15927
+ lines.push(this.escapeMarkdown(text));
15928
+ lines.push('');
15929
+ }
15930
+ }
15931
+ // TableOfContentsElement is intentionally skipped: its content is generated
15932
+ // from the headings, which are already represented in the Markdown output.
15324
15933
  }
15325
15934
 
15326
15935
  /**
15327
- * Converts a paragraph to a Markdown line.
15936
+ * Converts a paragraph to a Markdown block (heading, list item, block quote,
15937
+ * or plain paragraph). Returns null for empty, non-list paragraphs.
15328
15938
  * @internal
15329
15939
  */
15330
- private paragraphToMarkdown(para: Paragraph): string | null {
15331
- const text = this.paragraphContentToMarkdown(para);
15332
- if (!text && !para.hasNumbering()) return null;
15940
+ private paragraphToMarkdown(para: Paragraph, ctx: MarkdownContext): string | null {
15941
+ const inline = this.paragraphContentToMarkdown(para, ctx);
15942
+ const hasNumbering = para.hasNumbering();
15943
+ if (!inline && !hasNumbering) return null;
15333
15944
 
15334
- // Headings
15945
+ // Headings take precedence over every other block style.
15335
15946
  const headingLevel = para.detectHeadingLevel();
15336
15947
  if (headingLevel !== null && headingLevel >= 1 && headingLevel <= 6) {
15337
- return '#'.repeat(headingLevel) + ' ' + text;
15948
+ return '#'.repeat(headingLevel) + ' ' + this.toSingleLine(inline);
15338
15949
  }
15339
15950
 
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}`;
15951
+ const style = (para.getStyle() ?? '').toLowerCase();
15952
+ const isListStyle =
15953
+ style.includes('listbullet') ||
15954
+ style.includes('list bullet') ||
15955
+ style.includes('listnumber') ||
15956
+ style.includes('list number');
15957
+
15958
+ if (hasNumbering || isListStyle) {
15959
+ const level = para.getNumbering()?.level ?? 0;
15960
+ const indent = ' '.repeat(Math.max(0, level));
15961
+ const marker = this.isOrderedList(para) ? '1.' : '-';
15962
+ return `${indent}${marker} ${this.toSingleLine(inline)}`;
15963
+ }
15964
+
15965
+ // Block quote (Quote / IntenseQuote styles).
15966
+ if (style.includes('quote')) {
15967
+ return this.toSingleLine(inline)
15968
+ .split('\n')
15969
+ .map((l) => `> ${l}`)
15970
+ .join('\n');
15346
15971
  }
15347
15972
 
15348
- return text;
15973
+ return inline;
15974
+ }
15975
+
15976
+ /**
15977
+ * Determines whether a numbered paragraph uses an ordered (decimal/letter/
15978
+ * roman) format rather than a bullet. Resolves the numbering definition when
15979
+ * available, falling back to the paragraph's style name.
15980
+ * @internal
15981
+ */
15982
+ private isOrderedList(para: Paragraph): boolean {
15983
+ const numbering = para.getNumbering();
15984
+ if (numbering) {
15985
+ const instance = this.numberingManager.getInstance(numbering.numId);
15986
+ if (instance) {
15987
+ const abstract = this.numberingManager.getAbstractNumbering(instance.getAbstractNumId());
15988
+ const levelDef = abstract?.getLevel(numbering.level);
15989
+ const format = levelDef?.getFormat();
15990
+ if (format) return format !== 'bullet';
15991
+ }
15992
+ }
15993
+ const style = (para.getStyle() ?? '').toLowerCase();
15994
+ if (style.includes('bullet')) return false;
15995
+ if (style.includes('number')) return true;
15996
+ return true;
15349
15997
  }
15350
15998
 
15351
15999
  /**
15352
16000
  * Converts paragraph inline content to Markdown with formatting.
15353
16001
  * @internal
15354
16002
  */
15355
- private paragraphContentToMarkdown(para: Paragraph): string {
15356
- const parts: string[] = [];
16003
+ private paragraphContentToMarkdown(para: Paragraph, ctx: MarkdownContext): string {
16004
+ return this.inlineContentToMarkdown(para.getContent(), ctx);
16005
+ }
15357
16006
 
15358
- for (const item of para.getContent()) {
15359
- if (item instanceof Run) {
15360
- const runText = item.getText();
15361
- if (!runText) continue;
16007
+ /**
16008
+ * Converts a sequence of inline content items (runs, hyperlinks, fields,
16009
+ * revisions, shapes, text boxes, preserved elements) to Markdown.
16010
+ * @internal
16011
+ */
16012
+ private inlineContentToMarkdown(items: ParagraphContent[], ctx: MarkdownContext): string {
16013
+ const parts: string[] = [];
15362
16014
 
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}*`;
16015
+ for (const item of items) {
16016
+ // ImageRun extends Run, so it must be checked first.
16017
+ if (item instanceof ImageRun) {
16018
+ if (ctx.opts.images) {
16019
+ const image = item.getImageElement();
16020
+ const alt = this.escapeMarkdown(image.getAltText() || 'image');
16021
+ const src = image.getRelationshipId() ?? 'image';
16022
+ parts.push(`![${alt}](${src})`);
15373
16023
  }
15374
-
15375
- if (fmt.strike) {
15376
- md = `~~${md}~~`;
16024
+ } else if (item instanceof Run) {
16025
+ parts.push(this.runToMarkdown(item, ctx));
16026
+ } else if (item instanceof Hyperlink) {
16027
+ const runs = item.getRuns();
16028
+ let label =
16029
+ runs.length > 0
16030
+ ? runs.map((r) => this.runToMarkdown(r, ctx, true)).join('')
16031
+ : this.escapeMarkdown(item.getText());
16032
+ if (!label) label = this.escapeMarkdown(item.getText());
16033
+ let url = item.getUrl() ?? '';
16034
+ const anchor = item.getAnchor();
16035
+ if (anchor) url = url ? `${url}#${anchor}` : `#${anchor}`;
16036
+ parts.push(`[${label}](${url})`);
16037
+ } else if (item instanceof Revision) {
16038
+ const inner = this.inlineContentToMarkdown(item.getContent(), ctx);
16039
+ if (!inner) continue;
16040
+ const type = item.getType();
16041
+ if (type === 'delete' || type === 'moveFrom') {
16042
+ // Deleted/moved-away content (only present in 'preserve' mode): mark it
16043
+ // so the information survives without claiming it as final body text.
16044
+ parts.push(ctx.opts.htmlFallback ? `<del>${inner}</del>` : `~~${inner}~~`);
16045
+ } else {
16046
+ parts.push(inner);
15377
16047
  }
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}\``;
16048
+ } else if (item instanceof ComplexField) {
16049
+ const result = item.getResult();
16050
+ if (result) parts.push(this.escapeMarkdown(result));
16051
+ } else if (item instanceof Field) {
16052
+ const result = item.getCachedResult();
16053
+ if (result) parts.push(this.escapeMarkdown(result));
16054
+ } else if (item instanceof TextBox) {
16055
+ const text = item
16056
+ .getParagraphs()
16057
+ .map((p) => this.paragraphContentToMarkdown(p, ctx))
16058
+ .filter((s) => s !== '')
16059
+ .join(' ');
16060
+ if (text) parts.push(text);
16061
+ } else if (item instanceof Shape) {
16062
+ const text = item.getText();
16063
+ if (text) parts.push(this.escapeMarkdown(text));
16064
+ } else if (item instanceof PreservedElement) {
16065
+ const type = item.getElementType();
16066
+ // Comment range/reference markers carry no inline body text.
16067
+ if (!type.startsWith('w:comment')) {
16068
+ const text = this.extractTextFromRawXml(item.getRawXml());
16069
+ if (text) parts.push(this.escapeMarkdown(text));
15385
16070
  }
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
16071
  }
15393
- // Revisions, fields, shapes, etc. extract text if possible
16072
+ // RangeMarker (bookmarks) carry no display text and are skipped.
15394
16073
  }
15395
16074
 
15396
16075
  return parts.join('');
15397
16076
  }
15398
16077
 
15399
16078
  /**
15400
- * Converts a table to Markdown table lines.
16079
+ * Converts a single run to Markdown, applying inline formatting and emitting
16080
+ * footnote/endnote markers. Tabs, breaks and symbols are preserved.
16081
+ * @internal
16082
+ */
16083
+ private runToMarkdown(run: Run, ctx: MarkdownContext, inLink = false): string {
16084
+ const fmt = run.getFormatting();
16085
+ const isMono = !!(
16086
+ fmt.font &&
16087
+ /^(courier|consolas|monaco|menlo|source code|fira code|jetbrains mono)/i.test(fmt.font)
16088
+ );
16089
+ const escape = (s: string): string => (isMono ? s : this.escapeMarkdown(s));
16090
+
16091
+ const segments: string[] = [];
16092
+ const markers: string[] = [];
16093
+
16094
+ for (const content of run.getContent()) {
16095
+ switch (content.type) {
16096
+ case 'text':
16097
+ case 'instructionText':
16098
+ segments.push(escape(content.value ?? ''));
16099
+ break;
16100
+ case 'tab':
16101
+ segments.push('\t');
16102
+ break;
16103
+ case 'carriageReturn':
16104
+ segments.push('\n');
16105
+ break;
16106
+ case 'break':
16107
+ // Page/column breaks are layout-only; line breaks become hard breaks.
16108
+ if (content.breakType !== 'page' && content.breakType !== 'column') {
16109
+ segments.push(ctx.opts.htmlFallback ? '<br>' : '\n');
16110
+ }
16111
+ break;
16112
+ case 'noBreakHyphen':
16113
+ segments.push('‑');
16114
+ break;
16115
+ case 'symbol':
16116
+ if (content.symbolChar) {
16117
+ const code = parseInt(content.symbolChar, 16);
16118
+ if (!Number.isNaN(code)) segments.push(escape(String.fromCharCode(code)));
16119
+ }
16120
+ break;
16121
+ case 'footnoteReference':
16122
+ if (ctx.opts.footnotes && content.footnoteId !== undefined) {
16123
+ markers.push(this.noteMarker(content.footnoteId, 'fn', ctx));
16124
+ }
16125
+ break;
16126
+ case 'endnoteReference':
16127
+ if (ctx.opts.footnotes && content.endnoteId !== undefined) {
16128
+ markers.push(this.noteMarker(content.endnoteId, 'en', ctx));
16129
+ }
16130
+ break;
16131
+ // Field chars, VML, soft hyphens and other layout markers contribute no text.
16132
+ default:
16133
+ break;
16134
+ }
16135
+ }
16136
+
16137
+ let text = segments.join('');
16138
+ if (text) {
16139
+ if (isMono) {
16140
+ text = `\`${text}\``;
16141
+ }
16142
+ // Emphasis (bold/italic innermost, strikethrough outermost).
16143
+ if (fmt.bold && fmt.italic) text = `***${text}***`;
16144
+ else if (fmt.bold) text = `**${text}**`;
16145
+ else if (fmt.italic) text = `*${text}*`;
16146
+ if (fmt.strike || fmt.dstrike) text = `~~${text}~~`;
16147
+
16148
+ if (ctx.opts.htmlFallback) {
16149
+ if (fmt.superscript) text = `<sup>${text}</sup>`;
16150
+ else if (fmt.subscript) text = `<sub>${text}</sub>`;
16151
+ if (fmt.highlight && fmt.highlight !== 'none') text = `<mark>${text}</mark>`;
16152
+ // Underline and color are the conventional appearance of a hyperlink, so
16153
+ // suppress them inside link labels to avoid redundant markup; elsewhere
16154
+ // they carry real information and are preserved as inline HTML.
16155
+ if (!inLink) {
16156
+ if (fmt.underline && fmt.underline !== 'none') text = `<u>${text}</u>`;
16157
+ if (fmt.color && fmt.color !== 'auto' && /^[0-9a-fA-F]{6}$/.test(fmt.color)) {
16158
+ text = `<span style="color:#${fmt.color}">${text}</span>`;
16159
+ }
16160
+ }
16161
+ }
16162
+ }
16163
+
16164
+ // Footnote/endnote markers follow the text, unaffected by run formatting.
16165
+ return text + markers.join('');
16166
+ }
16167
+
16168
+ /**
16169
+ * Resolves a footnote/endnote reference to a GFM marker and records its
16170
+ * definition (once) for later emission.
16171
+ * @internal
16172
+ */
16173
+ private noteMarker(id: number, kind: 'fn' | 'en', ctx: MarkdownContext): string {
16174
+ const marker = `${kind}${id}`;
16175
+ if (!ctx.noteMarkers.has(marker)) {
16176
+ ctx.noteMarkers.add(marker);
16177
+ const note =
16178
+ kind === 'fn' ? this.footnoteManager.getFootnote(id) : this.endnoteManager.getEndnote(id);
16179
+ let text = '';
16180
+ if (note) {
16181
+ text = note
16182
+ .getParagraphs()
16183
+ .map((p) => this.paragraphContentToMarkdown(p, ctx))
16184
+ .filter((s) => s !== '')
16185
+ .join(' ')
16186
+ .replace(/\s*\n\s*/g, ' ')
16187
+ .trim();
16188
+ }
16189
+ ctx.notes.push({ marker, text });
16190
+ }
16191
+ return `[^${marker}]`;
16192
+ }
16193
+
16194
+ /**
16195
+ * Converts a table to Markdown. Simple grid tables become GFM pipe tables;
16196
+ * tables with merged or nested cells fall back to inline HTML (when enabled)
16197
+ * so structural information is preserved.
15401
16198
  * @internal
15402
16199
  */
15403
- private tableToMarkdown(table: Table): string[] {
15404
- const data = table.toArray();
15405
- if (data.length === 0) return [];
16200
+ private tableToMarkdown(table: Table, ctx: MarkdownContext): string[] {
16201
+ const rows = table.getRows();
16202
+ if (rows.length === 0) return [];
16203
+
16204
+ const isComplex = rows.some((row) =>
16205
+ row
16206
+ .getCells()
16207
+ .some(
16208
+ (cell) =>
16209
+ (cell.getColumnSpan() ?? 1) > 1 ||
16210
+ cell.getVerticalMerge() !== undefined ||
16211
+ cell.hasNestedTables()
16212
+ )
16213
+ );
15406
16214
 
15407
- const colCount = Math.max(...data.map((row) => row.length));
16215
+ if (isComplex && ctx.opts.htmlFallback) {
16216
+ return this.tableToHtmlBlock(table);
16217
+ }
16218
+
16219
+ const matrix = rows.map((row) => row.getCells().map((cell) => this.cellToMarkdown(cell, ctx)));
16220
+ const colCount = Math.max(...matrix.map((row) => row.length));
15408
16221
  if (colCount === 0) return [];
15409
16222
 
15410
- // Normalize all rows to same column count
15411
- const normalized = data.map((row) => {
16223
+ const normalized = matrix.map((row) => {
15412
16224
  const padded = [...row];
15413
16225
  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());
16226
+ return padded;
15416
16227
  });
15417
16228
 
15418
16229
  const lines: string[] = [];
15419
-
15420
- // Header row
15421
16230
  lines.push('| ' + normalized[0]!.join(' | ') + ' |');
15422
-
15423
- // Separator row
15424
16231
  lines.push('| ' + normalized[0]!.map(() => '---').join(' | ') + ' |');
15425
-
15426
- // Data rows
15427
16232
  for (let i = 1; i < normalized.length; i++) {
15428
16233
  lines.push('| ' + normalized[i]!.join(' | ') + ' |');
15429
16234
  }
15430
-
15431
16235
  return lines;
15432
16236
  }
15433
16237
 
16238
+ /**
16239
+ * Renders a single table cell as inline Markdown text (formatting preserved),
16240
+ * with pipes escaped and line breaks collapsed for pipe-table compatibility.
16241
+ * @internal
16242
+ */
16243
+ private cellToMarkdown(cell: TableCell, ctx: MarkdownContext): string {
16244
+ const text = cell
16245
+ .getParagraphs()
16246
+ .map((p) => this.paragraphContentToMarkdown(p, ctx))
16247
+ .filter((s) => s !== '')
16248
+ .join(' ');
16249
+ return text.replace(/\|/g, '\\|').replace(/<br>/g, ' ').replace(/\n/g, ' ').trim();
16250
+ }
16251
+
16252
+ /**
16253
+ * Renders a table with merged or nested cells as an inline HTML table,
16254
+ * honoring `colspan`/`rowspan` from grid and vertical-merge information.
16255
+ * @internal
16256
+ */
16257
+ private tableToHtmlBlock(table: Table): string[] {
16258
+ const rows = table.getRows();
16259
+
16260
+ // Precompute each cell's starting grid column (accounting for column spans).
16261
+ const rowCells = rows.map((row) => {
16262
+ let gridStart = 0;
16263
+ return row.getCells().map((cell) => {
16264
+ const span = cell.getColumnSpan() ?? 1;
16265
+ const entry = { cell, gridStart, span };
16266
+ gridStart += span;
16267
+ return entry;
16268
+ });
16269
+ });
16270
+
16271
+ const html: string[] = ['<table>'];
16272
+ for (let ri = 0; ri < rowCells.length; ri++) {
16273
+ html.push('<tr>');
16274
+ for (const { cell, gridStart, span } of rowCells[ri]!) {
16275
+ // Cells continuing a vertical merge are covered by the rowspan above.
16276
+ if (cell.getVerticalMerge() === 'continue') continue;
16277
+
16278
+ let rowspan = 1;
16279
+ if (cell.getVerticalMerge() === 'restart') {
16280
+ for (let rj = ri + 1; rj < rowCells.length; rj++) {
16281
+ const below = rowCells[rj]!.find((e) => e.gridStart === gridStart);
16282
+ if (below?.cell.getVerticalMerge() === 'continue') rowspan++;
16283
+ else break;
16284
+ }
16285
+ }
16286
+
16287
+ const tag = ri === 0 ? 'th' : 'td';
16288
+ const attrs =
16289
+ (span > 1 ? ` colspan="${span}"` : '') + (rowspan > 1 ? ` rowspan="${rowspan}"` : '');
16290
+
16291
+ let content =
16292
+ cell
16293
+ .getParagraphs()
16294
+ .map((p) => this.paragraphContentToHTML(p))
16295
+ .filter((s) => s !== '')
16296
+ .join('<br>') || this.escapeHTML(cell.getText());
16297
+
16298
+ if (cell.hasNestedTables()) {
16299
+ const nestedText = cell
16300
+ .getRawNestedContent()
16301
+ .map((n) => this.extractTextFromRawXml(n.xml))
16302
+ .filter((s) => s)
16303
+ .join(' ');
16304
+ if (nestedText) content += `<br>${this.escapeHTML(nestedText)}`;
16305
+ }
16306
+
16307
+ html.push(`<${tag}${attrs}>${content}</${tag}>`);
16308
+ }
16309
+ html.push('</tr>');
16310
+ }
16311
+ html.push('</table>');
16312
+ return html;
16313
+ }
16314
+
16315
+ /**
16316
+ * Escapes Markdown-significant characters in literal text so they render
16317
+ * verbatim rather than as formatting.
16318
+ * @internal
16319
+ */
16320
+ private escapeMarkdown(text: string): string {
16321
+ return text.replace(/([\\`*_[\]<>])/g, '\\$1');
16322
+ }
16323
+
16324
+ /**
16325
+ * Collapses hard/soft line breaks to single spaces for contexts that must
16326
+ * stay on one line (headings, list items, table cells).
16327
+ * @internal
16328
+ */
16329
+ private toSingleLine(text: string): string {
16330
+ return text.replace(/<br>/g, ' ').replace(/\s*\n\s*/g, ' ');
16331
+ }
16332
+
16333
+ /**
16334
+ * Extracts visible text from a raw OOXML fragment by concatenating the
16335
+ * contents of its `w:t` elements and decoding XML entities. Used as a
16336
+ * last-resort so preserved/round-trip elements never silently lose text.
16337
+ * @internal
16338
+ */
16339
+ private extractTextFromRawXml(xml: string): string {
16340
+ if (!xml.includes('<w:t')) return '';
16341
+ const parts: string[] = [];
16342
+ const regex = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g;
16343
+ let match: RegExpExecArray | null;
16344
+ while ((match = regex.exec(xml)) !== null) {
16345
+ parts.push(match[1] ?? '');
16346
+ }
16347
+ return parts
16348
+ .join('')
16349
+ .replace(/&lt;/g, '<')
16350
+ .replace(/&gt;/g, '>')
16351
+ .replace(/&quot;/g, '"')
16352
+ .replace(/&apos;/g, "'")
16353
+ .replace(/&amp;/g, '&')
16354
+ .trim();
16355
+ }
16356
+
15434
16357
  /**
15435
16358
  * Converts the document to an HTML string
15436
16359
  *
@@ -15567,8 +16490,13 @@ export class Document {
15567
16490
 
15568
16491
  parts.push(html);
15569
16492
  } else if (item instanceof Hyperlink) {
15570
- const url = this.escapeHTML(item.getUrl() || '');
15571
- const linkText = this.escapeHTML(item.getText() || url);
16493
+ // Word stores the fragment/anchor (e.g. "!/view?docid=…") separately
16494
+ // from the base URL; recombine them so deep links are not truncated.
16495
+ let rawUrl = item.getUrl() ?? '';
16496
+ const anchor = item.getAnchor();
16497
+ if (anchor) rawUrl = rawUrl ? `${rawUrl}#${anchor}` : `#${anchor}`;
16498
+ const url = this.escapeHTML(rawUrl);
16499
+ const linkText = this.escapeHTML(item.getText() || rawUrl);
15572
16500
  parts.push(`<a href="${url}">${linkText}</a>`);
15573
16501
  }
15574
16502
  }
@@ -15871,7 +16799,12 @@ export class Document {
15871
16799
  let index: number;
15872
16800
 
15873
16801
  if (typeof paragraphOrIndex === 'number') {
15874
- index = paragraphOrIndex;
16802
+ // Numeric index is paragraph-ordinal (matching getParagraphAt /
16803
+ // getParagraphIndex), not a bodyElements index — tables or SDTs before
16804
+ // the target would otherwise shift it onto the wrong paragraph.
16805
+ const para = this.getParagraphAt(paragraphOrIndex);
16806
+ if (!para) return false;
16807
+ index = this.bodyElements.indexOf(para);
15875
16808
  } else {
15876
16809
  // Find the index of the paragraph
15877
16810
  index = this.bodyElements.indexOf(paragraphOrIndex);
@@ -15884,13 +16817,12 @@ export class Document {
15884
16817
  // No paragraphRemoved event in this branch — the paragraph is still
15885
16818
  // structurally present, just marked deleted via revision.
15886
16819
  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
- }
16820
+ // clearContent with tracking bound REPLACES each Run/Hyperlink with
16821
+ // its delete revision in place. Appending a revision built from
16822
+ // getRuns() would leave the originals live alongside the w:del copy,
16823
+ // so the text would serialize twice and survive an accept-all.
16824
+ this.bindTrackingToElement(element);
16825
+ element.clearContent();
15894
16826
  return true;
15895
16827
  }
15896
16828
  this.bodyElements.splice(index, 1);
@@ -16511,23 +17443,12 @@ export class Document {
16511
17443
  }
16512
17444
  };
16513
17445
 
17446
+ // getAllParagraphs() already walks table-cell paragraphs, so a separate
17447
+ // table pass would report each table hyperlink twice.
16514
17448
  for (const paragraph of this.getAllParagraphs()) {
16515
17449
  extractHyperlinksFromParagraph(paragraph);
16516
17450
  }
16517
17451
 
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
17452
  return hyperlinks;
16532
17453
  }
16533
17454
 
@@ -16721,6 +17642,8 @@ export class Document {
16721
17642
  getBookmarks(): { bookmark: Bookmark; paragraph: Paragraph }[] {
16722
17643
  const bookmarks: { bookmark: Bookmark; paragraph: Paragraph }[] = [];
16723
17644
 
17645
+ // getAllParagraphs() already walks table-cell paragraphs, so a separate
17646
+ // table pass would report each table bookmark twice.
16724
17647
  for (const paragraph of this.getAllParagraphs()) {
16725
17648
  // Get bookmarks that start in this paragraph
16726
17649
  for (const bookmark of paragraph.getBookmarksStart()) {
@@ -16728,19 +17651,6 @@ export class Document {
16728
17651
  }
16729
17652
  }
16730
17653
 
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
17654
  return bookmarks;
16745
17655
  }
16746
17656
 
@@ -16780,27 +17690,14 @@ export class Document {
16780
17690
  getFields(): { field: FieldLike; paragraph: Paragraph; table?: Table }[] {
16781
17691
  const results: { field: FieldLike; paragraph: Paragraph; table?: Table }[] = [];
16782
17692
 
16783
- // Get fields from all body paragraphs
17693
+ // getAllParagraphs() already walks table-cell paragraphs, so a separate
17694
+ // table pass would report each table field twice.
16784
17695
  for (const paragraph of this.getAllParagraphs()) {
16785
17696
  for (const field of paragraph.getFields()) {
16786
17697
  results.push({ field, paragraph });
16787
17698
  }
16788
17699
  }
16789
17700
 
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
17701
  return results;
16805
17702
  }
16806
17703
 
@@ -16892,7 +17789,14 @@ export class Document {
16892
17789
 
16893
17790
  // 5. If format changed (e.g., bmp → png), update filename + relationships
16894
17791
  if (result.newExtension !== extension) {
16895
- const newFilename = filename.replace(/\.[^.]+$/, `.${result.newExtension}`);
17792
+ // Swapping the extension can collide with a distinct existing media
17793
+ // part (e.g. image1.bmp → image1.png when image1.png already exists;
17794
+ // non-Word producers don't share Word's global numbering). Resolve to
17795
+ // a fresh, unused name before renaming so neither image is clobbered.
17796
+ const newFilename = this.resolveUniqueMediaFilename(
17797
+ filename.replace(/\.[^.]+$/, `.${result.newExtension}`),
17798
+ filename
17799
+ );
16896
17800
  this.imageManager.updateEntryFilename(image, newFilename);
16897
17801
 
16898
17802
  // Update relationship targets across all relationship managers
@@ -16908,6 +17812,33 @@ export class Document {
16908
17812
  return { optimizedCount, totalSavedBytes: totalSaved };
16909
17813
  }
16910
17814
 
17815
+ /**
17816
+ * Resolves a media filename that does not collide with an existing ZIP media
17817
+ * part or another registered image entry. If the candidate is free, it is
17818
+ * returned unchanged; otherwise a numeric suffix is appended to the base name
17819
+ * (image1.png → image1-1.png → image1-2.png …) until a unique name is found.
17820
+ * @param candidate The desired filename (base name + extension, no directory)
17821
+ * @param currentFilename The filename being renamed away from (excluded from collision check)
17822
+ * @private
17823
+ */
17824
+ private resolveUniqueMediaFilename(candidate: string, currentFilename: string): string {
17825
+ const dotIndex = candidate.lastIndexOf('.');
17826
+ const base = dotIndex === -1 ? candidate : candidate.slice(0, dotIndex);
17827
+ const ext = dotIndex === -1 ? '' : candidate.slice(dotIndex);
17828
+
17829
+ const isTaken = (name: string): boolean =>
17830
+ this.zipHandler.hasFile(`word/media/${name}`) ||
17831
+ this.imageManager.isFilenameOwnedByOther(name, currentFilename);
17832
+
17833
+ let name = candidate;
17834
+ let counter = 1;
17835
+ while (isTaken(name)) {
17836
+ name = `${base}-${counter}${ext}`;
17837
+ counter++;
17838
+ }
17839
+ return name;
17840
+ }
17841
+
16911
17842
  /**
16912
17843
  * Updates an image relationship target when the image format changes.
16913
17844
  * Handles both document body and header/footer relationships.
@@ -17296,7 +18227,7 @@ export class Document {
17296
18227
 
17297
18228
  this.bodyElements.forEach((element, index) => {
17298
18229
  if (element instanceof Paragraph) {
17299
- const isEmpty = element.getText().trim() === '';
18230
+ const isEmpty = this.isParagraphRemovableBlank(element);
17300
18231
  if (isEmpty && lastWasEmpty) {
17301
18232
  toRemove.push(index);
17302
18233
  }
@@ -17392,6 +18323,44 @@ export class Document {
17392
18323
  return { removed, normalized };
17393
18324
  }
17394
18325
 
18326
+ /**
18327
+ * Determines whether a body paragraph is safely removable as a duplicate
18328
+ * empty paragraph. Text emptiness alone is insufficient: a paragraph with no
18329
+ * visible text can still carry a bookmark/comment anchor, an inline image or
18330
+ * shape, a range marker, or a section break (sectPr) in its formatting.
18331
+ * Removing such a paragraph silently destroys that content. Mirrors the
18332
+ * richer blankness standard used by TableCell.isParaBlank.
18333
+ * @private
18334
+ */
18335
+ private isParagraphRemovableBlank(para: Paragraph): boolean {
18336
+ if (para.getText().trim() !== '') return false;
18337
+
18338
+ // Inline section break — removing it merges sections and drops that
18339
+ // section's page setup / headers / margins.
18340
+ if (para.formatting.sectPr !== undefined) return false;
18341
+
18342
+ // Bookmark anchors — destroying them breaks REF fields / internal hyperlinks
18343
+ // targeting them ("Error! Bookmark not defined.").
18344
+ if (para.getBookmarksStart().length > 0 || para.getBookmarksEnd().length > 0) {
18345
+ return false;
18346
+ }
18347
+
18348
+ // Comment anchors.
18349
+ if (para.getCommentsStart().length > 0 || para.getCommentsEnd().length > 0) {
18350
+ return false;
18351
+ }
18352
+
18353
+ // Inline images, shapes, and range markers (bookmark/comment range markers)
18354
+ // are invisible to getText() but are real content.
18355
+ for (const item of para.getContent()) {
18356
+ if (item instanceof ImageRun || item instanceof Shape || item instanceof RangeMarker) {
18357
+ return false;
18358
+ }
18359
+ }
18360
+
18361
+ return true;
18362
+ }
18363
+
17395
18364
  /**
17396
18365
  * Sets the document language
17397
18366
  * @param language - Language code (e.g., 'en-US', 'es-ES', 'fr-FR')
@@ -17403,6 +18372,7 @@ export class Document {
17403
18372
  this.properties = {};
17404
18373
  }
17405
18374
  this.properties.language = language;
18375
+ this._corePropsModified = true;
17406
18376
 
17407
18377
  return this;
17408
18378
  }
@@ -17701,7 +18671,9 @@ export class Document {
17701
18671
  ? new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')
17702
18672
  : new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
17703
18673
 
17704
- // Process body paragraphs
18674
+ // getAllParagraphs() walks recursively and already includes every
18675
+ // table-cell paragraph, so a separate table loop would re-apply the
18676
+ // replacement to the same Run instances (e.g. 'cat'->'cats' => 'catss')
17705
18677
  for (const para of this.getAllParagraphs()) {
17706
18678
  for (const run of para.getRuns()) {
17707
18679
  const text = run.getText();
@@ -17725,36 +18697,6 @@ export class Document {
17725
18697
  }
17726
18698
  }
17727
18699
 
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
18700
  return replacedCount;
17759
18701
  }
17760
18702