docxmlater 11.0.9 → 12.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +51 -13
  2. package/dist/core/Document.d.ts +28 -2
  3. package/dist/core/Document.d.ts.map +1 -1
  4. package/dist/core/Document.js +725 -170
  5. package/dist/core/Document.js.map +1 -1
  6. package/dist/core/DocumentGenerator.d.ts +1 -0
  7. package/dist/core/DocumentGenerator.d.ts.map +1 -1
  8. package/dist/core/DocumentGenerator.js +77 -25
  9. package/dist/core/DocumentGenerator.js.map +1 -1
  10. package/dist/core/DocumentParser.d.ts +7 -3
  11. package/dist/core/DocumentParser.d.ts.map +1 -1
  12. package/dist/core/DocumentParser.js +431 -85
  13. package/dist/core/DocumentParser.js.map +1 -1
  14. package/dist/core/RelationshipManager.d.ts.map +1 -1
  15. package/dist/core/RelationshipManager.js +2 -2
  16. package/dist/core/RelationshipManager.js.map +1 -1
  17. package/dist/elements/Comment.d.ts +15 -1
  18. package/dist/elements/Comment.d.ts.map +1 -1
  19. package/dist/elements/Comment.js +67 -12
  20. package/dist/elements/Comment.js.map +1 -1
  21. package/dist/elements/CommentManager.d.ts +4 -0
  22. package/dist/elements/CommentManager.d.ts.map +1 -1
  23. package/dist/elements/CommentManager.js +74 -16
  24. package/dist/elements/CommentManager.js.map +1 -1
  25. package/dist/elements/EndnoteManager.d.ts.map +1 -1
  26. package/dist/elements/EndnoteManager.js.map +1 -1
  27. package/dist/elements/Field.d.ts +10 -1
  28. package/dist/elements/Field.d.ts.map +1 -1
  29. package/dist/elements/Field.js +76 -34
  30. package/dist/elements/Field.js.map +1 -1
  31. package/dist/elements/FieldHelpers.d.ts +1 -0
  32. package/dist/elements/FieldHelpers.d.ts.map +1 -1
  33. package/dist/elements/FieldHelpers.js +8 -4
  34. package/dist/elements/FieldHelpers.js.map +1 -1
  35. package/dist/elements/Footer.d.ts +3 -1
  36. package/dist/elements/Footer.d.ts.map +1 -1
  37. package/dist/elements/Footer.js +39 -53
  38. package/dist/elements/Footer.js.map +1 -1
  39. package/dist/elements/FootnoteManager.d.ts.map +1 -1
  40. package/dist/elements/FootnoteManager.js.map +1 -1
  41. package/dist/elements/Header.d.ts +3 -1
  42. package/dist/elements/Header.d.ts.map +1 -1
  43. package/dist/elements/Header.js +39 -53
  44. package/dist/elements/Header.js.map +1 -1
  45. package/dist/elements/HeaderFooterManager.d.ts +4 -2
  46. package/dist/elements/HeaderFooterManager.d.ts.map +1 -1
  47. package/dist/elements/HeaderFooterManager.js +36 -6
  48. package/dist/elements/HeaderFooterManager.js.map +1 -1
  49. package/dist/elements/Hyperlink.d.ts +6 -1
  50. package/dist/elements/Hyperlink.d.ts.map +1 -1
  51. package/dist/elements/Hyperlink.js +69 -44
  52. package/dist/elements/Hyperlink.js.map +1 -1
  53. package/dist/elements/Image.d.ts.map +1 -1
  54. package/dist/elements/Image.js +33 -6
  55. package/dist/elements/Image.js.map +1 -1
  56. package/dist/elements/ImageManager.d.ts +1 -0
  57. package/dist/elements/ImageManager.d.ts.map +1 -1
  58. package/dist/elements/ImageManager.js +11 -0
  59. package/dist/elements/ImageManager.js.map +1 -1
  60. package/dist/elements/Paragraph.d.ts +3 -0
  61. package/dist/elements/Paragraph.d.ts.map +1 -1
  62. package/dist/elements/Paragraph.js +91 -26
  63. package/dist/elements/Paragraph.js.map +1 -1
  64. package/dist/elements/Revision.d.ts +4 -0
  65. package/dist/elements/Revision.d.ts.map +1 -1
  66. package/dist/elements/Revision.js +369 -29
  67. package/dist/elements/Revision.js.map +1 -1
  68. package/dist/elements/Run.d.ts +1 -0
  69. package/dist/elements/Run.d.ts.map +1 -1
  70. package/dist/elements/Run.js +24 -4
  71. package/dist/elements/Run.js.map +1 -1
  72. package/dist/elements/Section.d.ts.map +1 -1
  73. package/dist/elements/Section.js +17 -7
  74. package/dist/elements/Section.js.map +1 -1
  75. package/dist/elements/Shape.d.ts.map +1 -1
  76. package/dist/elements/Shape.js +50 -42
  77. package/dist/elements/Shape.js.map +1 -1
  78. package/dist/elements/StructuredDocumentTag.d.ts +9 -1
  79. package/dist/elements/StructuredDocumentTag.d.ts.map +1 -1
  80. package/dist/elements/StructuredDocumentTag.js +46 -6
  81. package/dist/elements/StructuredDocumentTag.js.map +1 -1
  82. package/dist/elements/Table.d.ts +2 -0
  83. package/dist/elements/Table.d.ts.map +1 -1
  84. package/dist/elements/Table.js +90 -42
  85. package/dist/elements/Table.js.map +1 -1
  86. package/dist/elements/TableCell.d.ts +1 -0
  87. package/dist/elements/TableCell.d.ts.map +1 -1
  88. package/dist/elements/TableCell.js +32 -13
  89. package/dist/elements/TableCell.js.map +1 -1
  90. package/dist/elements/TableOfContents.d.ts.map +1 -1
  91. package/dist/elements/TableOfContents.js +5 -7
  92. package/dist/elements/TableOfContents.js.map +1 -1
  93. package/dist/elements/TableRow.d.ts +1 -0
  94. package/dist/elements/TableRow.d.ts.map +1 -1
  95. package/dist/elements/TableRow.js +16 -1
  96. package/dist/elements/TableRow.js.map +1 -1
  97. package/dist/elements/TextBox.d.ts.map +1 -1
  98. package/dist/elements/TextBox.js +55 -42
  99. package/dist/elements/TextBox.js.map +1 -1
  100. package/dist/esm/core/Document.js +726 -171
  101. package/dist/esm/core/Document.js.map +1 -1
  102. package/dist/esm/core/DocumentGenerator.js +77 -25
  103. package/dist/esm/core/DocumentGenerator.js.map +1 -1
  104. package/dist/esm/core/DocumentParser.js +431 -85
  105. package/dist/esm/core/DocumentParser.js.map +1 -1
  106. package/dist/esm/core/RelationshipManager.js +2 -2
  107. package/dist/esm/core/RelationshipManager.js.map +1 -1
  108. package/dist/esm/elements/Comment.js +67 -12
  109. package/dist/esm/elements/Comment.js.map +1 -1
  110. package/dist/esm/elements/CommentManager.js +74 -16
  111. package/dist/esm/elements/CommentManager.js.map +1 -1
  112. package/dist/esm/elements/EndnoteManager.js.map +1 -1
  113. package/dist/esm/elements/Field.js +77 -35
  114. package/dist/esm/elements/Field.js.map +1 -1
  115. package/dist/esm/elements/FieldHelpers.js +7 -4
  116. package/dist/esm/elements/FieldHelpers.js.map +1 -1
  117. package/dist/esm/elements/Footer.js +39 -53
  118. package/dist/esm/elements/Footer.js.map +1 -1
  119. package/dist/esm/elements/FootnoteManager.js.map +1 -1
  120. package/dist/esm/elements/Header.js +39 -53
  121. package/dist/esm/elements/Header.js.map +1 -1
  122. package/dist/esm/elements/HeaderFooterManager.js +36 -6
  123. package/dist/esm/elements/HeaderFooterManager.js.map +1 -1
  124. package/dist/esm/elements/Hyperlink.js +69 -44
  125. package/dist/esm/elements/Hyperlink.js.map +1 -1
  126. package/dist/esm/elements/Image.js +33 -6
  127. package/dist/esm/elements/Image.js.map +1 -1
  128. package/dist/esm/elements/ImageManager.js +11 -0
  129. package/dist/esm/elements/ImageManager.js.map +1 -1
  130. package/dist/esm/elements/Paragraph.js +91 -26
  131. package/dist/esm/elements/Paragraph.js.map +1 -1
  132. package/dist/esm/elements/Revision.js +369 -29
  133. package/dist/esm/elements/Revision.js.map +1 -1
  134. package/dist/esm/elements/Run.js +24 -4
  135. package/dist/esm/elements/Run.js.map +1 -1
  136. package/dist/esm/elements/Section.js +17 -7
  137. package/dist/esm/elements/Section.js.map +1 -1
  138. package/dist/esm/elements/Shape.js +50 -42
  139. package/dist/esm/elements/Shape.js.map +1 -1
  140. package/dist/esm/elements/StructuredDocumentTag.js +46 -6
  141. package/dist/esm/elements/StructuredDocumentTag.js.map +1 -1
  142. package/dist/esm/elements/Table.js +90 -42
  143. package/dist/esm/elements/Table.js.map +1 -1
  144. package/dist/esm/elements/TableCell.js +32 -13
  145. package/dist/esm/elements/TableCell.js.map +1 -1
  146. package/dist/esm/elements/TableOfContents.js +5 -7
  147. package/dist/esm/elements/TableOfContents.js.map +1 -1
  148. package/dist/esm/elements/TableRow.js +16 -1
  149. package/dist/esm/elements/TableRow.js.map +1 -1
  150. package/dist/esm/elements/TextBox.js +55 -42
  151. package/dist/esm/elements/TextBox.js.map +1 -1
  152. package/dist/esm/formatting/AbstractNumbering.js +1 -1
  153. package/dist/esm/formatting/AbstractNumbering.js.map +1 -1
  154. package/dist/esm/formatting/NumberingLevel.js +8 -6
  155. package/dist/esm/formatting/NumberingLevel.js.map +1 -1
  156. package/dist/esm/formatting/NumberingManager.js +16 -1
  157. package/dist/esm/formatting/NumberingManager.js.map +1 -1
  158. package/dist/esm/formatting/Style.js +14 -0
  159. package/dist/esm/formatting/Style.js.map +1 -1
  160. package/dist/esm/formatting/StylesManager.js +14 -2
  161. package/dist/esm/formatting/StylesManager.js.map +1 -1
  162. package/dist/esm/helpers/CleanupHelper.js +103 -26
  163. package/dist/esm/helpers/CleanupHelper.js.map +1 -1
  164. package/dist/esm/images/ImageOptimizer.js +52 -4
  165. package/dist/esm/images/ImageOptimizer.js.map +1 -1
  166. package/dist/esm/index.js +1 -0
  167. package/dist/esm/index.js.map +1 -1
  168. package/dist/esm/internal.js +1 -1
  169. package/dist/esm/internal.js.map +1 -1
  170. package/dist/esm/processors/CompatibilityUpgrader.js +7 -1
  171. package/dist/esm/processors/CompatibilityUpgrader.js.map +1 -1
  172. package/dist/esm/processors/InMemoryRevisionAcceptor.js +134 -13
  173. package/dist/esm/processors/InMemoryRevisionAcceptor.js.map +1 -1
  174. package/dist/esm/processors/RevisionWalker.js +58 -8
  175. package/dist/esm/processors/RevisionWalker.js.map +1 -1
  176. package/dist/esm/processors/SelectiveRevisionAcceptor.js +116 -17
  177. package/dist/esm/processors/SelectiveRevisionAcceptor.js.map +1 -1
  178. package/dist/esm/processors/cnfStyleDecoder.js +7 -7
  179. package/dist/esm/processors/cnfStyleDecoder.js.map +1 -1
  180. package/dist/esm/processors/stripTrackedChanges.js +16 -5
  181. package/dist/esm/processors/stripTrackedChanges.js.map +1 -1
  182. package/dist/esm/utils/deepClone.js +24 -11
  183. package/dist/esm/utils/deepClone.js.map +1 -1
  184. package/dist/esm/utils/units.js +1 -1
  185. package/dist/esm/utils/units.js.map +1 -1
  186. package/dist/esm/utils/validation.js +11 -23
  187. package/dist/esm/utils/validation.js.map +1 -1
  188. package/dist/esm/utils/xmlSanitization.js +131 -7
  189. package/dist/esm/utils/xmlSanitization.js.map +1 -1
  190. package/dist/esm/validation/RevisionAutoFixer.js +69 -55
  191. package/dist/esm/validation/RevisionAutoFixer.js.map +1 -1
  192. package/dist/esm/validation/RevisionValidator.js +24 -20
  193. package/dist/esm/validation/RevisionValidator.js.map +1 -1
  194. package/dist/esm/xml/XMLBuilder.js +52 -20
  195. package/dist/esm/xml/XMLBuilder.js.map +1 -1
  196. package/dist/esm/xml/XMLParser.js +55 -13
  197. package/dist/esm/xml/XMLParser.js.map +1 -1
  198. package/dist/esm/zip/ZipHandler.js +37 -13
  199. package/dist/esm/zip/ZipHandler.js.map +1 -1
  200. package/dist/esm/zip/ZipReader.js +53 -6
  201. package/dist/esm/zip/ZipReader.js.map +1 -1
  202. package/dist/esm/zip/errors.js +7 -0
  203. package/dist/esm/zip/errors.js.map +1 -1
  204. package/dist/esm/zip/types.js +4 -0
  205. package/dist/esm/zip/types.js.map +1 -1
  206. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  207. package/dist/formatting/AbstractNumbering.js +1 -1
  208. package/dist/formatting/AbstractNumbering.js.map +1 -1
  209. package/dist/formatting/NumberingLevel.d.ts +1 -1
  210. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  211. package/dist/formatting/NumberingLevel.js +8 -6
  212. package/dist/formatting/NumberingLevel.js.map +1 -1
  213. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  214. package/dist/formatting/NumberingManager.js +16 -1
  215. package/dist/formatting/NumberingManager.js.map +1 -1
  216. package/dist/formatting/Style.d.ts.map +1 -1
  217. package/dist/formatting/Style.js +14 -0
  218. package/dist/formatting/Style.js.map +1 -1
  219. package/dist/formatting/StylesManager.d.ts +2 -0
  220. package/dist/formatting/StylesManager.d.ts.map +1 -1
  221. package/dist/formatting/StylesManager.js +14 -2
  222. package/dist/formatting/StylesManager.js.map +1 -1
  223. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  224. package/dist/helpers/CleanupHelper.js +103 -26
  225. package/dist/helpers/CleanupHelper.js.map +1 -1
  226. package/dist/images/ImageOptimizer.d.ts.map +1 -1
  227. package/dist/images/ImageOptimizer.js +52 -4
  228. package/dist/images/ImageOptimizer.js.map +1 -1
  229. package/dist/index.d.ts +3 -1
  230. package/dist/index.d.ts.map +1 -1
  231. package/dist/index.js +4 -2
  232. package/dist/index.js.map +1 -1
  233. package/dist/internal.d.ts +1 -1
  234. package/dist/internal.d.ts.map +1 -1
  235. package/dist/internal.js +2 -1
  236. package/dist/internal.js.map +1 -1
  237. package/dist/processors/CompatibilityUpgrader.d.ts.map +1 -1
  238. package/dist/processors/CompatibilityUpgrader.js +7 -1
  239. package/dist/processors/CompatibilityUpgrader.js.map +1 -1
  240. package/dist/processors/InMemoryRevisionAcceptor.d.ts +1 -0
  241. package/dist/processors/InMemoryRevisionAcceptor.d.ts.map +1 -1
  242. package/dist/processors/InMemoryRevisionAcceptor.js +135 -13
  243. package/dist/processors/InMemoryRevisionAcceptor.js.map +1 -1
  244. package/dist/processors/RevisionWalker.d.ts +2 -0
  245. package/dist/processors/RevisionWalker.d.ts.map +1 -1
  246. package/dist/processors/RevisionWalker.js +58 -8
  247. package/dist/processors/RevisionWalker.js.map +1 -1
  248. package/dist/processors/SelectiveRevisionAcceptor.d.ts.map +1 -1
  249. package/dist/processors/SelectiveRevisionAcceptor.js +116 -17
  250. package/dist/processors/SelectiveRevisionAcceptor.js.map +1 -1
  251. package/dist/processors/cnfStyleDecoder.d.ts.map +1 -1
  252. package/dist/processors/cnfStyleDecoder.js +7 -7
  253. package/dist/processors/cnfStyleDecoder.js.map +1 -1
  254. package/dist/processors/stripTrackedChanges.d.ts.map +1 -1
  255. package/dist/processors/stripTrackedChanges.js +16 -5
  256. package/dist/processors/stripTrackedChanges.js.map +1 -1
  257. package/dist/utils/deepClone.d.ts.map +1 -1
  258. package/dist/utils/deepClone.js +24 -11
  259. package/dist/utils/deepClone.js.map +1 -1
  260. package/dist/utils/units.d.ts.map +1 -1
  261. package/dist/utils/units.js +1 -1
  262. package/dist/utils/units.js.map +1 -1
  263. package/dist/utils/validation.d.ts.map +1 -1
  264. package/dist/utils/validation.js +11 -23
  265. package/dist/utils/validation.js.map +1 -1
  266. package/dist/utils/xmlSanitization.d.ts +2 -0
  267. package/dist/utils/xmlSanitization.d.ts.map +1 -1
  268. package/dist/utils/xmlSanitization.js +133 -7
  269. package/dist/utils/xmlSanitization.js.map +1 -1
  270. package/dist/validation/RevisionAutoFixer.d.ts +6 -2
  271. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  272. package/dist/validation/RevisionAutoFixer.js +68 -54
  273. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  274. package/dist/validation/RevisionValidator.d.ts +2 -0
  275. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  276. package/dist/validation/RevisionValidator.js +26 -21
  277. package/dist/validation/RevisionValidator.js.map +1 -1
  278. package/dist/xml/XMLBuilder.d.ts +1 -0
  279. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  280. package/dist/xml/XMLBuilder.js +52 -20
  281. package/dist/xml/XMLBuilder.js.map +1 -1
  282. package/dist/xml/XMLParser.d.ts +1 -0
  283. package/dist/xml/XMLParser.d.ts.map +1 -1
  284. package/dist/xml/XMLParser.js +55 -13
  285. package/dist/xml/XMLParser.js.map +1 -1
  286. package/dist/zip/ZipHandler.d.ts.map +1 -1
  287. package/dist/zip/ZipHandler.js +37 -13
  288. package/dist/zip/ZipHandler.js.map +1 -1
  289. package/dist/zip/ZipReader.d.ts.map +1 -1
  290. package/dist/zip/ZipReader.js +52 -5
  291. package/dist/zip/ZipReader.js.map +1 -1
  292. package/dist/zip/errors.d.ts +3 -0
  293. package/dist/zip/errors.d.ts.map +1 -1
  294. package/dist/zip/errors.js +9 -1
  295. package/dist/zip/errors.js.map +1 -1
  296. package/dist/zip/types.d.ts +4 -0
  297. package/dist/zip/types.d.ts.map +1 -1
  298. package/dist/zip/types.js +4 -0
  299. package/dist/zip/types.js.map +1 -1
  300. package/package.json +2 -2
  301. package/src/core/Document.ts +1159 -240
  302. package/src/core/DocumentGenerator.ts +106 -26
  303. package/src/core/DocumentParser.ts +692 -120
  304. package/src/core/RelationshipManager.ts +446 -442
  305. package/src/elements/Comment.ts +123 -18
  306. package/src/elements/CommentManager.ts +121 -22
  307. package/src/elements/EndnoteManager.ts +4 -1
  308. package/src/elements/Field.ts +134 -43
  309. package/src/elements/FieldHelpers.ts +20 -4
  310. package/src/elements/Footer.ts +62 -67
  311. package/src/elements/FootnoteManager.ts +4 -1
  312. package/src/elements/Header.ts +62 -67
  313. package/src/elements/HeaderFooterManager.ts +56 -8
  314. package/src/elements/Hyperlink.ts +150 -55
  315. package/src/elements/Image.ts +51 -7
  316. package/src/elements/ImageManager.ts +24 -0
  317. package/src/elements/Paragraph.ts +161 -40
  318. package/src/elements/Revision.ts +446 -47
  319. package/src/elements/Run.ts +44 -6
  320. package/src/elements/Section.ts +25 -9
  321. package/src/elements/Shape.ts +52 -44
  322. package/src/elements/StructuredDocumentTag.ts +83 -8
  323. package/src/elements/Table.ts +151 -50
  324. package/src/elements/TableCell.ts +57 -17
  325. package/src/elements/TableOfContents.ts +10 -9
  326. package/src/elements/TableRow.ts +35 -3
  327. package/src/elements/TextBox.ts +58 -43
  328. package/src/formatting/AbstractNumbering.ts +4 -2
  329. package/src/formatting/NumberingLevel.ts +50 -12
  330. package/src/formatting/NumberingManager.ts +32 -3
  331. package/src/formatting/Style.ts +14 -0
  332. package/src/formatting/StylesManager.ts +29 -2
  333. package/src/helpers/CleanupHelper.ts +122 -30
  334. package/src/images/ImageOptimizer.ts +64 -5
  335. package/src/index.ts +7 -0
  336. package/src/internal.ts +1 -0
  337. package/src/processors/CompatibilityUpgrader.ts +15 -2
  338. package/src/processors/InMemoryRevisionAcceptor.ts +276 -17
  339. package/src/processors/RevisionWalker.ts +83 -7
  340. package/src/processors/SelectiveRevisionAcceptor.ts +164 -28
  341. package/src/processors/cnfStyleDecoder.ts +18 -12
  342. package/src/processors/stripTrackedChanges.ts +47 -10
  343. package/src/utils/deepClone.ts +48 -14
  344. package/src/utils/units.ts +3 -1
  345. package/src/utils/validation.ts +30 -32
  346. package/src/utils/xmlSanitization.ts +218 -22
  347. package/src/validation/RevisionAutoFixer.ts +109 -65
  348. package/src/validation/RevisionValidator.ts +48 -23
  349. package/src/xml/XMLBuilder.ts +82 -22
  350. package/src/xml/XMLParser.ts +87 -20
  351. package/src/zip/ZipHandler.ts +61 -21
  352. package/src/zip/ZipReader.ts +135 -13
  353. package/src/zip/errors.ts +15 -0
  354. package/src/zip/types.ts +44 -2
@@ -9,12 +9,14 @@ import { Endnote, EndnoteType } from '../elements/Endnote.js';
9
9
  import { Footnote, FootnoteType } from '../elements/Footnote.js';
10
10
  import { BookmarkManager } from '../elements/BookmarkManager.js';
11
11
  import { Comment } from '../elements/Comment.js';
12
+ import type { CommentContentItem, CommentParagraph } from '../elements/Comment.js';
12
13
  import { CustomXmlBlock } from '../elements/CustomXml.js';
13
14
  import { PreservedElement } from '../elements/PreservedElement.js';
14
15
  import { RegisteredBodyElement } from '../elements/RegisteredBodyElement.js';
15
16
  import { ElementRegistry } from './ElementRegistry.js';
16
17
  import { MathParagraph } from '../elements/MathElement.js';
17
18
  import { ComplexField, Field } from '../elements/Field.js';
19
+ import type { CachedFieldResultRun } from '../elements/Field.js';
18
20
  import { isHyperlinkInstruction, parseHyperlinkInstruction } from '../elements/FieldHelpers.js';
19
21
  import { Footer } from '../elements/Footer.js';
20
22
  import { Header } from '../elements/Header.js';
@@ -53,6 +55,7 @@ import {
53
55
  parseOnOffAttribute,
54
56
  } from '../utils/parsingHelpers.js';
55
57
  import { halfPointsToPoints } from '../utils/units.js';
58
+ import { normalizeAnchorSizeRel } from '../utils/xmlSanitization.js';
56
59
  import type { ShadingConfig } from '../elements/CommonTypes.js';
57
60
 
58
61
  // Create scoped logger for DocumentParser operations
@@ -133,6 +136,7 @@ export class DocumentParser {
133
136
  themeColor?: string;
134
137
  themeTint?: string;
135
138
  themeShade?: string;
139
+ rawInnerXml?: string;
136
140
  };
137
141
  }> {
138
142
  const logger = getLogger();
@@ -368,9 +372,18 @@ export class DocumentParser {
368
372
  parsed['w:sdt'],
369
373
  relationshipManager,
370
374
  zipHandler,
371
- imageManager
375
+ imageManager,
376
+ elementXml
372
377
  );
373
- if (sdt) bodyElements.push(sdt);
378
+ if (sdt) {
379
+ bodyElements.push(sdt);
380
+ } else {
381
+ // A null result means the content control could not be modeled.
382
+ // Preserve the raw block so the SDT and everything inside it
383
+ // survives the save, mirroring the failed registered-element
384
+ // path below — otherwise the whole control would be deleted.
385
+ bodyElements.push(new PreservedElement(elementXml, 'sdt', 'block'));
386
+ }
374
387
  pos = next.pos + elementXml.length;
375
388
  } else {
376
389
  pos = next.pos + 1;
@@ -1072,6 +1085,7 @@ export class DocumentParser {
1072
1085
  | 'w:permEnd'
1073
1086
  | 'm:oMath'
1074
1087
  | 'w:ruby'
1088
+ | 'w:sdt'
1075
1089
  | 'w:commentRangeStart'
1076
1090
  | 'w:commentRangeEnd';
1077
1091
  pos: number;
@@ -1093,6 +1107,7 @@ export class DocumentParser {
1093
1107
  let permEndIndex = 0;
1094
1108
  let oMathIndex = 0;
1095
1109
  let rubyIndex = 0;
1110
+ let sdtIndex = 0;
1096
1111
  let commentRangeStartIndex = 0;
1097
1112
  let commentRangeEndIndex = 0;
1098
1113
 
@@ -1252,6 +1267,18 @@ export class DocumentParser {
1252
1267
  index: rubyIndex++,
1253
1268
  });
1254
1269
  searchPos = selfClosing ? tagEnd + 1 : findClosingTagEnd(paraContent, 'w:ruby', tagEnd);
1270
+ } else if (tagName === 'w:sdt') {
1271
+ // Inline structured document tag (CT_SdtRun, ECMA-376 §17.5.2.31) —
1272
+ // date pickers, dropdowns, citations inline in a sentence. Preserve
1273
+ // as raw XML; skipping past the closing tag also keeps run indices
1274
+ // aligned with pElement['w:r'] (sdt-nested runs are not direct
1275
+ // paragraph children in the parsed object).
1276
+ children.push({
1277
+ type: 'w:sdt',
1278
+ pos: tagStart,
1279
+ index: sdtIndex++,
1280
+ });
1281
+ searchPos = selfClosing ? tagEnd + 1 : findClosingTagEnd(paraContent, 'w:sdt', tagEnd);
1255
1282
  } else {
1256
1283
  searchPos = tagEnd + 1;
1257
1284
  }
@@ -1340,21 +1367,57 @@ export class DocumentParser {
1340
1367
  if (runXml) {
1341
1368
  paragraph.addContent(new PreservedElement(runXml, 'w:r', 'inline'));
1342
1369
  }
1370
+ } else if (runObj['mc:AlternateContent']) {
1371
+ // Word 2010+ emits every text box, WordArt, and wps shape as
1372
+ // <w:r><mc:AlternateContent><mc:Choice>… with a VML fallback
1373
+ // (ECMA-376 Part 3 §10). There is no editing model for these,
1374
+ // so preserve the whole run verbatim — otherwise the shape and
1375
+ // all its w:txbxContent text are deleted on save.
1376
+ const runXml = extractRunXmlAtPosition(child.pos);
1377
+ if (runXml) {
1378
+ paragraph.addContent(new PreservedElement(runXml, 'w:r', 'inline'));
1379
+ }
1343
1380
  } else if (runObj['w:drawing']) {
1381
+ let imageRun: ImageRun | null = null;
1344
1382
  if (zipHandler && imageManager) {
1345
- const imageRun = await this.parseDrawingFromObject(
1383
+ imageRun = await this.parseDrawingFromObject(
1346
1384
  runObj['w:drawing'],
1347
1385
  zipHandler,
1348
1386
  relationshipManager,
1349
1387
  imageManager
1350
1388
  );
1351
- if (imageRun) {
1352
- // Preserve the parent run's w:rPr (rFonts, noProof, b, etc.)
1353
- // without this, ImageRun.toXML() emits <w:r><w:drawing/></w:r>
1354
- // and Word recalculates line height with the default font,
1355
- // shifting the image and clipping it into adjacent cells.
1356
- this.parseRunPropertiesFromObject(runObj['w:rPr'], imageRun);
1357
- paragraph.addRun(imageRun);
1389
+ }
1390
+ if (imageRun) {
1391
+ // Preserve the parent run's w:rPr (rFonts, noProof, b, etc.)
1392
+ // without this, ImageRun.toXML() emits <w:r><w:drawing/></w:r>
1393
+ // and Word recalculates line height with the default font,
1394
+ // shifting the image and clipping it into adjacent cells.
1395
+ this.parseRunPropertiesFromObject(runObj['w:rPr'], imageRun);
1396
+ paragraph.addRun(imageRun);
1397
+ // EG_RunInnerContent (ECMA-376 §17.3.3) lets a single run mix a
1398
+ // drawing with text/tab/break siblings. Word isolates drawings,
1399
+ // but other generators do not — parse any sibling content as a
1400
+ // separate Run so its text is not dropped alongside the image.
1401
+ if (
1402
+ runObj['w:t'] ||
1403
+ runObj['w:tab'] ||
1404
+ runObj['w:br'] ||
1405
+ runObj['w:cr'] ||
1406
+ runObj['w:noBreakHyphen']
1407
+ ) {
1408
+ const siblingRun = this.parseRunFromObject(runObj);
1409
+ if (siblingRun) {
1410
+ paragraph.addRun(siblingRun);
1411
+ }
1412
+ }
1413
+ } else {
1414
+ // Charts (c:chart), SmartArt (dgm:relIds), and other
1415
+ // non-picture graphicData have no editing model — preserve
1416
+ // the whole run verbatim so the drawing reference survives
1417
+ // round-trip instead of leaving an orphaned part.
1418
+ const runXml = extractRunXmlAtPosition(child.pos);
1419
+ if (runXml) {
1420
+ paragraph.addContent(new PreservedElement(runXml, 'w:r', 'inline'));
1358
1421
  }
1359
1422
  }
1360
1423
  } else if (runObj['w:pict']) {
@@ -1401,16 +1464,22 @@ export class DocumentParser {
1401
1464
  if (child.index < hyperlinkArray.length) {
1402
1465
  const hyperlinkObj = hyperlinkArray[child.index];
1403
1466
 
1404
- // Hyperlinks containing tracked changes (w:del/w:ins inside w:hyperlink)
1405
- // cannot survive parseHyperlinkFromObject round-trip preserve as raw XML
1467
+ // Hyperlinks containing tracked changes (w:del/w:ins/w:moveFrom/
1468
+ // w:moveTo inside w:hyperlink) are always flattened to an editable
1469
+ // Hyperlink object, regardless of the revisionHandling mode (including
1470
+ // 'preserve'). This is a deliberate exception to 'preserve' — see the
1471
+ // flattening note below and the core module documentation.
1406
1472
  const hasRevisionChildren =
1407
1473
  hyperlinkObj['w:del'] ||
1408
1474
  hyperlinkObj['w:ins'] ||
1409
1475
  hyperlinkObj['w:moveFrom'] ||
1410
1476
  hyperlinkObj['w:moveTo'];
1411
1477
  if (hasRevisionChildren) {
1412
- // Flatten revisions to make hyperlink editable (setUrl/setText).
1413
- // Trades revision fidelity inside the hyperlink for editability.
1478
+ // Flatten revisions to make the hyperlink editable (setUrl/setText):
1479
+ // unwrap w:ins/w:moveTo runs (kept), drop w:del/w:moveFrom content.
1480
+ // Applied in every load mode — 'preserve' does not retain revision
1481
+ // markup inside hyperlinks. Trades in-hyperlink revision fidelity
1482
+ // for editability.
1414
1483
  const flattenedObj = { ...hyperlinkObj };
1415
1484
  const allRuns: any[] = [];
1416
1485
 
@@ -1566,6 +1635,14 @@ export class DocumentParser {
1566
1635
  if (elementXml) {
1567
1636
  paragraph.addContent(new PreservedElement(elementXml, child.type, 'inline'));
1568
1637
  }
1638
+ } else if (child.type === 'w:sdt') {
1639
+ // Inline content control — no run-level editing model, so preserve
1640
+ // the whole w:sdt verbatim; dropping it deletes the user-visible
1641
+ // text inside the control on every save.
1642
+ const sdtXml = extractElementXmlAtPosition(child.pos, 'w:sdt');
1643
+ if (sdtXml) {
1644
+ paragraph.addContent(new PreservedElement(sdtXml, 'w:sdt', 'inline'));
1645
+ }
1569
1646
  }
1570
1647
  }
1571
1648
  }
@@ -1634,39 +1711,98 @@ export class DocumentParser {
1634
1711
  // that is NOT inside hyperlinks to avoid duplicate content
1635
1712
  const hyperlinkXmls = XMLParser.extractElements(revisionXml, 'w:hyperlink');
1636
1713
 
1637
- // Create a version of the XML with hyperlinks removed to extract standalone runs
1638
- // Use split().join() instead of replace() to remove ALL occurrences of identical hyperlinks
1639
- // (replace() only removes the first match, causing duplicate content)
1714
+ // Blank out hyperlinks with same-length padding (not removal) so that
1715
+ // standalone-run positions still line up with revisionXml needed below
1716
+ // to restore the original document order of interleaved runs/hyperlinks.
1717
+ // Use split().join() instead of replace() to blank ALL occurrences of
1718
+ // identical hyperlinks (replace() only hits the first match, causing
1719
+ // duplicate content)
1640
1720
  let xmlWithoutHyperlinks = revisionXml;
1641
1721
  for (const hyperlinkXml of hyperlinkXmls) {
1642
- xmlWithoutHyperlinks = xmlWithoutHyperlinks.split(hyperlinkXml).join('');
1722
+ xmlWithoutHyperlinks = xmlWithoutHyperlinks
1723
+ .split(hyperlinkXml)
1724
+ .join(' '.repeat(hyperlinkXml.length));
1643
1725
  }
1644
1726
 
1645
1727
  // Extract runs from the XML without hyperlinks (these are standalone runs)
1646
1728
  const runXmls = XMLParser.extractElements(xmlWithoutHyperlinks, 'w:r');
1647
1729
 
1730
+ // Interleave runs and hyperlinks by source position. Two type-grouped
1731
+ // passes (all runs, then all hyperlinks) would emit every hyperlink
1732
+ // after the last run, reordering revision text on round-trip under
1733
+ // preserve mode.
1734
+ const orderedChildren: { pos: number; type: 'w:r' | 'w:hyperlink'; xml: string }[] = [];
1735
+ let runCursor = 0;
1736
+ for (const runXml of runXmls) {
1737
+ const pos = xmlWithoutHyperlinks.indexOf(runXml, runCursor);
1738
+ orderedChildren.push({ pos: pos === -1 ? runCursor : pos, type: 'w:r', xml: runXml });
1739
+ if (pos !== -1) {
1740
+ runCursor = pos + runXml.length;
1741
+ }
1742
+ }
1743
+ let hyperlinkCursor = 0;
1744
+ for (const hyperlinkXml of hyperlinkXmls) {
1745
+ const pos = revisionXml.indexOf(hyperlinkXml, hyperlinkCursor);
1746
+ orderedChildren.push({
1747
+ pos: pos === -1 ? hyperlinkCursor : pos,
1748
+ type: 'w:hyperlink',
1749
+ xml: hyperlinkXml,
1750
+ });
1751
+ if (pos !== -1) {
1752
+ hyperlinkCursor = pos + hyperlinkXml.length;
1753
+ }
1754
+ }
1755
+ orderedChildren.sort((a, b) => a.pos - b.pos);
1756
+
1648
1757
  // Use RevisionContent to hold both Run and Hyperlink objects
1649
1758
  const content: import('../elements/RevisionContent.js').RevisionContent[] = [];
1650
1759
 
1651
- // Parse standalone runs (not inside hyperlinks)
1652
- for (const runXml of runXmls) {
1653
- // Parse the run object
1760
+ for (const child of orderedChildren) {
1761
+ if (child.type === 'w:hyperlink') {
1762
+ // Parse hyperlink inside revision (for tracked hyperlink changes)
1763
+ const hyperlinkObj = XMLParser.parseToObject(child.xml, { trimValues: false });
1764
+ const hyperlinkResult = this.parseHyperlinkFromObject(
1765
+ hyperlinkObj['w:hyperlink'],
1766
+ relationshipManager
1767
+ );
1768
+ if (hyperlinkResult.hyperlink) {
1769
+ content.push(hyperlinkResult.hyperlink);
1770
+ }
1771
+ // Collect bookmarks from hyperlinks inside revisions
1772
+ result.bookmarkStarts.push(...hyperlinkResult.bookmarkStarts);
1773
+ result.bookmarkEnds.push(...hyperlinkResult.bookmarkEnds);
1774
+ continue;
1775
+ }
1776
+
1777
+ // Parse standalone run (not inside hyperlinks)
1778
+ const runXml = child.xml;
1654
1779
  const runObj = XMLParser.parseToObject(runXml, { trimValues: false });
1655
1780
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1656
1781
  const runElement = runObj['w:r'] as any;
1657
1782
 
1658
1783
  // Check if this run contains a drawing (image)
1659
1784
  if (runElement?.['w:drawing']) {
1785
+ let imageRun: ImageRun | null = null;
1660
1786
  if (zipHandler && imageManager) {
1661
- const imageRun = await this.parseDrawingFromObject(
1787
+ imageRun = await this.parseDrawingFromObject(
1662
1788
  runElement['w:drawing'],
1663
1789
  zipHandler,
1664
1790
  relationshipManager,
1665
1791
  imageManager
1666
1792
  );
1667
- if (imageRun) {
1668
- imageRun.setRawRunXml(runXml);
1669
- content.push(imageRun);
1793
+ }
1794
+ if (imageRun) {
1795
+ imageRun.setRawRunXml(runXml);
1796
+ content.push(imageRun);
1797
+ } else {
1798
+ // Non-picture graphicData (charts, SmartArt) — RevisionContent
1799
+ // cannot hold a PreservedElement, so carry the w:drawing through
1800
+ // as raw XML inside a passthrough run to keep the reference.
1801
+ const drawingXmls = XMLParser.extractElements(runXml, 'w:drawing');
1802
+ if (drawingXmls.length > 0 && drawingXmls[0]) {
1803
+ const run = Run.createFromContent([{ type: 'vml', rawXml: drawingXmls[0] }]);
1804
+ this.parseRunPropertiesFromObject(runElement['w:rPr'], run);
1805
+ content.push(run);
1670
1806
  }
1671
1807
  }
1672
1808
  } else {
@@ -1678,21 +1814,6 @@ export class DocumentParser {
1678
1814
  }
1679
1815
  }
1680
1816
 
1681
- // Parse hyperlinks inside revision (for tracked hyperlink changes)
1682
- for (const hyperlinkXml of hyperlinkXmls) {
1683
- const hyperlinkObj = XMLParser.parseToObject(hyperlinkXml, { trimValues: false });
1684
- const hyperlinkResult = this.parseHyperlinkFromObject(
1685
- hyperlinkObj['w:hyperlink'],
1686
- relationshipManager
1687
- );
1688
- if (hyperlinkResult.hyperlink) {
1689
- content.push(hyperlinkResult.hyperlink);
1690
- }
1691
- // Collect bookmarks from hyperlinks inside revisions
1692
- result.bookmarkStarts.push(...hyperlinkResult.bookmarkStarts);
1693
- result.bookmarkEnds.push(...hyperlinkResult.bookmarkEnds);
1694
- }
1695
-
1696
1817
  // Extract bookmarks directly inside the revision (not nested in hyperlinks)
1697
1818
  const bookmarkStartXmls = XMLParser.extractElements(xmlWithoutHyperlinks, 'w:bookmarkStart');
1698
1819
  const bookmarkEndXmls = XMLParser.extractElements(xmlWithoutHyperlinks, 'w:bookmarkEnd');
@@ -1748,16 +1869,23 @@ export class DocumentParser {
1748
1869
  /**
1749
1870
  * Parses comments from word/comments.xml
1750
1871
  * @param commentsXml - Raw XML content of comments.xml
1872
+ * @param commentsExtendedXml - Optional word/commentsExtended.xml content;
1873
+ * its w15:commentEx entries carry the resolved state (w15:done) keyed by
1874
+ * the w14:paraId of each comment's last paragraph
1751
1875
  * @returns Array of parsed Comment objects
1752
1876
  */
1753
- parseCommentsXml(commentsXml: string): Comment[] {
1877
+ parseCommentsXml(commentsXml: string, commentsExtendedXml?: string): Comment[] {
1754
1878
  const comments: Comment[] = [];
1755
1879
 
1880
+ const doneByParaId = commentsExtendedXml
1881
+ ? this.parseCommentsExtendedDone(commentsExtendedXml)
1882
+ : undefined;
1883
+
1756
1884
  // Extract all w:comment elements
1757
1885
  const commentXmls = XMLParser.extractElements(commentsXml, 'w:comment');
1758
1886
 
1759
1887
  for (const commentXml of commentXmls) {
1760
- const comment = this.parseCommentFromXml(commentXml);
1888
+ const comment = this.parseCommentFromXml(commentXml, doneByParaId);
1761
1889
  if (comment) {
1762
1890
  comments.push(comment);
1763
1891
  }
@@ -1766,12 +1894,35 @@ export class DocumentParser {
1766
1894
  return comments;
1767
1895
  }
1768
1896
 
1897
+ /**
1898
+ * Builds the paraId -> done map from commentsExtended.xml. Word stores
1899
+ * comment resolution there (w15:commentEx w15:done) rather than on
1900
+ * w:comment, which declares no done attribute.
1901
+ */
1902
+ private parseCommentsExtendedDone(commentsExtendedXml: string): Map<string, boolean> {
1903
+ const doneByParaId = new Map<string, boolean>();
1904
+ for (const commentExXml of XMLParser.extractElements(commentsExtendedXml, 'w15:commentEx')) {
1905
+ const paraId = XMLParser.extractAttribute(commentExXml, 'w15:paraId');
1906
+ if (!paraId) {
1907
+ continue;
1908
+ }
1909
+ const done = parseOnOffAttribute(XMLParser.extractAttribute(commentExXml, 'w15:done'));
1910
+ doneByParaId.set(paraId.toUpperCase(), done);
1911
+ }
1912
+ return doneByParaId;
1913
+ }
1914
+
1769
1915
  /**
1770
1916
  * Parses a single comment element from XML
1771
1917
  * @param commentXml - XML string for one w:comment element
1918
+ * @param doneByParaId - Resolved state from commentsExtended.xml, keyed by
1919
+ * uppercased last-paragraph w14:paraId
1772
1920
  * @returns Parsed Comment or null
1773
1921
  */
1774
- private parseCommentFromXml(commentXml: string): Comment | null {
1922
+ private parseCommentFromXml(
1923
+ commentXml: string,
1924
+ doneByParaId?: Map<string, boolean>
1925
+ ): Comment | null {
1775
1926
  try {
1776
1927
  // Extract attributes
1777
1928
  const idAttr = XMLParser.extractAttribute(commentXml, 'w:id');
@@ -1792,20 +1943,45 @@ export class DocumentParser {
1792
1943
  const id = parseInt(idAttr, 10);
1793
1944
  const date = dateAttr ? new Date(dateAttr) : new Date();
1794
1945
  const parentId = parentIdAttr ? parseInt(parentIdAttr, 10) : undefined;
1795
- // Per ECMA-376 §17.17.4, w:done is ST_OnOff — accept 1/0/true/false/on/off
1796
- const done = parseOnOffAttribute(doneAttr);
1797
-
1798
- // Parse content (runs from paragraphs within the comment)
1946
+ // Accept legacy done attributes as ST_OnOff (1/0/true/false/on/off);
1947
+ // commentsExtended.xml (checked below) is the authoritative source
1948
+ let done = parseOnOffAttribute(doneAttr);
1949
+
1950
+ // Parse content per source paragraph so regeneration can restore the
1951
+ // original <w:p> boundaries, paragraph properties, and hyperlink
1952
+ // wrappers — multi-paragraph comments are legal per ECMA-376
1953
+ // §17.13.4.2 and flattening them concatenates words across the lost
1954
+ // paragraph breaks
1799
1955
  const runs: Run[] = [];
1800
- const runXmls = XMLParser.extractElements(commentXml, 'w:r');
1956
+ const paragraphs: CommentParagraph[] = [];
1957
+ const paraXmls = XMLParser.extractElements(commentXml, 'w:p');
1958
+ const runSources = paraXmls.length > 0 ? paraXmls : [commentXml];
1801
1959
 
1802
- for (const runXml of runXmls) {
1803
- const runObj = XMLParser.parseToObject(runXml, { trimValues: false });
1804
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1805
- const run = this.parseRunFromObject(runObj['w:r'] as any);
1806
- if (run) {
1807
- runs.push(run);
1960
+ // commentsExtended.xml references a comment via the w14:paraId of its
1961
+ // last paragraph; when an entry exists it overrides the legacy attr
1962
+ if (doneByParaId && paraXmls.length > 0) {
1963
+ const lastParaXml = paraXmls[paraXmls.length - 1]!;
1964
+ const lastParaId = XMLParser.extractAttribute(lastParaXml, 'w14:paraId');
1965
+ if (lastParaId) {
1966
+ const extendedDone = doneByParaId.get(lastParaId.toUpperCase());
1967
+ if (extendedDone !== undefined) {
1968
+ done = extendedDone;
1969
+ }
1970
+ }
1971
+ }
1972
+
1973
+ for (const sourceXml of runSources) {
1974
+ const content = this.parseCommentParagraphContent(sourceXml);
1975
+ for (const item of content) {
1976
+ if (item instanceof Run) {
1977
+ runs.push(item);
1978
+ } else {
1979
+ runs.push(...item.runs);
1980
+ }
1808
1981
  }
1982
+ const pPr =
1983
+ paraXmls.length > 0 ? XMLParser.extractElements(sourceXml, 'w:pPr')[0] : undefined;
1984
+ paragraphs.push({ pPr, content });
1809
1985
  }
1810
1986
 
1811
1987
  // Create comment with parsed data
@@ -1819,6 +1995,10 @@ export class DocumentParser {
1819
1995
  done,
1820
1996
  });
1821
1997
 
1998
+ if (paraXmls.length > 0) {
1999
+ comment.setParagraphs(paragraphs);
2000
+ }
2001
+
1822
2002
  return comment;
1823
2003
  } catch (error: unknown) {
1824
2004
  defaultLogger.warn(
@@ -1829,15 +2009,76 @@ export class DocumentParser {
1829
2009
  }
1830
2010
  }
1831
2011
 
2012
+ /**
2013
+ * Parses one comment paragraph's inline content in document order.
2014
+ * Top-level runs become Run instances; w:hyperlink wrappers pass through
2015
+ * as raw XML (their r:id targets live in word/_rels/comments.xml.rels,
2016
+ * which is preserved verbatim) with their inner runs parsed alongside so
2017
+ * text extraction still sees the link text.
2018
+ * @param sourceXml - XML of one w:p element (or the whole w:comment when
2019
+ * it has no paragraph children)
2020
+ * @returns Ordered paragraph content
2021
+ */
2022
+ private parseCommentParagraphContent(sourceXml: string): CommentContentItem[] {
2023
+ const positioned: Array<{ pos: number; item: CommentContentItem }> = [];
2024
+
2025
+ const hyperlinkRanges: Array<{ start: number; end: number }> = [];
2026
+ let hyperlinkCursor = 0;
2027
+ for (const hyperlinkXml of XMLParser.extractElements(sourceXml, 'w:hyperlink')) {
2028
+ const start = sourceXml.indexOf(hyperlinkXml, hyperlinkCursor);
2029
+ if (start === -1) {
2030
+ continue;
2031
+ }
2032
+ hyperlinkCursor = start + hyperlinkXml.length;
2033
+ hyperlinkRanges.push({ start, end: hyperlinkCursor });
2034
+
2035
+ const innerRuns: Run[] = [];
2036
+ for (const runXml of XMLParser.extractElements(hyperlinkXml, 'w:r')) {
2037
+ const run = this.parseCommentRun(runXml);
2038
+ if (run) {
2039
+ innerRuns.push(run);
2040
+ }
2041
+ }
2042
+ positioned.push({ pos: start, item: { rawXml: hyperlinkXml, runs: innerRuns } });
2043
+ }
2044
+
2045
+ let runCursor = 0;
2046
+ for (const runXml of XMLParser.extractElements(sourceXml, 'w:r')) {
2047
+ const start = sourceXml.indexOf(runXml, runCursor);
2048
+ if (start === -1) {
2049
+ continue;
2050
+ }
2051
+ runCursor = start + runXml.length;
2052
+ // Runs inside a hyperlink are already covered by its raw passthrough
2053
+ if (hyperlinkRanges.some((range) => start >= range.start && start < range.end)) {
2054
+ continue;
2055
+ }
2056
+ const run = this.parseCommentRun(runXml);
2057
+ if (run) {
2058
+ positioned.push({ pos: start, item: run });
2059
+ }
2060
+ }
2061
+
2062
+ return positioned.sort((a, b) => a.pos - b.pos).map((entry) => entry.item);
2063
+ }
2064
+
2065
+ private parseCommentRun(runXml: string): Run | null {
2066
+ const runObj = XMLParser.parseToObject(runXml, { trimValues: false });
2067
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2068
+ return this.parseRunFromObject(runObj['w:r'] as any);
2069
+ }
2070
+
1832
2071
  /**
1833
2072
  * Parses footnotes.xml into Footnote array
2073
+ * @param partRels - Part-scoped relationships (word/_rels/footnotes.xml.rels)
2074
+ * used to resolve hyperlink r:id targets inside footnote content
1834
2075
  */
1835
- parseFootnotesXml(footnotesXml: string): Footnote[] {
2076
+ parseFootnotesXml(footnotesXml: string, partRels?: RelationshipManager): Footnote[] {
1836
2077
  const footnotes: Footnote[] = [];
1837
2078
  const footnoteXmls = XMLParser.extractElements(footnotesXml, 'w:footnote');
1838
2079
 
1839
2080
  for (const footnoteXml of footnoteXmls) {
1840
- const footnote = this.parseFootnoteFromXml(footnoteXml);
2081
+ const footnote = this.parseFootnoteFromXml(footnoteXml, partRels);
1841
2082
  if (footnote) {
1842
2083
  footnotes.push(footnote);
1843
2084
  }
@@ -1846,7 +2087,10 @@ export class DocumentParser {
1846
2087
  return footnotes;
1847
2088
  }
1848
2089
 
1849
- private parseFootnoteFromXml(footnoteXml: string): Footnote | null {
2090
+ private parseFootnoteFromXml(
2091
+ footnoteXml: string,
2092
+ partRels?: RelationshipManager
2093
+ ): Footnote | null {
1850
2094
  try {
1851
2095
  const idAttr = XMLParser.extractAttribute(footnoteXml, 'w:id');
1852
2096
  const typeAttr = XMLParser.extractAttribute(footnoteXml, 'w:type');
@@ -1870,7 +2114,7 @@ export class DocumentParser {
1870
2114
 
1871
2115
  const paraXmls = XMLParser.extractElements(footnoteXml, 'w:p');
1872
2116
  for (const paraXml of paraXmls) {
1873
- const para = this.parseNoteParaFromXml(paraXml);
2117
+ const para = this.parseNoteParaFromXml(paraXml, partRels);
1874
2118
  if (para) {
1875
2119
  footnote.addParagraph(para);
1876
2120
  }
@@ -1888,13 +2132,15 @@ export class DocumentParser {
1888
2132
 
1889
2133
  /**
1890
2134
  * Parses endnotes.xml into Endnote array
2135
+ * @param partRels - Part-scoped relationships (word/_rels/endnotes.xml.rels)
2136
+ * used to resolve hyperlink r:id targets inside endnote content
1891
2137
  */
1892
- parseEndnotesXml(endnotesXml: string): Endnote[] {
2138
+ parseEndnotesXml(endnotesXml: string, partRels?: RelationshipManager): Endnote[] {
1893
2139
  const endnotes: Endnote[] = [];
1894
2140
  const endnoteXmls = XMLParser.extractElements(endnotesXml, 'w:endnote');
1895
2141
 
1896
2142
  for (const endnoteXml of endnoteXmls) {
1897
- const endnote = this.parseEndnoteFromXml(endnoteXml);
2143
+ const endnote = this.parseEndnoteFromXml(endnoteXml, partRels);
1898
2144
  if (endnote) {
1899
2145
  endnotes.push(endnote);
1900
2146
  }
@@ -1903,7 +2149,7 @@ export class DocumentParser {
1903
2149
  return endnotes;
1904
2150
  }
1905
2151
 
1906
- private parseEndnoteFromXml(endnoteXml: string): Endnote | null {
2152
+ private parseEndnoteFromXml(endnoteXml: string, partRels?: RelationshipManager): Endnote | null {
1907
2153
  try {
1908
2154
  const idAttr = XMLParser.extractAttribute(endnoteXml, 'w:id');
1909
2155
  const typeAttr = XMLParser.extractAttribute(endnoteXml, 'w:type');
@@ -1927,7 +2173,7 @@ export class DocumentParser {
1927
2173
 
1928
2174
  const paraXmls = XMLParser.extractElements(endnoteXml, 'w:p');
1929
2175
  for (const paraXml of paraXmls) {
1930
- const para = this.parseNoteParaFromXml(paraXml);
2176
+ const para = this.parseNoteParaFromXml(paraXml, partRels);
1931
2177
  if (para) {
1932
2178
  endnote.addParagraph(para);
1933
2179
  }
@@ -1945,20 +2191,80 @@ export class DocumentParser {
1945
2191
 
1946
2192
  /**
1947
2193
  * Parses a paragraph from a footnote or endnote element.
1948
- * Uses run-level parsing (like comments) to avoid async dependency on relationship manager.
2194
+ * Uses run-level parsing (like comments) to avoid async dependency on the
2195
+ * document relationship manager. Paragraph properties (pStyle FootnoteText
2196
+ * per ECMA-376 §17.7.4) and w:hyperlink wrappers are parsed so regeneration
2197
+ * after a real edit keeps note styling and working links instead of
2198
+ * flattening hyperlinks to plain runs; r:id targets resolve against the
2199
+ * part-scoped rels when provided.
1949
2200
  */
1950
- private parseNoteParaFromXml(paraXml: string): Paragraph | null {
2201
+ private parseNoteParaFromXml(paraXml: string, partRels?: RelationshipManager): Paragraph | null {
1951
2202
  try {
1952
2203
  const para = new Paragraph();
1953
- const runXmls = XMLParser.extractElements(paraXml, 'w:r');
1954
- for (const runXml of runXmls) {
1955
- const runObj = XMLParser.parseToObject(runXml, { trimValues: false });
1956
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1957
- const run = this.parseRunFromObject(runObj['w:r'] as any);
1958
- if (run) {
1959
- para.addRun(run);
2204
+
2205
+ const paraObj = XMLParser.parseToObject(paraXml, { trimValues: false });
2206
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2207
+ const pElement = paraObj['w:p'] as any;
2208
+ if (pElement?.['w:pPr']) {
2209
+ this.parseParagraphPropertiesFromObject(pElement['w:pPr'], para);
2210
+ }
2211
+
2212
+ // Scan children in document order, consuming w:r/w:hyperlink subtrees
2213
+ // whole so hyperlink inner runs are not double-counted as paragraph
2214
+ // runs; unhandled containers (w:ins, w:smartTag, ...) are descended
2215
+ // into so their nested runs still contribute text as before
2216
+ const pPrEnd = paraXml.indexOf('</w:pPr>');
2217
+ const contentStart = pPrEnd !== -1 ? pPrEnd + '</w:pPr>'.length : paraXml.indexOf('>') + 1;
2218
+ const contentEnd = paraXml.lastIndexOf('</w:p>');
2219
+ if (contentEnd <= contentStart) {
2220
+ return para;
2221
+ }
2222
+ const content = paraXml.substring(contentStart, contentEnd);
2223
+
2224
+ let searchPos = 0;
2225
+ while (searchPos < content.length) {
2226
+ const tagStart = content.indexOf('<', searchPos);
2227
+ if (tagStart === -1) break;
2228
+ const tagEnd = content.indexOf('>', tagStart);
2229
+ if (tagEnd === -1) break;
2230
+ const tagBody = content.substring(tagStart + 1, tagEnd);
2231
+ const tagName = tagBody.split(/[\s/>]/)[0];
2232
+ const selfClosing = tagBody.endsWith('/');
2233
+
2234
+ if ((tagName === 'w:r' || tagName === 'w:hyperlink') && !selfClosing) {
2235
+ const closingTag = `</${tagName}>`;
2236
+ const closingPos = content.indexOf(closingTag, tagEnd);
2237
+ const childEnd = closingPos === -1 ? tagEnd + 1 : closingPos + closingTag.length;
2238
+ const childXml = content.substring(tagStart, childEnd);
2239
+ const childObj = XMLParser.parseToObject(childXml, { trimValues: false });
2240
+
2241
+ if (tagName === 'w:r') {
2242
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2243
+ const run = this.parseRunFromObject(childObj['w:r'] as any);
2244
+ if (run) {
2245
+ para.addRun(run);
2246
+ }
2247
+ } else {
2248
+ const result = this.parseHyperlinkFromObject(
2249
+ childObj['w:hyperlink'],
2250
+ partRels ?? RelationshipManager.create()
2251
+ );
2252
+ if (result.hyperlink) {
2253
+ para.addHyperlink(result.hyperlink);
2254
+ }
2255
+ for (const bookmark of result.bookmarkStarts) {
2256
+ para.addBookmarkStart(bookmark);
2257
+ }
2258
+ for (const bookmark of result.bookmarkEnds) {
2259
+ para.addBookmarkEnd(bookmark);
2260
+ }
2261
+ }
2262
+ searchPos = childEnd;
2263
+ } else {
2264
+ searchPos = tagEnd + 1;
1960
2265
  }
1961
2266
  }
2267
+
1962
2268
  return para;
1963
2269
  } catch {
1964
2270
  return null;
@@ -2120,16 +2426,40 @@ export class DocumentParser {
2120
2426
  if (elementIndex < runArray.length) {
2121
2427
  const child = runArray[elementIndex];
2122
2428
  if (child['w:drawing']) {
2429
+ let imageRun: ImageRun | null = null;
2123
2430
  if (zipHandler && imageManager) {
2124
- const imageRun = await this.parseDrawingFromObject(
2431
+ imageRun = await this.parseDrawingFromObject(
2125
2432
  child['w:drawing'],
2126
2433
  zipHandler,
2127
2434
  relationshipManager,
2128
2435
  imageManager
2129
2436
  );
2130
- if (imageRun) {
2131
- this.parseRunPropertiesFromObject(child['w:rPr'], imageRun);
2132
- paragraph.addRun(imageRun);
2437
+ }
2438
+ if (imageRun) {
2439
+ this.parseRunPropertiesFromObject(child['w:rPr'], imageRun);
2440
+ paragraph.addRun(imageRun);
2441
+ // A run may legally mix a drawing with text/tab/break
2442
+ // siblings (ECMA-376 §17.3.3) — parse those as a separate
2443
+ // Run so the text is not dropped alongside the image.
2444
+ if (
2445
+ child['w:t'] ||
2446
+ child['w:tab'] ||
2447
+ child['w:br'] ||
2448
+ child['w:cr'] ||
2449
+ child['w:noBreakHyphen']
2450
+ ) {
2451
+ const siblingRun = this.parseRunFromObject(child);
2452
+ if (siblingRun) {
2453
+ paragraph.addRun(siblingRun);
2454
+ }
2455
+ }
2456
+ } else {
2457
+ // No raw paragraph XML on this path — rebuild the run from
2458
+ // the parsed object so non-picture drawings (charts,
2459
+ // SmartArt) survive round-trip instead of being dropped.
2460
+ const runXml = this.objectToXml({ 'w:r': child });
2461
+ if (runXml) {
2462
+ paragraph.addContent(new PreservedElement(runXml, 'w:r', 'inline'));
2133
2463
  }
2134
2464
  }
2135
2465
  } else {
@@ -2181,17 +2511,41 @@ export class DocumentParser {
2181
2511
 
2182
2512
  for (const child of runChildren) {
2183
2513
  if (child['w:drawing']) {
2514
+ let imageRun: ImageRun | null = null;
2184
2515
  if (zipHandler && imageManager) {
2185
2516
  // Parse as image run
2186
- const imageRun = await this.parseDrawingFromObject(
2517
+ imageRun = await this.parseDrawingFromObject(
2187
2518
  child['w:drawing'],
2188
2519
  zipHandler,
2189
2520
  relationshipManager,
2190
2521
  imageManager
2191
2522
  );
2192
- if (imageRun) {
2193
- this.parseRunPropertiesFromObject(child['w:rPr'], imageRun);
2194
- paragraph.addRun(imageRun);
2523
+ }
2524
+ if (imageRun) {
2525
+ this.parseRunPropertiesFromObject(child['w:rPr'], imageRun);
2526
+ paragraph.addRun(imageRun);
2527
+ // A run may legally mix a drawing with text/tab/break siblings
2528
+ // (ECMA-376 §17.3.3) — parse those as a separate Run so the text
2529
+ // is not dropped alongside the image.
2530
+ if (
2531
+ child['w:t'] ||
2532
+ child['w:tab'] ||
2533
+ child['w:br'] ||
2534
+ child['w:cr'] ||
2535
+ child['w:noBreakHyphen']
2536
+ ) {
2537
+ const siblingRun = this.parseRunFromObject(child);
2538
+ if (siblingRun) {
2539
+ paragraph.addRun(siblingRun);
2540
+ }
2541
+ }
2542
+ } else {
2543
+ // No raw paragraph XML on this path — rebuild the run from the
2544
+ // parsed object so non-picture drawings (charts, SmartArt)
2545
+ // survive round-trip instead of being dropped.
2546
+ const runXml = this.objectToXml({ 'w:r': child });
2547
+ if (runXml) {
2548
+ paragraph.addContent(new PreservedElement(runXml, 'w:r', 'inline'));
2195
2549
  }
2196
2550
  }
2197
2551
  } else {
@@ -3787,13 +4141,18 @@ export class DocumentParser {
3787
4141
  hyperlink.setTooltip(parsed.tooltip);
3788
4142
  }
3789
4143
 
3790
- // Group field runs by paragraph index
3791
- const runsByParagraph = new Map<number, Set<number>>();
4144
+ // Group field runs by paragraph, keyed by Run OBJECT IDENTITY (not index).
4145
+ // Adjacent HYPERLINK fields chain across paragraph boundaries (one paragraph holds
4146
+ // the previous field's `end` followed by the next field's `begin`). Processing the
4147
+ // earlier field mutates that shared paragraph via setContent, shifting run indices;
4148
+ // a captured runIndex for a later field would then point at the wrong run and drop
4149
+ // its content. Matching by object identity is stable across those mutations.
4150
+ const runsByParagraph = new Map<number, Set<Run>>();
3792
4151
  for (const fr of fieldTracker.fieldRuns) {
3793
4152
  if (!runsByParagraph.has(fr.paragraphIndex)) {
3794
4153
  runsByParagraph.set(fr.paragraphIndex, new Set());
3795
4154
  }
3796
- runsByParagraph.get(fr.paragraphIndex)!.add(fr.runIndex);
4155
+ runsByParagraph.get(fr.paragraphIndex)!.add(fr.run);
3797
4156
  }
3798
4157
 
3799
4158
  // Process each affected paragraph
@@ -3801,7 +4160,7 @@ export class DocumentParser {
3801
4160
 
3802
4161
  for (const pIdx of affectedParagraphIndices) {
3803
4162
  const paragraph = allParagraphs[pIdx]!;
3804
- const runIndicesToRemove = runsByParagraph.get(pIdx)!;
4163
+ const runsToRemove = runsByParagraph.get(pIdx)!;
3805
4164
  const content = paragraph.getContent();
3806
4165
 
3807
4166
  if (pIdx === targetParagraphIndex) {
@@ -3810,7 +4169,7 @@ export class DocumentParser {
3810
4169
  let hyperlinkInserted = false;
3811
4170
 
3812
4171
  for (let rIdx = 0; rIdx < content.length; rIdx++) {
3813
- if (runIndicesToRemove.has(rIdx)) {
4172
+ if (runsToRemove.has(content[rIdx] as Run)) {
3814
4173
  // Insert Hyperlink at position of first field run in this paragraph
3815
4174
  if (!hyperlinkInserted) {
3816
4175
  newContent.push(hyperlink);
@@ -3826,7 +4185,7 @@ export class DocumentParser {
3826
4185
  } else {
3827
4186
  // Other paragraphs: remove field runs entirely
3828
4187
  const newContent = content.filter(
3829
- (_: ParagraphContent, rIdx: number) => !runIndicesToRemove.has(rIdx)
4188
+ (item: ParagraphContent) => !runsToRemove.has(item as Run)
3830
4189
  );
3831
4190
  paragraph.setContent(newContent);
3832
4191
  }
@@ -4149,10 +4508,15 @@ export class DocumentParser {
4149
4508
  if (node === undefined || node === null) {
4150
4509
  return '';
4151
4510
  }
4511
+ // XMLParser.parseElementToObject already unescaped entities while
4512
+ // building #text, so the value here is plain text. A second
4513
+ // unescapeXml would corrupt literal entity sequences (e.g. text that
4514
+ // was authored as "&lt;" — stored as "&amp;lt;" — would collapse to
4515
+ // "<"), breaking load→save fidelity.
4152
4516
  if (typeof node === 'object') {
4153
- return XMLBuilder.unescapeXml(node['#text'] || '');
4517
+ return node['#text'] != null ? String(node['#text']) : '';
4154
4518
  }
4155
- return XMLBuilder.unescapeXml(String(node));
4519
+ return String(node);
4156
4520
  };
4157
4521
 
4158
4522
  // Field-character attributes (w:dirty, w:fldLock, w:lock on w:fldChar) are
@@ -4984,6 +5348,11 @@ export class DocumentParser {
4984
5348
  anchor: finalAnchor,
4985
5349
  text: displayText,
4986
5350
  formatting,
5351
+ // Word-authored hyperlinks usually carry only <w:rStyle w:val="Hyperlink"/>
5352
+ // (or no rPr at all) — merging the constructor's default styling
5353
+ // underneath would inject direct formatting that never existed in the
5354
+ // source and override the document's Hyperlink character style
5355
+ preserveFormatting: true,
4987
5356
  tooltip,
4988
5357
  relationshipId: finalRelationshipId,
4989
5358
  tgtFrame,
@@ -4991,9 +5360,20 @@ export class DocumentParser {
4991
5360
  docLocation,
4992
5361
  });
4993
5362
 
4994
- // If we successfully parsed a run with tabs/breaks, use it instead of the default run
4995
- // This preserves TOC structure (text tab text)
4996
- if (parsedRun && parsedRun.getContent().length > 1) {
5363
+ // Replace the constructor-built run with the parsed run(s). Word splits
5364
+ // a hyperlink's display text into one run per formatting change, so
5365
+ // multi-run sources must keep every run with its own rPr — collapsing
5366
+ // to the first run's formatting silently drops bold/italic/color on
5367
+ // the later runs. Single runs are preserved verbatim when they carry
5368
+ // text or structure like TOC tabs/breaks that a plain text run cannot
5369
+ // represent. Empty-text runs keep the constructor run so the
5370
+ // display-text fallback ('[Link]'/URL) survives.
5371
+ if (parsedRuns.length > 1 && (text || parsedRuns.some((r) => r.getContent().length > 1))) {
5372
+ hyperlink.setRuns(parsedRuns);
5373
+ } else if (
5374
+ parsedRun &&
5375
+ ((parsedRuns.length === 1 && text) || parsedRun.getContent().length > 1)
5376
+ ) {
4997
5377
  hyperlink.setRun(parsedRun);
4998
5378
  }
4999
5379
 
@@ -5215,6 +5595,32 @@ export class DocumentParser {
5215
5595
  dirty,
5216
5596
  });
5217
5597
 
5598
+ // Per ECMA-376 §17.16.16 CT_SimpleField, the child runs hold the
5599
+ // field's current (cached) result. Word does not refresh fields on
5600
+ // open by default, so the cached text + per-run formatting must
5601
+ // survive round-trip — otherwise the visible content is silently
5602
+ // replaced by a synthetic placeholder on save.
5603
+ const resultRuns = fieldObj['w:r'];
5604
+ const resultRunArray = Array.isArray(resultRuns)
5605
+ ? resultRuns
5606
+ : resultRuns
5607
+ ? [resultRuns]
5608
+ : [];
5609
+ const cachedRuns: CachedFieldResultRun[] = [];
5610
+ for (const runObj of resultRunArray) {
5611
+ const run = this.parseRunFromObject(runObj);
5612
+ if (run) {
5613
+ const runFormatting = run.getFormatting();
5614
+ cachedRuns.push({
5615
+ text: run.getText(),
5616
+ formatting: Object.keys(runFormatting).length > 0 ? runFormatting : undefined,
5617
+ });
5618
+ }
5619
+ }
5620
+ if (cachedRuns.length > 0) {
5621
+ field.setCachedResultRuns(cachedRuns);
5622
+ }
5623
+
5218
5624
  return field;
5219
5625
  } catch (error: unknown) {
5220
5626
  defaultLogger.warn(
@@ -5459,7 +5865,11 @@ export class DocumentParser {
5459
5865
  // coerces purely-numeric font names ("2010", etc.) to JS
5460
5866
  // numbers; cast through String() so RunFormatting's
5461
5867
  // declared-string font fields keep their type contract.
5462
- if (rFonts['@_w:ascii'] !== undefined) run.setFont(String(rFonts['@_w:ascii']));
5868
+ // Use setFontAscii (not setFont) so only the declared ascii slot is set;
5869
+ // setFont mirrors into hAnsi/cs for programmatic convenience, which would
5870
+ // fabricate those slots on round-trip and override style/theme-inherited
5871
+ // fonts.
5872
+ if (rFonts['@_w:ascii'] !== undefined) run.setFontAscii(String(rFonts['@_w:ascii']));
5463
5873
  // Parse additional font variants per ECMA-376 Part 1 §17.3.2.26
5464
5874
  if (rFonts['@_w:hAnsi'] !== undefined) run.setFontHAnsi(String(rFonts['@_w:hAnsi']));
5465
5875
  if (rFonts['@_w:eastAsia'] !== undefined) run.setFontEastAsia(String(rFonts['@_w:eastAsia']));
@@ -6426,7 +6836,11 @@ export class DocumentParser {
6426
6836
  'wp:cNvGraphicFramePr',
6427
6837
  'a:graphic',
6428
6838
  ]);
6429
- if (anchorExtras) image._setRawPassthrough('anchor-extra', anchorExtras);
6839
+ if (anchorExtras) {
6840
+ // Repair malformed wp14:sizeRelH/sizeRelV (bare-text percentage) so the
6841
+ // preserved passthrough stays schema-valid on save.
6842
+ image._setRawPassthrough('anchor-extra', normalizeAnchorSizeRel(anchorExtras));
6843
+ }
6430
6844
  }
6431
6845
 
6432
6846
  // DocPr extras (a:hlinkClick, a:hlinkHover, a:extLst)
@@ -6491,7 +6905,13 @@ export class DocumentParser {
6491
6905
 
6492
6906
  return {
6493
6907
  type,
6494
- side: wrapObj['@_wrapText'] || 'bothSides',
6908
+ // wrapText only exists on CT_WrapSquare/Tight/Through. For wrapNone and
6909
+ // wrapTopAndBottom it must stay undefined so serialization never emits an
6910
+ // (invalid) wrapText attribute on those elements.
6911
+ side:
6912
+ type === 'none' || type === 'topAndBottom'
6913
+ ? wrapObj['@_wrapText']
6914
+ : wrapObj['@_wrapText'] || 'bothSides',
6495
6915
  // Distance attributes are on the wrap element, not the anchor
6496
6916
  distanceTop: wrapObj['@_distT'] ? parseInt(wrapObj['@_distT'], 10) : undefined,
6497
6917
  distanceBottom: wrapObj['@_distB'] ? parseInt(wrapObj['@_distB'], 10) : undefined,
@@ -7926,7 +8346,8 @@ export class DocumentParser {
7926
8346
  sdtObj: any,
7927
8347
  relationshipManager: RelationshipManager,
7928
8348
  zipHandler: ZipHandler,
7929
- imageManager: ImageManager
8349
+ imageManager: ImageManager,
8350
+ rawSdtXml?: string
7930
8351
  ): Promise<StructuredDocumentTag | TableOfContentsElement | null> {
7931
8352
  try {
7932
8353
  if (!sdtObj) return null;
@@ -8020,10 +8441,18 @@ export class DocumentParser {
8020
8441
  // <w14:checked> is CT_OnOff in the Word 2010+ extension namespace.
8021
8442
  // Honour every ST_OnOff literal ("1"/"0"/"true"/"false"/"on"/"off")
8022
8443
  // and treat a bare self-closing `<w14:checked/>` as true.
8444
+ // CT_SdtCheckboxSymbol pairs the character code with a glyph font;
8445
+ // capture w14:font (String-cast — XMLParser coerces numeric-looking
8446
+ // values) so a custom font like Wingdings survives re-serialization
8447
+ // instead of being rewritten to the MS Gothic default.
8448
+ const rawCheckedFont = checkboxElement?.['w14:checkedState']?.['@_w14:font'];
8449
+ const rawUncheckedFont = checkboxElement?.['w14:uncheckedState']?.['@_w14:font'];
8023
8450
  properties.checkbox = {
8024
8451
  checked: parseOoxmlBoolean(checkboxElement?.['w14:checked'], '@_w14:val'),
8025
8452
  checkedState: String(checkboxElement?.['w14:checkedState']?.['@_w14:val'] ?? ''),
8026
8453
  uncheckedState: String(checkboxElement?.['w14:uncheckedState']?.['@_w14:val'] ?? ''),
8454
+ checkedFont: rawCheckedFont === undefined ? undefined : String(rawCheckedFont),
8455
+ uncheckedFont: rawUncheckedFont === undefined ? undefined : String(rawUncheckedFont),
8027
8456
  };
8028
8457
  } else if (sdtPr['w:picture']) {
8029
8458
  properties.controlType = 'picture';
@@ -8078,6 +8507,43 @@ export class DocumentParser {
8078
8507
  }
8079
8508
  }
8080
8509
 
8510
+ // Capture the original w:sdtPr / w:sdtEndPr markup. The modeled
8511
+ // properties above are a subset of CT_SdtPr (ECMA-376 §17.5.2.38);
8512
+ // rebuilding sdtPr from them on save would drop unmodeled children
8513
+ // (w:rPr, w15:appearance, w15:color, w:temporary, repeatingSection,
8514
+ // ...), and w:sdtEndPr has no model at all. Prefer the verbatim
8515
+ // substring when the caller supplied the raw element XML; nested
8516
+ // SDTs only have the parsed object, so reconstruct from it. Both
8517
+ // outer parts precede w:sdtContent per CT_SdtBlock, so when present
8518
+ // the first occurrence in the raw XML belongs to this SDT, never to
8519
+ // a nested one.
8520
+ let rawSdtPrXml: string | undefined;
8521
+ if (sdtPr !== undefined) {
8522
+ if (rawSdtXml) {
8523
+ const prPos = rawSdtXml.indexOf('<w:sdtPr');
8524
+ if (prPos !== -1) {
8525
+ rawSdtPrXml = this.extractSingleElement(rawSdtXml, 'w:sdtPr', prPos) || undefined;
8526
+ }
8527
+ }
8528
+ if (rawSdtPrXml === undefined) {
8529
+ rawSdtPrXml = this.objectToXml({ 'w:sdtPr': sdtPr }) || undefined;
8530
+ }
8531
+ }
8532
+ let rawSdtEndPrXml: string | undefined;
8533
+ const sdtEndPr = sdtObj['w:sdtEndPr'];
8534
+ if (sdtEndPr !== undefined) {
8535
+ if (rawSdtXml) {
8536
+ const endPrPos = rawSdtXml.indexOf('<w:sdtEndPr');
8537
+ if (endPrPos !== -1) {
8538
+ rawSdtEndPrXml =
8539
+ this.extractSingleElement(rawSdtXml, 'w:sdtEndPr', endPrPos) || undefined;
8540
+ }
8541
+ }
8542
+ if (rawSdtEndPrXml === undefined) {
8543
+ rawSdtEndPrXml = this.objectToXml({ 'w:sdtEndPr': sdtEndPr }) || undefined;
8544
+ }
8545
+ }
8546
+
8081
8547
  // Parse SDT content (sdtContent)
8082
8548
  const content: any[] = [];
8083
8549
  const sdtContent = sdtObj['w:sdtContent'];
@@ -8138,6 +8604,23 @@ export class DocumentParser {
8138
8604
  );
8139
8605
  if (nestedSdt) content.push(nestedSdt);
8140
8606
  }
8607
+ } else if (!elementType.startsWith('@_') && elementType !== '#text') {
8608
+ // CT_SdtContentBlock also admits w:customXml plus
8609
+ // EG_RunLevelElts (bookmark/comment/perm range markers,
8610
+ // w:proofErr, w:ins/w:del, math, move-range markers).
8611
+ // Regenerating document.xml without them unbalances range
8612
+ // pairs and drops content, so pass them through as raw XML —
8613
+ // mirrors the body-level preservation of these same types.
8614
+ const others = sdtContent[elementType];
8615
+ const otherArray = Array.isArray(others)
8616
+ ? others
8617
+ : others !== undefined
8618
+ ? [others]
8619
+ : [];
8620
+ if (elementIndex < otherArray.length) {
8621
+ const rawXml = this.objectToXml({ [elementType]: otherArray[elementIndex] });
8622
+ if (rawXml) content.push(new PreservedElement(rawXml, elementType, 'block'));
8623
+ }
8141
8624
  }
8142
8625
  }
8143
8626
  } else {
@@ -8181,6 +8664,33 @@ export class DocumentParser {
8181
8664
  );
8182
8665
  if (nestedSdt) content.push(nestedSdt);
8183
8666
  }
8667
+
8668
+ // Preserve remaining CT_SdtContentBlock children (range markers,
8669
+ // w:customXml, math, tracked-change wrappers) that the typed
8670
+ // branches above do not model — same raw passthrough as the
8671
+ // ordered path, original order unknown without _orderedChildren.
8672
+ for (const key of Object.keys(sdtContent)) {
8673
+ if (
8674
+ key === 'w:p' ||
8675
+ key === 'w:tbl' ||
8676
+ key === 'w:sdt' ||
8677
+ key === '#text' ||
8678
+ key === '_orderedChildren' ||
8679
+ key.startsWith('@_')
8680
+ ) {
8681
+ continue;
8682
+ }
8683
+ const others = sdtContent[key];
8684
+ const otherArray = Array.isArray(others)
8685
+ ? others
8686
+ : others !== undefined
8687
+ ? [others]
8688
+ : [];
8689
+ for (const otherObj of otherArray) {
8690
+ const rawXml = this.objectToXml({ [key]: otherObj });
8691
+ if (rawXml) content.push(new PreservedElement(rawXml, key, 'block'));
8692
+ }
8693
+ }
8184
8694
  }
8185
8695
  }
8186
8696
 
@@ -8193,7 +8703,10 @@ export class DocumentParser {
8193
8703
  }
8194
8704
  }
8195
8705
 
8196
- return new StructuredDocumentTag(properties, content);
8706
+ const sdt = new StructuredDocumentTag(properties, content);
8707
+ if (rawSdtPrXml !== undefined) sdt._setRawSdtPrXml(rawSdtPrXml);
8708
+ if (rawSdtEndPrXml !== undefined) sdt._setRawSdtEndPrXml(rawSdtEndPrXml);
8709
+ return sdt;
8197
8710
  } catch (error: unknown) {
8198
8711
  defaultLogger.warn(
8199
8712
  '[DocumentParser] Failed to parse SDT:',
@@ -8663,8 +9176,13 @@ export class DocumentParser {
8663
9176
  // FIX (v1.3.1): Use _orderedChildren to maintain document order of elements
8664
9177
  // This fixes TOC tab preservation - tabs must be in correct position
8665
9178
  const buildXml = (o: any, name?: string): string => {
8666
- if (typeof o === 'string') return o;
8667
- if (typeof o !== 'object') return String(o);
9179
+ // Text-only elements parse to a bare primitive (e.g. <w:t>Hi</w:t>
9180
+ // becomes 'Hi'); when a tag name is known the wrapper must be
9181
+ // re-emitted or the reconstructed XML silently loses the element.
9182
+ if (typeof o !== 'object' || o === null) {
9183
+ const text = String(o ?? '');
9184
+ return name ? `<${name}>${text}</${name}>` : text;
9185
+ }
8668
9186
 
8669
9187
  const keys = Object.keys(o);
8670
9188
 
@@ -9053,6 +9571,16 @@ export class DocumentParser {
9053
9571
  }
9054
9572
  }
9055
9573
 
9574
+ // The nested previous <w:sectPr> inside <w:sectPrChange> holds the
9575
+ // section's pre-revision state. Because extractElements is a flat scan,
9576
+ // any per-property call below would otherwise match that nested previous
9577
+ // element when the live sectPr omits the property — resurrecting a
9578
+ // tracked-removed property (e.g. titlePg/bidi) as a live one. Strip the
9579
+ // change subtree so only current properties are read here; the original
9580
+ // string is retained for the sectPrChange extraction further down.
9581
+ const sectPrWithChange = sectPr;
9582
+ sectPr = sectPr.replace(/<w:sectPrChange[\s\S]*?<\/w:sectPrChange>/, '');
9583
+
9056
9584
  const sectionProps: SectionProperties = {};
9057
9585
 
9058
9586
  // Parse page size
@@ -9459,8 +9987,10 @@ export class DocumentParser {
9459
9987
 
9460
9988
  const section = new Section(sectionProps);
9461
9989
 
9462
- // Parse section property change (w:sectPrChange) per ECMA-376 Part 1 §17.13.5.32
9463
- const sectPrChangeElements = XMLParser.extractElements(sectPr, 'w:sectPrChange');
9990
+ // Parse section property change (w:sectPrChange) per ECMA-376 Part 1 §17.13.5.32.
9991
+ // Use the unstripped string: the change subtree was removed from `sectPr`
9992
+ // above so it could not leak into the live property reads.
9993
+ const sectPrChangeElements = XMLParser.extractElements(sectPrWithChange, 'w:sectPrChange');
9464
9994
  if (sectPrChangeElements.length > 0 && sectPrChangeElements[0]) {
9465
9995
  const changeXml = sectPrChangeElements[0];
9466
9996
  const id = XMLParser.extractAttribute(changeXml, 'w:id') || '0';
@@ -9525,22 +10055,36 @@ export class DocumentParser {
9525
10055
  }
9526
10056
  }
9527
10057
 
9528
- // Extract basedOn
9529
- const basedOnElement = XMLParser.extractBetweenTags(styleXml, '<w:basedOn', '</w:basedOn>');
9530
- const basedOn = basedOnElement
9531
- ? XMLParser.extractAttribute(`<w:basedOn${basedOnElement}`, 'w:val')
9532
- : undefined;
10058
+ // Extract basedOn (CT_String is empty-content per ECMA-376 §17.7.4.3, so Word
10059
+ // serializes <w:basedOn w:val="..."/> self-closing; extractBetweenTags only
10060
+ // matches an explicit closing tag and would drop the inheritance chain)
10061
+ let basedOn: string | undefined;
10062
+ const basedOnAttrs = XMLParser.extractSelfClosingTag(styleXml, 'w:basedOn');
10063
+ if (basedOnAttrs) {
10064
+ basedOn = XMLParser.extractAttribute(`<w:basedOn${basedOnAttrs}/>`, 'w:val');
10065
+ }
9533
10066
 
9534
- // Extract next
9535
- const nextElement = XMLParser.extractBetweenTags(styleXml, '<w:next', '</w:next>');
9536
- const next = nextElement
9537
- ? XMLParser.extractAttribute(`<w:next${nextElement}`, 'w:val')
9538
- : undefined;
10067
+ // Extract next (same empty-content serialization as basedOn)
10068
+ let next: string | undefined;
10069
+ const nextAttrs = XMLParser.extractSelfClosingTag(styleXml, 'w:next');
10070
+ if (nextAttrs) {
10071
+ next = XMLParser.extractAttribute(`<w:next${nextAttrs}/>`, 'w:val');
10072
+ }
10073
+
10074
+ // Conditional table-style blocks (w:tblStylePr) carry their own pPr/rPr.
10075
+ // Per CT_Style (ECMA-376 §17.7.4.17) the root pPr/rPr precede those
10076
+ // blocks, so a first-match search over the full element would misattribute
10077
+ // a conditional block's formatting (e.g. firstRow bold/centered in banded
10078
+ // table styles) as the style's unconditional base formatting whenever the
10079
+ // root pPr/rPr are absent. Search a copy with the conditional blocks
10080
+ // stripped; parseConditionalFormattingFromXml still receives the full
10081
+ // styleXml and parses those blocks separately.
10082
+ const styleBase = styleXml.replace(/<w:tblStylePr[\s\S]*?<\/w:tblStylePr>/g, '');
9539
10083
 
9540
10084
  // Parse paragraph formatting (w:pPr)
9541
10085
  let paragraphFormatting: ParagraphFormatting | undefined;
9542
10086
  let styleNumPr: { numId?: number; ilvl?: number } | undefined;
9543
- const pPrXml = XMLParser.extractBetweenTags(styleXml, '<w:pPr>', '</w:pPr>');
10087
+ const pPrXml = XMLParser.extractBetweenTags(styleBase, '<w:pPr>', '</w:pPr>');
9544
10088
  if (pPrXml) {
9545
10089
  paragraphFormatting = this.parseParagraphFormattingFromXml(pPrXml);
9546
10090
 
@@ -9562,7 +10106,7 @@ export class DocumentParser {
9562
10106
 
9563
10107
  // Parse run formatting (w:rPr)
9564
10108
  let runFormatting: RunFormatting | undefined;
9565
- const rPrXml = XMLParser.extractBetweenTags(styleXml, '<w:rPr>', '</w:rPr>');
10109
+ const rPrXml = XMLParser.extractBetweenTags(styleBase, '<w:rPr>', '</w:rPr>');
9566
10110
  if (rPrXml) {
9567
10111
  runFormatting = this.parseRunFormattingFromXml(rPrXml);
9568
10112
  }
@@ -11176,15 +11720,43 @@ export class DocumentParser {
11176
11720
  * Parses document background (w:background) per ECMA-376 Part 1 §17.2.1
11177
11721
  * The w:background element appears as a child of w:document, before w:body
11178
11722
  */
11179
- private parseDocumentBackground(
11180
- docXml: string
11181
- ): { color?: string; themeColor?: string; themeTint?: string; themeShade?: string } | undefined {
11182
- const bgMatch = /<w:background([^>]*?)\/>/.exec(docXml);
11183
- if (!bgMatch?.[1]) return undefined;
11184
-
11185
- const attrStr = bgMatch[1];
11186
- const result: { color?: string; themeColor?: string; themeTint?: string; themeShade?: string } =
11187
- {};
11723
+ private parseDocumentBackground(docXml: string):
11724
+ | {
11725
+ color?: string;
11726
+ themeColor?: string;
11727
+ themeTint?: string;
11728
+ themeShade?: string;
11729
+ rawInnerXml?: string;
11730
+ }
11731
+ | undefined {
11732
+ // Match both the self-closing form (flat-color backgrounds) and the
11733
+ // expanded form: Word emits <w:background ...><v:background>...</v:background>
11734
+ // </w:background> for picture/gradient/texture page backgrounds per
11735
+ // ECMA-376 §17.2.1, and that child fill must survive the round-trip.
11736
+ const bgMatch = /<w:background([^>]*?)(\/>|>)/.exec(docXml);
11737
+ if (!bgMatch) return undefined;
11738
+
11739
+ const attrStr = bgMatch[1] ?? '';
11740
+ const result: {
11741
+ color?: string;
11742
+ themeColor?: string;
11743
+ themeTint?: string;
11744
+ themeShade?: string;
11745
+ rawInnerXml?: string;
11746
+ } = {};
11747
+
11748
+ // Expanded form: capture inner content verbatim as a passthrough slot
11749
+ // (VML has no object model here; re-emitted untouched by the generator)
11750
+ if (bgMatch[2] === '>') {
11751
+ const innerStart = bgMatch.index + bgMatch[0].length;
11752
+ const closeIndex = docXml.indexOf('</w:background>', innerStart);
11753
+ if (closeIndex !== -1) {
11754
+ const inner = docXml.substring(innerStart, closeIndex);
11755
+ if (inner.length > 0) {
11756
+ result.rawInnerXml = inner;
11757
+ }
11758
+ }
11759
+ }
11188
11760
 
11189
11761
  const colorMatch = /w:color="([^"]+)"/.exec(attrStr);
11190
11762
  if (colorMatch?.[1]) result.color = colorMatch[1];