docxmlater 10.4.0 → 11.0.4

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 (699) hide show
  1. package/README.md +3 -3
  2. package/dist/constants/legacyCompatFlags.d.ts +1 -1
  3. package/dist/constants/legacyCompatFlags.d.ts.map +1 -1
  4. package/dist/constants/legacyCompatFlags.js.map +1 -1
  5. package/dist/core/Document.d.ts +75 -67
  6. package/dist/core/Document.d.ts.map +1 -1
  7. package/dist/core/Document.js +618 -414
  8. package/dist/core/Document.js.map +1 -1
  9. package/dist/core/DocumentContent.d.ts +11 -10
  10. package/dist/core/DocumentContent.d.ts.map +1 -1
  11. package/dist/core/DocumentContent.js +19 -19
  12. package/dist/core/DocumentContent.js.map +1 -1
  13. package/dist/core/DocumentEvents.d.ts +39 -0
  14. package/dist/core/DocumentEvents.d.ts.map +1 -0
  15. package/dist/core/DocumentEvents.js +51 -0
  16. package/dist/core/DocumentEvents.js.map +1 -0
  17. package/dist/core/DocumentGenerator.d.ts +11 -11
  18. package/dist/core/DocumentGenerator.d.ts.map +1 -1
  19. package/dist/core/DocumentGenerator.js +72 -52
  20. package/dist/core/DocumentGenerator.js.map +1 -1
  21. package/dist/core/DocumentParser.d.ts +15 -15
  22. package/dist/core/DocumentParser.d.ts.map +1 -1
  23. package/dist/core/DocumentParser.js +2100 -1076
  24. package/dist/core/DocumentParser.js.map +1 -1
  25. package/dist/core/DocumentValidator.d.ts +3 -3
  26. package/dist/core/DocumentValidator.d.ts.map +1 -1
  27. package/dist/core/DocumentValidator.js +31 -31
  28. package/dist/core/DocumentValidator.js.map +1 -1
  29. package/dist/core/ElementRegistry.d.ts +22 -0
  30. package/dist/core/ElementRegistry.d.ts.map +1 -0
  31. package/dist/core/ElementRegistry.js +27 -0
  32. package/dist/core/ElementRegistry.js.map +1 -0
  33. package/dist/core/Relationship.js +4 -4
  34. package/dist/core/Relationship.js.map +1 -1
  35. package/dist/core/RelationshipManager.d.ts +1 -1
  36. package/dist/core/RelationshipManager.d.ts.map +1 -1
  37. package/dist/core/RelationshipManager.js +32 -32
  38. package/dist/core/RelationshipManager.js.map +1 -1
  39. package/dist/elements/AlternateContent.d.ts +1 -1
  40. package/dist/elements/AlternateContent.d.ts.map +1 -1
  41. package/dist/elements/AlternateContent.js.map +1 -1
  42. package/dist/elements/Bookmark.d.ts +6 -1
  43. package/dist/elements/Bookmark.d.ts.map +1 -1
  44. package/dist/elements/Bookmark.js +19 -3
  45. package/dist/elements/Bookmark.js.map +1 -1
  46. package/dist/elements/BookmarkManager.d.ts +1 -1
  47. package/dist/elements/BookmarkManager.d.ts.map +1 -1
  48. package/dist/elements/BookmarkManager.js +7 -7
  49. package/dist/elements/BookmarkManager.js.map +1 -1
  50. package/dist/elements/Comment.d.ts +2 -2
  51. package/dist/elements/Comment.d.ts.map +1 -1
  52. package/dist/elements/Comment.js +4 -4
  53. package/dist/elements/Comment.js.map +1 -1
  54. package/dist/elements/CommentManager.d.ts +2 -2
  55. package/dist/elements/CommentManager.d.ts.map +1 -1
  56. package/dist/elements/CommentManager.js +9 -9
  57. package/dist/elements/CommentManager.js.map +1 -1
  58. package/dist/elements/CommonTypes.d.ts +9 -4
  59. package/dist/elements/CommonTypes.d.ts.map +1 -1
  60. package/dist/elements/CommonTypes.js +1 -0
  61. package/dist/elements/CommonTypes.js.map +1 -1
  62. package/dist/elements/CustomXml.d.ts +1 -1
  63. package/dist/elements/CustomXml.d.ts.map +1 -1
  64. package/dist/elements/CustomXml.js.map +1 -1
  65. package/dist/elements/Endnote.d.ts +2 -2
  66. package/dist/elements/Endnote.d.ts.map +1 -1
  67. package/dist/elements/Endnote.js +9 -9
  68. package/dist/elements/Endnote.js.map +1 -1
  69. package/dist/elements/EndnoteManager.d.ts +1 -1
  70. package/dist/elements/EndnoteManager.d.ts.map +1 -1
  71. package/dist/elements/EndnoteManager.js +11 -11
  72. package/dist/elements/EndnoteManager.js.map +1 -1
  73. package/dist/elements/Field.d.ts +9 -5
  74. package/dist/elements/Field.d.ts.map +1 -1
  75. package/dist/elements/Field.js +21 -9
  76. package/dist/elements/Field.js.map +1 -1
  77. package/dist/elements/FieldHelpers.d.ts +1 -1
  78. package/dist/elements/FieldHelpers.d.ts.map +1 -1
  79. package/dist/elements/FieldHelpers.js +10 -10
  80. package/dist/elements/FieldHelpers.js.map +1 -1
  81. package/dist/elements/Footer.d.ts +3 -3
  82. package/dist/elements/Footer.d.ts.map +1 -1
  83. package/dist/elements/Footer.js +5 -5
  84. package/dist/elements/Footer.js.map +1 -1
  85. package/dist/elements/Footnote.d.ts +2 -2
  86. package/dist/elements/Footnote.d.ts.map +1 -1
  87. package/dist/elements/Footnote.js +9 -9
  88. package/dist/elements/Footnote.js.map +1 -1
  89. package/dist/elements/FootnoteManager.d.ts +1 -1
  90. package/dist/elements/FootnoteManager.d.ts.map +1 -1
  91. package/dist/elements/FootnoteManager.js +11 -11
  92. package/dist/elements/FootnoteManager.js.map +1 -1
  93. package/dist/elements/Header.d.ts +3 -3
  94. package/dist/elements/Header.d.ts.map +1 -1
  95. package/dist/elements/Header.js +5 -5
  96. package/dist/elements/Header.js.map +1 -1
  97. package/dist/elements/HeaderFooterManager.d.ts +2 -2
  98. package/dist/elements/HeaderFooterManager.d.ts.map +1 -1
  99. package/dist/elements/HeaderFooterManager.js.map +1 -1
  100. package/dist/elements/Hyperlink.d.ts +5 -5
  101. package/dist/elements/Hyperlink.d.ts.map +1 -1
  102. package/dist/elements/Hyperlink.js +29 -29
  103. package/dist/elements/Hyperlink.js.map +1 -1
  104. package/dist/elements/Image.d.ts +1 -1
  105. package/dist/elements/Image.d.ts.map +1 -1
  106. package/dist/elements/Image.js +67 -67
  107. package/dist/elements/Image.js.map +1 -1
  108. package/dist/elements/ImageManager.d.ts +1 -1
  109. package/dist/elements/ImageManager.d.ts.map +1 -1
  110. package/dist/elements/ImageManager.js +4 -4
  111. package/dist/elements/ImageManager.js.map +1 -1
  112. package/dist/elements/ImageRun.d.ts +3 -3
  113. package/dist/elements/ImageRun.d.ts.map +1 -1
  114. package/dist/elements/ImageRun.js +2 -2
  115. package/dist/elements/ImageRun.js.map +1 -1
  116. package/dist/elements/MathElement.d.ts +1 -1
  117. package/dist/elements/MathElement.d.ts.map +1 -1
  118. package/dist/elements/MathElement.js.map +1 -1
  119. package/dist/elements/Paragraph.d.ts +34 -19
  120. package/dist/elements/Paragraph.d.ts.map +1 -1
  121. package/dist/elements/Paragraph.js +286 -231
  122. package/dist/elements/Paragraph.js.map +1 -1
  123. package/dist/elements/PreservedElement.d.ts +1 -1
  124. package/dist/elements/PreservedElement.d.ts.map +1 -1
  125. package/dist/elements/PreservedElement.js.map +1 -1
  126. package/dist/elements/PropertyChangeTypes.d.ts +2 -2
  127. package/dist/elements/PropertyChangeTypes.d.ts.map +1 -1
  128. package/dist/elements/PropertyChangeTypes.js.map +1 -1
  129. package/dist/elements/RangeMarker.d.ts +14 -1
  130. package/dist/elements/RangeMarker.d.ts.map +1 -1
  131. package/dist/elements/RangeMarker.js +46 -8
  132. package/dist/elements/RangeMarker.js.map +1 -1
  133. package/dist/elements/RegisteredBodyElement.d.ts +15 -0
  134. package/dist/elements/RegisteredBodyElement.d.ts.map +1 -0
  135. package/dist/elements/RegisteredBodyElement.js +44 -0
  136. package/dist/elements/RegisteredBodyElement.js.map +1 -0
  137. package/dist/elements/Revision.d.ts +8 -8
  138. package/dist/elements/Revision.d.ts.map +1 -1
  139. package/dist/elements/Revision.js +12 -12
  140. package/dist/elements/Revision.js.map +1 -1
  141. package/dist/elements/RevisionContent.d.ts +3 -3
  142. package/dist/elements/RevisionContent.d.ts.map +1 -1
  143. package/dist/elements/RevisionContent.js.map +1 -1
  144. package/dist/elements/RevisionManager.d.ts +2 -2
  145. package/dist/elements/RevisionManager.d.ts.map +1 -1
  146. package/dist/elements/RevisionManager.js +2 -2
  147. package/dist/elements/RevisionManager.js.map +1 -1
  148. package/dist/elements/Run.d.ts +16 -10
  149. package/dist/elements/Run.d.ts.map +1 -1
  150. package/dist/elements/Run.js +199 -173
  151. package/dist/elements/Run.js.map +1 -1
  152. package/dist/elements/Section.d.ts +4 -2
  153. package/dist/elements/Section.d.ts.map +1 -1
  154. package/dist/elements/Section.js +152 -145
  155. package/dist/elements/Section.js.map +1 -1
  156. package/dist/elements/Shape.d.ts +3 -3
  157. package/dist/elements/Shape.d.ts.map +1 -1
  158. package/dist/elements/Shape.js +12 -12
  159. package/dist/elements/Shape.js.map +1 -1
  160. package/dist/elements/StructuredDocumentTag.d.ts +3 -3
  161. package/dist/elements/StructuredDocumentTag.d.ts.map +1 -1
  162. package/dist/elements/StructuredDocumentTag.js +39 -39
  163. package/dist/elements/StructuredDocumentTag.js.map +1 -1
  164. package/dist/elements/Table.d.ts +16 -10
  165. package/dist/elements/Table.d.ts.map +1 -1
  166. package/dist/elements/Table.js +118 -89
  167. package/dist/elements/Table.js.map +1 -1
  168. package/dist/elements/TableCell.d.ts +11 -11
  169. package/dist/elements/TableCell.d.ts.map +1 -1
  170. package/dist/elements/TableCell.js +108 -78
  171. package/dist/elements/TableCell.js.map +1 -1
  172. package/dist/elements/TableGridChange.d.ts +1 -1
  173. package/dist/elements/TableGridChange.d.ts.map +1 -1
  174. package/dist/elements/TableGridChange.js +3 -3
  175. package/dist/elements/TableGridChange.js.map +1 -1
  176. package/dist/elements/TableOfContents.d.ts +1 -1
  177. package/dist/elements/TableOfContents.d.ts.map +1 -1
  178. package/dist/elements/TableOfContents.js +2 -2
  179. package/dist/elements/TableOfContents.js.map +1 -1
  180. package/dist/elements/TableOfContentsElement.d.ts +2 -2
  181. package/dist/elements/TableOfContentsElement.d.ts.map +1 -1
  182. package/dist/elements/TableOfContentsElement.js +5 -5
  183. package/dist/elements/TableOfContentsElement.js.map +1 -1
  184. package/dist/elements/TableRow.d.ts +18 -7
  185. package/dist/elements/TableRow.d.ts.map +1 -1
  186. package/dist/elements/TableRow.js +127 -74
  187. package/dist/elements/TableRow.js.map +1 -1
  188. package/dist/elements/TextBox.d.ts +4 -4
  189. package/dist/elements/TextBox.d.ts.map +1 -1
  190. package/dist/elements/TextBox.js +6 -6
  191. package/dist/elements/TextBox.js.map +1 -1
  192. package/dist/esm/constants/legacyCompatFlags.js +97 -0
  193. package/dist/esm/constants/legacyCompatFlags.js.map +1 -0
  194. package/dist/esm/constants/limits.js +36 -0
  195. package/dist/esm/constants/limits.js.map +1 -0
  196. package/dist/esm/core/Document.js +8498 -0
  197. package/dist/esm/core/Document.js.map +1 -0
  198. package/dist/esm/core/DocumentContent.js +190 -0
  199. package/dist/esm/core/DocumentContent.js.map +1 -0
  200. package/dist/esm/core/DocumentEvents.js +47 -0
  201. package/dist/esm/core/DocumentEvents.js.map +1 -0
  202. package/dist/esm/core/DocumentGenerator.js +764 -0
  203. package/dist/esm/core/DocumentGenerator.js.map +1 -0
  204. package/dist/esm/core/DocumentIdManager.js +67 -0
  205. package/dist/esm/core/DocumentIdManager.js.map +1 -0
  206. package/dist/esm/core/DocumentParser.js +8760 -0
  207. package/dist/esm/core/DocumentParser.js.map +1 -0
  208. package/dist/esm/core/DocumentValidator.js +222 -0
  209. package/dist/esm/core/DocumentValidator.js.map +1 -0
  210. package/dist/esm/core/ElementRegistry.js +24 -0
  211. package/dist/esm/core/ElementRegistry.js.map +1 -0
  212. package/dist/esm/core/Relationship.js +177 -0
  213. package/dist/esm/core/Relationship.js.map +1 -0
  214. package/dist/esm/core/RelationshipManager.js +202 -0
  215. package/dist/esm/core/RelationshipManager.js.map +1 -0
  216. package/dist/esm/elements/AlternateContent.js +19 -0
  217. package/dist/esm/elements/AlternateContent.js.map +1 -0
  218. package/dist/esm/elements/Bookmark.js +115 -0
  219. package/dist/esm/elements/Bookmark.js.map +1 -0
  220. package/dist/esm/elements/BookmarkManager.js +99 -0
  221. package/dist/esm/elements/BookmarkManager.js.map +1 -0
  222. package/dist/esm/elements/Comment.js +181 -0
  223. package/dist/esm/elements/Comment.js.map +1 -0
  224. package/dist/esm/elements/CommentManager.js +233 -0
  225. package/dist/esm/elements/CommentManager.js.map +1 -0
  226. package/dist/esm/elements/CommonTypes.js +106 -0
  227. package/dist/esm/elements/CommonTypes.js.map +1 -0
  228. package/dist/esm/elements/CustomXml.js +19 -0
  229. package/dist/esm/elements/CustomXml.js.map +1 -0
  230. package/dist/esm/elements/Endnote.js +107 -0
  231. package/dist/esm/elements/Endnote.js.map +1 -0
  232. package/dist/esm/elements/EndnoteManager.js +119 -0
  233. package/dist/esm/elements/EndnoteManager.js.map +1 -0
  234. package/dist/esm/elements/Field.js +856 -0
  235. package/dist/esm/elements/Field.js.map +1 -0
  236. package/dist/esm/elements/FieldHelpers.js +134 -0
  237. package/dist/esm/elements/FieldHelpers.js.map +1 -0
  238. package/dist/esm/elements/FontManager.js +158 -0
  239. package/dist/esm/elements/FontManager.js.map +1 -0
  240. package/dist/esm/elements/Footer.js +141 -0
  241. package/dist/esm/elements/Footer.js.map +1 -0
  242. package/dist/esm/elements/Footnote.js +107 -0
  243. package/dist/esm/elements/Footnote.js.map +1 -0
  244. package/dist/esm/elements/FootnoteManager.js +119 -0
  245. package/dist/esm/elements/FootnoteManager.js.map +1 -0
  246. package/dist/esm/elements/Header.js +141 -0
  247. package/dist/esm/elements/Header.js.map +1 -0
  248. package/dist/esm/elements/HeaderFooterManager.js +87 -0
  249. package/dist/esm/elements/HeaderFooterManager.js.map +1 -0
  250. package/dist/esm/elements/Hyperlink.js +586 -0
  251. package/dist/esm/elements/Hyperlink.js.map +1 -0
  252. package/dist/esm/elements/Image.js +1288 -0
  253. package/dist/esm/elements/Image.js.map +1 -0
  254. package/dist/esm/elements/ImageManager.js +223 -0
  255. package/dist/esm/elements/ImageManager.js.map +1 -0
  256. package/dist/esm/elements/ImageRun.js +29 -0
  257. package/dist/esm/elements/ImageRun.js.map +1 -0
  258. package/dist/esm/elements/MathElement.js +37 -0
  259. package/dist/esm/elements/MathElement.js.map +1 -0
  260. package/dist/esm/elements/Paragraph.js +2308 -0
  261. package/dist/esm/elements/Paragraph.js.map +1 -0
  262. package/dist/esm/elements/PreservedElement.js +29 -0
  263. package/dist/esm/elements/PreservedElement.js.map +1 -0
  264. package/dist/esm/elements/PropertyChangeTypes.js +53 -0
  265. package/dist/esm/elements/PropertyChangeTypes.js.map +1 -0
  266. package/dist/esm/elements/RangeMarker.js +219 -0
  267. package/dist/esm/elements/RangeMarker.js.map +1 -0
  268. package/dist/esm/elements/RegisteredBodyElement.js +40 -0
  269. package/dist/esm/elements/RegisteredBodyElement.js.map +1 -0
  270. package/dist/esm/elements/Revision.js +498 -0
  271. package/dist/esm/elements/Revision.js.map +1 -0
  272. package/dist/esm/elements/RevisionContent.js +18 -0
  273. package/dist/esm/elements/RevisionContent.js.map +1 -0
  274. package/dist/esm/elements/RevisionManager.js +486 -0
  275. package/dist/esm/elements/RevisionManager.js.map +1 -0
  276. package/dist/esm/elements/Run.js +1465 -0
  277. package/dist/esm/elements/Run.js.map +1 -0
  278. package/dist/esm/elements/Section.js +978 -0
  279. package/dist/esm/elements/Section.js.map +1 -0
  280. package/dist/esm/elements/Shape.js +493 -0
  281. package/dist/esm/elements/Shape.js.map +1 -0
  282. package/dist/esm/elements/StructuredDocumentTag.js +471 -0
  283. package/dist/esm/elements/StructuredDocumentTag.js.map +1 -0
  284. package/dist/esm/elements/Table.js +1456 -0
  285. package/dist/esm/elements/Table.js.map +1 -0
  286. package/dist/esm/elements/TableCell.js +835 -0
  287. package/dist/esm/elements/TableCell.js.map +1 -0
  288. package/dist/esm/elements/TableGridChange.js +52 -0
  289. package/dist/esm/elements/TableGridChange.js.map +1 -0
  290. package/dist/esm/elements/TableOfContents.js +389 -0
  291. package/dist/esm/elements/TableOfContents.js.map +1 -0
  292. package/dist/esm/elements/TableOfContentsElement.js +29 -0
  293. package/dist/esm/elements/TableOfContentsElement.js.map +1 -0
  294. package/dist/esm/elements/TableRow.js +555 -0
  295. package/dist/esm/elements/TableRow.js.map +1 -0
  296. package/dist/esm/elements/TextBox.js +459 -0
  297. package/dist/esm/elements/TextBox.js.map +1 -0
  298. package/dist/esm/formatting/AbstractNumbering.js +325 -0
  299. package/dist/esm/formatting/AbstractNumbering.js.map +1 -0
  300. package/dist/esm/formatting/NumberingInstance.js +150 -0
  301. package/dist/esm/formatting/NumberingInstance.js.map +1 -0
  302. package/dist/esm/formatting/NumberingLevel.js +608 -0
  303. package/dist/esm/formatting/NumberingLevel.js.map +1 -0
  304. package/dist/esm/formatting/NumberingManager.js +423 -0
  305. package/dist/esm/formatting/NumberingManager.js.map +1 -0
  306. package/dist/esm/formatting/Style.js +1151 -0
  307. package/dist/esm/formatting/Style.js.map +1 -0
  308. package/dist/esm/formatting/StylesManager.js +557 -0
  309. package/dist/esm/formatting/StylesManager.js.map +1 -0
  310. package/dist/esm/helpers/CleanupHelper.js +350 -0
  311. package/dist/esm/helpers/CleanupHelper.js.map +1 -0
  312. package/dist/esm/images/ImageOptimizer.js +161 -0
  313. package/dist/esm/images/ImageOptimizer.js.map +1 -0
  314. package/dist/esm/index.js +75 -0
  315. package/dist/esm/index.js.map +1 -0
  316. package/dist/esm/internal.js +16 -0
  317. package/dist/esm/internal.js.map +1 -0
  318. package/dist/esm/managers/DrawingManager.js +163 -0
  319. package/dist/esm/managers/DrawingManager.js.map +1 -0
  320. package/dist/esm/package.json +3 -0
  321. package/dist/esm/processors/ChangelogGenerator.js +970 -0
  322. package/dist/esm/processors/ChangelogGenerator.js.map +1 -0
  323. package/dist/esm/processors/CompatibilityUpgrader.js +130 -0
  324. package/dist/esm/processors/CompatibilityUpgrader.js.map +1 -0
  325. package/dist/esm/processors/InMemoryRevisionAcceptor.js +530 -0
  326. package/dist/esm/processors/InMemoryRevisionAcceptor.js.map +1 -0
  327. package/dist/esm/processors/MoveOperationHelper.js +57 -0
  328. package/dist/esm/processors/MoveOperationHelper.js.map +1 -0
  329. package/dist/esm/processors/RevisionAwareProcessor.js +232 -0
  330. package/dist/esm/processors/RevisionAwareProcessor.js.map +1 -0
  331. package/dist/esm/processors/RevisionWalker.js +278 -0
  332. package/dist/esm/processors/RevisionWalker.js.map +1 -0
  333. package/dist/{utils → esm/processors}/SelectiveRevisionAcceptor.js +81 -42
  334. package/dist/esm/processors/SelectiveRevisionAcceptor.js.map +1 -0
  335. package/dist/esm/processors/ShadingResolver.js +66 -0
  336. package/dist/esm/processors/ShadingResolver.js.map +1 -0
  337. package/dist/esm/processors/acceptRevisions.js +416 -0
  338. package/dist/esm/processors/acceptRevisions.js.map +1 -0
  339. package/dist/esm/processors/cnfStyleDecoder.js +89 -0
  340. package/dist/esm/processors/cnfStyleDecoder.js.map +1 -0
  341. package/dist/esm/processors/stripTrackedChanges.js +201 -0
  342. package/dist/esm/processors/stripTrackedChanges.js.map +1 -0
  343. package/dist/esm/tracking/DocumentTrackingContext.js +531 -0
  344. package/dist/esm/tracking/DocumentTrackingContext.js.map +1 -0
  345. package/dist/esm/tracking/TrackingContext.js +2 -0
  346. package/dist/esm/tracking/TrackingContext.js.map +1 -0
  347. package/dist/esm/types/compatibility-types.js +8 -0
  348. package/dist/esm/types/compatibility-types.js.map +1 -0
  349. package/dist/esm/types/document-types.js +2 -0
  350. package/dist/esm/types/document-types.js.map +1 -0
  351. package/dist/esm/types/formatting.js +2 -0
  352. package/dist/esm/types/formatting.js.map +1 -0
  353. package/dist/esm/types/list-types.js +2 -0
  354. package/dist/esm/types/list-types.js.map +1 -0
  355. package/dist/esm/types/settings-types.js +2 -0
  356. package/dist/esm/types/settings-types.js.map +1 -0
  357. package/dist/esm/types/styleConfig.js +2 -0
  358. package/dist/esm/types/styleConfig.js.map +1 -0
  359. package/dist/esm/utils/KeyedRegistry.js +32 -0
  360. package/dist/esm/utils/KeyedRegistry.js.map +1 -0
  361. package/dist/esm/utils/corruptionDetection.js +155 -0
  362. package/dist/esm/utils/corruptionDetection.js.map +1 -0
  363. package/dist/esm/utils/dateFormatting.js +4 -0
  364. package/dist/esm/utils/dateFormatting.js.map +1 -0
  365. package/dist/esm/utils/deepClone.js +40 -0
  366. package/dist/esm/utils/deepClone.js.map +1 -0
  367. package/dist/esm/utils/deepEqual.js +47 -0
  368. package/dist/esm/utils/deepEqual.js.map +1 -0
  369. package/dist/esm/utils/diagnostics.js +69 -0
  370. package/dist/esm/utils/diagnostics.js.map +1 -0
  371. package/dist/esm/utils/errorHandling.js +36 -0
  372. package/dist/esm/utils/errorHandling.js.map +1 -0
  373. package/dist/esm/utils/formatting.js +93 -0
  374. package/dist/esm/utils/formatting.js.map +1 -0
  375. package/dist/esm/utils/list-detection.js +148 -0
  376. package/dist/esm/utils/list-detection.js.map +1 -0
  377. package/dist/esm/utils/logger.js +205 -0
  378. package/dist/esm/utils/logger.js.map +1 -0
  379. package/dist/esm/utils/parsingHelpers.js +56 -0
  380. package/dist/esm/utils/parsingHelpers.js.map +1 -0
  381. package/dist/esm/utils/textDiff.js +42 -0
  382. package/dist/esm/utils/textDiff.js.map +1 -0
  383. package/dist/esm/utils/units.js +152 -0
  384. package/dist/esm/utils/units.js.map +1 -0
  385. package/dist/esm/utils/validation.js +285 -0
  386. package/dist/esm/utils/validation.js.map +1 -0
  387. package/dist/esm/utils/xmlSanitization.js +54 -0
  388. package/dist/esm/utils/xmlSanitization.js.map +1 -0
  389. package/dist/esm/validation/RevisionAutoFixer.js +340 -0
  390. package/dist/esm/validation/RevisionAutoFixer.js.map +1 -0
  391. package/dist/esm/validation/RevisionValidator.js +240 -0
  392. package/dist/esm/validation/RevisionValidator.js.map +1 -0
  393. package/dist/esm/validation/ValidationRuleRegistry.js +40 -0
  394. package/dist/esm/validation/ValidationRuleRegistry.js.map +1 -0
  395. package/dist/esm/validation/ValidationRules.js +92 -0
  396. package/dist/esm/validation/ValidationRules.js.map +1 -0
  397. package/dist/esm/validation/index.js +4 -0
  398. package/dist/esm/validation/index.js.map +1 -0
  399. package/dist/esm/xml/XMLBuilder.js +434 -0
  400. package/dist/esm/xml/XMLBuilder.js.map +1 -0
  401. package/dist/esm/xml/XMLParser.js +486 -0
  402. package/dist/esm/xml/XMLParser.js.map +1 -0
  403. package/dist/esm/zip/ZipHandler.js +298 -0
  404. package/dist/esm/zip/ZipHandler.js.map +1 -0
  405. package/dist/esm/zip/ZipReader.js +147 -0
  406. package/dist/esm/zip/ZipReader.js.map +1 -0
  407. package/dist/esm/zip/ZipWriter.js +199 -0
  408. package/dist/esm/zip/ZipWriter.js.map +1 -0
  409. package/dist/esm/zip/errors.js +43 -0
  410. package/dist/esm/zip/errors.js.map +1 -0
  411. package/dist/esm/zip/types.js +31 -0
  412. package/dist/esm/zip/types.js.map +1 -0
  413. package/dist/formatting/AbstractNumbering.d.ts +2 -2
  414. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  415. package/dist/formatting/AbstractNumbering.js +33 -33
  416. package/dist/formatting/AbstractNumbering.js.map +1 -1
  417. package/dist/formatting/NumberingInstance.d.ts +2 -2
  418. package/dist/formatting/NumberingInstance.d.ts.map +1 -1
  419. package/dist/formatting/NumberingInstance.js +7 -7
  420. package/dist/formatting/NumberingInstance.js.map +1 -1
  421. package/dist/formatting/NumberingLevel.d.ts +11 -2
  422. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  423. package/dist/formatting/NumberingLevel.js +111 -25
  424. package/dist/formatting/NumberingLevel.js.map +1 -1
  425. package/dist/formatting/NumberingManager.d.ts +4 -4
  426. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  427. package/dist/formatting/NumberingManager.js +28 -28
  428. package/dist/formatting/NumberingManager.js.map +1 -1
  429. package/dist/formatting/Style.d.ts +14 -7
  430. package/dist/formatting/Style.d.ts.map +1 -1
  431. package/dist/formatting/Style.js +309 -112
  432. package/dist/formatting/Style.js.map +1 -1
  433. package/dist/formatting/StylesManager.d.ts +2 -2
  434. package/dist/formatting/StylesManager.d.ts.map +1 -1
  435. package/dist/formatting/StylesManager.js +52 -52
  436. package/dist/formatting/StylesManager.js.map +1 -1
  437. package/dist/helpers/CleanupHelper.d.ts +1 -1
  438. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  439. package/dist/helpers/CleanupHelper.js +15 -15
  440. package/dist/helpers/CleanupHelper.js.map +1 -1
  441. package/dist/index.d.ts +81 -90
  442. package/dist/index.d.ts.map +1 -1
  443. package/dist/index.js +286 -317
  444. package/dist/index.js.map +1 -1
  445. package/dist/internal.d.ts +16 -0
  446. package/dist/internal.d.ts.map +1 -0
  447. package/dist/internal.js +42 -0
  448. package/dist/internal.js.map +1 -0
  449. package/dist/managers/DrawingManager.d.ts +3 -3
  450. package/dist/managers/DrawingManager.d.ts.map +1 -1
  451. package/dist/managers/DrawingManager.js +12 -12
  452. package/dist/managers/DrawingManager.js.map +1 -1
  453. package/dist/{utils → processors}/ChangelogGenerator.d.ts +2 -2
  454. package/dist/processors/ChangelogGenerator.d.ts.map +1 -0
  455. package/dist/{utils → processors}/ChangelogGenerator.js +2 -2
  456. package/dist/processors/ChangelogGenerator.js.map +1 -0
  457. package/dist/processors/CompatibilityUpgrader.d.ts.map +1 -0
  458. package/dist/{utils → processors}/CompatibilityUpgrader.js +10 -10
  459. package/dist/processors/CompatibilityUpgrader.js.map +1 -0
  460. package/dist/{utils → processors}/InMemoryRevisionAcceptor.d.ts +3 -3
  461. package/dist/processors/InMemoryRevisionAcceptor.d.ts.map +1 -0
  462. package/dist/{utils → processors}/InMemoryRevisionAcceptor.js +84 -27
  463. package/dist/processors/InMemoryRevisionAcceptor.js.map +1 -0
  464. package/dist/{utils → processors}/MoveOperationHelper.d.ts +4 -4
  465. package/dist/processors/MoveOperationHelper.d.ts.map +1 -0
  466. package/dist/{utils → processors}/MoveOperationHelper.js +10 -10
  467. package/dist/processors/MoveOperationHelper.js.map +1 -0
  468. package/dist/{utils → processors}/RevisionAwareProcessor.d.ts +3 -3
  469. package/dist/processors/RevisionAwareProcessor.d.ts.map +1 -0
  470. package/dist/{utils → processors}/RevisionAwareProcessor.js +2 -2
  471. package/dist/processors/RevisionAwareProcessor.js.map +1 -0
  472. package/dist/{utils → processors}/RevisionWalker.d.ts +2 -1
  473. package/dist/processors/RevisionWalker.d.ts.map +1 -0
  474. package/dist/{utils → processors}/RevisionWalker.js +28 -0
  475. package/dist/processors/RevisionWalker.js.map +1 -0
  476. package/dist/{utils → processors}/SelectiveRevisionAcceptor.d.ts +4 -3
  477. package/dist/processors/SelectiveRevisionAcceptor.d.ts.map +1 -0
  478. package/dist/processors/SelectiveRevisionAcceptor.js +402 -0
  479. package/dist/processors/SelectiveRevisionAcceptor.js.map +1 -0
  480. package/dist/processors/ShadingResolver.d.ts +6 -0
  481. package/dist/processors/ShadingResolver.d.ts.map +1 -0
  482. package/dist/{utils → processors}/ShadingResolver.js +2 -2
  483. package/dist/processors/ShadingResolver.js.map +1 -0
  484. package/dist/{utils → processors}/acceptRevisions.d.ts +1 -1
  485. package/dist/processors/acceptRevisions.d.ts.map +1 -0
  486. package/dist/{utils → processors}/acceptRevisions.js +24 -4
  487. package/dist/processors/acceptRevisions.js.map +1 -0
  488. package/dist/{utils → processors}/cnfStyleDecoder.d.ts +1 -1
  489. package/dist/processors/cnfStyleDecoder.d.ts.map +1 -0
  490. package/dist/processors/cnfStyleDecoder.js.map +1 -0
  491. package/dist/processors/stripTrackedChanges.d.ts +3 -0
  492. package/dist/processors/stripTrackedChanges.d.ts.map +1 -0
  493. package/dist/{utils → processors}/stripTrackedChanges.js +16 -6
  494. package/dist/processors/stripTrackedChanges.js.map +1 -0
  495. package/dist/tracking/DocumentTrackingContext.d.ts +4 -4
  496. package/dist/tracking/DocumentTrackingContext.d.ts.map +1 -1
  497. package/dist/tracking/DocumentTrackingContext.js +38 -43
  498. package/dist/tracking/DocumentTrackingContext.js.map +1 -1
  499. package/dist/tracking/TrackingContext.d.ts +8 -8
  500. package/dist/tracking/TrackingContext.d.ts.map +1 -1
  501. package/dist/tracking/TrackingContext.js.map +1 -1
  502. package/dist/types/document-types.d.ts +28 -0
  503. package/dist/types/document-types.d.ts.map +1 -0
  504. package/dist/types/document-types.js +3 -0
  505. package/dist/types/document-types.js.map +1 -0
  506. package/dist/types/formatting.d.ts +4 -4
  507. package/dist/types/formatting.d.ts.map +1 -1
  508. package/dist/types/formatting.js.map +1 -1
  509. package/dist/types/settings-types.d.ts +6 -0
  510. package/dist/types/settings-types.d.ts.map +1 -1
  511. package/dist/types/settings-types.js.map +1 -1
  512. package/dist/utils/KeyedRegistry.d.ts +13 -0
  513. package/dist/utils/KeyedRegistry.d.ts.map +1 -0
  514. package/dist/utils/KeyedRegistry.js +36 -0
  515. package/dist/utils/KeyedRegistry.js.map +1 -0
  516. package/dist/utils/corruptionDetection.d.ts +1 -1
  517. package/dist/utils/corruptionDetection.d.ts.map +1 -1
  518. package/dist/utils/corruptionDetection.js +4 -4
  519. package/dist/utils/corruptionDetection.js.map +1 -1
  520. package/dist/utils/deepEqual.d.ts +2 -0
  521. package/dist/utils/deepEqual.d.ts.map +1 -0
  522. package/dist/utils/deepEqual.js +50 -0
  523. package/dist/utils/deepEqual.js.map +1 -0
  524. package/dist/utils/list-detection.d.ts +2 -2
  525. package/dist/utils/list-detection.d.ts.map +1 -1
  526. package/dist/utils/list-detection.js.map +1 -1
  527. package/dist/utils/parsingHelpers.d.ts +1 -1
  528. package/dist/utils/parsingHelpers.d.ts.map +1 -1
  529. package/dist/utils/parsingHelpers.js +2 -2
  530. package/dist/utils/parsingHelpers.js.map +1 -1
  531. package/dist/utils/validation.js +7 -7
  532. package/dist/utils/validation.js.map +1 -1
  533. package/dist/utils/xmlSanitization.js +2 -2
  534. package/dist/utils/xmlSanitization.js.map +1 -1
  535. package/dist/validation/RevisionAutoFixer.d.ts +4 -4
  536. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  537. package/dist/validation/RevisionAutoFixer.js +11 -11
  538. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  539. package/dist/validation/RevisionValidator.d.ts +5 -4
  540. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  541. package/dist/validation/RevisionValidator.js +29 -30
  542. package/dist/validation/RevisionValidator.js.map +1 -1
  543. package/dist/validation/ValidationRuleRegistry.d.ts +27 -0
  544. package/dist/validation/ValidationRuleRegistry.d.ts.map +1 -0
  545. package/dist/validation/ValidationRuleRegistry.js +43 -0
  546. package/dist/validation/ValidationRuleRegistry.js.map +1 -0
  547. package/dist/validation/index.d.ts +3 -3
  548. package/dist/validation/index.d.ts.map +1 -1
  549. package/dist/validation/index.js +10 -10
  550. package/dist/validation/index.js.map +1 -1
  551. package/dist/xml/XMLBuilder.d.ts +6 -1
  552. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  553. package/dist/xml/XMLBuilder.js +11 -6
  554. package/dist/xml/XMLBuilder.js.map +1 -1
  555. package/dist/xml/XMLParser.js +6 -6
  556. package/dist/xml/XMLParser.js.map +1 -1
  557. package/dist/zip/ZipHandler.d.ts +1 -1
  558. package/dist/zip/ZipHandler.d.ts.map +1 -1
  559. package/dist/zip/ZipHandler.js +8 -8
  560. package/dist/zip/ZipHandler.js.map +1 -1
  561. package/dist/zip/ZipReader.d.ts +1 -1
  562. package/dist/zip/ZipReader.d.ts.map +1 -1
  563. package/dist/zip/ZipReader.js +14 -14
  564. package/dist/zip/ZipReader.js.map +1 -1
  565. package/dist/zip/ZipWriter.d.ts +1 -1
  566. package/dist/zip/ZipWriter.d.ts.map +1 -1
  567. package/dist/zip/ZipWriter.js +10 -10
  568. package/dist/zip/ZipWriter.js.map +1 -1
  569. package/package.json +20 -4
  570. package/src/constants/legacyCompatFlags.ts +1 -1
  571. package/src/core/Document.ts +478 -167
  572. package/src/core/DocumentContent.ts +14 -11
  573. package/src/core/DocumentEvents.ts +90 -0
  574. package/src/core/DocumentGenerator.ts +49 -22
  575. package/src/core/DocumentParser.ts +2235 -620
  576. package/src/core/DocumentValidator.ts +7 -7
  577. package/src/core/ElementRegistry.ts +69 -0
  578. package/src/core/Relationship.ts +1 -1
  579. package/src/core/RelationshipManager.ts +4 -4
  580. package/src/elements/AlternateContent.ts +1 -1
  581. package/src/elements/Bookmark.ts +52 -4
  582. package/src/elements/BookmarkManager.ts +2 -2
  583. package/src/elements/Comment.ts +3 -3
  584. package/src/elements/CommentManager.ts +4 -4
  585. package/src/elements/CommonTypes.ts +45 -7
  586. package/src/elements/CustomXml.ts +1 -1
  587. package/src/elements/Endnote.ts +2 -2
  588. package/src/elements/EndnoteManager.ts +3 -3
  589. package/src/elements/Field.ts +44 -10
  590. package/src/elements/FieldHelpers.ts +2 -2
  591. package/src/elements/Footer.ts +4 -4
  592. package/src/elements/Footnote.ts +2 -2
  593. package/src/elements/FootnoteManager.ts +3 -3
  594. package/src/elements/Header.ts +4 -4
  595. package/src/elements/HeaderFooterManager.ts +2 -2
  596. package/src/elements/Hyperlink.ts +16 -12
  597. package/src/elements/Image.ts +3 -3
  598. package/src/elements/ImageManager.ts +2 -2
  599. package/src/elements/ImageRun.ts +3 -3
  600. package/src/elements/MathElement.ts +1 -1
  601. package/src/elements/Paragraph.ts +221 -88
  602. package/src/elements/PreservedElement.ts +1 -1
  603. package/src/elements/PropertyChangeTypes.ts +2 -2
  604. package/src/elements/RangeMarker.ts +153 -12
  605. package/src/elements/RegisteredBodyElement.ts +52 -0
  606. package/src/elements/Revision.ts +14 -14
  607. package/src/elements/RevisionContent.ts +3 -3
  608. package/src/elements/RevisionManager.ts +3 -3
  609. package/src/elements/Run.ts +221 -94
  610. package/src/elements/Section.ts +136 -69
  611. package/src/elements/Shape.ts +4 -4
  612. package/src/elements/StructuredDocumentTag.ts +3 -3
  613. package/src/elements/Table.ts +91 -27
  614. package/src/elements/TableCell.ts +62 -34
  615. package/src/elements/TableGridChange.ts +1 -1
  616. package/src/elements/TableOfContents.ts +1 -1
  617. package/src/elements/TableOfContentsElement.ts +2 -2
  618. package/src/elements/TableRow.ts +192 -48
  619. package/src/elements/TextBox.ts +5 -5
  620. package/src/formatting/AbstractNumbering.ts +3 -3
  621. package/src/formatting/NumberingInstance.ts +2 -2
  622. package/src/formatting/NumberingLevel.ts +201 -10
  623. package/src/formatting/NumberingManager.ts +5 -5
  624. package/src/formatting/Style.ts +382 -86
  625. package/src/formatting/StylesManager.ts +4 -4
  626. package/src/helpers/CleanupHelper.ts +6 -6
  627. package/src/index.ts +118 -127
  628. package/src/internal.ts +79 -0
  629. package/src/managers/DrawingManager.ts +3 -3
  630. package/src/{utils → processors}/ChangelogGenerator.ts +3 -3
  631. package/src/{utils → processors}/CompatibilityUpgrader.ts +2 -2
  632. package/src/{utils → processors}/InMemoryRevisionAcceptor.ts +100 -12
  633. package/src/{utils → processors}/MoveOperationHelper.ts +5 -5
  634. package/src/{utils → processors}/RevisionAwareProcessor.ts +3 -3
  635. package/src/{utils → processors}/RevisionWalker.ts +42 -1
  636. package/src/{utils → processors}/SelectiveRevisionAcceptor.ts +98 -39
  637. package/src/{utils → processors}/ShadingResolver.ts +5 -5
  638. package/src/{utils → processors}/acceptRevisions.ts +77 -9
  639. package/src/{utils → processors}/cnfStyleDecoder.ts +1 -1
  640. package/src/{utils → processors}/stripTrackedChanges.ts +35 -10
  641. package/src/tracking/DocumentTrackingContext.ts +12 -14
  642. package/src/tracking/TrackingContext.ts +8 -8
  643. package/src/types/document-types.ts +53 -0
  644. package/src/types/formatting.ts +4 -4
  645. package/src/types/settings-types.ts +32 -0
  646. package/src/utils/KeyedRegistry.ts +41 -0
  647. package/src/utils/corruptionDetection.ts +2 -2
  648. package/src/utils/deepEqual.ts +58 -0
  649. package/src/utils/list-detection.ts +2 -2
  650. package/src/utils/parsingHelpers.ts +11 -3
  651. package/src/utils/validation.ts +3 -3
  652. package/src/utils/xmlSanitization.ts +1 -1
  653. package/src/validation/RevisionAutoFixer.ts +5 -5
  654. package/src/validation/RevisionValidator.ts +39 -28
  655. package/src/validation/ValidationRuleRegistry.ts +86 -0
  656. package/src/validation/index.ts +3 -3
  657. package/src/xml/XMLBuilder.ts +13 -3
  658. package/src/xml/XMLParser.ts +2 -2
  659. package/src/zip/ZipHandler.ts +4 -4
  660. package/src/zip/ZipReader.ts +3 -3
  661. package/src/zip/ZipWriter.ts +3 -3
  662. package/dist/utils/ChangelogGenerator.d.ts.map +0 -1
  663. package/dist/utils/ChangelogGenerator.js.map +0 -1
  664. package/dist/utils/CompatibilityUpgrader.d.ts.map +0 -1
  665. package/dist/utils/CompatibilityUpgrader.js.map +0 -1
  666. package/dist/utils/InMemoryRevisionAcceptor.d.ts.map +0 -1
  667. package/dist/utils/InMemoryRevisionAcceptor.js.map +0 -1
  668. package/dist/utils/MoveOperationHelper.d.ts.map +0 -1
  669. package/dist/utils/MoveOperationHelper.js.map +0 -1
  670. package/dist/utils/RevisionAwareProcessor.d.ts.map +0 -1
  671. package/dist/utils/RevisionAwareProcessor.js.map +0 -1
  672. package/dist/utils/RevisionWalker.d.ts.map +0 -1
  673. package/dist/utils/RevisionWalker.js.map +0 -1
  674. package/dist/utils/SelectiveRevisionAcceptor.d.ts.map +0 -1
  675. package/dist/utils/SelectiveRevisionAcceptor.js.map +0 -1
  676. package/dist/utils/ShadingResolver.d.ts +0 -6
  677. package/dist/utils/ShadingResolver.d.ts.map +0 -1
  678. package/dist/utils/ShadingResolver.js.map +0 -1
  679. package/dist/utils/acceptRevisions.d.ts.map +0 -1
  680. package/dist/utils/acceptRevisions.js.map +0 -1
  681. package/dist/utils/cnfStyleDecoder.d.ts.map +0 -1
  682. package/dist/utils/cnfStyleDecoder.js.map +0 -1
  683. package/dist/utils/stripTrackedChanges.d.ts +0 -3
  684. package/dist/utils/stripTrackedChanges.d.ts.map +0 -1
  685. package/dist/utils/stripTrackedChanges.js.map +0 -1
  686. package/src/__tests__/helper-methods.test.ts +0 -512
  687. package/src/constants/CLAUDE.md +0 -28
  688. package/src/core/CLAUDE.md +0 -113
  689. package/src/elements/CLAUDE.md +0 -142
  690. package/src/formatting/CLAUDE.md +0 -78
  691. package/src/managers/CLAUDE.md +0 -47
  692. package/src/tracking/CLAUDE.md +0 -30
  693. package/src/types/CLAUDE.md +0 -39
  694. package/src/utils/CLAUDE.md +0 -168
  695. package/src/validation/CLAUDE.md +0 -40
  696. package/src/xml/CLAUDE.md +0 -65
  697. package/src/zip/CLAUDE.md +0 -55
  698. /package/dist/{utils → processors}/CompatibilityUpgrader.d.ts +0 -0
  699. /package/dist/{utils → processors}/cnfStyleDecoder.js +0 -0
@@ -3,24 +3,26 @@
3
3
  * Extracts content from ZIP archives and converts XML to structured data
4
4
  */
5
5
 
6
- import { AlternateContent } from '../elements/AlternateContent';
7
- import { Bookmark } from '../elements/Bookmark';
8
- import { Endnote, EndnoteType } from '../elements/Endnote';
9
- import { Footnote, FootnoteType } from '../elements/Footnote';
10
- import { BookmarkManager } from '../elements/BookmarkManager';
11
- import { Comment } from '../elements/Comment';
12
- import { CustomXmlBlock } from '../elements/CustomXml';
13
- import { PreservedElement } from '../elements/PreservedElement';
14
- import { MathParagraph } from '../elements/MathElement';
15
- import { ComplexField, Field } from '../elements/Field';
16
- import { isHyperlinkInstruction, parseHyperlinkInstruction } from '../elements/FieldHelpers';
17
- import { Footer } from '../elements/Footer';
18
- import { Header } from '../elements/Header';
19
- import { Hyperlink } from '../elements/Hyperlink';
20
- import { ImageManager } from '../elements/ImageManager';
21
- import { ImageRun } from '../elements/ImageRun';
22
- import { Paragraph, ParagraphFormatting, ParagraphContent } from '../elements/Paragraph';
23
- import { Revision } from '../elements/Revision';
6
+ import { AlternateContent } from '../elements/AlternateContent.js';
7
+ import { Bookmark } from '../elements/Bookmark.js';
8
+ import { Endnote, EndnoteType } from '../elements/Endnote.js';
9
+ import { Footnote, FootnoteType } from '../elements/Footnote.js';
10
+ import { BookmarkManager } from '../elements/BookmarkManager.js';
11
+ import { Comment } from '../elements/Comment.js';
12
+ import { CustomXmlBlock } from '../elements/CustomXml.js';
13
+ import { PreservedElement } from '../elements/PreservedElement.js';
14
+ import { RegisteredBodyElement } from '../elements/RegisteredBodyElement.js';
15
+ import { ElementRegistry } from './ElementRegistry.js';
16
+ import { MathParagraph } from '../elements/MathElement.js';
17
+ import { ComplexField, Field } from '../elements/Field.js';
18
+ import { isHyperlinkInstruction, parseHyperlinkInstruction } from '../elements/FieldHelpers.js';
19
+ import { Footer } from '../elements/Footer.js';
20
+ import { Header } from '../elements/Header.js';
21
+ import { Hyperlink } from '../elements/Hyperlink.js';
22
+ import { ImageManager } from '../elements/ImageManager.js';
23
+ import { ImageRun } from '../elements/ImageRun.js';
24
+ import { Paragraph, ParagraphFormatting, ParagraphContent } from '../elements/Paragraph.js';
25
+ import { Revision } from '../elements/Revision.js';
24
26
  import {
25
27
  BreakType,
26
28
  FormFieldCheckBox,
@@ -30,35 +32,40 @@ import {
30
32
  Run,
31
33
  RunContent,
32
34
  RunFormatting,
33
- } from '../elements/Run';
34
- import { Section, SectionProperties, SectionType } from '../elements/Section';
35
- import { StructuredDocumentTag } from '../elements/StructuredDocumentTag';
36
- import { Table, TableBorder } from '../elements/Table';
37
- import { TableCell } from '../elements/TableCell';
38
- import { TableOfContents } from '../elements/TableOfContents';
39
- import { TableOfContentsElement } from '../elements/TableOfContentsElement';
40
- import { TableGridChange } from '../elements/TableGridChange';
41
- import { TableRow } from '../elements/TableRow';
42
- import { AbstractNumbering } from '../formatting/AbstractNumbering';
43
- import { NumberingInstance } from '../formatting/NumberingInstance';
44
- import { Style, StyleProperties, StyleType } from '../formatting/Style';
45
- import { logParagraphContent, logParsing, logTextDirection } from '../utils/diagnostics';
46
- import { getGlobalLogger, createScopedLogger, ILogger, defaultLogger } from '../utils/logger';
47
- import { safeParseInt, isExplicitlySet, parseOoxmlBoolean } from '../utils/parsingHelpers';
48
- import { halfPointsToPoints } from '../utils/units';
49
- import type { ShadingConfig } from '../elements/CommonTypes';
35
+ } from '../elements/Run.js';
36
+ import { Section, SectionProperties, SectionType } from '../elements/Section.js';
37
+ import { StructuredDocumentTag } from '../elements/StructuredDocumentTag.js';
38
+ import { Table, TableBorder } from '../elements/Table.js';
39
+ import { TableCell } from '../elements/TableCell.js';
40
+ import { TableOfContents } from '../elements/TableOfContents.js';
41
+ import { TableOfContentsElement } from '../elements/TableOfContentsElement.js';
42
+ import { TableGridChange } from '../elements/TableGridChange.js';
43
+ import { TableRow } from '../elements/TableRow.js';
44
+ import { AbstractNumbering } from '../formatting/AbstractNumbering.js';
45
+ import { NumberingInstance } from '../formatting/NumberingInstance.js';
46
+ import { Style, StyleProperties, StyleType } from '../formatting/Style.js';
47
+ import { logParagraphContent, logParsing, logTextDirection } from '../utils/diagnostics.js';
48
+ import { getGlobalLogger, createScopedLogger, ILogger, defaultLogger } from '../utils/logger.js';
49
+ import {
50
+ safeParseInt,
51
+ isExplicitlySet,
52
+ parseOoxmlBoolean,
53
+ parseOnOffAttribute,
54
+ } from '../utils/parsingHelpers.js';
55
+ import { halfPointsToPoints } from '../utils/units.js';
56
+ import type { ShadingConfig } from '../elements/CommonTypes.js';
50
57
 
51
58
  // Create scoped logger for DocumentParser operations
52
59
  function getLogger(): ILogger {
53
60
  return createScopedLogger(getGlobalLogger(), 'DocumentParser');
54
61
  }
55
- import { XMLBuilder, XMLElement } from '../xml/XMLBuilder';
56
- import { XMLParser } from '../xml/XMLParser';
57
- import { ZipHandler } from '../zip/ZipHandler';
58
- import { DOCX_PATHS } from '../zip/types';
59
- import { DocumentProperties } from './Document';
60
- import { BodyElement } from './DocumentContent';
61
- import { RelationshipManager } from './RelationshipManager';
62
+ import { XMLBuilder, XMLElement } from '../xml/XMLBuilder.js';
63
+ import { XMLParser } from '../xml/XMLParser.js';
64
+ import { ZipHandler } from '../zip/ZipHandler.js';
65
+ import { DOCX_PATHS } from '../zip/types.js';
66
+ import type { DocumentProperties } from '../types/document-types.js';
67
+ import { BodyElement } from './DocumentContent.js';
68
+ import { RelationshipManager } from './RelationshipManager.js';
62
69
 
63
70
  /**
64
71
  * Parse error tracking
@@ -242,7 +249,7 @@ export class DocumentParser {
242
249
  const nextCXml = this.findNextTopLevelTag(bodyContent, 'w:customXml', pos);
243
250
  const nextAltChunk = this.findNextTopLevelTag(bodyContent, 'w:altChunk', pos);
244
251
 
245
- const candidates = [];
252
+ const candidates: { type: string; pos: number; registeredTag?: string }[] = [];
246
253
  if (nextP !== -1) candidates.push({ type: 'p', pos: nextP });
247
254
  if (nextTbl !== -1) candidates.push({ type: 'tbl', pos: nextTbl });
248
255
  if (nextSdt !== -1) candidates.push({ type: 'sdt', pos: nextSdt });
@@ -251,6 +258,15 @@ export class DocumentParser {
251
258
  if (nextCXml !== -1) candidates.push({ type: 'customXml', pos: nextCXml });
252
259
  if (nextAltChunk !== -1) candidates.push({ type: 'altChunk', pos: nextAltChunk });
253
260
 
261
+ // ElementRegistry plugin tags — consumer-registered handlers for
262
+ // qualified-name body elements outside the framework's native set.
263
+ // We scan each registered tag at every step so the position-based
264
+ // dispatch picks up registered elements interleaved with native ones.
265
+ for (const tag of ElementRegistry.registeredTags()) {
266
+ const p = this.findNextTopLevelTag(bodyContent, tag, pos);
267
+ if (p !== -1) candidates.push({ type: 'registered', pos: p, registeredTag: tag });
268
+ }
269
+
254
270
  if (candidates.length === 0) break;
255
271
 
256
272
  candidates.sort((a, b) => a.pos - b.pos);
@@ -399,6 +415,29 @@ export class DocumentParser {
399
415
  } else {
400
416
  pos = next.pos + 1;
401
417
  }
418
+ } else if (next.type === 'registered' && next.registeredTag) {
419
+ // Consumer-registered element via ElementRegistry. Hand the raw XML
420
+ // to the handler's parse(); on save the model is round-tripped via
421
+ // handler.serialize(). A throwing parse degrades gracefully to a
422
+ // PreservedElement so a buggy custom parser cannot fail the load.
423
+ const tag = next.registeredTag;
424
+ const elementXml = this.extractSingleElement(bodyContent, tag, next.pos);
425
+ if (elementXml) {
426
+ const handler = ElementRegistry.get(tag);
427
+ if (handler) {
428
+ try {
429
+ const model = handler.parse(elementXml);
430
+ bodyElements.push(new RegisteredBodyElement(tag, model, handler, elementXml));
431
+ } catch {
432
+ bodyElements.push(new PreservedElement(elementXml, tag, 'block'));
433
+ }
434
+ } else {
435
+ bodyElements.push(new PreservedElement(elementXml, tag, 'block'));
436
+ }
437
+ pos = next.pos + elementXml.length;
438
+ } else {
439
+ pos = next.pos + 1;
440
+ }
402
441
  }
403
442
 
404
443
  // Attach any pending body-level bookmarkStarts to the just-parsed element
@@ -507,10 +546,18 @@ export class DocumentParser {
507
546
  if (idAttr) {
508
547
  const id = parseInt(idAttr, 10);
509
548
  if (!isNaN(id)) {
549
+ // CT_MarkupRange §17.13.5 — preserve w:displacedByCustomXml.
550
+ const displacedAttr = XMLParser.extractAttribute(
551
+ bookmarkEndXml,
552
+ 'w:displacedByCustomXml'
553
+ );
554
+ const displacedByCustomXml =
555
+ displacedAttr === 'next' || displacedAttr === 'prev' ? displacedAttr : undefined;
510
556
  const bookmark = new Bookmark({
511
557
  name: `_end_${id}`,
512
558
  id: id,
513
559
  skipNormalization: true,
560
+ displacedByCustomXml,
514
561
  });
515
562
  bookmarks.push(bookmark);
516
563
  }
@@ -905,14 +952,27 @@ export class DocumentParser {
905
952
  }
906
953
  }
907
954
 
908
- // Parse w14:paraId and w14:textId if present
909
- const paraId = pElement['w14:paraId'];
955
+ // Parse w14:paraId and w14:textId (Word 2010+ paragraph identifiers
956
+ // per MC-DOCX §2.6.19, ST_LongHexNumber — 8-char hex string). These
957
+ // are XML *attributes* on w:p, so XMLParser stores them under the
958
+ // @_-prefixed keys. The previous lookup (`pElement['w14:paraId']`)
959
+ // accessed an element-shaped key that never exists, silently
960
+ // dropping both IDs on every load → save cycle. XMLParser's
961
+ // numeric coercion of purely-digit hex strings (e.g. "00000001" →
962
+ // 1) means we normalise back to the zero-padded 8-char form so
963
+ // validators accept the output.
964
+ const normaliseHexId = (raw: unknown): string | undefined => {
965
+ if (raw === undefined || raw === null) return undefined;
966
+ const asStr = typeof raw === 'number' ? raw.toString(16) : String(raw);
967
+ return asStr.toUpperCase().padStart(8, '0');
968
+ };
969
+ const paraId = normaliseHexId(pElement['@_w14:paraId']);
910
970
  if (paraId) {
911
- paragraph.formatting.paraId = paraId as string;
971
+ paragraph.formatting.paraId = paraId;
912
972
  }
913
- const textId = pElement['w14:textId'];
973
+ const textId = normaliseHexId(pElement['@_w14:textId']);
914
974
  if (textId) {
915
- paragraph.formatting.textId = textId as string;
975
+ paragraph.formatting.textId = textId;
916
976
  }
917
977
 
918
978
  // CRITICAL FIX: Preserve document order of paragraph children (runs, hyperlinks, fields)
@@ -1344,9 +1404,61 @@ export class DocumentParser {
1344
1404
  hyperlinkObj['w:moveFrom'] ||
1345
1405
  hyperlinkObj['w:moveTo'];
1346
1406
  if (hasRevisionChildren) {
1347
- const rawXml = extractElementXmlAtPosition(child.pos, 'w:hyperlink');
1348
- if (rawXml) {
1349
- paragraph.addContent(new PreservedElement(rawXml, 'w:hyperlink', 'inline'));
1407
+ // Flatten revisions to make hyperlink editable (setUrl/setText).
1408
+ // Trades revision fidelity inside the hyperlink for editability.
1409
+ const flattenedObj = { ...hyperlinkObj };
1410
+ const allRuns: any[] = [];
1411
+
1412
+ // Keep existing direct runs
1413
+ if (flattenedObj['w:r']) {
1414
+ const directRuns = Array.isArray(flattenedObj['w:r'])
1415
+ ? flattenedObj['w:r']
1416
+ : [flattenedObj['w:r']];
1417
+ allRuns.push(...directRuns);
1418
+ }
1419
+
1420
+ // Unwrap w:ins runs (inserted content — keep)
1421
+ if (flattenedObj['w:ins']) {
1422
+ const insArr = Array.isArray(flattenedObj['w:ins'])
1423
+ ? flattenedObj['w:ins']
1424
+ : [flattenedObj['w:ins']];
1425
+ for (const ins of insArr) {
1426
+ if (ins['w:r']) {
1427
+ const insRuns = Array.isArray(ins['w:r']) ? ins['w:r'] : [ins['w:r']];
1428
+ allRuns.push(...insRuns);
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ // Unwrap w:moveTo runs (move destination — keep)
1434
+ if (flattenedObj['w:moveTo']) {
1435
+ const moveToArr = Array.isArray(flattenedObj['w:moveTo'])
1436
+ ? flattenedObj['w:moveTo']
1437
+ : [flattenedObj['w:moveTo']];
1438
+ for (const mt of moveToArr) {
1439
+ if (mt['w:r']) {
1440
+ const mtRuns = Array.isArray(mt['w:r']) ? mt['w:r'] : [mt['w:r']];
1441
+ allRuns.push(...mtRuns);
1442
+ }
1443
+ }
1444
+ }
1445
+
1446
+ // Drop w:del and w:moveFrom (deleted/moved-away content)
1447
+ flattenedObj['w:r'] = allRuns.length > 0 ? allRuns : undefined;
1448
+ delete flattenedObj['w:del'];
1449
+ delete flattenedObj['w:ins'];
1450
+ delete flattenedObj['w:moveFrom'];
1451
+ delete flattenedObj['w:moveTo'];
1452
+
1453
+ const result = this.parseHyperlinkFromObject(flattenedObj, relationshipManager);
1454
+ if (result.hyperlink) {
1455
+ paragraph.addHyperlink(result.hyperlink);
1456
+ }
1457
+ for (const bookmark of result.bookmarkStarts) {
1458
+ paragraph.addBookmarkStart(bookmark);
1459
+ }
1460
+ for (const bookmark of result.bookmarkEnds) {
1461
+ paragraph.addBookmarkEnd(bookmark);
1350
1462
  }
1351
1463
  } else {
1352
1464
  const result = this.parseHyperlinkFromObject(hyperlinkObj, relationshipManager);
@@ -1481,7 +1593,7 @@ export class DocumentParser {
1481
1593
  } = { revision: null, bookmarkStarts: [], bookmarkEnds: [] };
1482
1594
  try {
1483
1595
  // Map XML tag to RevisionType
1484
- let revisionType: import('../elements/Revision').RevisionType;
1596
+ let revisionType: import('../elements/Revision.js').RevisionType;
1485
1597
  switch (tagName) {
1486
1598
  case 'w:ins':
1487
1599
  revisionType = 'insert';
@@ -1529,7 +1641,7 @@ export class DocumentParser {
1529
1641
  const runXmls = XMLParser.extractElements(xmlWithoutHyperlinks, 'w:r');
1530
1642
 
1531
1643
  // Use RevisionContent to hold both Run and Hyperlink objects
1532
- const content: import('../elements/RevisionContent').RevisionContent[] = [];
1644
+ const content: import('../elements/RevisionContent.js').RevisionContent[] = [];
1533
1645
 
1534
1646
  // Parse standalone runs (not inside hyperlinks)
1535
1647
  for (const runXml of runXmls) {
@@ -1675,8 +1787,8 @@ export class DocumentParser {
1675
1787
  const id = parseInt(idAttr, 10);
1676
1788
  const date = dateAttr ? new Date(dateAttr) : new Date();
1677
1789
  const parentId = parentIdAttr ? parseInt(parentIdAttr, 10) : undefined;
1678
- // Per ECMA-376, w:done="1" or "true" indicates resolved
1679
- const done = doneAttr === '1' || doneAttr === 'true';
1790
+ // Per ECMA-376 §17.17.4, w:done is ST_OnOff accept 1/0/true/false/on/off
1791
+ const done = parseOnOffAttribute(doneAttr);
1680
1792
 
1681
1793
  // Parse content (runs from paragraphs within the comment)
1682
1794
  const runs: Run[] = [];
@@ -1869,12 +1981,20 @@ export class DocumentParser {
1869
1981
  // Parse optional column range for table bookmarks (ECMA-376 §17.16.5)
1870
1982
  const colFirstAttr = XMLParser.extractAttribute(bookmarkXml, 'w:colFirst');
1871
1983
  const colLastAttr = XMLParser.extractAttribute(bookmarkXml, 'w:colLast');
1984
+ // Parse optional w:displacedByCustomXml per CT_MarkupRange (§17.13.5).
1985
+ // Without this the attribute was dropped on load, so any Word document
1986
+ // with custom-XML-displaced bookmarks lost the disambiguator even
1987
+ // though the model now supports round-tripping it.
1988
+ const displacedAttr = XMLParser.extractAttribute(bookmarkXml, 'w:displacedByCustomXml');
1989
+ const displacedByCustomXml =
1990
+ displacedAttr === 'next' || displacedAttr === 'prev' ? displacedAttr : undefined;
1872
1991
  const bookmark = new Bookmark({
1873
1992
  name: nameAttr,
1874
1993
  id: id,
1875
1994
  skipNormalization: true,
1876
1995
  colFirst: colFirstAttr ? parseInt(colFirstAttr, 10) : undefined,
1877
1996
  colLast: colLastAttr ? parseInt(colLastAttr, 10) : undefined,
1997
+ displacedByCustomXml,
1878
1998
  });
1879
1999
 
1880
2000
  // Register with BookmarkManager to enable hasBookmark() checks
@@ -1917,12 +2037,22 @@ export class DocumentParser {
1917
2037
 
1918
2038
  const id = parseInt(idAttr, 10);
1919
2039
 
2040
+ // CT_MarkupRange (§17.13.5) also permits w:displacedByCustomXml on
2041
+ // the end marker. Previously dropped on load, so a Word document
2042
+ // whose bookmark-end was displaced across a custom-XML node lost
2043
+ // the disambiguator even though the Bookmark model already emits
2044
+ // it from toEndXML().
2045
+ const displacedAttr = XMLParser.extractAttribute(bookmarkXml, 'w:displacedByCustomXml');
2046
+ const displacedByCustomXml =
2047
+ displacedAttr === 'next' || displacedAttr === 'prev' ? displacedAttr : undefined;
2048
+
1920
2049
  // Create a placeholder bookmark for the end marker
1921
2050
  // The name doesn't matter for bookmarkEnd as it only uses the ID
1922
2051
  const bookmark = new Bookmark({
1923
2052
  name: `_end_${id}`,
1924
2053
  id: id,
1925
2054
  skipNormalization: true,
2055
+ displacedByCustomXml,
1926
2056
  });
1927
2057
 
1928
2058
  return bookmark;
@@ -1944,12 +2074,23 @@ export class DocumentParser {
1944
2074
  try {
1945
2075
  const paragraph = new Paragraph();
1946
2076
 
1947
- // Parse w14:paraId and w14:textId attributes from paragraph element (Word 2010+)
1948
- const paraId = paraObj['w14:paraId'];
2077
+ // Parse w14:paraId and w14:textId attributes from paragraph element
2078
+ // (Word 2010+, ST_LongHexNumber 8-char hex). XMLParser keys
2079
+ // attributes under the @_ prefix and may numeric-coerce purely-
2080
+ // digit hex strings like "00000001" to the number 1 — normalise
2081
+ // back to 8-char uppercase hex so the output passes strict
2082
+ // validation. The prior code used the un-prefixed element-shaped
2083
+ // keys and always saw `undefined`.
2084
+ const normaliseHexId = (raw: unknown): string | undefined => {
2085
+ if (raw === undefined || raw === null) return undefined;
2086
+ const asStr = typeof raw === 'number' ? raw.toString(16) : String(raw);
2087
+ return asStr.toUpperCase().padStart(8, '0');
2088
+ };
2089
+ const paraId = normaliseHexId(paraObj['@_w14:paraId']);
1949
2090
  if (paraId) {
1950
2091
  paragraph.formatting.paraId = paraId;
1951
2092
  }
1952
- const textId = paraObj['w14:textId'];
2093
+ const textId = normaliseHexId(paraObj['@_w14:textId']);
1953
2094
  if (textId) {
1954
2095
  paragraph.formatting.textId = textId;
1955
2096
  }
@@ -2119,6 +2260,22 @@ export class DocumentParser {
2119
2260
  // Extract the formatting and set it as paragraph mark properties
2120
2261
  paragraph.setParagraphMarkFormatting(tempRun.getFormatting());
2121
2262
 
2263
+ // Transfer w:rPrChange (CT_ParaRPrChange, §17.3.1.30) from the
2264
+ // temp run onto the paragraph's formatting. Without this the
2265
+ // paragraph-mark rPrChange is silently dropped because
2266
+ // `tempRun.getFormatting()` exposes RunFormatting fields only —
2267
+ // `propertyChangeRevision` is a separate field on Run that was
2268
+ // previously discarded along with the temp run.
2269
+ const rPrChangeRev = tempRun.getPropertyChangeRevision();
2270
+ if (rPrChangeRev) {
2271
+ paragraph.formatting.paragraphMarkRunPropertiesChange = {
2272
+ id: rPrChangeRev.id,
2273
+ author: rPrChangeRev.author,
2274
+ date: rPrChangeRev.date,
2275
+ previousProperties: rPrChangeRev.previousProperties,
2276
+ };
2277
+ }
2278
+
2122
2279
  // Parse paragraph mark deletion tracking (w:del in w:pPr/w:rPr)
2123
2280
  // Per ECMA-376 Part 1 §17.13.5.14 - indicates the paragraph mark was deleted
2124
2281
  if (rPrObj['w:del']) {
@@ -2158,9 +2315,14 @@ export class DocumentParser {
2158
2315
  paragraph.setAlignment(pPrObj['w:jc']['@_w:val']);
2159
2316
  }
2160
2317
 
2161
- // Style
2162
- if (pPrObj['w:pStyle']?.['@_w:val']) {
2163
- paragraph.setStyle(pPrObj['w:pStyle']['@_w:val']);
2318
+ // Style (w:pStyle per ECMA-376 §17.3.1.27 — `w:val` is ST_String
2319
+ // referencing a style ID). Cast via String(...) so purely-numeric
2320
+ // style IDs that XMLParser's `parseAttributeValue: true` coerces to
2321
+ // JS numbers (e.g., a custom styleId of "1") survive as strings,
2322
+ // matching the `style?: string` field contract on
2323
+ // ParagraphFormatting.
2324
+ if (pPrObj['w:pStyle']?.['@_w:val'] !== undefined) {
2325
+ paragraph.setStyle(String(pPrObj['w:pStyle']['@_w:val']));
2164
2326
  }
2165
2327
 
2166
2328
  // Indentation
@@ -2179,6 +2341,24 @@ export class DocumentParser {
2179
2341
  // Parse hanging indent per ECMA-376 Part 1 §17.3.1.17
2180
2342
  if (isExplicitlySet(ind['@_w:hanging']))
2181
2343
  paragraph.setHangingIndent(safeParseInt(ind['@_w:hanging']));
2344
+
2345
+ // CJK character-unit indentation attributes per ECMA-376 §17.3.1.12.
2346
+ // start/endChars are bidi-aware alternatives to left/rightChars; collapse
2347
+ // them onto the leftChars/rightChars fields the same way the twips parser
2348
+ // collapses w:start → left. Values are ST_DecimalNumber (hundredths of a
2349
+ // character unit), and 0 is a legitimate value — use isExplicitlySet so
2350
+ // number-0 from XMLParser.parseAttributeValue is preserved.
2351
+ if (!paragraph.formatting.indentation) paragraph.formatting.indentation = {};
2352
+ const leftCharsVal = ind['@_w:startChars'] ?? ind['@_w:leftChars'];
2353
+ const rightCharsVal = ind['@_w:endChars'] ?? ind['@_w:rightChars'];
2354
+ if (isExplicitlySet(leftCharsVal))
2355
+ paragraph.formatting.indentation.leftChars = safeParseInt(leftCharsVal);
2356
+ if (isExplicitlySet(rightCharsVal))
2357
+ paragraph.formatting.indentation.rightChars = safeParseInt(rightCharsVal);
2358
+ if (isExplicitlySet(ind['@_w:firstLineChars']))
2359
+ paragraph.formatting.indentation.firstLineChars = safeParseInt(ind['@_w:firstLineChars']);
2360
+ if (isExplicitlySet(ind['@_w:hangingChars']))
2361
+ paragraph.formatting.indentation.hangingChars = safeParseInt(ind['@_w:hangingChars']);
2182
2362
  }
2183
2363
 
2184
2364
  // Spacing (ECMA-376 §17.3.1.33 — 8 attributes)
@@ -2199,14 +2379,13 @@ export class DocumentParser {
2199
2379
  paragraph.formatting.spacing.beforeLines = safeParseInt(spacing['@_w:beforeLines']);
2200
2380
  if (isExplicitlySet(spacing['@_w:afterLines']))
2201
2381
  paragraph.formatting.spacing.afterLines = safeParseInt(spacing['@_w:afterLines']);
2382
+ // ST_OnOff per ECMA-376 §17.17.4 — accept 1/0/true/false/on/off
2202
2383
  const beforeAuto = spacing['@_w:beforeAutospacing'];
2203
2384
  if (beforeAuto !== undefined)
2204
- paragraph.formatting.spacing.beforeAutospacing =
2205
- String(beforeAuto) === '1' || String(beforeAuto) === 'true';
2385
+ paragraph.formatting.spacing.beforeAutospacing = parseOnOffAttribute(beforeAuto);
2206
2386
  const afterAuto = spacing['@_w:afterAutospacing'];
2207
2387
  if (afterAuto !== undefined)
2208
- paragraph.formatting.spacing.afterAutospacing =
2209
- String(afterAuto) === '1' || String(afterAuto) === 'true';
2388
+ paragraph.formatting.spacing.afterAutospacing = parseOnOffAttribute(afterAuto);
2210
2389
  }
2211
2390
 
2212
2391
  // Keep properties — preserve explicit val="0" to override style inheritance
@@ -2254,7 +2433,12 @@ export class DocumentParser {
2254
2433
  const pBdr = pPrObj['w:pBdr'];
2255
2434
  const borders: any = {};
2256
2435
 
2257
- // Helper function to parse border definition
2436
+ // Helper function to parse border definition.
2437
+ // Covers the full CT_Border attribute set per ECMA-376 §17.18.2:
2438
+ // w:val, w:sz, w:color, w:space, w:themeColor, w:themeTint,
2439
+ // w:themeShade, w:shadow, w:frame. The last two are ST_OnOff —
2440
+ // route through parseOnOffAttribute so "off"/"false"/"0"/"on"
2441
+ // all resolve correctly even after XMLParser numeric coercion.
2258
2442
  const parseBorder = (borderObj: any): any => {
2259
2443
  if (!borderObj) return undefined;
2260
2444
  const border: any = {};
@@ -2263,6 +2447,15 @@ export class DocumentParser {
2263
2447
  if (borderObj['@_w:color']) border.color = borderObj['@_w:color'];
2264
2448
  if (borderObj['@_w:space'] !== undefined)
2265
2449
  border.space = safeParseInt(borderObj['@_w:space']);
2450
+ if (borderObj['@_w:themeColor']) border.themeColor = String(borderObj['@_w:themeColor']);
2451
+ if (borderObj['@_w:themeTint']) border.themeTint = String(borderObj['@_w:themeTint']);
2452
+ if (borderObj['@_w:themeShade']) border.themeShade = String(borderObj['@_w:themeShade']);
2453
+ if (borderObj['@_w:shadow'] !== undefined) {
2454
+ border.shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
2455
+ }
2456
+ if (borderObj['@_w:frame'] !== undefined) {
2457
+ border.frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
2458
+ }
2266
2459
  return Object.keys(border).length > 0 ? border : undefined;
2267
2460
  };
2268
2461
 
@@ -2301,7 +2494,15 @@ export class DocumentParser {
2301
2494
 
2302
2495
  for (const tabObj of tabElements) {
2303
2496
  const tab: any = {};
2304
- if (tabObj['@_w:pos']) tab.position = parseInt(tabObj['@_w:pos'], 10);
2497
+ // w:pos is REQUIRED per §17.3.1.38 and is ST_SignedTwipsMeasure — 0 and
2498
+ // negative values are both valid. Use `!== undefined` so that XMLParser's
2499
+ // parseAttributeValue coercion of "0" to number 0 doesn't silently drop
2500
+ // tabs at the left margin (the previous `if (tabObj['@_w:pos'])` truthy
2501
+ // check turned pos=0 into an invisible tab-loss bug).
2502
+ if (tabObj['@_w:pos'] !== undefined) {
2503
+ const parsed = parseInt(String(tabObj['@_w:pos']), 10);
2504
+ if (!isNaN(parsed)) tab.position = parsed;
2505
+ }
2305
2506
  if (tabObj['@_w:val']) tab.val = tabObj['@_w:val'];
2306
2507
  if (tabObj['@_w:leader']) tab.leader = tabObj['@_w:leader'];
2307
2508
 
@@ -2317,19 +2518,10 @@ export class DocumentParser {
2317
2518
 
2318
2519
  // Widow control per ECMA-376 Part 1 §17.3.1.40
2319
2520
  if (pPrObj['w:widowControl'] !== undefined) {
2320
- const widowControlVal = pPrObj['w:widowControl']?.['@_w:val'];
2321
- // Parse w:val attribute - can be "0"/"1" or "false"/"true"
2322
- if (
2323
- widowControlVal === '0' ||
2324
- widowControlVal === 'false' ||
2325
- widowControlVal === false ||
2326
- widowControlVal === 0
2327
- ) {
2328
- paragraph.setWidowControl(false);
2329
- } else {
2330
- // If w:val is "1", "true", true, 1, or undefined (element present without val), default to true
2331
- paragraph.setWidowControl(true);
2332
- }
2521
+ // Delegate to parseOoxmlBoolean so every ST_OnOff literal — including
2522
+ // "off" / "on" resolves correctly. The previous bespoke check missed
2523
+ // "off", silently flipping explicit-off to explicit-on.
2524
+ paragraph.setWidowControl(parseOoxmlBoolean(pPrObj['w:widowControl']));
2333
2525
  }
2334
2526
 
2335
2527
  // Outline level per ECMA-376 Part 1 §17.3.1.19
@@ -2345,15 +2537,11 @@ export class DocumentParser {
2345
2537
  paragraph.setSuppressLineNumbers(parseOoxmlBoolean(pPrObj['w:suppressLineNumbers']));
2346
2538
  }
2347
2539
 
2348
- // Bidirectional layout per ECMA-376 Part 1 §17.3.1.6
2540
+ // Bidirectional layout per ECMA-376 Part 1 §17.3.1.6 — delegate to
2541
+ // parseOoxmlBoolean so "off"/"on" literals resolve correctly (the
2542
+ // previous bespoke check missed them).
2349
2543
  if (pPrObj['w:bidi'] !== undefined) {
2350
- const bidiVal = pPrObj['w:bidi']?.['@_w:val'];
2351
- if (bidiVal === '0' || bidiVal === 'false' || bidiVal === false || bidiVal === 0) {
2352
- paragraph.setBidi(false);
2353
- } else {
2354
- // Default is true when element present without val attribute or val="1"
2355
- paragraph.setBidi(true);
2356
- }
2544
+ paragraph.setBidi(parseOoxmlBoolean(pPrObj['w:bidi']));
2357
2545
  }
2358
2546
 
2359
2547
  // Text direction per ECMA-376 Part 1 §17.3.1.36
@@ -2371,20 +2559,10 @@ export class DocumentParser {
2371
2559
  paragraph.setMirrorIndents(parseOoxmlBoolean(pPrObj['w:mirrorIndents']));
2372
2560
  }
2373
2561
 
2374
- // Auto-adjust right indent per ECMA-376 Part 1 §17.3.1.1
2562
+ // Auto-adjust right indent per ECMA-376 Part 1 §17.3.1.1 — delegate to
2563
+ // parseOoxmlBoolean so "off"/"on" literals resolve correctly.
2375
2564
  if (pPrObj['w:adjustRightInd'] !== undefined) {
2376
- const adjustRightIndVal = pPrObj['w:adjustRightInd']?.['@_w:val'];
2377
- if (
2378
- adjustRightIndVal === '0' ||
2379
- adjustRightIndVal === 'false' ||
2380
- adjustRightIndVal === false ||
2381
- adjustRightIndVal === 0
2382
- ) {
2383
- paragraph.setAdjustRightInd(false);
2384
- } else {
2385
- // Default is true when element present without val attribute or val="1"
2386
- paragraph.setAdjustRightInd(true);
2387
- }
2565
+ paragraph.setAdjustRightInd(parseOoxmlBoolean(pPrObj['w:adjustRightInd']));
2388
2566
  }
2389
2567
 
2390
2568
  // Text frame properties per ECMA-376 Part 1 §17.3.1.11
@@ -2458,11 +2636,16 @@ export class DocumentParser {
2458
2636
  }
2459
2637
  }
2460
2638
 
2461
- // HTML div ID per ECMA-376 Part 1 §17.3.1.9
2639
+ // HTML div ID per ECMA-376 Part 1 §17.3.1.10 (CT_DivId). `w:val` is
2640
+ // ST_DecimalNumber — 0 is a valid ID referencing the first div in
2641
+ // web settings. XMLParser coerces `"0"` to the number 0, and the
2642
+ // previous `if (divIdVal)` truthy check silently dropped it, breaking
2643
+ // the paragraph's link to div index 0 on every round-trip.
2462
2644
  if (pPrObj['w:divId']) {
2463
2645
  const divIdVal = pPrObj['w:divId']?.['@_w:val'];
2464
- if (divIdVal) {
2465
- paragraph.setDivId(parseInt(divIdVal, 10));
2646
+ if (isExplicitlySet(divIdVal)) {
2647
+ const parsed = safeParseInt(divIdVal);
2648
+ if (!isNaN(parsed)) paragraph.setDivId(parsed);
2466
2649
  }
2467
2650
  }
2468
2651
 
@@ -2477,13 +2660,27 @@ export class DocumentParser {
2477
2660
  }
2478
2661
  }
2479
2662
 
2480
- // Paragraph property change tracking per ECMA-376 Part 1 §17.3.1.27
2663
+ // Paragraph property change tracking per ECMA-376 Part 1 §17.3.1.27.
2664
+ // CT_TrackChange attributes — `w:id` (ST_DecimalNumber, required),
2665
+ // `w:author` (ST_String, required), `w:date` (ST_DateTime, optional).
2666
+ // XMLParser coerces `w:id="0"` to the number 0; the previous
2667
+ // `if (changeObj['@_w:id'])` truthy gate silently dropped id=0,
2668
+ // producing `<w:pPrChange w:author="…" w:date="…"/>` on emission —
2669
+ // missing the required `w:id` and failing strict validation. The
2670
+ // sibling `trPrChange` / `tblPrChange` / `tcPrChange` / `sectPrChange`
2671
+ // parsers already use `|| '0'` or `!== undefined` for the same reason.
2481
2672
  if (pPrObj['w:pPrChange']) {
2482
2673
  const changeObj = pPrObj['w:pPrChange'];
2483
2674
  const change: any = {};
2484
- if (changeObj['@_w:author']) change.author = String(changeObj['@_w:author']);
2485
- if (changeObj['@_w:date']) change.date = String(changeObj['@_w:date']);
2486
- if (changeObj['@_w:id']) change.id = String(changeObj['@_w:id']);
2675
+ if (changeObj['@_w:author'] !== undefined) {
2676
+ change.author = String(changeObj['@_w:author']);
2677
+ }
2678
+ if (changeObj['@_w:date'] !== undefined) {
2679
+ change.date = String(changeObj['@_w:date']);
2680
+ }
2681
+ if (changeObj['@_w:id'] !== undefined) {
2682
+ change.id = String(changeObj['@_w:id']);
2683
+ }
2487
2684
 
2488
2685
  // Parse child w:pPr for previousProperties to preserve tracked change history
2489
2686
  if (changeObj['w:pPr']) {
@@ -2514,7 +2711,11 @@ export class DocumentParser {
2514
2711
  }
2515
2712
 
2516
2713
  // Parse previous indentation
2517
- // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to w:left/w:right
2714
+ // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to w:left/w:right.
2715
+ // Also parse the six CJK character-unit variants (ST_DecimalNumber) per §17.3.1.12;
2716
+ // these round-trip alongside the twips so Word's rendering of the tracked "previous"
2717
+ // state stays locale-accurate for CJK-authored documents. Matches the iteration-21
2718
+ // fix on the main-path parser.
2518
2719
  if (prevPPr['w:ind']) {
2519
2720
  const ind = prevPPr['w:ind'];
2520
2721
  previousProperties.indentation = {};
@@ -2526,6 +2727,18 @@ export class DocumentParser {
2526
2727
  previousProperties.indentation.firstLine = parseInt(ind['@_w:firstLine'], 10);
2527
2728
  if (ind['@_w:hanging'] !== undefined)
2528
2729
  previousProperties.indentation.hanging = parseInt(ind['@_w:hanging'], 10);
2730
+ // CJK character-unit variants. startChars/endChars collapse onto
2731
+ // leftChars/rightChars (same pattern as the twips variants).
2732
+ const leftCharsVal = ind['@_w:startChars'] ?? ind['@_w:leftChars'];
2733
+ const rightCharsVal = ind['@_w:endChars'] ?? ind['@_w:rightChars'];
2734
+ if (leftCharsVal !== undefined)
2735
+ previousProperties.indentation.leftChars = parseInt(leftCharsVal, 10);
2736
+ if (rightCharsVal !== undefined)
2737
+ previousProperties.indentation.rightChars = parseInt(rightCharsVal, 10);
2738
+ if (ind['@_w:firstLineChars'] !== undefined)
2739
+ previousProperties.indentation.firstLineChars = parseInt(ind['@_w:firstLineChars'], 10);
2740
+ if (ind['@_w:hangingChars'] !== undefined)
2741
+ previousProperties.indentation.hangingChars = parseInt(ind['@_w:hangingChars'], 10);
2529
2742
  }
2530
2743
 
2531
2744
  // Parse previous alignment
@@ -2549,48 +2762,51 @@ export class DocumentParser {
2549
2762
  previousProperties.spacing.beforeLines = parseInt(spacing['@_w:beforeLines'], 10);
2550
2763
  if (spacing['@_w:afterLines'] !== undefined)
2551
2764
  previousProperties.spacing.afterLines = parseInt(spacing['@_w:afterLines'], 10);
2765
+ // ST_OnOff per ECMA-376 §17.17.4 — accept 1/0/true/false/on/off
2552
2766
  const beforeAuto = spacing['@_w:beforeAutospacing'];
2553
2767
  if (beforeAuto !== undefined)
2554
- previousProperties.spacing.beforeAutospacing =
2555
- String(beforeAuto) === '1' || String(beforeAuto) === 'true';
2768
+ previousProperties.spacing.beforeAutospacing = parseOnOffAttribute(beforeAuto);
2556
2769
  const afterAuto = spacing['@_w:afterAutospacing'];
2557
2770
  if (afterAuto !== undefined)
2558
- previousProperties.spacing.afterAutospacing =
2559
- String(afterAuto) === '1' || String(afterAuto) === 'true';
2771
+ previousProperties.spacing.afterAutospacing = parseOnOffAttribute(afterAuto);
2560
2772
  }
2561
2773
 
2562
- // Parse previous keepNext/keepLines/pageBreakBefore
2774
+ // CT_OnOff properties per ECMA-376 §17.17.4 — accept "1"/"0"/"true"/"false"/"on"/"off"
2775
+ // plus the number forms produced by fast-xml-parser's parseAttributeValue. Using
2776
+ // parseOoxmlBoolean() keeps pPrChange round-trips consistent with the main pPr parser;
2777
+ // the previous `!== '0'` pattern silently flipped "false", "off", and the numeric 0.
2563
2778
  if (prevPPr['w:keepNext']) {
2564
- previousProperties.keepNext = prevPPr['w:keepNext']['@_w:val'] !== '0';
2779
+ previousProperties.keepNext = parseOoxmlBoolean(prevPPr['w:keepNext']);
2565
2780
  }
2566
2781
  if (prevPPr['w:keepLines']) {
2567
- previousProperties.keepLines = prevPPr['w:keepLines']['@_w:val'] !== '0';
2782
+ previousProperties.keepLines = parseOoxmlBoolean(prevPPr['w:keepLines']);
2568
2783
  }
2569
2784
  if (prevPPr['w:pageBreakBefore']) {
2570
- previousProperties.pageBreakBefore = prevPPr['w:pageBreakBefore']['@_w:val'] !== '0';
2785
+ previousProperties.pageBreakBefore = parseOoxmlBoolean(prevPPr['w:pageBreakBefore']);
2571
2786
  }
2572
2787
 
2573
2788
  // === Extended paragraph property parsing per ECMA-376 Part 1 §17.3.1 ===
2574
2789
 
2575
2790
  // Parse widowControl (w:widowControl) - orphan/widow control
2576
2791
  if (prevPPr['w:widowControl']) {
2577
- previousProperties.widowControl = prevPPr['w:widowControl']['@_w:val'] !== '0';
2792
+ previousProperties.widowControl = parseOoxmlBoolean(prevPPr['w:widowControl']);
2578
2793
  }
2579
2794
 
2580
2795
  // Parse suppressAutoHyphens (w:suppressAutoHyphens)
2581
2796
  if (prevPPr['w:suppressAutoHyphens']) {
2582
- previousProperties.suppressAutoHyphens =
2583
- prevPPr['w:suppressAutoHyphens']['@_w:val'] !== '0';
2797
+ previousProperties.suppressAutoHyphens = parseOoxmlBoolean(
2798
+ prevPPr['w:suppressAutoHyphens']
2799
+ );
2584
2800
  }
2585
2801
 
2586
2802
  // Parse contextualSpacing (w:contextualSpacing)
2587
2803
  if (prevPPr['w:contextualSpacing']) {
2588
- previousProperties.contextualSpacing = prevPPr['w:contextualSpacing']['@_w:val'] !== '0';
2804
+ previousProperties.contextualSpacing = parseOoxmlBoolean(prevPPr['w:contextualSpacing']);
2589
2805
  }
2590
2806
 
2591
2807
  // Parse mirrorIndents (w:mirrorIndents)
2592
2808
  if (prevPPr['w:mirrorIndents']) {
2593
- previousProperties.mirrorIndents = prevPPr['w:mirrorIndents']['@_w:val'] !== '0';
2809
+ previousProperties.mirrorIndents = parseOoxmlBoolean(prevPPr['w:mirrorIndents']);
2594
2810
  }
2595
2811
 
2596
2812
  // Parse outlineLevel (w:outlineLvl @w:val)
@@ -2598,40 +2814,106 @@ export class DocumentParser {
2598
2814
  previousProperties.outlineLevel = parseInt(prevPPr['w:outlineLvl']['@_w:val'], 10);
2599
2815
  }
2600
2816
 
2817
+ // Parse previous text frame properties (w:framePr) per ECMA-376
2818
+ // Part 1 §17.3.1.11 CT_FramePr. The pPrChange emitter already
2819
+ // rebuilds every framePr attribute (see Paragraph.ts §3634), but
2820
+ // the parser never read them — so a tracked change to any
2821
+ // frame property (drop-cap, text-box positioning, wrap mode,
2822
+ // anchor lock…) silently lost the previous state on round-trip.
2823
+ if (prevPPr['w:framePr']) {
2824
+ const framePr = prevPPr['w:framePr'];
2825
+ const frameProps: any = {};
2826
+ if (isExplicitlySet(framePr['@_w:w'])) frameProps.w = safeParseInt(framePr['@_w:w']);
2827
+ if (isExplicitlySet(framePr['@_w:h'])) frameProps.h = safeParseInt(framePr['@_w:h']);
2828
+ if (framePr['@_w:hRule']) frameProps.hRule = String(framePr['@_w:hRule']);
2829
+ if (isExplicitlySet(framePr['@_w:x'])) frameProps.x = safeParseInt(framePr['@_w:x']);
2830
+ if (isExplicitlySet(framePr['@_w:y'])) frameProps.y = safeParseInt(framePr['@_w:y']);
2831
+ if (framePr['@_w:xAlign']) frameProps.xAlign = String(framePr['@_w:xAlign']);
2832
+ if (framePr['@_w:yAlign']) frameProps.yAlign = String(framePr['@_w:yAlign']);
2833
+ if (framePr['@_w:hAnchor']) frameProps.hAnchor = String(framePr['@_w:hAnchor']);
2834
+ if (framePr['@_w:vAnchor']) frameProps.vAnchor = String(framePr['@_w:vAnchor']);
2835
+ if (isExplicitlySet(framePr['@_w:hSpace'])) {
2836
+ frameProps.hSpace = safeParseInt(framePr['@_w:hSpace']);
2837
+ }
2838
+ if (isExplicitlySet(framePr['@_w:vSpace'])) {
2839
+ frameProps.vSpace = safeParseInt(framePr['@_w:vSpace']);
2840
+ }
2841
+ if (framePr['@_w:wrap']) frameProps.wrap = String(framePr['@_w:wrap']);
2842
+ if (framePr['@_w:dropCap']) frameProps.dropCap = String(framePr['@_w:dropCap']);
2843
+ if (isExplicitlySet(framePr['@_w:lines'])) {
2844
+ frameProps.lines = safeParseInt(framePr['@_w:lines']);
2845
+ }
2846
+ if (isExplicitlySet(framePr['@_w:anchorLock'])) {
2847
+ frameProps.anchorLock = parseOnOffAttribute(String(framePr['@_w:anchorLock']), true);
2848
+ }
2849
+ if (Object.keys(frameProps).length > 0) {
2850
+ previousProperties.framePr = frameProps;
2851
+ }
2852
+ }
2853
+
2601
2854
  // Parse bidi (w:bidi) - right-to-left paragraph
2602
2855
  if (prevPPr['w:bidi']) {
2603
- previousProperties.bidi = prevPPr['w:bidi']['@_w:val'] !== '0';
2856
+ previousProperties.bidi = parseOoxmlBoolean(prevPPr['w:bidi']);
2604
2857
  }
2605
2858
 
2606
2859
  // Parse suppressLineNumbers (w:suppressLineNumbers)
2607
2860
  if (prevPPr['w:suppressLineNumbers']) {
2608
- previousProperties.suppressLineNumbers =
2609
- prevPPr['w:suppressLineNumbers']['@_w:val'] !== '0';
2861
+ previousProperties.suppressLineNumbers = parseOoxmlBoolean(
2862
+ prevPPr['w:suppressLineNumbers']
2863
+ );
2610
2864
  }
2611
2865
 
2612
2866
  // Parse adjustRightInd (w:adjustRightInd)
2613
2867
  if (prevPPr['w:adjustRightInd']) {
2614
- previousProperties.adjustRightInd = prevPPr['w:adjustRightInd']['@_w:val'] !== '0';
2868
+ previousProperties.adjustRightInd = parseOoxmlBoolean(prevPPr['w:adjustRightInd']);
2615
2869
  }
2616
2870
 
2617
2871
  // Parse snapToGrid (w:snapToGrid)
2618
2872
  if (prevPPr['w:snapToGrid']) {
2619
- previousProperties.snapToGrid = prevPPr['w:snapToGrid']['@_w:val'] !== '0';
2873
+ previousProperties.snapToGrid = parseOoxmlBoolean(prevPPr['w:snapToGrid']);
2620
2874
  }
2621
2875
 
2622
2876
  // Parse wordWrap (w:wordWrap)
2623
2877
  if (prevPPr['w:wordWrap']) {
2624
- previousProperties.wordWrap = prevPPr['w:wordWrap']['@_w:val'] !== '0';
2878
+ previousProperties.wordWrap = parseOoxmlBoolean(prevPPr['w:wordWrap']);
2625
2879
  }
2626
2880
 
2627
2881
  // Parse autoSpaceDE (w:autoSpaceDE) - East Asian/numeric spacing
2628
2882
  if (prevPPr['w:autoSpaceDE']) {
2629
- previousProperties.autoSpaceDE = prevPPr['w:autoSpaceDE']['@_w:val'] !== '0';
2883
+ previousProperties.autoSpaceDE = parseOoxmlBoolean(prevPPr['w:autoSpaceDE']);
2630
2884
  }
2631
2885
 
2632
2886
  // Parse autoSpaceDN (w:autoSpaceDN) - East Asian/Western spacing
2633
2887
  if (prevPPr['w:autoSpaceDN']) {
2634
- previousProperties.autoSpaceDN = prevPPr['w:autoSpaceDN']['@_w:val'] !== '0';
2888
+ previousProperties.autoSpaceDN = parseOoxmlBoolean(prevPPr['w:autoSpaceDN']);
2889
+ }
2890
+
2891
+ // Parse kinsoku / overflowPunct / topLinePunct / suppressOverlap —
2892
+ // CJK typography CT_OnOff flags. The Paragraph pPrChange generator
2893
+ // already emits these in the previous-properties block, but the
2894
+ // parser was missing the read side, so tracked paragraph-property
2895
+ // revisions that recorded any of these four flags were silently
2896
+ // dropped on load → save. Uses `parseOoxmlBoolean` to honour every
2897
+ // ST_OnOff literal (bare, 1/0, true/false, on/off).
2898
+ if (prevPPr['w:kinsoku']) {
2899
+ (previousProperties as { kinsoku?: boolean }).kinsoku = parseOoxmlBoolean(
2900
+ prevPPr['w:kinsoku']
2901
+ );
2902
+ }
2903
+ if (prevPPr['w:overflowPunct']) {
2904
+ (previousProperties as { overflowPunct?: boolean }).overflowPunct = parseOoxmlBoolean(
2905
+ prevPPr['w:overflowPunct']
2906
+ );
2907
+ }
2908
+ if (prevPPr['w:topLinePunct']) {
2909
+ (previousProperties as { topLinePunct?: boolean }).topLinePunct = parseOoxmlBoolean(
2910
+ prevPPr['w:topLinePunct']
2911
+ );
2912
+ }
2913
+ if (prevPPr['w:suppressOverlap']) {
2914
+ (previousProperties as { suppressOverlap?: boolean }).suppressOverlap = parseOoxmlBoolean(
2915
+ prevPPr['w:suppressOverlap']
2916
+ );
2635
2917
  }
2636
2918
 
2637
2919
  // Parse textDirection (w:textDirection @w:val)
@@ -2644,23 +2926,68 @@ export class DocumentParser {
2644
2926
  previousProperties.textAlignment = String(prevPPr['w:textAlignment']['@_w:val']);
2645
2927
  }
2646
2928
 
2929
+ // Parse previous divId (w:divId) per ECMA-376 §17.3.1.10 —
2930
+ // ST_DecimalNumber referencing a web-settings div. Zero is a
2931
+ // legal ID (first div). XMLParser coerces `"0"` to number 0, so
2932
+ // gate via `isExplicitlySet` to preserve divId=0 on tracked
2933
+ // previous state. The pPrChange emitter (Paragraph.ts §3915)
2934
+ // re-emits prev.divId via `!== undefined`.
2935
+ if (prevPPr['w:divId']?.['@_w:val'] !== undefined) {
2936
+ const rawDivId = prevPPr['w:divId']['@_w:val'];
2937
+ const parsedDivId = safeParseInt(rawDivId);
2938
+ if (!isNaN(parsedDivId)) {
2939
+ previousProperties.divId = parsedDivId;
2940
+ }
2941
+ }
2942
+
2943
+ // Parse previous cnfStyle (w:cnfStyle) per ECMA-376 §17.3.1.8 —
2944
+ // 12-character bitmask identifying which conditional-formatting
2945
+ // flags from the parent table style apply. XMLParser coerces
2946
+ // purely-numeric hex strings, but the custom parseValue keeps
2947
+ // 7+-digit strings as-is (so 12-char bitmasks survive); use
2948
+ // String + padStart to defensively normalise any shorter form.
2949
+ if (prevPPr['w:cnfStyle']?.['@_w:val'] !== undefined) {
2950
+ previousProperties.cnfStyle = String(prevPPr['w:cnfStyle']['@_w:val']).padStart(12, '0');
2951
+ }
2952
+
2647
2953
  // Parse paragraph borders (w:pBdr) per ECMA-376 Part 1 §17.3.1.24
2954
+ // Previous versions stored the attribute values under the wrong
2955
+ // field names (`val`/`sz` instead of `style`/`size`) — the
2956
+ // paragraph emitter reads `style`/`size`, so every tracked
2957
+ // previous border collapsed to `<w:top w:val="nil"/>` on
2958
+ // round-trip. The CT_Border attribute coverage here now matches
2959
+ // the main parser (§17.18.2): all nine attrs, with shadow/frame
2960
+ // routed through parseOnOffAttribute so ST_OnOff literals
2961
+ // ("on"/"off"/"true"/"false") resolve correctly.
2648
2962
  if (prevPPr['w:pBdr']) {
2649
2963
  const pBdr = prevPPr['w:pBdr'];
2650
2964
  previousProperties.borders = {};
2651
2965
 
2652
2966
  const parseBorder = (borderObj: any) => {
2653
2967
  if (!borderObj) return undefined;
2654
- return {
2655
- val: borderObj['@_w:val'],
2656
- sz: borderObj['@_w:sz'] !== undefined ? parseInt(borderObj['@_w:sz'], 10) : undefined,
2657
- space:
2658
- borderObj['@_w:space'] !== undefined
2659
- ? parseInt(borderObj['@_w:space'], 10)
2660
- : undefined,
2661
- color: borderObj['@_w:color'],
2662
- themeColor: borderObj['@_w:themeColor'],
2663
- };
2968
+ const border: any = {};
2969
+ if (borderObj['@_w:val']) border.style = borderObj['@_w:val'];
2970
+ if (borderObj['@_w:sz'] !== undefined) border.size = safeParseInt(borderObj['@_w:sz']);
2971
+ if (borderObj['@_w:space'] !== undefined) {
2972
+ border.space = safeParseInt(borderObj['@_w:space']);
2973
+ }
2974
+ if (borderObj['@_w:color']) border.color = borderObj['@_w:color'];
2975
+ if (borderObj['@_w:themeColor']) {
2976
+ border.themeColor = String(borderObj['@_w:themeColor']);
2977
+ }
2978
+ if (borderObj['@_w:themeTint']) {
2979
+ border.themeTint = String(borderObj['@_w:themeTint']);
2980
+ }
2981
+ if (borderObj['@_w:themeShade']) {
2982
+ border.themeShade = String(borderObj['@_w:themeShade']);
2983
+ }
2984
+ if (borderObj['@_w:shadow'] !== undefined) {
2985
+ border.shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
2986
+ }
2987
+ if (borderObj['@_w:frame'] !== undefined) {
2988
+ border.frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
2989
+ }
2990
+ return Object.keys(border).length > 0 ? border : undefined;
2664
2991
  };
2665
2992
 
2666
2993
  if (pBdr['w:top']) previousProperties.borders.top = parseBorder(pBdr['w:top']);
@@ -3821,11 +4148,15 @@ export class DocumentParser {
3821
4148
  return XMLBuilder.unescapeXml(String(node));
3822
4149
  };
3823
4150
 
3824
- const parseBooleanAttr = (value: any): boolean | undefined => {
4151
+ // Field-character attributes (w:dirty, w:fldLock, w:lock on w:fldChar) are
4152
+ // ST_OnOff per ECMA-376 §17.16.18. Delegate to parseOnOffAttribute so every
4153
+ // literal is honoured — the previous inline check missed "on" (silently
4154
+ // coerced to false) and was tighter than the spec requires.
4155
+ const parseBooleanAttr = (value: unknown): boolean | undefined => {
3825
4156
  if (value === undefined || value === null) {
3826
4157
  return undefined;
3827
4158
  }
3828
- return value === '1' || value === 1 || value === true || value === 'true';
4159
+ return parseOnOffAttribute(value);
3829
4160
  };
3830
4161
 
3831
4162
  // Parse w:ffData from a fldChar object (form field data per ECMA-376 §17.16.17)
@@ -3840,15 +4171,13 @@ export class DocumentParser {
3840
4171
  if (ffDataObj['w:name']?.['@_w:val'] !== undefined) {
3841
4172
  ffd.name = String(ffDataObj['w:name']['@_w:val']);
3842
4173
  }
3843
- // w:enabled (presence = true, w:val="0" = false)
4174
+ // w:enabled — CT_OnOff per ECMA-376 §17.16.11; presence = true, w:val honours ST_OnOff
3844
4175
  if (ffDataObj['w:enabled'] !== undefined) {
3845
- const enabledVal = ffDataObj['w:enabled']?.['@_w:val'];
3846
- ffd.enabled = enabledVal === '0' || enabledVal === 0 ? false : true;
4176
+ ffd.enabled = parseOoxmlBoolean(ffDataObj['w:enabled']);
3847
4177
  }
3848
- // w:calcOnExit
4178
+ // w:calcOnExit — CT_OnOff per ECMA-376 §17.16.4; presence = true, w:val honours ST_OnOff
3849
4179
  if (ffDataObj['w:calcOnExit'] !== undefined) {
3850
- const calcVal = ffDataObj['w:calcOnExit']?.['@_w:val'];
3851
- ffd.calcOnExit = calcVal === '1' || calcVal === 1 || calcVal === true;
4180
+ ffd.calcOnExit = parseOoxmlBoolean(ffDataObj['w:calcOnExit']);
3852
4181
  }
3853
4182
  // w:helpText
3854
4183
  if (ffDataObj['w:helpText']?.['@_w:val'] !== undefined) {
@@ -3884,13 +4213,14 @@ export class DocumentParser {
3884
4213
  if (ffDataObj['w:checkBox'] !== undefined) {
3885
4214
  const cb: XmlNode = ffDataObj['w:checkBox'];
3886
4215
  const checkBox: FormFieldCheckBox = { type: 'checkBox' };
3887
- if (cb['w:default']?.['@_w:val'] !== undefined) {
3888
- checkBox.defaultChecked =
3889
- cb['w:default']['@_w:val'] === '1' || cb['w:default']['@_w:val'] === 1;
4216
+ // w:default / w:checked are CT_OnOff per ECMA-376 §17.16.18 —
4217
+ // honour every ST_OnOff literal ("true"/"false"/"1"/"0"/"on"/"off")
4218
+ // and treat a bare self-closing element as true.
4219
+ if (cb['w:default'] !== undefined) {
4220
+ checkBox.defaultChecked = parseOoxmlBoolean(cb['w:default']);
3890
4221
  }
3891
- if (cb['w:checked']?.['@_w:val'] !== undefined) {
3892
- checkBox.checked =
3893
- cb['w:checked']['@_w:val'] === '1' || cb['w:checked']['@_w:val'] === 1;
4222
+ if (cb['w:checked'] !== undefined) {
4223
+ checkBox.checked = parseOoxmlBoolean(cb['w:checked']);
3894
4224
  }
3895
4225
  if (cb['w:size']?.['@_w:val'] !== undefined) {
3896
4226
  checkBox.size = Number(cb['w:size']['@_w:val']);
@@ -4094,30 +4424,47 @@ export class DocumentParser {
4094
4424
  content.push({ type: 'annotationRef' });
4095
4425
  break;
4096
4426
 
4097
- // Footnote reference (w:footnoteReference) per ECMA-376 Part 1 §17.11.13
4427
+ // Footnote reference (w:footnoteReference) per ECMA-376 Part 1 §17.11.13.
4428
+ // w:customMarkFollows is ST_OnOff — honour every literal via parseOnOffAttribute.
4098
4429
  case 'w:footnoteReference': {
4099
4430
  const fnRefElements = toArray(runObj['w:footnoteReference']);
4100
4431
  const fnRef = fnRefElements[elementIndex] || fnRefElements[0];
4101
4432
  const fnId = fnRef?.['@_w:id'];
4433
+ const fnCustomMark = fnRef?.['@_w:customMarkFollows'];
4102
4434
  content.push({
4103
4435
  type: 'footnoteReference',
4104
4436
  footnoteId: fnId !== undefined ? parseInt(fnId, 10) : undefined,
4437
+ customMarkFollows:
4438
+ fnCustomMark !== undefined ? parseOnOffAttribute(fnCustomMark) : undefined,
4105
4439
  });
4106
4440
  break;
4107
4441
  }
4108
4442
 
4109
- // Endnote reference (w:endnoteReference) per ECMA-376 Part 1 §17.11.2
4443
+ // Endnote reference (w:endnoteReference) per ECMA-376 Part 1 §17.11.2.
4444
+ // Same ST_OnOff treatment for w:customMarkFollows.
4110
4445
  case 'w:endnoteReference': {
4111
4446
  const enRefElements = toArray(runObj['w:endnoteReference']);
4112
4447
  const enRef = enRefElements[elementIndex] || enRefElements[0];
4113
4448
  const enId = enRef?.['@_w:id'];
4449
+ const enCustomMark = enRef?.['@_w:customMarkFollows'];
4114
4450
  content.push({
4115
4451
  type: 'endnoteReference',
4116
4452
  endnoteId: enId !== undefined ? parseInt(enId, 10) : undefined,
4453
+ customMarkFollows:
4454
+ enCustomMark !== undefined ? parseOnOffAttribute(enCustomMark) : undefined,
4117
4455
  });
4118
4456
  break;
4119
4457
  }
4120
4458
 
4459
+ // Auto-numbered marks INSIDE a footnote/endnote body per
4460
+ // ECMA-376 §17.11.14 / §17.11.3. Empty self-closing elements.
4461
+ case 'w:footnoteRef':
4462
+ content.push({ type: 'footnoteRef' });
4463
+ break;
4464
+ case 'w:endnoteRef':
4465
+ content.push({ type: 'endnoteRef' });
4466
+ break;
4467
+
4121
4468
  case 'w:dayShort':
4122
4469
  content.push({ type: 'dayShort' });
4123
4470
  break;
@@ -4303,14 +4650,18 @@ export class DocumentParser {
4303
4650
  if (runObj['w:annotationRef'] !== undefined) {
4304
4651
  content.push({ type: 'annotationRef' });
4305
4652
  }
4306
- // Footnote/endnote reference fallback
4653
+ // Footnote/endnote reference fallback. w:customMarkFollows is ST_OnOff
4654
+ // per ECMA-376 §17.11.13 / §17.11.2 — honour every literal.
4307
4655
  if (runObj['w:footnoteReference'] !== undefined) {
4308
4656
  const fnRefElements = toArray(runObj['w:footnoteReference']);
4309
4657
  for (const fnRef of fnRefElements) {
4310
4658
  const fnId = fnRef?.['@_w:id'];
4659
+ const fnCustomMark = fnRef?.['@_w:customMarkFollows'];
4311
4660
  content.push({
4312
4661
  type: 'footnoteReference',
4313
4662
  footnoteId: fnId !== undefined ? parseInt(fnId, 10) : undefined,
4663
+ customMarkFollows:
4664
+ fnCustomMark !== undefined ? parseOnOffAttribute(fnCustomMark) : undefined,
4314
4665
  });
4315
4666
  }
4316
4667
  }
@@ -4318,12 +4669,22 @@ export class DocumentParser {
4318
4669
  const enRefElements = toArray(runObj['w:endnoteReference']);
4319
4670
  for (const enRef of enRefElements) {
4320
4671
  const enId = enRef?.['@_w:id'];
4672
+ const enCustomMark = enRef?.['@_w:customMarkFollows'];
4321
4673
  content.push({
4322
4674
  type: 'endnoteReference',
4323
4675
  endnoteId: enId !== undefined ? parseInt(enId, 10) : undefined,
4676
+ customMarkFollows:
4677
+ enCustomMark !== undefined ? parseOnOffAttribute(enCustomMark) : undefined,
4324
4678
  });
4325
4679
  }
4326
4680
  }
4681
+ // Auto-numbered marks INSIDE a footnote/endnote body — empty elements.
4682
+ if (runObj['w:footnoteRef'] !== undefined) {
4683
+ content.push({ type: 'footnoteRef' });
4684
+ }
4685
+ if (runObj['w:endnoteRef'] !== undefined) {
4686
+ content.push({ type: 'endnoteRef' });
4687
+ }
4327
4688
  if (runObj['w:dayShort'] !== undefined) {
4328
4689
  content.push({ type: 'dayShort' });
4329
4690
  }
@@ -4422,12 +4783,40 @@ export class DocumentParser {
4422
4783
  : [hyperlinkObj['w:bookmarkStart']];
4423
4784
  for (const bs of bookmarkStarts) {
4424
4785
  const id = bs['@_w:id'];
4425
- const name = bs['@_w:name'];
4786
+ // w:name is ST_String per §17.16.5 CT_Bookmark. XMLParser
4787
+ // coerces purely-numeric bookmark names ("12345") to JS
4788
+ // numbers; cast so Bookmark.name holds the declared string
4789
+ // type contract (parent parsers already do the same —
4790
+ // iter 125 toOptString helper).
4791
+ const rawName = bs['@_w:name'];
4792
+ const name =
4793
+ rawName === undefined || rawName === null || rawName === ''
4794
+ ? undefined
4795
+ : String(rawName);
4426
4796
  if (id !== undefined && name) {
4797
+ // CT_Bookmark per ECMA-376 §17.16.5: the object-form parser
4798
+ // must carry the same four "markup" attributes that the
4799
+ // XML-string bookmarkStart parser handles — colFirst/colLast
4800
+ // (table-column-scoped bookmarks) and displacedByCustomXml
4801
+ // (custom-XML boundary disambiguator). Previously dropped
4802
+ // whenever a hyperlink wrapped a bookmark, so inline
4803
+ // hyperlinks anchored to table-column bookmarks lost their
4804
+ // column range on round-trip.
4805
+ const rawColFirst = bs['@_w:colFirst'];
4806
+ const rawColLast = bs['@_w:colLast'];
4807
+ const rawDisplaced = bs['@_w:displacedByCustomXml'];
4808
+ const colFirst =
4809
+ rawColFirst === undefined ? undefined : parseInt(String(rawColFirst), 10);
4810
+ const colLast = rawColLast === undefined ? undefined : parseInt(String(rawColLast), 10);
4811
+ const displacedByCustomXml =
4812
+ rawDisplaced === 'next' || rawDisplaced === 'prev' ? rawDisplaced : undefined;
4427
4813
  const bookmark = new Bookmark({
4428
4814
  name: name,
4429
4815
  id: typeof id === 'number' ? id : parseInt(id, 10),
4430
4816
  skipNormalization: true,
4817
+ colFirst: Number.isNaN(colFirst as number) ? undefined : colFirst,
4818
+ colLast: Number.isNaN(colLast as number) ? undefined : colLast,
4819
+ displacedByCustomXml,
4431
4820
  });
4432
4821
  result.bookmarkStarts.push(bookmark);
4433
4822
  // Also register with BookmarkManager
@@ -4449,23 +4838,47 @@ export class DocumentParser {
4449
4838
  for (const be of bookmarkEnds) {
4450
4839
  const id = be['@_w:id'];
4451
4840
  if (id !== undefined) {
4841
+ // CT_MarkupRange per ECMA-376 §17.13.5 — preserve
4842
+ // w:displacedByCustomXml on bookmarkEnd when a custom-XML
4843
+ // boundary forced the marker to be displaced.
4844
+ const rawDisplaced = be['@_w:displacedByCustomXml'];
4845
+ const displacedByCustomXml =
4846
+ rawDisplaced === 'next' || rawDisplaced === 'prev' ? rawDisplaced : undefined;
4452
4847
  const bookmark = new Bookmark({
4453
4848
  name: `_end_${id}`,
4454
4849
  id: typeof id === 'number' ? id : parseInt(id, 10),
4455
4850
  skipNormalization: true,
4851
+ displacedByCustomXml,
4456
4852
  });
4457
4853
  result.bookmarkEnds.push(bookmark);
4458
4854
  }
4459
4855
  }
4460
4856
  }
4461
4857
 
4462
- // Extract hyperlink attributes
4463
- const relationshipId = hyperlinkObj['@_r:id'];
4464
- const anchor = hyperlinkObj['@_w:anchor'];
4465
- const tooltip = hyperlinkObj['@_w:tooltip'];
4466
- const tgtFrame = hyperlinkObj['@_w:tgtFrame'];
4467
- const history = hyperlinkObj['@_w:history'];
4468
- const docLocation = hyperlinkObj['@_w:docLocation'];
4858
+ // Extract hyperlink attributes. Per ECMA-376 §17.16.22 CT_Hyperlink,
4859
+ // w:anchor / w:tooltip / w:tgtFrame / w:docLocation / r:id are all
4860
+ // ST_String. XMLParser's `parseAttributeValue: true` coerces
4861
+ // purely-numeric strings (e.g., a bookmark name like "12345") to
4862
+ // JS numbers — cast via String(...) so downstream `Hyperlink`
4863
+ // storage and string-method callers see the declared `string`
4864
+ // type contract.
4865
+ const toOptString = (v: unknown): string | undefined =>
4866
+ v === undefined || v === null ? undefined : String(v);
4867
+ const relationshipId = toOptString(hyperlinkObj['@_r:id']);
4868
+ const anchor = toOptString(hyperlinkObj['@_w:anchor']);
4869
+ const tooltip = toOptString(hyperlinkObj['@_w:tooltip']);
4870
+ const tgtFrame = toOptString(hyperlinkObj['@_w:tgtFrame']);
4871
+ // w:history is CT_OnOff per ECMA-376 §17.16.22 — honour every
4872
+ // ST_OnOff literal ("1"/"0"/"true"/"false"/"on"/"off") and every
4873
+ // XMLParser-coerced form (number 0/1, boolean). The Hyperlink
4874
+ // serializer accepts a string, so normalise to the canonical
4875
+ // "1"/"0" form. Without this, `w:history="0"` or `w:history="false"`
4876
+ // coerced to falsy values and the emitter's truthy check dropped
4877
+ // the attribute on round-trip.
4878
+ const rawHistory = hyperlinkObj['@_w:history'];
4879
+ const history =
4880
+ rawHistory === undefined ? undefined : parseOnOffAttribute(rawHistory) ? '1' : '0';
4881
+ const docLocation = toOptString(hyperlinkObj['@_w:docLocation']);
4469
4882
 
4470
4883
  // Parse runs inside the hyperlink
4471
4884
  const runs = hyperlinkObj['w:r'];
@@ -4763,8 +5176,20 @@ export class DocumentParser {
4763
5176
  }
4764
5177
 
4765
5178
  // Extract field type from instruction (first word)
4766
- const typeMatch = instruction.trim().match(/^(\w+)/);
4767
- const type = (typeMatch?.[1] || 'PAGE') as import('../elements/Field').FieldType;
5179
+ const typeMatch = String(instruction)
5180
+ .trim()
5181
+ .match(/^(\w+)/);
5182
+ const type = (typeMatch?.[1] || 'PAGE') as import('../elements/Field.js').FieldType;
5183
+
5184
+ // CT_SimpleField (§17.16.16) carries two ST_OnOff attributes besides
5185
+ // the required w:instr — w:fldLock (update lock) and w:dirty
5186
+ // (cached-result staleness). Previously neither was parsed, so
5187
+ // Word's "update field" indicator and "lock field" flag were
5188
+ // silently cleared on every load → save round-trip.
5189
+ const fldLockRaw = fieldObj['@_w:fldLock'];
5190
+ const dirtyRaw = fieldObj['@_w:dirty'];
5191
+ const fldLock = fldLockRaw !== undefined ? parseOnOffAttribute(fldLockRaw) : undefined;
5192
+ const dirty = dirtyRaw !== undefined ? parseOnOffAttribute(dirtyRaw) : undefined;
4768
5193
 
4769
5194
  // Parse run formatting from w:rPr if present
4770
5195
  let formatting: RunFormatting | undefined;
@@ -4777,8 +5202,10 @@ export class DocumentParser {
4777
5202
  // Create field with instruction
4778
5203
  const field = Field.create({
4779
5204
  type,
4780
- instruction,
5205
+ instruction: String(instruction),
4781
5206
  formatting,
5207
+ fldLock,
5208
+ dirty,
4782
5209
  });
4783
5210
 
4784
5211
  return field;
@@ -4796,15 +5223,25 @@ export class DocumentParser {
4796
5223
  private parseRunPropertiesFromObject(rPrObj: any, run: Run): void {
4797
5224
  if (!rPrObj) return;
4798
5225
 
4799
- // Parse character style reference (w:rStyle) per ECMA-376 Part 1 §17.3.2.36
5226
+ // Parse character style reference (w:rStyle) per ECMA-376 Part 1
5227
+ // §17.3.2.36 — `w:val` is ST_String referencing a style ID. Cast
5228
+ // via String(...) so a purely-numeric style ID (e.g., "1") that
5229
+ // XMLParser coerces to the number 1 survives as the string "1",
5230
+ // matching the `characterStyle?: string` field contract on
5231
+ // RunFormatting.
4800
5232
  if (rPrObj['w:rStyle']) {
4801
5233
  const styleId = rPrObj['w:rStyle']['@_w:val'];
4802
- if (styleId) {
4803
- run.setCharacterStyle(styleId);
5234
+ if (styleId !== undefined && styleId !== null && styleId !== '') {
5235
+ run.setCharacterStyle(String(styleId));
4804
5236
  }
4805
5237
  }
4806
5238
 
4807
- // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.5
5239
+ // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.5 — CT_Border
5240
+ // §17.18.2 attribute set: val / sz / space / color / themeColor /
5241
+ // themeTint / themeShade / shadow / frame. Previously only the first
5242
+ // four were read, so themed character borders lost their theme linkage
5243
+ // on round-trip. The emitter (Run.generateRunPropertiesXML) handles
5244
+ // all nine since iteration 79.
4808
5245
  if (rPrObj['w:bdr']) {
4809
5246
  const bdr = rPrObj['w:bdr'];
4810
5247
  const border: any = {};
@@ -4812,6 +5249,20 @@ export class DocumentParser {
4812
5249
  if (bdr['@_w:sz']) border.size = parseInt(bdr['@_w:sz'], 10);
4813
5250
  if (bdr['@_w:color']) border.color = bdr['@_w:color'];
4814
5251
  if (bdr['@_w:space']) border.space = parseInt(bdr['@_w:space'], 10);
5252
+ // Per ECMA-376 §17.18.82 CT_Border: themeTint / themeShade are
5253
+ // ST_UcharHexNumber (2-char hex). XMLParser coerces purely-digit
5254
+ // hex strings like "80" / "50" to JS numbers; cast via String(...)
5255
+ // so the declared `string` contract on the model holds for any
5256
+ // downstream code that calls string methods (.toUpperCase(), etc.).
5257
+ if (bdr['@_w:themeColor']) border.themeColor = String(bdr['@_w:themeColor']);
5258
+ if (bdr['@_w:themeTint']) border.themeTint = String(bdr['@_w:themeTint']);
5259
+ if (bdr['@_w:themeShade']) border.themeShade = String(bdr['@_w:themeShade']);
5260
+ if (bdr['@_w:shadow'] !== undefined) {
5261
+ border.shadow = parseOnOffAttribute(String(bdr['@_w:shadow']), true);
5262
+ }
5263
+ if (bdr['@_w:frame'] !== undefined) {
5264
+ border.frame = parseOnOffAttribute(String(bdr['@_w:frame']), true);
5265
+ }
4815
5266
  if (Object.keys(border).length > 0) {
4816
5267
  run.setBorder(border);
4817
5268
  }
@@ -4831,26 +5282,30 @@ export class DocumentParser {
4831
5282
  if (val) run.setEmphasis(val);
4832
5283
  }
4833
5284
 
4834
- // Parse boolean text effects — use parseOoxmlBoolean to correctly handle w:val="0"/"false"
4835
- // Per ECMA-376, <w:xxx/> or <w:xxx w:val="1"/> = true; <w:xxx w:val="0"/> = false (explicit off)
4836
- if (parseOoxmlBoolean(rPrObj['w:outline'])) run.setOutline(true);
4837
- if (parseOoxmlBoolean(rPrObj['w:shadow'])) run.setShadow(true);
4838
- if (parseOoxmlBoolean(rPrObj['w:emboss'])) run.setEmboss(true);
4839
- if (parseOoxmlBoolean(rPrObj['w:imprint'])) run.setImprint(true);
4840
- if (parseOoxmlBoolean(rPrObj['w:noProof'])) run.setNoProof(true);
5285
+ // CT_OnOff text effects — presence + w:val both matter. Use `!== undefined`
5286
+ // to detect presence, then parseOoxmlBoolean() for the value, so an explicit
5287
+ // `<w:outline w:val="0"/>` override of a style-inherited true is preserved
5288
+ // (not silently dropped into "inherit"). Applies to all OnOffType rPr flags
5289
+ // per ECMA-376 §17.3.2.
5290
+ if (rPrObj['w:outline'] !== undefined) run.setOutline(parseOoxmlBoolean(rPrObj['w:outline']));
5291
+ if (rPrObj['w:shadow'] !== undefined) run.setShadow(parseOoxmlBoolean(rPrObj['w:shadow']));
5292
+ if (rPrObj['w:emboss'] !== undefined) run.setEmboss(parseOoxmlBoolean(rPrObj['w:emboss']));
5293
+ if (rPrObj['w:imprint'] !== undefined) run.setImprint(parseOoxmlBoolean(rPrObj['w:imprint']));
5294
+ if (rPrObj['w:noProof'] !== undefined) run.setNoProof(parseOoxmlBoolean(rPrObj['w:noProof']));
4841
5295
  // snapToGrid: default when absent is true (§17.3.2.34), so explicit val="0" must be preserved
4842
5296
  if (rPrObj['w:snapToGrid'] !== undefined) {
4843
5297
  run.setSnapToGrid(parseOoxmlBoolean(rPrObj['w:snapToGrid']));
4844
5298
  }
4845
- if (parseOoxmlBoolean(rPrObj['w:vanish'])) run.setVanish(true);
4846
- if (parseOoxmlBoolean(rPrObj['w:specVanish'])) run.setSpecVanish(true);
5299
+ if (rPrObj['w:vanish'] !== undefined) run.setVanish(parseOoxmlBoolean(rPrObj['w:vanish']));
5300
+ if (rPrObj['w:specVanish'] !== undefined)
5301
+ run.setSpecVanish(parseOoxmlBoolean(rPrObj['w:specVanish']));
4847
5302
 
4848
5303
  // Boolean properties - use parseOoxmlBoolean helper
4849
5304
  // Per ECMA-376: <w:b/> or <w:b w:val="1"/> or <w:b w:val="true"/> means true
4850
5305
  // <w:b w:val="0"/> or <w:b w:val="false"/> means false (omit from document)
4851
5306
 
4852
5307
  // Parse RTL text (w:rtl) per ECMA-376 Part 1 §17.3.2.30
4853
- if (parseOoxmlBoolean(rPrObj['w:rtl'])) run.setRTL(true);
5308
+ if (rPrObj['w:rtl'] !== undefined) run.setRTL(parseOoxmlBoolean(rPrObj['w:rtl']));
4854
5309
 
4855
5310
  // b, bCs, i, iCs: preserve explicit val="0" to override style-inherited formatting
4856
5311
  if (rPrObj['w:b'] !== undefined) run.setBold(parseOoxmlBoolean(rPrObj['w:b']));
@@ -4867,11 +5322,12 @@ export class DocumentParser {
4867
5322
  run.setSmallCaps(parseOoxmlBoolean(rPrObj['w:smallCaps']));
4868
5323
  if (rPrObj['w:caps'] !== undefined) run.setAllCaps(parseOoxmlBoolean(rPrObj['w:caps']));
4869
5324
 
4870
- // Parse complex script flag (w:cs) per ECMA-376 Part 1 §17.3.2.7
4871
- if (parseOoxmlBoolean(rPrObj['w:cs'])) run.setComplexScript(true);
5325
+ // Parse complex script flag (w:cs) per ECMA-376 Part 1 §17.3.2.7 — CT_OnOff
5326
+ if (rPrObj['w:cs'] !== undefined) run.setComplexScript(parseOoxmlBoolean(rPrObj['w:cs']));
4872
5327
 
4873
- // Parse web hidden (w:webHidden) per ECMA-376 Part 1 §17.3.2.44
4874
- if (parseOoxmlBoolean(rPrObj['w:webHidden'])) run.setWebHidden(true);
5328
+ // Parse web hidden (w:webHidden) per ECMA-376 Part 1 §17.3.2.44 — CT_OnOff
5329
+ if (rPrObj['w:webHidden'] !== undefined)
5330
+ run.setWebHidden(parseOoxmlBoolean(rPrObj['w:webHidden']));
4875
5331
 
4876
5332
  if (rPrObj['w:u']) {
4877
5333
  // XMLParser adds @_ prefix to attributes
@@ -4891,28 +5347,37 @@ export class DocumentParser {
4891
5347
  );
4892
5348
  }
4893
5349
 
4894
- // Parse character spacing (w:spacing) per ECMA-376 Part 1 §17.3.2.33
5350
+ // Parse character spacing (w:spacing) per ECMA-376 Part 1 §17.3.2.35.
5351
+ // ST_SignedTwipsMeasure — 0 and negative values are valid (default /
5352
+ // tighter spacing). XMLParser.parseAttributeValue coerces "0" to number 0,
5353
+ // which is falsy — so the previous `if (val)` truthy check silently dropped
5354
+ // explicit zero / baseline-reset formatting on every run that used it.
5355
+ // Matches the rPrChange parser below which already uses `!== undefined`.
4895
5356
  if (rPrObj['w:spacing']) {
4896
5357
  const val = rPrObj['w:spacing']['@_w:val'];
4897
- if (val) run.setCharacterSpacing(parseInt(val, 10));
5358
+ if (val !== undefined) run.setCharacterSpacing(parseInt(String(val), 10));
4898
5359
  }
4899
5360
 
4900
- // Parse horizontal scaling (w:w) per ECMA-376 Part 1 §17.3.2.43
5361
+ // Parse horizontal scaling (w:w) per ECMA-376 Part 1 §17.3.2.43.
5362
+ // ST_TextScale — min 1 per schema, so value 0 is not spec-valid; keep
5363
+ // truthy check as a mild sanity guard against malformed sources.
4901
5364
  if (rPrObj['w:w']) {
4902
5365
  const val = rPrObj['w:w']['@_w:val'];
4903
- if (val) run.setScaling(parseInt(val, 10));
5366
+ if (val) run.setScaling(parseInt(String(val), 10));
4904
5367
  }
4905
5368
 
4906
- // Parse vertical position (w:position) per ECMA-376 Part 1 §17.3.2.31
5369
+ // Parse vertical position (w:position) per ECMA-376 Part 1 §17.3.2.31.
5370
+ // ST_SignedHpsMeasure — 0 = baseline (default / explicit reset).
4907
5371
  if (rPrObj['w:position']) {
4908
5372
  const val = rPrObj['w:position']['@_w:val'];
4909
- if (val) run.setPosition(parseInt(val, 10));
5373
+ if (val !== undefined) run.setPosition(parseInt(String(val), 10));
4910
5374
  }
4911
5375
 
4912
- // Parse kerning (w:kern) per ECMA-376 Part 1 §17.3.2.20
5376
+ // Parse kerning (w:kern) per ECMA-376 Part 1 §17.3.2.20.
5377
+ // ST_HpsMeasure — 0 means "kern at every size" (no minimum threshold).
4913
5378
  if (rPrObj['w:kern']) {
4914
5379
  const val = rPrObj['w:kern']['@_w:val'];
4915
- if (val) run.setKerning(parseInt(val, 10));
5380
+ if (val !== undefined) run.setKerning(parseInt(String(val), 10));
4916
5381
  }
4917
5382
 
4918
5383
  // Parse language (w:lang) per ECMA-376 Part 1 §17.3.2.20 (CT_Language)
@@ -4932,14 +5397,27 @@ export class DocumentParser {
4932
5397
  }
4933
5398
  }
4934
5399
 
4935
- // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1 §17.3.2.10
5400
+ // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1
5401
+ // §17.3.2.10 CT_EastAsianLayout. `w:vert` / `w:vertCompress` /
5402
+ // `w:combine` are ST_OnOff attributes — route through
5403
+ // parseOnOffAttribute so every literal ("1"/"0"/"true"/"false"/
5404
+ // "on"/"off") resolves correctly. The previous truthy gate both
5405
+ // dropped explicit false (`w:vert="0"` → coerced 0 → undefined) AND
5406
+ // wrongly marked `w:vert="off"` as true (non-empty string is truthy
5407
+ // without parsing).
4936
5408
  if (rPrObj['w:eastAsianLayout']) {
4937
5409
  const layoutObj = rPrObj['w:eastAsianLayout'];
4938
5410
  const layout: any = {};
4939
5411
  if (layoutObj['@_w:id'] !== undefined) layout.id = Number(layoutObj['@_w:id']);
4940
- if (layoutObj['@_w:vert']) layout.vert = true;
4941
- if (layoutObj['@_w:vertCompress']) layout.vertCompress = true;
4942
- if (layoutObj['@_w:combine']) layout.combine = true;
5412
+ if (layoutObj['@_w:vert'] !== undefined) {
5413
+ layout.vert = parseOnOffAttribute(String(layoutObj['@_w:vert']), true);
5414
+ }
5415
+ if (layoutObj['@_w:vertCompress'] !== undefined) {
5416
+ layout.vertCompress = parseOnOffAttribute(String(layoutObj['@_w:vertCompress']), true);
5417
+ }
5418
+ if (layoutObj['@_w:combine'] !== undefined) {
5419
+ layout.combine = parseOnOffAttribute(String(layoutObj['@_w:combine']), true);
5420
+ }
4943
5421
  if (layoutObj['@_w:combineBrackets'])
4944
5422
  layout.combineBrackets = layoutObj['@_w:combineBrackets'];
4945
5423
 
@@ -4969,17 +5447,23 @@ export class DocumentParser {
4969
5447
 
4970
5448
  if (rPrObj['w:rFonts']) {
4971
5449
  const rFonts = rPrObj['w:rFonts'];
4972
- if (rFonts['@_w:ascii']) run.setFont(rFonts['@_w:ascii']);
5450
+ // Per ECMA-376 §17.3.2.26 CT_Fonts, all four literal-font
5451
+ // attributes (ascii/hAnsi/eastAsia/cs) are ST_String. XMLParser
5452
+ // coerces purely-numeric font names ("2010", etc.) to JS
5453
+ // numbers; cast through String() so RunFormatting's
5454
+ // declared-string font fields keep their type contract.
5455
+ if (rFonts['@_w:ascii'] !== undefined) run.setFont(String(rFonts['@_w:ascii']));
4973
5456
  // Parse additional font variants per ECMA-376 Part 1 §17.3.2.26
4974
- if (rFonts['@_w:hAnsi']) run.setFontHAnsi(rFonts['@_w:hAnsi']);
4975
- if (rFonts['@_w:eastAsia']) run.setFontEastAsia(rFonts['@_w:eastAsia']);
4976
- if (rFonts['@_w:cs']) run.setFontCs(rFonts['@_w:cs']);
4977
- if (rFonts['@_w:hint']) run.setFontHint(rFonts['@_w:hint']);
5457
+ if (rFonts['@_w:hAnsi'] !== undefined) run.setFontHAnsi(String(rFonts['@_w:hAnsi']));
5458
+ if (rFonts['@_w:eastAsia'] !== undefined) run.setFontEastAsia(String(rFonts['@_w:eastAsia']));
5459
+ if (rFonts['@_w:cs'] !== undefined) run.setFontCs(String(rFonts['@_w:cs']));
5460
+ if (rFonts['@_w:hint']) run.setFontHint(String(rFonts['@_w:hint']));
4978
5461
  // Parse theme font references per ECMA-376 Part 1 §17.3.2.26
4979
- if (rFonts['@_w:asciiTheme']) run.setFontAsciiTheme(rFonts['@_w:asciiTheme']);
4980
- if (rFonts['@_w:hAnsiTheme']) run.setFontHAnsiTheme(rFonts['@_w:hAnsiTheme']);
4981
- if (rFonts['@_w:eastAsiaTheme']) run.setFontEastAsiaTheme(rFonts['@_w:eastAsiaTheme']);
4982
- if (rFonts['@_w:cstheme']) run.setFontCsTheme(rFonts['@_w:cstheme']);
5462
+ if (rFonts['@_w:asciiTheme']) run.setFontAsciiTheme(String(rFonts['@_w:asciiTheme']));
5463
+ if (rFonts['@_w:hAnsiTheme']) run.setFontHAnsiTheme(String(rFonts['@_w:hAnsiTheme']));
5464
+ if (rFonts['@_w:eastAsiaTheme'])
5465
+ run.setFontEastAsiaTheme(String(rFonts['@_w:eastAsiaTheme']));
5466
+ if (rFonts['@_w:cstheme']) run.setFontCsTheme(String(rFonts['@_w:cstheme']));
4983
5467
  }
4984
5468
 
4985
5469
  if (rPrObj['w:sz']) {
@@ -5047,7 +5531,7 @@ export class DocumentParser {
5047
5531
  // This records what the run formatting was BEFORE a change was made
5048
5532
  if (rPrObj['w:rPrChange']) {
5049
5533
  const changeObj = rPrObj['w:rPrChange'];
5050
- const propChange: import('../elements/PropertyChangeTypes').RunPropertyChange = {
5534
+ const propChange: import('../elements/PropertyChangeTypes.js').RunPropertyChange = {
5051
5535
  id: changeObj['@_w:id'] !== undefined ? parseInt(String(changeObj['@_w:id']), 10) : 0,
5052
5536
  author: changeObj['@_w:author'] ? String(changeObj['@_w:author']) : '',
5053
5537
  date: changeObj['@_w:date'] ? new Date(String(changeObj['@_w:date'])) : new Date(),
@@ -5057,7 +5541,7 @@ export class DocumentParser {
5057
5541
  // Parse previous run properties from child w:rPr element
5058
5542
  if (changeObj['w:rPr']) {
5059
5543
  const prevRPr = changeObj['w:rPr'];
5060
- const prevProps: Partial<import('../elements/Run').RunFormatting> = {};
5544
+ const prevProps: Partial<import('../elements/Run.js').RunFormatting> = {};
5061
5545
 
5062
5546
  // Parse previous bold
5063
5547
  if (prevRPr['w:b']) {
@@ -5069,10 +5553,22 @@ export class DocumentParser {
5069
5553
  prevProps.italic = parseOoxmlBoolean(prevRPr['w:i']);
5070
5554
  }
5071
5555
 
5072
- // Parse previous underline
5556
+ // Parse previous underline — CT_Underline per §17.3.2.40 has `val`
5557
+ // plus color / themeColor / themeTint / themeShade. Main rPr parser
5558
+ // reads all of them; rPrChange previously only read `val`, so
5559
+ // underline color metadata on tracked "previous" state was dropped.
5073
5560
  if (prevRPr['w:u']) {
5074
- const uVal = prevRPr['w:u']['@_w:val'];
5561
+ const uObj = prevRPr['w:u'];
5562
+ const uVal = uObj['@_w:val'];
5075
5563
  prevProps.underline = uVal || true;
5564
+ if (uObj['@_w:color']) prevProps.underlineColor = uObj['@_w:color'];
5565
+ if (uObj['@_w:themeColor']) prevProps.underlineThemeColor = uObj['@_w:themeColor'];
5566
+ if (uObj['@_w:themeTint'] !== undefined) {
5567
+ prevProps.underlineThemeTint = parseInt(String(uObj['@_w:themeTint']), 16);
5568
+ }
5569
+ if (uObj['@_w:themeShade'] !== undefined) {
5570
+ prevProps.underlineThemeShade = parseInt(String(uObj['@_w:themeShade']), 16);
5571
+ }
5076
5572
  }
5077
5573
 
5078
5574
  // Parse previous strikethrough
@@ -5081,13 +5577,28 @@ export class DocumentParser {
5081
5577
  }
5082
5578
 
5083
5579
  // Parse previous font (all w:rFonts attributes per ECMA-376 Part 1 §17.3.2.26)
5580
+ // including theme font references (asciiTheme/hAnsiTheme/eastAsiaTheme/
5581
+ // cstheme). Previously only the literal-font attributes were read, so
5582
+ // rPrChange tracked history of theme-font changes lost the theme linkage
5583
+ // on round-trip — a paragraph whose "previous" font was a theme
5584
+ // reference (e.g. w:asciiTheme="minorHAnsi") silently dropped it.
5084
5585
  if (prevRPr['w:rFonts']) {
5085
5586
  const rFonts = prevRPr['w:rFonts'];
5086
- if (rFonts['@_w:ascii']) prevProps.font = rFonts['@_w:ascii'];
5087
- if (rFonts['@_w:hAnsi']) prevProps.fontHAnsi = rFonts['@_w:hAnsi'];
5088
- if (rFonts['@_w:eastAsia']) prevProps.fontEastAsia = rFonts['@_w:eastAsia'];
5089
- if (rFonts['@_w:cs']) prevProps.fontCs = rFonts['@_w:cs'];
5090
- if (rFonts['@_w:hint']) prevProps.fontHint = rFonts['@_w:hint'];
5587
+ // Mirror the main-path String() casts on rPrChange
5588
+ // previous-font reads — ECMA-376 §17.3.2.26 CT_Fonts declares
5589
+ // ascii/hAnsi/eastAsia/cs as ST_String, so purely-numeric
5590
+ // font names must survive round-trip as strings here too.
5591
+ if (rFonts['@_w:ascii'] !== undefined) prevProps.font = String(rFonts['@_w:ascii']);
5592
+ if (rFonts['@_w:hAnsi'] !== undefined) prevProps.fontHAnsi = String(rFonts['@_w:hAnsi']);
5593
+ if (rFonts['@_w:eastAsia'] !== undefined)
5594
+ prevProps.fontEastAsia = String(rFonts['@_w:eastAsia']);
5595
+ if (rFonts['@_w:cs'] !== undefined) prevProps.fontCs = String(rFonts['@_w:cs']);
5596
+ if (rFonts['@_w:hint']) prevProps.fontHint = String(rFonts['@_w:hint']);
5597
+ if (rFonts['@_w:asciiTheme']) prevProps.fontAsciiTheme = String(rFonts['@_w:asciiTheme']);
5598
+ if (rFonts['@_w:hAnsiTheme']) prevProps.fontHAnsiTheme = String(rFonts['@_w:hAnsiTheme']);
5599
+ if (rFonts['@_w:eastAsiaTheme'])
5600
+ prevProps.fontEastAsiaTheme = String(rFonts['@_w:eastAsiaTheme']);
5601
+ if (rFonts['@_w:cstheme']) prevProps.fontCsTheme = String(rFonts['@_w:cstheme']);
5091
5602
  }
5092
5603
 
5093
5604
  // Parse previous size (half-points to points)
@@ -5285,17 +5796,33 @@ export class DocumentParser {
5285
5796
  }
5286
5797
  }
5287
5798
 
5288
- // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.4
5289
- // Maps to TextBorder interface: style, size, color, space
5799
+ // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.5 — full
5800
+ // CT_Border attribute set for rPrChange previous-properties fidelity.
5290
5801
  if (prevRPr['w:bdr']) {
5291
5802
  const bdrObj = prevRPr['w:bdr'];
5292
- prevProps.border = {
5293
- style: bdrObj['@_w:val'] as import('../elements/Run').TextBorderStyle,
5803
+ const tb: import('../elements/Run.js').TextBorder = {
5804
+ style: bdrObj['@_w:val'] as import('../elements/Run.js').TextBorderStyle,
5294
5805
  size: bdrObj['@_w:sz'] !== undefined ? safeParseInt(bdrObj['@_w:sz']) : undefined,
5295
5806
  space:
5296
5807
  bdrObj['@_w:space'] !== undefined ? safeParseInt(bdrObj['@_w:space']) : undefined,
5297
5808
  color: bdrObj['@_w:color'],
5298
5809
  };
5810
+ // String(...) cast: XMLParser coerces "80"/"50" hex to numbers
5811
+ // — preserve the declared string contract on the model.
5812
+ if (bdrObj['@_w:themeColor']) {
5813
+ tb.themeColor = String(
5814
+ bdrObj['@_w:themeColor']
5815
+ ) as import('../elements/Run.js').ThemeColorValue;
5816
+ }
5817
+ if (bdrObj['@_w:themeTint']) tb.themeTint = String(bdrObj['@_w:themeTint']);
5818
+ if (bdrObj['@_w:themeShade']) tb.themeShade = String(bdrObj['@_w:themeShade']);
5819
+ if (bdrObj['@_w:shadow'] !== undefined) {
5820
+ tb.shadow = parseOnOffAttribute(String(bdrObj['@_w:shadow']), true);
5821
+ }
5822
+ if (bdrObj['@_w:frame'] !== undefined) {
5823
+ tb.frame = parseOnOffAttribute(String(bdrObj['@_w:frame']), true);
5824
+ }
5825
+ prevProps.border = tb;
5299
5826
  }
5300
5827
 
5301
5828
  // Parse character shading (w:shd) per ECMA-376 Part 1 §17.3.2.32
@@ -5306,24 +5833,54 @@ export class DocumentParser {
5306
5833
  }
5307
5834
  }
5308
5835
 
5309
- // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1 §17.3.2.10
5836
+ // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1
5837
+ // §17.3.2.10 CT_EastAsianLayout. Parity-fix with the main rPr
5838
+ // parser: route the three ST_OnOff attributes through
5839
+ // parseOnOffAttribute so every literal — including "0"
5840
+ // (explicit-false override) and "off" — resolves correctly. The
5841
+ // previous truthy gate both dropped explicit-false (XMLParser
5842
+ // coerces "0" to number 0 → falsy → undefined) and wrongly
5843
+ // coerced "off" to true.
5310
5844
  if (prevRPr['w:eastAsianLayout']) {
5311
5845
  const eaObj = prevRPr['w:eastAsianLayout'];
5312
5846
  prevProps.eastAsianLayout = {
5313
5847
  id: eaObj['@_w:id'] !== undefined ? safeParseInt(eaObj['@_w:id']) : undefined,
5314
- combine: eaObj['@_w:combine']
5315
- ? parseOoxmlBoolean({ '@_w:val': eaObj['@_w:combine'] })
5316
- : undefined,
5848
+ combine:
5849
+ eaObj['@_w:combine'] !== undefined
5850
+ ? parseOnOffAttribute(String(eaObj['@_w:combine']), true)
5851
+ : undefined,
5317
5852
  combineBrackets: eaObj['@_w:combineBrackets'],
5318
- vert: eaObj['@_w:vert']
5319
- ? parseOoxmlBoolean({ '@_w:val': eaObj['@_w:vert'] })
5320
- : undefined,
5321
- vertCompress: eaObj['@_w:vertCompress']
5322
- ? parseOoxmlBoolean({ '@_w:val': eaObj['@_w:vertCompress'] })
5323
- : undefined,
5853
+ vert:
5854
+ eaObj['@_w:vert'] !== undefined
5855
+ ? parseOnOffAttribute(String(eaObj['@_w:vert']), true)
5856
+ : undefined,
5857
+ vertCompress:
5858
+ eaObj['@_w:vertCompress'] !== undefined
5859
+ ? parseOnOffAttribute(String(eaObj['@_w:vertCompress']), true)
5860
+ : undefined,
5324
5861
  };
5325
5862
  }
5326
5863
 
5864
+ // Collect w14: namespace elements from the previous rPr for
5865
+ // passthrough (Word 2010+ text effects: w14:textOutline,
5866
+ // w14:shadow, w14:reflection, w14:glow, w14:ligatures,
5867
+ // w14:numForm, w14:numSpacing, w14:cntxtAlts, w14:stylisticSets).
5868
+ // The main rPr parser already collects these and the rPrChange
5869
+ // emitter (via generateRunPropertiesXML line 3130) re-emits
5870
+ // prevProps.rawW14Properties, but the rPrChange parser never
5871
+ // captured them — so tracked changes to any w14 text effect
5872
+ // silently lost the previous state on load → save.
5873
+ const prevRawW14: string[] = [];
5874
+ for (const key of Object.keys(prevRPr)) {
5875
+ if (key.startsWith('w14:')) {
5876
+ const rawXml = this.objectToXml({ [key]: prevRPr[key] });
5877
+ if (rawXml) prevRawW14.push(rawXml);
5878
+ }
5879
+ }
5880
+ if (prevRawW14.length > 0) {
5881
+ (prevProps as { rawW14Properties?: string[] }).rawW14Properties = prevRawW14;
5882
+ }
5883
+
5327
5884
  propChange.previousProperties = prevProps;
5328
5885
  }
5329
5886
 
@@ -5394,8 +5951,15 @@ export class DocumentParser {
5394
5951
  let docPrId = 1;
5395
5952
  let hidden = false;
5396
5953
  if (docPrObj) {
5397
- name = docPrObj['@_name'] || 'image';
5398
- description = docPrObj['@_descr'] || 'Image';
5954
+ // wp:docPr @name and @descr are xsd:string per ECMA-376
5955
+ // §20.4.2.5 CT_NonVisualDrawingProps. XMLParser coerces
5956
+ // purely-numeric values ("2010") to JS numbers; cast through
5957
+ // String() so Image.name / Image.description keep the declared
5958
+ // string contract (matches the @_title handling below).
5959
+ const rawName = docPrObj['@_name'];
5960
+ name = rawName !== undefined && rawName !== null ? String(rawName) : 'image';
5961
+ const rawDescr = docPrObj['@_descr'];
5962
+ description = rawDescr !== undefined && rawDescr !== null ? String(rawDescr) : 'Image';
5399
5963
  if (docPrObj['@_title']) {
5400
5964
  title = String(docPrObj['@_title']);
5401
5965
  }
@@ -5724,7 +6288,7 @@ export class DocumentParser {
5724
6288
  );
5725
6289
 
5726
6290
  // Create image from buffer with all properties
5727
- const { Image: ImageClass } = await import('../elements/Image');
6291
+ const { Image: ImageClass } = await import('../elements/Image.js');
5728
6292
  const image = await ImageClass.create({
5729
6293
  source: imageData,
5730
6294
  width,
@@ -6085,12 +6649,36 @@ export class DocumentParser {
6085
6649
  */
6086
6650
  private parseBorderElement(borderObj: any): TableBorder | undefined {
6087
6651
  if (!borderObj) return undefined;
6652
+ // Extract the full CT_Border attribute set per ECMA-376 §17.18.2:
6653
+ // val (required) / sz / space / color / themeColor / themeTint /
6654
+ // themeShade / shadow / frame. Previously the last five were silently
6655
+ // dropped on load, so themed borders and shadow/frame flags were lost
6656
+ // on every round-trip.
6088
6657
  const border: TableBorder = {
6089
6658
  style: (borderObj['@_w:val'] || 'single') as TableBorder['style'],
6090
6659
  };
6091
6660
  if (borderObj['@_w:sz'] !== undefined) border.size = safeParseInt(borderObj['@_w:sz']);
6092
6661
  if (borderObj['@_w:space'] !== undefined) border.space = safeParseInt(borderObj['@_w:space']);
6093
- if (borderObj['@_w:color']) border.color = borderObj['@_w:color'];
6662
+ if (borderObj['@_w:color']) border.color = String(borderObj['@_w:color']);
6663
+ // String(...) cast: themeTint / themeShade are ST_UcharHexNumber
6664
+ // (2-char hex) declared as `string` on the model. XMLParser coerces
6665
+ // purely-digit hex like "80"/"50" to numbers — cast to preserve
6666
+ // the type contract.
6667
+ if (borderObj['@_w:themeColor']) {
6668
+ (border as any).themeColor = String(borderObj['@_w:themeColor']);
6669
+ }
6670
+ if (borderObj['@_w:themeTint']) {
6671
+ (border as any).themeTint = String(borderObj['@_w:themeTint']);
6672
+ }
6673
+ if (borderObj['@_w:themeShade']) {
6674
+ (border as any).themeShade = String(borderObj['@_w:themeShade']);
6675
+ }
6676
+ if (borderObj['@_w:shadow'] !== undefined) {
6677
+ (border as any).shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
6678
+ }
6679
+ if (borderObj['@_w:frame'] !== undefined) {
6680
+ (border as any).frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
6681
+ }
6094
6682
  return border;
6095
6683
  }
6096
6684
 
@@ -6136,8 +6724,10 @@ export class DocumentParser {
6136
6724
  const gridChange = TableGridChange.create(
6137
6725
  safeParseInt(changeObj['@_w:id'], 0),
6138
6726
  prevWidths,
6139
- changeObj['@_w:author'] || undefined,
6140
- changeObj['@_w:date'] ? new Date(changeObj['@_w:date']) : undefined
6727
+ changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : undefined,
6728
+ changeObj['@_w:date'] !== undefined
6729
+ ? new Date(String(changeObj['@_w:date']))
6730
+ : undefined
6141
6731
  );
6142
6732
  table.setTblGridChange(gridChange);
6143
6733
  }
@@ -6232,53 +6822,90 @@ export class DocumentParser {
6232
6822
  private parseTablePropertiesFromObject(tblPrObj: any, table: Table): void {
6233
6823
  if (!tblPrObj) return;
6234
6824
 
6235
- // Parse table style reference (w:tblStyle)
6825
+ // Parse table style reference (w:tblStyle) per ECMA-376 §17.7.4.62.
6826
+ // w:val is ST_String — cast through String() so purely-numeric
6827
+ // custom style IDs ("2025", "1", …) don't leak as JS numbers
6828
+ // through XMLParser's parseAttributeValue coercion into the
6829
+ // string-typed `formatting.style` field.
6236
6830
  if (tblPrObj['w:tblStyle']) {
6237
6831
  const styleId = tblPrObj['w:tblStyle']['@_w:val'];
6238
- if (styleId) {
6239
- table.setStyle(styleId);
6240
- }
6241
- }
6242
-
6243
- // Parse table look flags (w:tblLook) - for conditional formatting
6244
- // Supports both hex string format (w:val="04A0") and individual attributes
6832
+ if (styleId !== undefined && styleId !== null && styleId !== '') {
6833
+ table.setStyle(String(styleId));
6834
+ }
6835
+ }
6836
+
6837
+ // Parse table look flags (w:tblLook) per ECMA-376 §17.4.57 supports both
6838
+ // hex-string format (w:val="04A0") AND individual ST_OnOff attributes
6839
+ // (firstRow/lastRow/firstColumn/lastColumn/noHBand/noVBand).
6840
+ //
6841
+ // XMLParser.parseToObject runs with `parseAttributeValue: true` by default,
6842
+ // so `"1"` coerces to the number `1` and `"true"` to the boolean `true`.
6843
+ // The previous `=== '1'` strict-string comparison missed both coerced
6844
+ // forms, silently flipping every individually-set flag to OFF and
6845
+ // producing `tblLook="0000"` for every Word-authored document whose
6846
+ // tblLook used the expanded attribute syntax. Route each attribute
6847
+ // through `parseOoxmlBoolean` (attribute form) so string/number/boolean
6848
+ // representations all resolve correctly.
6245
6849
  if (tblPrObj['w:tblLook']) {
6246
6850
  const look = tblPrObj['w:tblLook'];
6247
6851
  if (look['@_w:val']) {
6248
6852
  // Hex string format
6249
6853
  table.setTblLook(look['@_w:val']);
6250
6854
  } else {
6251
- // Individual attribute format - construct hex value
6252
- // Per ECMA-376 §17.4.57: bit5=firstRow, bit6=lastRow, bit7=firstCol, bit8=lastCol, bit9=noHBand, bit10=noVBand
6855
+ // Individual attribute format construct hex value.
6856
+ // Bits per §17.4.57: firstRow=0x0020, lastRow=0x0040, firstCol=0x0080,
6857
+ // lastCol=0x0100, noHBand=0x0200, noVBand=0x0400.
6858
+ const attrIsOn = (name: string): boolean => {
6859
+ const v = look[name];
6860
+ if (v === undefined) return false;
6861
+ // parseOoxmlBoolean accepts the value wrapped as `{'@_w:val': v}` —
6862
+ // handles string "1"/"0"/"true"/"false"/"on"/"off", number 1/0,
6863
+ // and boolean true/false uniformly.
6864
+ return parseOoxmlBoolean({ '@_w:val': v });
6865
+ };
6253
6866
  let value = 0;
6254
- if (look['@_w:firstRow'] === '1') value |= 0x0020;
6255
- if (look['@_w:lastRow'] === '1') value |= 0x0040;
6256
- if (look['@_w:firstColumn'] === '1') value |= 0x0080;
6257
- if (look['@_w:lastColumn'] === '1') value |= 0x0100;
6258
- if (look['@_w:noHBand'] === '1') value |= 0x0200;
6259
- if (look['@_w:noVBand'] === '1') value |= 0x0400;
6867
+ if (attrIsOn('@_w:firstRow')) value |= 0x0020;
6868
+ if (attrIsOn('@_w:lastRow')) value |= 0x0040;
6869
+ if (attrIsOn('@_w:firstColumn')) value |= 0x0080;
6870
+ if (attrIsOn('@_w:lastColumn')) value |= 0x0100;
6871
+ if (attrIsOn('@_w:noHBand')) value |= 0x0200;
6872
+ if (attrIsOn('@_w:noVBand')) value |= 0x0400;
6260
6873
  table.setTblLook(value.toString(16).toUpperCase().padStart(4, '0'));
6261
6874
  }
6262
6875
  }
6263
6876
 
6264
- // Parse table positioning (tblpPr) - for floating tables
6877
+ // Parse table positioning (tblpPr) - for floating tables.
6878
+ // Per ECMA-376 §17.4.52 CT_TblPPr, the six numeric attributes
6879
+ // (tblpX/tblpY/leftFromText/rightFromText/topFromText/bottomFromText)
6880
+ // are ST_SignedTwipsMeasure / ST_TwipsMeasure where 0 is a valid
6881
+ // value (e.g. float table anchored exactly at the anchor point).
6882
+ // XMLParser coerces "0" to the number 0 (falsy), so the previous
6883
+ // truthy gate silently dropped zero-offset positions. Table's
6884
+ // emitter uses `!== undefined`, so the asymmetry lost zeroes on
6885
+ // round-trip. Route each numeric read through isExplicitlySet +
6886
+ // safeParseInt.
6265
6887
  if (tblPrObj['w:tblpPr']) {
6266
6888
  const tblpPr = tblPrObj['w:tblpPr'];
6267
6889
  const position: any = {};
6268
6890
 
6269
- if (tblpPr['@_w:tblpX']) position.x = parseInt(tblpPr['@_w:tblpX'], 10);
6270
- if (tblpPr['@_w:tblpY']) position.y = parseInt(tblpPr['@_w:tblpY'], 10);
6891
+ if (isExplicitlySet(tblpPr['@_w:tblpX'])) position.x = safeParseInt(tblpPr['@_w:tblpX']);
6892
+ if (isExplicitlySet(tblpPr['@_w:tblpY'])) position.y = safeParseInt(tblpPr['@_w:tblpY']);
6271
6893
  if (tblpPr['@_w:horzAnchor']) position.horizontalAnchor = tblpPr['@_w:horzAnchor'];
6272
6894
  if (tblpPr['@_w:vertAnchor']) position.verticalAnchor = tblpPr['@_w:vertAnchor'];
6273
6895
  if (tblpPr['@_w:tblpXSpec']) position.horizontalAlignment = tblpPr['@_w:tblpXSpec'];
6274
6896
  if (tblpPr['@_w:tblpYSpec']) position.verticalAlignment = tblpPr['@_w:tblpYSpec'];
6275
- if (tblpPr['@_w:leftFromText'])
6276
- position.leftFromText = parseInt(tblpPr['@_w:leftFromText'], 10);
6277
- if (tblpPr['@_w:rightFromText'])
6278
- position.rightFromText = parseInt(tblpPr['@_w:rightFromText'], 10);
6279
- if (tblpPr['@_w:topFromText']) position.topFromText = parseInt(tblpPr['@_w:topFromText'], 10);
6280
- if (tblpPr['@_w:bottomFromText'])
6281
- position.bottomFromText = parseInt(tblpPr['@_w:bottomFromText'], 10);
6897
+ if (isExplicitlySet(tblpPr['@_w:leftFromText'])) {
6898
+ position.leftFromText = safeParseInt(tblpPr['@_w:leftFromText']);
6899
+ }
6900
+ if (isExplicitlySet(tblpPr['@_w:rightFromText'])) {
6901
+ position.rightFromText = safeParseInt(tblpPr['@_w:rightFromText']);
6902
+ }
6903
+ if (isExplicitlySet(tblpPr['@_w:topFromText'])) {
6904
+ position.topFromText = safeParseInt(tblpPr['@_w:topFromText']);
6905
+ }
6906
+ if (isExplicitlySet(tblpPr['@_w:bottomFromText'])) {
6907
+ position.bottomFromText = safeParseInt(tblpPr['@_w:bottomFromText']);
6908
+ }
6282
6909
 
6283
6910
  if (Object.keys(position).length > 0) {
6284
6911
  table.setPosition(position);
@@ -6291,9 +6918,9 @@ export class DocumentParser {
6291
6918
  table.setOverlap(val === 'overlap');
6292
6919
  }
6293
6920
 
6294
- // Parse bidirectional visual layout
6921
+ // Parse bidirectional visual layout — CT_OnOff, honour w:val per ECMA-376 §17.17.4
6295
6922
  if (tblPrObj['w:bidiVisual']) {
6296
- table.setBidiVisual(true);
6923
+ table.setBidiVisual(parseOoxmlBoolean(tblPrObj['w:bidiVisual']));
6297
6924
  }
6298
6925
 
6299
6926
  // Parse table width — always set when w:tblW is present, including w:w="0" w:type="auto"
@@ -6306,24 +6933,37 @@ export class DocumentParser {
6306
6933
  table.setWidthType(widthType);
6307
6934
  }
6308
6935
 
6309
- // Parse table caption
6936
+ // Parse table caption — ST_String per §17.4.62. Cast through
6937
+ // String() so a purely-numeric caption ("42") is preserved as a
6938
+ // string in `formatting.caption` rather than a JS number.
6310
6939
  if (tblPrObj['w:tblCaption']) {
6311
6940
  const caption = tblPrObj['w:tblCaption']['@_w:val'];
6312
- if (caption) table.setCaption(caption);
6941
+ if (caption !== undefined && caption !== null && caption !== '') {
6942
+ table.setCaption(String(caption));
6943
+ }
6313
6944
  }
6314
6945
 
6315
- // Parse table description
6946
+ // Parse table description — ST_String per §17.4.63.
6316
6947
  if (tblPrObj['w:tblDescription']) {
6317
6948
  const description = tblPrObj['w:tblDescription']['@_w:val'];
6318
- if (description) table.setDescription(description);
6949
+ if (description !== undefined && description !== null && description !== '') {
6950
+ table.setDescription(String(description));
6951
+ }
6319
6952
  }
6320
6953
 
6321
- // Parse cell spacing
6954
+ // Parse table-level cell spacing (w:tblCellSpacing) per ECMA-376
6955
+ // §17.4.44 CT_TblCellSpacing. w:w is ST_MeasurementOrPercent; 0 is
6956
+ // a legal "explicit zero spacing" value (overrides any style-level
6957
+ // inherited tblCellSpacing). The emitter uses `!== undefined`, so
6958
+ // the previous `spacing > 0` gate created a parser/emitter
6959
+ // asymmetry: a tracked table-property change recording a *previous*
6960
+ // state of `<w:tblCellSpacing w:w="0" …/>` lost the override on
6961
+ // every round-trip.
6322
6962
  if (tblPrObj['w:tblCellSpacing']) {
6323
- const spacing = parseInt(tblPrObj['w:tblCellSpacing']['@_w:w'] || '0', 10);
6324
- const spacingType = tblPrObj['w:tblCellSpacing']['@_w:type'] || 'dxa';
6325
- if (spacing > 0) {
6326
- table.setCellSpacing(spacing);
6963
+ const rawW = tblPrObj['w:tblCellSpacing']['@_w:w'];
6964
+ if (isExplicitlySet(rawW)) {
6965
+ table.setCellSpacing(safeParseInt(rawW));
6966
+ const spacingType = tblPrObj['w:tblCellSpacing']['@_w:type'] || 'dxa';
6327
6967
  table.setCellSpacingType(spacingType);
6328
6968
  }
6329
6969
  }
@@ -6342,7 +6982,7 @@ export class DocumentParser {
6342
6982
  table.setIndent(indentVal);
6343
6983
  const indentType = tblPrObj['w:tblInd']['@_w:type'];
6344
6984
  if (indentType) {
6345
- table.setIndentType(indentType as import('../elements/Table').TableWidthType);
6985
+ table.setIndentType(indentType as import('../elements/Table.js').TableWidthType);
6346
6986
  }
6347
6987
  }
6348
6988
 
@@ -6408,15 +7048,26 @@ export class DocumentParser {
6408
7048
  }
6409
7049
  }
6410
7050
 
6411
- // Parse table borders (w:tblBorders) per ECMA-376 Part 1 §17.4.40
7051
+ // Parse table borders (w:tblBorders) per ECMA-376 Part 1 §17.4.40.
7052
+ // left / right have bidi-aware aliases `w:start` / `w:end` (the
7053
+ // preferred spelling in modern Word-authored documents). Prefer
7054
+ // them when present, falling back to the legacy names — the
7055
+ // internal model stores under `left` / `right`, matching the
7056
+ // emitter. Without this fallback, any table whose side borders
7057
+ // were authored with the bidi-aware form silently lost those
7058
+ // borders on every round-trip (the emitter would replace them
7059
+ // with absent w:left/w:right, and the parser would never revive
7060
+ // the w:start/w:end it dropped).
6412
7061
  if (tblPrObj['w:tblBorders']) {
6413
7062
  const bordersObj = tblPrObj['w:tblBorders'];
6414
- const borders: import('../elements/Table').TableBorders = {};
7063
+ const borders: import('../elements/Table.js').TableBorders = {};
6415
7064
 
6416
7065
  if (bordersObj['w:top']) borders.top = this.parseBorderElement(bordersObj['w:top']);
6417
7066
  if (bordersObj['w:bottom']) borders.bottom = this.parseBorderElement(bordersObj['w:bottom']);
6418
- if (bordersObj['w:left']) borders.left = this.parseBorderElement(bordersObj['w:left']);
6419
- if (bordersObj['w:right']) borders.right = this.parseBorderElement(bordersObj['w:right']);
7067
+ const leftBorder = bordersObj['w:start'] ?? bordersObj['w:left'];
7068
+ if (leftBorder) borders.left = this.parseBorderElement(leftBorder);
7069
+ const rightBorder = bordersObj['w:end'] ?? bordersObj['w:right'];
7070
+ if (rightBorder) borders.right = this.parseBorderElement(rightBorder);
6420
7071
  if (bordersObj['w:insideH'])
6421
7072
  borders.insideH = this.parseBorderElement(bordersObj['w:insideH']);
6422
7073
  if (bordersObj['w:insideV'])
@@ -6432,8 +7083,8 @@ export class DocumentParser {
6432
7083
  const changeObj = tblPrObj['w:tblPrChange'];
6433
7084
  table.setTblPrChange({
6434
7085
  id: String(changeObj['@_w:id'] || '0'),
6435
- author: changeObj['@_w:author'] || '',
6436
- date: changeObj['@_w:date'] || '',
7086
+ author: changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : '',
7087
+ date: changeObj['@_w:date'] !== undefined ? String(changeObj['@_w:date']) : '',
6437
7088
  previousProperties: this.parseGenericPreviousProperties(changeObj['w:tblPr']),
6438
7089
  });
6439
7090
  }
@@ -6510,14 +7161,20 @@ export class DocumentParser {
6510
7161
  private parseTableRowPropertiesFromObject(trPrObj: any, row: TableRow): void {
6511
7162
  if (!trPrObj) return;
6512
7163
 
6513
- // Parse row height (w:trHeight) per ECMA-376 Part 1 §17.4.81
6514
- // Per §17.18.33 (ST_HeightRule), when w:hRule is absent the default is "auto"
7164
+ // Parse row height (w:trHeight) per ECMA-376 Part 1 §17.4.81.
7165
+ // w:val is ST_TwipsMeasure; zero is a valid value and, combined
7166
+ // with w:hRule="exact", represents a hidden / collapsed row.
7167
+ // XMLParser coerces "0" to the number 0 (falsy), and the previous
7168
+ // `heightVal > 0` gate silently dropped explicit zero-height rows
7169
+ // even though the emitter (TableRow.ts §914) preserves them via
7170
+ // `!== undefined`. Route through isExplicitlySet so zero survives.
7171
+ // Per §17.18.33 (ST_HeightRule), when w:hRule is absent the
7172
+ // default is "auto".
6515
7173
  if (trPrObj['w:trHeight']) {
6516
- const heightVal = parseInt(trPrObj['w:trHeight']['@_w:val'] || '0', 10);
7174
+ const rawVal = trPrObj['w:trHeight']['@_w:val'];
6517
7175
  const heightRule = trPrObj['w:trHeight']['@_w:hRule'];
6518
- if (heightVal > 0) {
6519
- // Set height without defaulting hRule — setHeight defaults to 'atLeast'
6520
- // so we set height first, then override the rule only if explicitly present
7176
+ if (isExplicitlySet(rawVal)) {
7177
+ const heightVal = safeParseInt(rawVal);
6521
7178
  row.setHeight(heightVal);
6522
7179
  if (heightRule) {
6523
7180
  row.setHeightRule(heightRule);
@@ -6529,14 +7186,14 @@ export class DocumentParser {
6529
7186
  }
6530
7187
  }
6531
7188
 
6532
- // Parse table header row (w:tblHeader) per ECMA-376 Part 1 §17.4.49
7189
+ // Parse table header row (w:tblHeader) per ECMA-376 Part 1 §17.4.49 — CT_OnOff
6533
7190
  if (trPrObj['w:tblHeader']) {
6534
- row.setHeader(true);
7191
+ row.setHeader(parseOoxmlBoolean(trPrObj['w:tblHeader']));
6535
7192
  }
6536
7193
 
6537
- // Parse can't split (w:cantSplit) per ECMA-376 Part 1 §17.4.5
7194
+ // Parse can't split (w:cantSplit) per ECMA-376 Part 1 §17.4.5 — CT_OnOff
6538
7195
  if (trPrObj['w:cantSplit']) {
6539
- row.setCantSplit(true);
7196
+ row.setCantSplit(parseOoxmlBoolean(trPrObj['w:cantSplit']));
6540
7197
  }
6541
7198
 
6542
7199
  // Parse row justification (w:jc) per ECMA-376 Part 1 §17.4.79
@@ -6547,9 +7204,9 @@ export class DocumentParser {
6547
7204
  }
6548
7205
  }
6549
7206
 
6550
- // Parse hidden (w:hidden) per ECMA-376 Part 1 §17.4.23
7207
+ // Parse hidden (w:hidden) per ECMA-376 Part 1 §17.4.23 — CT_OnOff
6551
7208
  if (trPrObj['w:hidden']) {
6552
- row.setHidden(true);
7209
+ row.setHidden(parseOoxmlBoolean(trPrObj['w:hidden']));
6553
7210
  }
6554
7211
 
6555
7212
  // Parse grid before (w:gridBefore) per ECMA-376 Part 1 §17.4.15
@@ -6568,30 +7225,36 @@ export class DocumentParser {
6568
7225
  }
6569
7226
  }
6570
7227
 
6571
- // Parse width before (w:wBefore) per ECMA-376 Part 1 §17.4.83
7228
+ // Parse width before (w:wBefore) per ECMA-376 Part 1 §17.4.83.
7229
+ // w:w is ST_TblWidth; 0 paired with w:type="auto" is the idiomatic
7230
+ // "no width" form, and explicit 0 in dxa twips can override an
7231
+ // inherited wBefore. Previous `w > 0` gate silently dropped both.
6572
7232
  if (trPrObj['w:wBefore']) {
6573
- const w = parseInt(trPrObj['w:wBefore']['@_w:w'] || '0', 10);
6574
- const type = trPrObj['w:wBefore']['@_w:type'] || 'dxa';
6575
- if (w > 0) {
6576
- row.setWBefore(w, type);
7233
+ const rawW = trPrObj['w:wBefore']['@_w:w'];
7234
+ if (isExplicitlySet(rawW)) {
7235
+ const type = (trPrObj['w:wBefore']['@_w:type'] as string | undefined) || 'dxa';
7236
+ row.setWBefore(safeParseInt(rawW), type);
6577
7237
  }
6578
7238
  }
6579
7239
 
6580
- // Parse width after (w:wAfter) per ECMA-376 Part 1 §17.4.82
7240
+ // Parse width after (w:wAfter) per ECMA-376 Part 1 §17.4.82 — same
7241
+ // ST_TblWidth semantics as wBefore.
6581
7242
  if (trPrObj['w:wAfter']) {
6582
- const w = parseInt(trPrObj['w:wAfter']['@_w:w'] || '0', 10);
6583
- const type = trPrObj['w:wAfter']['@_w:type'] || 'dxa';
6584
- if (w > 0) {
6585
- row.setWAfter(w, type);
7243
+ const rawW = trPrObj['w:wAfter']['@_w:w'];
7244
+ if (isExplicitlySet(rawW)) {
7245
+ const type = (trPrObj['w:wAfter']['@_w:type'] as string | undefined) || 'dxa';
7246
+ row.setWAfter(safeParseInt(rawW), type);
6586
7247
  }
6587
7248
  }
6588
7249
 
6589
- // Parse row-level cell spacing (w:tblCellSpacing)
7250
+ // Parse row-level cell spacing (w:tblCellSpacing). Zero is a valid
7251
+ // override — "explicitly no extra spacing" on a row overriding a
7252
+ // non-zero table-level tblCellSpacing.
6590
7253
  if (trPrObj['w:tblCellSpacing']) {
6591
- const w = parseInt(trPrObj['w:tblCellSpacing']['@_w:w'] || '0', 10);
6592
- const type = trPrObj['w:tblCellSpacing']['@_w:type'] || 'dxa';
6593
- if (w > 0) {
6594
- row.setRowCellSpacing(w, type);
7254
+ const rawW = trPrObj['w:tblCellSpacing']['@_w:w'];
7255
+ if (isExplicitlySet(rawW)) {
7256
+ const type = (trPrObj['w:tblCellSpacing']['@_w:type'] as string | undefined) || 'dxa';
7257
+ row.setRowCellSpacing(safeParseInt(rawW), type);
6595
7258
  }
6596
7259
  }
6597
7260
 
@@ -6603,11 +7266,39 @@ export class DocumentParser {
6603
7266
  }
6604
7267
  }
6605
7268
 
6606
- // Parse divId (w:divId) per ECMA-376 Part 1 §17.4.9
7269
+ // Parse divId (w:divId) per ECMA-376 Part 1 §17.4.9. `w:val` is
7270
+ // ST_DecimalNumber; 0 is a valid reference to the first div in web
7271
+ // settings. The previous `val > 0` gate silently dropped it on load.
6607
7272
  if (trPrObj['w:divId']) {
6608
- const val = parseInt(trPrObj['w:divId']['@_w:val'] || '0', 10);
6609
- if (val > 0) {
6610
- row.setDivId(val);
7273
+ const rawVal = trPrObj['w:divId']['@_w:val'];
7274
+ if (isExplicitlySet(rawVal)) {
7275
+ const parsed = safeParseInt(rawVal);
7276
+ if (!isNaN(parsed)) row.setDivId(parsed);
7277
+ }
7278
+ }
7279
+
7280
+ // Parse tracked row insertion / deletion (CT_TrackChange inside CT_TrPr)
7281
+ // per ECMA-376 Part 1 §17.13.5.19 (ins) / §17.13.5.14 (del). These mark
7282
+ // the entire row as a tracked revision; a previous version silently
7283
+ // dropped both markers on load → save because the parser skipped them.
7284
+ if (trPrObj['w:ins']) {
7285
+ const insObj = Array.isArray(trPrObj['w:ins']) ? trPrObj['w:ins'][0] : trPrObj['w:ins'];
7286
+ if (insObj && typeof insObj === 'object') {
7287
+ row.setRowInsertion({
7288
+ id: String(insObj['@_w:id'] ?? '0'),
7289
+ author: String(insObj['@_w:author'] ?? ''),
7290
+ date: String(insObj['@_w:date'] ?? ''),
7291
+ });
7292
+ }
7293
+ }
7294
+ if (trPrObj['w:del']) {
7295
+ const delObj = Array.isArray(trPrObj['w:del']) ? trPrObj['w:del'][0] : trPrObj['w:del'];
7296
+ if (delObj && typeof delObj === 'object') {
7297
+ row.setRowDeletion({
7298
+ id: String(delObj['@_w:id'] ?? '0'),
7299
+ author: String(delObj['@_w:author'] ?? ''),
7300
+ date: String(delObj['@_w:date'] ?? ''),
7301
+ });
6611
7302
  }
6612
7303
  }
6613
7304
 
@@ -6616,8 +7307,8 @@ export class DocumentParser {
6616
7307
  const changeObj = trPrObj['w:trPrChange'];
6617
7308
  row.setTrPrChange({
6618
7309
  id: String(changeObj['@_w:id'] || '0'),
6619
- author: changeObj['@_w:author'] || '',
6620
- date: changeObj['@_w:date'] || '',
7310
+ author: changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : '',
7311
+ date: changeObj['@_w:date'] !== undefined ? String(changeObj['@_w:date']) : '',
6621
7312
  previousProperties: this.parseGenericPreviousProperties(changeObj['w:trPr']),
6622
7313
  });
6623
7314
  }
@@ -6633,11 +7324,15 @@ export class DocumentParser {
6633
7324
 
6634
7325
  const exceptions: any = {};
6635
7326
 
6636
- // Parse table width exception (w:tblW)
7327
+ // Parse table width exception (w:tblW). The `val > 0` gate previously
7328
+ // dropped both w:w="0" (explicit zero-width override, valid when
7329
+ // paired with w:type="nil"/"auto") and negative overrides. Route
7330
+ // through isExplicitlySet + safeParseInt so zero and negative widths
7331
+ // round-trip.
6637
7332
  if (tblPrExObj['w:tblW']) {
6638
- const widthVal = parseInt(tblPrExObj['w:tblW']['@_w:w'] || '0', 10);
6639
- if (widthVal > 0) {
6640
- exceptions.width = widthVal;
7333
+ const rawW = tblPrExObj['w:tblW']['@_w:w'];
7334
+ if (isExplicitlySet(rawW)) {
7335
+ exceptions.width = safeParseInt(rawW);
6641
7336
  }
6642
7337
  }
6643
7338
 
@@ -6649,19 +7344,26 @@ export class DocumentParser {
6649
7344
  }
6650
7345
  }
6651
7346
 
6652
- // Parse cell spacing exception (w:tblCellSpacing)
7347
+ // Parse cell spacing exception (w:tblCellSpacing). Zero-value
7348
+ // override is valid (= "explicit no cell spacing" on a row that
7349
+ // would otherwise inherit non-zero spacing from the table-level
7350
+ // tblCellSpacing).
6653
7351
  if (tblPrExObj['w:tblCellSpacing']) {
6654
- const val = parseInt(tblPrExObj['w:tblCellSpacing']['@_w:w'] || '0', 10);
6655
- if (val > 0) {
6656
- exceptions.cellSpacing = val;
7352
+ const rawW = tblPrExObj['w:tblCellSpacing']['@_w:w'];
7353
+ if (isExplicitlySet(rawW)) {
7354
+ exceptions.cellSpacing = safeParseInt(rawW);
6657
7355
  }
6658
7356
  }
6659
7357
 
6660
- // Parse table indentation exception (w:tblInd)
7358
+ // Parse table indentation exception (w:tblInd). Per ECMA-376
7359
+ // §17.4.62 CT_TblWidth, w:w is ST_MeasurementOrPercent — 0 is a
7360
+ // legal "reset" value and negative values indicate an outdent (table
7361
+ // hanging into the page margin). The previous `val > 0` check
7362
+ // silently dropped both.
6661
7363
  if (tblPrExObj['w:tblInd']) {
6662
- const val = parseInt(tblPrExObj['w:tblInd']['@_w:w'] || '0', 10);
6663
- if (val > 0) {
6664
- exceptions.indentation = val;
7364
+ const rawW = tblPrExObj['w:tblInd']['@_w:w'];
7365
+ if (isExplicitlySet(rawW)) {
7366
+ exceptions.indentation = safeParseInt(rawW);
6665
7367
  }
6666
7368
  }
6667
7369
 
@@ -6692,15 +7394,40 @@ export class DocumentParser {
6692
7394
  const borderNames = ['top', 'bottom', 'left', 'right', 'insideH', 'insideV'];
6693
7395
 
6694
7396
  for (const name of borderNames) {
6695
- const borderKey = `w:${name}`;
6696
- if (bordersObj[borderKey]) {
6697
- const borderObj = bordersObj[borderKey];
7397
+ // Prefer bidi-aware `w:start`/`w:end` aliases over legacy `w:left`/
7398
+ // `w:right` per ECMA-376 §17.4.40 CT_TblBorders. Modern Word-
7399
+ // authored documents emit the bidi-aware form by default; the
7400
+ // internal model stores under the legacy keys to match the emitter.
7401
+ const aliasKey = name === 'left' ? 'w:start' : name === 'right' ? 'w:end' : undefined;
7402
+ const borderObj = (aliasKey && bordersObj[aliasKey]) || bordersObj[`w:${name}`];
7403
+ if (borderObj) {
6698
7404
  borders[name] = {};
6699
7405
 
7406
+ // Full CT_Border attribute set (§17.18.2) — previously only the four
7407
+ // basic attrs were read, so tblPrEx borders lost themed-color linkage
7408
+ // on every round-trip.
6700
7409
  if (borderObj['@_w:val']) borders[name].style = borderObj['@_w:val'];
6701
7410
  if (borderObj['@_w:sz']) borders[name].size = parseInt(borderObj['@_w:sz'], 10);
6702
7411
  if (borderObj['@_w:space']) borders[name].space = parseInt(borderObj['@_w:space'], 10);
6703
- if (borderObj['@_w:color']) borders[name].color = borderObj['@_w:color'];
7412
+ if (borderObj['@_w:color']) borders[name].color = String(borderObj['@_w:color']);
7413
+ // String(...) cast: themeTint / themeShade are ST_UcharHexNumber
7414
+ // (2-char hex). XMLParser coerces purely-digit hex to numbers —
7415
+ // cast so the string contract on the model is preserved.
7416
+ if (borderObj['@_w:themeColor']) {
7417
+ borders[name].themeColor = String(borderObj['@_w:themeColor']);
7418
+ }
7419
+ if (borderObj['@_w:themeTint']) {
7420
+ borders[name].themeTint = String(borderObj['@_w:themeTint']);
7421
+ }
7422
+ if (borderObj['@_w:themeShade']) {
7423
+ borders[name].themeShade = String(borderObj['@_w:themeShade']);
7424
+ }
7425
+ if (borderObj['@_w:shadow'] !== undefined) {
7426
+ borders[name].shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
7427
+ }
7428
+ if (borderObj['@_w:frame'] !== undefined) {
7429
+ borders[name].frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
7430
+ }
6704
7431
  }
6705
7432
  }
6706
7433
 
@@ -6721,12 +7448,26 @@ export class DocumentParser {
6721
7448
  // Parse cell properties (w:tcPr) per ECMA-376 Part 1 §17.4.42
6722
7449
  const tcPr = cellObj['w:tcPr'];
6723
7450
  if (tcPr) {
6724
- // Parse cell width (w:tcW) with type per ECMA-376 Part 1 §17.4.81
7451
+ // Parse cell width (w:tcW) with type per ECMA-376 Part 1 §17.4.72
7452
+ // CT_TblWidth — w:w is ST_MeasurementOrPercent, w:type is
7453
+ // ST_TblWidth. Zero is a legal explicit override:
7454
+ // - `w:w="0" w:type="auto"` is the idiomatic "size to content"
7455
+ // form (also the default when w:tcW is absent).
7456
+ // - `w:w="0" w:type="dxa"` / `"pct"` / `"nil"` explicitly
7457
+ // override an inherited non-zero width back to zero.
7458
+ // The emitter at TableCell.ts:1353 uses `!== undefined`, so the
7459
+ // previous `widthVal > 0 || widthType === 'auto'` gate created a
7460
+ // parser/emitter asymmetry — any cell with an explicit zero
7461
+ // override in a non-auto width type silently reinherited the
7462
+ // style-level width on every round-trip.
6725
7463
  if (tcPr['w:tcW']) {
6726
- const widthVal = parseInt(tcPr['w:tcW']['@_w:w'] || '0', 10);
6727
- const widthType = tcPr['w:tcW']['@_w:type'] || 'dxa';
6728
- if (widthVal > 0 || widthType === 'auto') {
6729
- cell.setWidthType(widthVal, widthType);
7464
+ const rawW = tcPr['w:tcW']['@_w:w'];
7465
+ if (isExplicitlySet(rawW)) {
7466
+ const widthType = (tcPr['w:tcW']['@_w:type'] as string | undefined) || 'dxa';
7467
+ cell.setWidthType(
7468
+ safeParseInt(rawW),
7469
+ widthType as import('../elements/TableCell.js').CellWidthType
7470
+ );
6730
7471
  }
6731
7472
  }
6732
7473
 
@@ -6738,7 +7479,11 @@ export class DocumentParser {
6738
7479
  }
6739
7480
  }
6740
7481
 
6741
- // Parse cell borders (w:tcBorders)
7482
+ // Parse cell borders (w:tcBorders) per ECMA-376 Part 1 §17.4.66.
7483
+ // Supports both legacy LTR names (w:left / w:right) and bidi-
7484
+ // aware aliases (w:start / w:end). Prefer w:start / w:end when
7485
+ // present. Includes diagonal borders (w:tl2br / w:tr2bl) which
7486
+ // are cell-specific.
6742
7487
  if (tcPr['w:tcBorders']) {
6743
7488
  const bordersObj = tcPr['w:tcBorders'];
6744
7489
  const borders: any = {};
@@ -6746,8 +7491,10 @@ export class DocumentParser {
6746
7491
  if (bordersObj['w:top']) borders.top = this.parseBorderElement(bordersObj['w:top']);
6747
7492
  if (bordersObj['w:bottom'])
6748
7493
  borders.bottom = this.parseBorderElement(bordersObj['w:bottom']);
6749
- if (bordersObj['w:left']) borders.left = this.parseBorderElement(bordersObj['w:left']);
6750
- if (bordersObj['w:right']) borders.right = this.parseBorderElement(bordersObj['w:right']);
7494
+ const leftBorder = bordersObj['w:start'] ?? bordersObj['w:left'];
7495
+ if (leftBorder) borders.left = this.parseBorderElement(leftBorder);
7496
+ const rightBorder = bordersObj['w:end'] ?? bordersObj['w:right'];
7497
+ if (rightBorder) borders.right = this.parseBorderElement(rightBorder);
6751
7498
  if (bordersObj['w:tl2br']) borders.tl2br = this.parseBorderElement(bordersObj['w:tl2br']);
6752
7499
  if (bordersObj['w:tr2bl']) borders.tr2bl = this.parseBorderElement(bordersObj['w:tr2bl']);
6753
7500
 
@@ -6790,10 +7537,15 @@ export class DocumentParser {
6790
7537
  }
6791
7538
  }
6792
7539
 
6793
- // Parse vertical alignment (w:vAlign)
7540
+ // Parse vertical alignment (w:vAlign) per ECMA-376 §17.4.83.
7541
+ // ST_VerticalJc has four values (§17.18.101): top, center, both,
7542
+ // bottom. The previous whitelist dropped "both" silently — the
7543
+ // style-level parser accepts it, so the asymmetry truncated cell
7544
+ // vertical alignment on cells using the "both" (justified)
7545
+ // vertical alignment on load.
6794
7546
  if (tcPr['w:vAlign']) {
6795
7547
  const valign = tcPr['w:vAlign']['@_w:val'];
6796
- if (valign && (valign === 'top' || valign === 'center' || valign === 'bottom')) {
7548
+ if (valign === 'top' || valign === 'center' || valign === 'both' || valign === 'bottom') {
6797
7549
  cell.setVerticalAlignment(valign);
6798
7550
  }
6799
7551
  }
@@ -6814,14 +7566,14 @@ export class DocumentParser {
6814
7566
  }
6815
7567
  }
6816
7568
 
6817
- // Parse no wrap (w:noWrap) per ECMA-376 Part 1 §17.4.34
7569
+ // Parse no wrap (w:noWrap) per ECMA-376 Part 1 §17.4.34 — CT_OnOff
6818
7570
  if (tcPr['w:noWrap']) {
6819
- cell.setNoWrap(true);
7571
+ cell.setNoWrap(parseOoxmlBoolean(tcPr['w:noWrap']));
6820
7572
  }
6821
7573
 
6822
- // Parse hide mark (w:hideMark) per ECMA-376 Part 1 §17.4.24
7574
+ // Parse hide mark (w:hideMark) per ECMA-376 Part 1 §17.4.24 — CT_OnOff
6823
7575
  if (tcPr['w:hideMark']) {
6824
- cell.setHideMark(true);
7576
+ cell.setHideMark(parseOoxmlBoolean(tcPr['w:hideMark']));
6825
7577
  }
6826
7578
 
6827
7579
  // Parse headers (w:headers) per ECMA-376 Part 1 §17.4.26
@@ -6832,9 +7584,9 @@ export class DocumentParser {
6832
7584
  }
6833
7585
  }
6834
7586
 
6835
- // Parse fit text (w:tcFitText) per ECMA-376 Part 1 §17.4.68
7587
+ // Parse fit text (w:tcFitText) per ECMA-376 Part 1 §17.4.68 — CT_OnOff
6836
7588
  if (tcPr['w:tcFitText']) {
6837
- cell.setFitText(true);
7589
+ cell.setFitText(parseOoxmlBoolean(tcPr['w:tcFitText']));
6838
7590
  }
6839
7591
 
6840
7592
  // Parse vertical merge (w:vMerge) per ECMA-376 Part 1 §17.4.85
@@ -6861,10 +7613,11 @@ export class DocumentParser {
6861
7613
  // Parse table cell insertion marker (w:cellIns) per ECMA-376 Part 1 §17.13.5.5
6862
7614
  if (tcPr['w:cellIns']) {
6863
7615
  const cellIns = tcPr['w:cellIns'];
6864
- const id = parseInt(cellIns['@_w:id'] || '0', 10);
6865
- const author = cellIns['@_w:author'] || 'Unknown';
7616
+ const id = parseInt(String(cellIns['@_w:id'] ?? '0'), 10);
7617
+ const author =
7618
+ cellIns['@_w:author'] !== undefined ? String(cellIns['@_w:author']) : 'Unknown';
6866
7619
  const dateAttr = cellIns['@_w:date'];
6867
- const date = dateAttr ? new Date(dateAttr) : new Date();
7620
+ const date = dateAttr !== undefined ? new Date(String(dateAttr)) : new Date();
6868
7621
 
6869
7622
  const revision = new Revision({
6870
7623
  id,
@@ -6879,10 +7632,11 @@ export class DocumentParser {
6879
7632
  // Parse table cell deletion marker (w:cellDel) per ECMA-376 Part 1 §17.13.5.6
6880
7633
  if (tcPr['w:cellDel']) {
6881
7634
  const cellDel = tcPr['w:cellDel'];
6882
- const id = parseInt(cellDel['@_w:id'] || '0', 10);
6883
- const author = cellDel['@_w:author'] || 'Unknown';
7635
+ const id = parseInt(String(cellDel['@_w:id'] ?? '0'), 10);
7636
+ const author =
7637
+ cellDel['@_w:author'] !== undefined ? String(cellDel['@_w:author']) : 'Unknown';
6884
7638
  const dateAttr = cellDel['@_w:date'];
6885
- const date = dateAttr ? new Date(dateAttr) : new Date();
7639
+ const date = dateAttr !== undefined ? new Date(String(dateAttr)) : new Date();
6886
7640
 
6887
7641
  const revision = new Revision({
6888
7642
  id,
@@ -6897,10 +7651,11 @@ export class DocumentParser {
6897
7651
  // Parse table cell merge marker (w:cellMerge) per ECMA-376 Part 1 §17.13.5.4
6898
7652
  if (tcPr['w:cellMerge']) {
6899
7653
  const cellMerge = tcPr['w:cellMerge'];
6900
- const id = parseInt(cellMerge['@_w:id'] || '0', 10);
6901
- const author = cellMerge['@_w:author'] || 'Unknown';
7654
+ const id = parseInt(String(cellMerge['@_w:id'] ?? '0'), 10);
7655
+ const author =
7656
+ cellMerge['@_w:author'] !== undefined ? String(cellMerge['@_w:author']) : 'Unknown';
6902
7657
  const dateAttr = cellMerge['@_w:date'];
6903
- const date = dateAttr ? new Date(dateAttr) : new Date();
7658
+ const date = dateAttr !== undefined ? new Date(String(dateAttr)) : new Date();
6904
7659
  const vMergeAttr = cellMerge['@_w:vMerge'];
6905
7660
  const vMergeOrigAttr = cellMerge['@_w:vMergeOrig'];
6906
7661
  // ST_AnnotationVMerge uses "rest"/"cont" but API uses "restart"/"continue"
@@ -6925,8 +7680,8 @@ export class DocumentParser {
6925
7680
  const changeObj = tcPr['w:tcPrChange'];
6926
7681
  cell.setTcPrChange({
6927
7682
  id: String(changeObj['@_w:id'] || '0'),
6928
- author: changeObj['@_w:author'] || '',
6929
- date: changeObj['@_w:date'] || '',
7683
+ author: changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : '',
7684
+ date: changeObj['@_w:date'] !== undefined ? String(changeObj['@_w:date']) : '',
6930
7685
  previousProperties: this.parseGenericPreviousProperties(changeObj['w:tcPr']),
6931
7686
  });
6932
7687
  }
@@ -7174,28 +7929,40 @@ export class DocumentParser {
7174
7929
  // Parse SDT properties (sdtPr)
7175
7930
  const sdtPr = sdtObj['w:sdtPr'];
7176
7931
  if (sdtPr) {
7177
- // Parse ID
7932
+ // Parse `<w:id w:val="…"/>` per ECMA-376 §17.5.2.18. `w:val` is
7933
+ // ST_DecimalNumber (xsd:integer) — 0 is legal. XMLParser coerces
7934
+ // `"0"` to the number `0`, so the previous truthy gate silently
7935
+ // dropped w:id=0 on every load → save cycle. The emitter uses
7936
+ // `!== undefined`, creating a parser/emitter asymmetry.
7178
7937
  const idElement = sdtPr['w:id'];
7179
- if (idElement?.['@_w:val']) {
7180
- properties.id = parseInt(idElement['@_w:val'], 10);
7938
+ if (isExplicitlySet(idElement?.['@_w:val'])) {
7939
+ const parsed = safeParseInt(idElement['@_w:val']);
7940
+ if (!isNaN(parsed)) properties.id = parsed;
7181
7941
  }
7182
7942
 
7183
- // Parse tag
7943
+ // Parse `<w:tag w:val="…"/>` per ECMA-376 §17.5.2.34. `w:val`
7944
+ // is ST_String — any string is legal, including numeric-looking
7945
+ // strings like "123" that XMLParser coerces to the number 123.
7946
+ // Cast via `String(…)` so the tag round-trips as text rather
7947
+ // than leaking a JS number into a `tag?: string` field.
7184
7948
  const tagElement = sdtPr['w:tag'];
7185
- if (tagElement?.['@_w:val']) {
7186
- properties.tag = tagElement['@_w:val'];
7949
+ if (tagElement?.['@_w:val'] !== undefined) {
7950
+ properties.tag = String(tagElement['@_w:val']);
7187
7951
  }
7188
7952
 
7189
- // Parse lock
7953
+ // Parse lock — ST_Lock enum: "sdtLocked" / "contentLocked" /
7954
+ // "sdtContentLocked" / "unlocked". Always a non-numeric string,
7955
+ // so no XMLParser coercion concern; truthy check fine.
7190
7956
  const lockElement = sdtPr['w:lock'];
7191
7957
  if (lockElement?.['@_w:val']) {
7192
7958
  properties.lock = lockElement['@_w:val'];
7193
7959
  }
7194
7960
 
7195
- // Parse alias
7961
+ // Parse alias — ST_String. Same numeric-coercion concern as
7962
+ // `w:tag`; cast via `String(…)`.
7196
7963
  const aliasElement = sdtPr['w:alias'];
7197
- if (aliasElement?.['@_w:val']) {
7198
- properties.alias = aliasElement['@_w:val'];
7964
+ if (aliasElement?.['@_w:val'] !== undefined) {
7965
+ properties.alias = String(aliasElement['@_w:val']);
7199
7966
  }
7200
7967
 
7201
7968
  // Parse control type from various elements
@@ -7204,9 +7971,18 @@ export class DocumentParser {
7204
7971
  } else if (sdtPr['w:text']) {
7205
7972
  properties.controlType = 'plainText';
7206
7973
  const textElement = sdtPr['w:text'];
7974
+ // w:multiLine is an OPTIONAL ST_OnOff attribute per ECMA-376
7975
+ // §17.5.2.33 CT_SdtText. Only record a value when the source
7976
+ // actually set it — otherwise leave the field undefined so
7977
+ // the emitter (which uses `!== undefined`) preserves the
7978
+ // "attribute absent" state on round-trip. Previously the
7979
+ // parser unconditionally stored `false` for any absent
7980
+ // attribute, then the emitter wrote `w:multiLine="0"` —
7981
+ // adding spec-noise that wasn't in the source.
7982
+ const rawMultiLine = textElement?.['@_w:multiLine'];
7207
7983
  properties.plainText = {
7208
7984
  multiLine:
7209
- textElement?.['@_w:multiLine'] === '1' || textElement?.['@_w:multiLine'] === 'true',
7985
+ rawMultiLine === undefined ? undefined : parseOnOffAttribute(String(rawMultiLine)),
7210
7986
  };
7211
7987
  } else if (sdtPr['w:comboBox']) {
7212
7988
  properties.controlType = 'comboBox';
@@ -7234,14 +8010,11 @@ export class DocumentParser {
7234
8010
  } else if (sdtPr['w14:checkbox']) {
7235
8011
  properties.controlType = 'checkbox';
7236
8012
  const checkboxElement = sdtPr['w14:checkbox'];
7237
- // Handle both string and numeric values from XML parser
7238
- const checkedVal = checkboxElement?.['w14:checked']?.['@_w14:val'];
8013
+ // <w14:checked> is CT_OnOff in the Word 2010+ extension namespace.
8014
+ // Honour every ST_OnOff literal ("1"/"0"/"true"/"false"/"on"/"off")
8015
+ // and treat a bare self-closing `<w14:checked/>` as true.
7239
8016
  properties.checkbox = {
7240
- checked:
7241
- checkedVal === 1 ||
7242
- checkedVal === '1' ||
7243
- checkedVal === true ||
7244
- checkedVal === 'true',
8017
+ checked: parseOoxmlBoolean(checkboxElement?.['w14:checked'], '@_w14:val'),
7245
8018
  checkedState: String(checkboxElement?.['w14:checkedState']?.['@_w14:val'] ?? ''),
7246
8019
  uncheckedState: String(checkboxElement?.['w14:uncheckedState']?.['@_w14:val'] ?? ''),
7247
8020
  };
@@ -7291,11 +8064,10 @@ export class DocumentParser {
7291
8064
  };
7292
8065
  }
7293
8066
 
7294
- // Parse showing placeholder flag (w:showingPlcHdr)
8067
+ // Parse showing placeholder flag (w:showingPlcHdr) — CT_OnOff per ECMA-376 §17.5.2.40
7295
8068
  const showingPlcHdr = sdtPr['w:showingPlcHdr'];
7296
8069
  if (showingPlcHdr) {
7297
- const val = showingPlcHdr['@_w:val'];
7298
- properties.showingPlcHdr = val === '1' || val === 'true' || val === true;
8070
+ properties.showingPlcHdr = parseOoxmlBoolean(showingPlcHdr);
7299
8071
  }
7300
8072
  }
7301
8073
 
@@ -7835,7 +8607,25 @@ export class DocumentParser {
7835
8607
  }
7836
8608
 
7837
8609
  /**
7838
- * Helper to parse list items for combo box / dropdown
8610
+ * Helper to parse list items for combo box / dropdown per ECMA-376
8611
+ * Part 1 §17.5.2.13 CT_SdtListItem. `w:value` is required; both
8612
+ * `w:displayText` and `w:value` are ST_String so any string
8613
+ * (including the empty string) is legal.
8614
+ *
8615
+ * The previous truthy gate dropped legitimate list items whenever:
8616
+ * - `w:value="0"` / `w:value="123"` — XMLParser coerces numeric
8617
+ * strings to numbers; `0` fails the truthy check entirely, and
8618
+ * storing a raw number instead of a string breaks the `ListItem`
8619
+ * `value: string` contract downstream.
8620
+ * - `w:displayText=""` — empty displayText is legal (e.g. a
8621
+ * separator / blank choice); the gate dropped it.
8622
+ * The fix:
8623
+ * - Gate on presence (`!== undefined`), not truthiness.
8624
+ * - Coerce both attributes to `String(…)` so numeric-coerced
8625
+ * attribute values serialise back to their original textual form.
8626
+ * - Default missing `w:displayText` to the stringified `w:value`
8627
+ * (the idiomatic Word fallback when authors author list items
8628
+ * with only a value attribute).
7839
8629
  */
7840
8630
  private parseListItems(element: any): any {
7841
8631
  const items: any[] = [];
@@ -7843,17 +8633,18 @@ export class DocumentParser {
7843
8633
  const itemArray = Array.isArray(listItems) ? listItems : listItems ? [listItems] : [];
7844
8634
 
7845
8635
  for (const item of itemArray) {
7846
- if (item['@_w:displayText'] && item['@_w:value']) {
7847
- items.push({
7848
- displayText: item['@_w:displayText'],
7849
- value: item['@_w:value'],
7850
- });
7851
- }
8636
+ const rawValue = item['@_w:value'];
8637
+ if (rawValue === undefined) continue; // w:value is required by the schema
8638
+ const value = String(rawValue);
8639
+ const rawDisplay = item['@_w:displayText'];
8640
+ const displayText = rawDisplay === undefined ? value : String(rawDisplay);
8641
+ items.push({ displayText, value });
7852
8642
  }
7853
8643
 
8644
+ const rawLast = element?.['@_w:lastValue'];
7854
8645
  return {
7855
8646
  items,
7856
- lastValue: element?.['@_w:lastValue'],
8647
+ lastValue: rawLast === undefined ? undefined : String(rawLast),
7857
8648
  };
7858
8649
  }
7859
8650
 
@@ -8329,12 +9120,21 @@ export class DocumentParser {
8329
9120
  if (color) border.color = color;
8330
9121
  const space = XMLParser.extractAttribute(sideXml, 'w:space');
8331
9122
  if (space) border.space = parseInt(space.toString(), 10);
9123
+ // w:shadow and w:frame are ST_OnOff per ECMA-376 §17.17.4.
9124
+ // Use `!== undefined` gating so explicit-false survives round-trip
9125
+ // (previous code only stored `true`, silently dropping `w:shadow="0"`).
8332
9126
  const shadow = XMLParser.extractAttribute(sideXml, 'w:shadow');
8333
- if (shadow === '1' || shadow === 'true') border.shadow = true;
9127
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
8334
9128
  const frame = XMLParser.extractAttribute(sideXml, 'w:frame');
8335
- if (frame === '1' || frame === 'true') border.frame = true;
9129
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
8336
9130
  const themeColor = XMLParser.extractAttribute(sideXml, 'w:themeColor');
8337
9131
  if (themeColor) border.themeColor = themeColor;
9132
+ // Theme tint / shade per §17.18.82 — CT_TopBorder/CT_BottomBorder extend
9133
+ // CT_Border so inherit the full themed-color attribute set.
9134
+ const themeTint = XMLParser.extractAttribute(sideXml, 'w:themeTint');
9135
+ if (themeTint) border.themeTint = themeTint;
9136
+ const themeShade = XMLParser.extractAttribute(sideXml, 'w:themeShade');
9137
+ if (themeShade) border.themeShade = themeShade;
8338
9138
  const artId = XMLParser.extractAttribute(sideXml, 'w:id');
8339
9139
  if (artId) border.artId = parseInt(artId.toString(), 10);
8340
9140
  return Object.keys(border).length > 0 ? border : undefined;
@@ -8355,7 +9155,14 @@ export class DocumentParser {
8355
9155
  }
8356
9156
  }
8357
9157
 
8358
- // Parse columns (enhanced with separator and custom widths)
9158
+ // Parse columns per ECMA-376 §17.6.4 CT_Columns. Every attribute
9159
+ // (num / sep / space / equalWidth) is optional with spec-defined
9160
+ // defaults — num defaults to 1, equalWidth to true, sep to false,
9161
+ // space to 720 twips. The previous `if (num)` gate silently dropped
9162
+ // every `<w:cols>` that relied on the default num=1 (e.g. a bare
9163
+ // `<w:cols w:sep="1" w:space="720"/>` specifying a single column
9164
+ // with a separator), which is the exact form Word emits when the
9165
+ // user toggles the column separator without changing column count.
8359
9166
  const colsElements = XMLParser.extractElements(sectPr, 'w:cols');
8360
9167
  if (colsElements.length > 0) {
8361
9168
  const cols = colsElements[0];
@@ -8384,19 +9191,23 @@ export class DocumentParser {
8384
9191
  }
8385
9192
  }
8386
9193
 
8387
- // Helper to handle boolean conversion (XMLParser may return string or number)
8388
- const toBool = (val: any) => val === '1' || val === 1 || val === 'true' || val === true;
8389
-
8390
- if (num) {
8391
- sectionProps.columns = {
8392
- count: parseInt(num.toString(), 10),
8393
- space: space ? parseInt(space.toString(), 10) : undefined,
8394
- equalWidth: equalWidth ? toBool(equalWidth) : undefined,
8395
- separator: sep ? toBool(sep) : undefined,
8396
- columnWidths: columnWidths.length > 0 ? columnWidths : undefined,
8397
- columnSpaces: hasColumnSpaces ? columnSpaces : undefined,
8398
- };
8399
- }
9194
+ // Spec default for num is 1; fall back to column-count from
9195
+ // child `<w:col>` children when available (the expanded per-column
9196
+ // form), otherwise to the literal default.
9197
+ const count = num
9198
+ ? parseInt(num.toString(), 10)
9199
+ : columnWidths.length > 0
9200
+ ? columnWidths.length
9201
+ : 1;
9202
+
9203
+ sectionProps.columns = {
9204
+ count,
9205
+ space: space ? parseInt(space.toString(), 10) : undefined,
9206
+ equalWidth: equalWidth ? parseOnOffAttribute(equalWidth) : undefined,
9207
+ separator: sep ? parseOnOffAttribute(sep) : undefined,
9208
+ columnWidths: columnWidths.length > 0 ? columnWidths : undefined,
9209
+ columnSpaces: hasColumnSpaces ? columnSpaces : undefined,
9210
+ };
8400
9211
  }
8401
9212
  }
8402
9213
 
@@ -8437,9 +9248,13 @@ export class DocumentParser {
8437
9248
  }
8438
9249
  }
8439
9250
 
8440
- // Parse title page flag
8441
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:titlePg')) {
8442
- sectionProps.titlePage = true;
9251
+ // Parse title page flag (w:titlePg) — CT_OnOff per ECMA-376 §17.6.23;
9252
+ // honour w:val so an explicit `w:val="0"` override of an inherited
9253
+ // true is not silently flipped to true.
9254
+ const titlePgEls = XMLParser.extractElements(sectPr, 'w:titlePg');
9255
+ if (titlePgEls.length > 0 && titlePgEls[0]) {
9256
+ const v = XMLParser.extractAttribute(titlePgEls[0], 'w:val');
9257
+ sectionProps.titlePage = parseOnOffAttribute(v, true);
8443
9258
  }
8444
9259
 
8445
9260
  // Parse header references
@@ -8522,14 +9337,18 @@ export class DocumentParser {
8522
9337
  }
8523
9338
  }
8524
9339
 
8525
- // Parse bidi (right-to-left section layout)
8526
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:bidi')) {
8527
- sectionProps.bidi = true;
9340
+ // Parse bidi (w:bidi) — CT_OnOff per ECMA-376 §17.6.1 (RTL section)
9341
+ const bidiEls = XMLParser.extractElements(sectPr, 'w:bidi');
9342
+ if (bidiEls.length > 0 && bidiEls[0]) {
9343
+ const v = XMLParser.extractAttribute(bidiEls[0], 'w:val');
9344
+ sectionProps.bidi = parseOnOffAttribute(v, true);
8528
9345
  }
8529
9346
 
8530
- // Parse RTL gutter
8531
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:rtlGutter')) {
8532
- sectionProps.rtlGutter = true;
9347
+ // Parse RTL gutter (w:rtlGutter) — CT_OnOff per ECMA-376 §17.6.16
9348
+ const rtlGutterEls = XMLParser.extractElements(sectPr, 'w:rtlGutter');
9349
+ if (rtlGutterEls.length > 0 && rtlGutterEls[0]) {
9350
+ const v = XMLParser.extractAttribute(rtlGutterEls[0], 'w:val');
9351
+ sectionProps.rtlGutter = parseOnOffAttribute(v, true);
8533
9352
  }
8534
9353
 
8535
9354
  // Parse document grid (w:docGrid)
@@ -8607,14 +9426,18 @@ export class DocumentParser {
8607
9426
  if (Object.keys(props).length > 0) sectionProps.endnotePr = props;
8608
9427
  }
8609
9428
 
8610
- // Parse noEndnote
8611
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:noEndnote')) {
8612
- sectionProps.noEndnote = true;
9429
+ // Parse noEndnote (w:noEndnote) — CT_OnOff per ECMA-376 §17.11.14
9430
+ const noEndEls = XMLParser.extractElements(sectPr, 'w:noEndnote');
9431
+ if (noEndEls.length > 0 && noEndEls[0]) {
9432
+ const v = XMLParser.extractAttribute(noEndEls[0], 'w:val');
9433
+ sectionProps.noEndnote = parseOnOffAttribute(v, true);
8613
9434
  }
8614
9435
 
8615
- // Parse form protection
8616
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:formProt')) {
8617
- sectionProps.formProt = true;
9436
+ // Parse form protection (w:formProt) — CT_OnOff per ECMA-376 §17.6.8
9437
+ const formProtEls = XMLParser.extractElements(sectPr, 'w:formProt');
9438
+ if (formProtEls.length > 0 && formProtEls[0]) {
9439
+ const v = XMLParser.extractAttribute(formProtEls[0], 'w:val');
9440
+ sectionProps.formProt = parseOnOffAttribute(v, true);
8618
9441
  }
8619
9442
 
8620
9443
  // Parse printer settings (w:printerSettings r:id)
@@ -8737,34 +9560,45 @@ export class DocumentParser {
8737
9560
  runFormatting = this.parseRunFormattingFromXml(rPrXml);
8738
9561
  }
8739
9562
 
8740
- // Parse metadata properties (Phase 5.3)
8741
- // qFormat - Quick style gallery
8742
- const qFormat = styleXml.includes('<w:qFormat/>') || styleXml.includes('<w:qFormat ');
8743
-
8744
- // semiHidden - Hide from recommended list
8745
- const semiHidden = styleXml.includes('<w:semiHidden/>') || styleXml.includes('<w:semiHidden ');
8746
-
8747
- // unhideWhenUsed - Auto-show when applied
8748
- const unhideWhenUsed =
8749
- styleXml.includes('<w:unhideWhenUsed/>') || styleXml.includes('<w:unhideWhenUsed ');
8750
-
8751
- // locked - Prevent modification
8752
- const locked = styleXml.includes('<w:locked/>') || styleXml.includes('<w:locked ');
8753
-
8754
- // personal - User-specific style
8755
- const personal = styleXml.includes('<w:personal/>') || styleXml.includes('<w:personal ');
8756
-
8757
- // personalCompose - Style for composing new messages
8758
- const personalCompose =
8759
- styleXml.includes('<w:personalCompose/>') || styleXml.includes('<w:personalCompose ');
8760
-
8761
- // personalReply - Style for replying to messages
8762
- const personalReply =
8763
- styleXml.includes('<w:personalReply/>') || styleXml.includes('<w:personalReply ');
9563
+ // Parse metadata CT_OnOff flags per ECMA-376 §17.7.4 (OnOffType bindings).
9564
+ // Each flag honours `w:val` so an explicit `<w:qFormat w:val="0"/>` override
9565
+ // of a based-on style's qFormat=true round-trips as `false`. The old code
9566
+ // detected presence via `styleXml.includes('<w:qFormat/>')` which ignored
9567
+ // w:val entirely and flipped any explicit-false to true.
9568
+ const parseStyleOnOffFlag = (tagName: string): boolean | undefined => {
9569
+ const els = XMLParser.extractElements(styleXml, tagName);
9570
+ if (els.length === 0 || !els[0]) return undefined;
9571
+ const v = XMLParser.extractAttribute(els[0], 'w:val');
9572
+ return parseOnOffAttribute(v, true);
9573
+ };
8764
9574
 
8765
- // autoRedefine - Update style from formatting
8766
- const autoRedefine =
8767
- styleXml.includes('<w:autoRedefine/>') || styleXml.includes('<w:autoRedefine ');
9575
+ const qFormat = parseStyleOnOffFlag('w:qFormat');
9576
+ const semiHidden = parseStyleOnOffFlag('w:semiHidden');
9577
+ const unhideWhenUsed = parseStyleOnOffFlag('w:unhideWhenUsed');
9578
+ const locked = parseStyleOnOffFlag('w:locked');
9579
+ const personal = parseStyleOnOffFlag('w:personal');
9580
+ const personalCompose = parseStyleOnOffFlag('w:personalCompose');
9581
+ const personalReply = parseStyleOnOffFlag('w:personalReply');
9582
+ const autoRedefine = parseStyleOnOffFlag('w:autoRedefine');
9583
+ // `<w:hidden>` (CT_Style §17.7.4, OnOffOnlyType) — completely hide the
9584
+ // style. Previously not modeled; now round-trips as `properties.hidden`.
9585
+ const hidden = parseStyleOnOffFlag('w:hidden');
9586
+
9587
+ // `<w:rsid w:val="HEX"/>` (CT_Style §17.7.4, CT_LongHexNumber §17.18.50) —
9588
+ // revision-save ID stamp identifying the session in which this style
9589
+ // definition was last edited. Schema position: between `personalReply`
9590
+ // and `pPr`. Previously dropped entirely on parse, now preserved on
9591
+ // StyleProperties so round-trips stay faithful.
9592
+ let styleRsid: string | undefined;
9593
+ if (styleXml.includes('<w:rsid')) {
9594
+ const rsidTag = XMLParser.extractSelfClosingTag(styleXml, 'w:rsid');
9595
+ if (rsidTag) {
9596
+ const v = XMLParser.extractAttribute(`<w:rsid${rsidTag}`, 'w:val');
9597
+ if (v && v.length > 0) {
9598
+ styleRsid = v;
9599
+ }
9600
+ }
9601
+ }
8768
9602
 
8769
9603
  // uiPriority - Sort order
8770
9604
  let uiPriority: number | undefined;
@@ -8803,7 +9637,7 @@ export class DocumentParser {
8803
9637
  }
8804
9638
 
8805
9639
  // Parse table style properties (Phase 5.1)
8806
- let tableStyle: import('../formatting/Style').TableStyleProperties | undefined;
9640
+ let tableStyle: import('../formatting/Style.js').TableStyleProperties | undefined;
8807
9641
  if (typeAttr === 'table') {
8808
9642
  tableStyle = this.parseTableStyleProperties(styleXml);
8809
9643
  }
@@ -8815,21 +9649,27 @@ export class DocumentParser {
8815
9649
  type: typeAttr,
8816
9650
  basedOn,
8817
9651
  next,
8818
- isDefault: defaultAttr === '1' || defaultAttr === 'true',
8819
- customStyle: customStyleAttr === '1' || customStyleAttr === 'true',
9652
+ // w:default and w:customStyle are ST_OnOff per ECMA-376 §17.17.4
9653
+ isDefault: parseOnOffAttribute(defaultAttr),
9654
+ customStyle: parseOnOffAttribute(customStyleAttr),
8820
9655
  paragraphFormatting,
8821
9656
  numPr: styleNumPr,
8822
9657
  runFormatting,
8823
9658
  tableStyle,
8824
- // Metadata properties (Phase 5.3)
8825
- qFormat: qFormat || undefined,
8826
- semiHidden: semiHidden || undefined,
8827
- unhideWhenUsed: unhideWhenUsed || undefined,
8828
- locked: locked || undefined,
8829
- personal: personal || undefined,
8830
- personalCompose: personalCompose || undefined,
8831
- personalReply: personalReply || undefined,
8832
- autoRedefine: autoRedefine || undefined,
9659
+ // Metadata CT_OnOff flags (ECMA-376 §17.7.4). parseStyleOnOffFlag returns
9660
+ // undefined when the element is absent, or the actual boolean (true/false)
9661
+ // when present — preserve both "explicit false" (override) and "absent"
9662
+ // (inherit) faithfully through the Style properties record.
9663
+ qFormat,
9664
+ semiHidden,
9665
+ hidden,
9666
+ unhideWhenUsed,
9667
+ locked,
9668
+ personal,
9669
+ personalCompose,
9670
+ personalReply,
9671
+ rsid: styleRsid,
9672
+ autoRedefine,
8833
9673
  uiPriority,
8834
9674
  link,
8835
9675
  aliases,
@@ -8846,6 +9686,86 @@ export class DocumentParser {
8846
9686
  private parseParagraphFormattingFromXml(pPrXml: string): ParagraphFormatting {
8847
9687
  const formatting: ParagraphFormatting = {};
8848
9688
 
9689
+ // Parse framePr (text frame properties) per ECMA-376 Part 1 §17.3.1.11 —
9690
+ // CT_FramePr is a CT_PPrBase child (#5, between pageBreakBefore and
9691
+ // widowControl). Each attribute is independently optional; numeric
9692
+ // attributes (w/h/x/y/hSpace/vSpace/lines) may legitimately be zero
9693
+ // so use explicit string presence rather than truthy checks.
9694
+ const framePrTag = XMLParser.extractSelfClosingTag(pPrXml, 'w:framePr');
9695
+ if (framePrTag) {
9696
+ const fpStr = `<w:framePr${framePrTag}`;
9697
+ const frameProps: NonNullable<ParagraphFormatting['framePr']> = {};
9698
+ const wAttr = XMLParser.extractAttribute(fpStr, 'w:w');
9699
+ if (wAttr !== undefined) frameProps.w = parseInt(wAttr, 10);
9700
+ const hAttr = XMLParser.extractAttribute(fpStr, 'w:h');
9701
+ if (hAttr !== undefined) frameProps.h = parseInt(hAttr, 10);
9702
+ const hRule = XMLParser.extractAttribute(fpStr, 'w:hRule');
9703
+ if (hRule === 'auto' || hRule === 'atLeast' || hRule === 'exact') {
9704
+ frameProps.hRule = hRule;
9705
+ }
9706
+ const xAttr = XMLParser.extractAttribute(fpStr, 'w:x');
9707
+ if (xAttr !== undefined) frameProps.x = parseInt(xAttr, 10);
9708
+ const yAttr = XMLParser.extractAttribute(fpStr, 'w:y');
9709
+ if (yAttr !== undefined) frameProps.y = parseInt(yAttr, 10);
9710
+ const xAlign = XMLParser.extractAttribute(fpStr, 'w:xAlign');
9711
+ if (
9712
+ xAlign === 'left' ||
9713
+ xAlign === 'center' ||
9714
+ xAlign === 'right' ||
9715
+ xAlign === 'inside' ||
9716
+ xAlign === 'outside'
9717
+ ) {
9718
+ frameProps.xAlign = xAlign;
9719
+ }
9720
+ const yAlign = XMLParser.extractAttribute(fpStr, 'w:yAlign');
9721
+ if (
9722
+ yAlign === 'top' ||
9723
+ yAlign === 'center' ||
9724
+ yAlign === 'bottom' ||
9725
+ yAlign === 'inline' ||
9726
+ yAlign === 'inside' ||
9727
+ yAlign === 'outside'
9728
+ ) {
9729
+ frameProps.yAlign = yAlign;
9730
+ }
9731
+ const hAnchor = XMLParser.extractAttribute(fpStr, 'w:hAnchor');
9732
+ if (hAnchor === 'page' || hAnchor === 'margin' || hAnchor === 'text') {
9733
+ frameProps.hAnchor = hAnchor;
9734
+ }
9735
+ const vAnchor = XMLParser.extractAttribute(fpStr, 'w:vAnchor');
9736
+ if (vAnchor === 'page' || vAnchor === 'margin' || vAnchor === 'text') {
9737
+ frameProps.vAnchor = vAnchor;
9738
+ }
9739
+ const hSpace = XMLParser.extractAttribute(fpStr, 'w:hSpace');
9740
+ if (hSpace !== undefined) frameProps.hSpace = parseInt(hSpace, 10);
9741
+ const vSpace = XMLParser.extractAttribute(fpStr, 'w:vSpace');
9742
+ if (vSpace !== undefined) frameProps.vSpace = parseInt(vSpace, 10);
9743
+ const wrap = XMLParser.extractAttribute(fpStr, 'w:wrap');
9744
+ if (
9745
+ wrap === 'around' ||
9746
+ wrap === 'auto' ||
9747
+ wrap === 'none' ||
9748
+ wrap === 'notBeside' ||
9749
+ wrap === 'through' ||
9750
+ wrap === 'tight'
9751
+ ) {
9752
+ frameProps.wrap = wrap;
9753
+ }
9754
+ const dropCap = XMLParser.extractAttribute(fpStr, 'w:dropCap');
9755
+ if (dropCap === 'none' || dropCap === 'drop' || dropCap === 'margin') {
9756
+ frameProps.dropCap = dropCap;
9757
+ }
9758
+ const lines = XMLParser.extractAttribute(fpStr, 'w:lines');
9759
+ if (lines !== undefined) frameProps.lines = parseInt(lines, 10);
9760
+ const anchorLock = XMLParser.extractAttribute(fpStr, 'w:anchorLock');
9761
+ if (anchorLock !== undefined) {
9762
+ frameProps.anchorLock = parseOnOffAttribute(anchorLock, true);
9763
+ }
9764
+ if (Object.keys(frameProps).length > 0) {
9765
+ formatting.framePr = frameProps;
9766
+ }
9767
+ }
9768
+
8849
9769
  // Parse alignment (w:jc)
8850
9770
  const jcElement = XMLParser.extractSelfClosingTag(pPrXml, 'w:jc');
8851
9771
  if (jcElement) {
@@ -8885,15 +9805,17 @@ export class DocumentParser {
8885
9805
  lineRule: validatedLineRule,
8886
9806
  beforeLines: beforeLines ? parseInt(beforeLines, 10) : undefined,
8887
9807
  afterLines: afterLines ? parseInt(afterLines, 10) : undefined,
8888
- beforeAutospacing: beforeAutosp
8889
- ? beforeAutosp === '1' || beforeAutosp === 'true'
8890
- : undefined,
8891
- afterAutospacing: afterAutosp ? afterAutosp === '1' || afterAutosp === 'true' : undefined,
9808
+ // ST_OnOff per ECMA-376 §17.17.4 — accept 1/0/true/false/on/off
9809
+ beforeAutospacing: beforeAutosp ? parseOnOffAttribute(beforeAutosp) : undefined,
9810
+ afterAutospacing: afterAutosp ? parseOnOffAttribute(afterAutosp) : undefined,
8892
9811
  };
8893
9812
  }
8894
9813
 
8895
9814
  // Parse indentation (w:ind)
8896
- // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to w:left/w:right
9815
+ // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to
9816
+ // w:left/w:right. §17.3.1.12 also defines six CJK character-unit variants
9817
+ // (ST_DecimalNumber) — parse those alongside so styles authored in CJK
9818
+ // locales preserve their character-unit indent spec through round-trip.
8897
9819
  const indElement = XMLParser.extractSelfClosingTag(pPrXml, 'w:ind');
8898
9820
  if (indElement) {
8899
9821
  const indTag = `<w:ind${indElement}`;
@@ -8903,33 +9825,137 @@ export class DocumentParser {
8903
9825
  const right = XMLParser.extractAttribute(indTag, 'w:right');
8904
9826
  const firstLine = XMLParser.extractAttribute(indTag, 'w:firstLine');
8905
9827
  const hanging = XMLParser.extractAttribute(indTag, 'w:hanging');
9828
+ // CJK character-unit variants. startChars/endChars collapse to
9829
+ // leftChars/rightChars (same bidi-aware rule as the twips pair).
9830
+ const startChars = XMLParser.extractAttribute(indTag, 'w:startChars');
9831
+ const leftChars = XMLParser.extractAttribute(indTag, 'w:leftChars');
9832
+ const endChars = XMLParser.extractAttribute(indTag, 'w:endChars');
9833
+ const rightChars = XMLParser.extractAttribute(indTag, 'w:rightChars');
9834
+ const firstLineChars = XMLParser.extractAttribute(indTag, 'w:firstLineChars');
9835
+ const hangingChars = XMLParser.extractAttribute(indTag, 'w:hangingChars');
8906
9836
 
8907
9837
  const leftVal = start || left;
8908
9838
  const rightVal = end || right;
9839
+ const leftCharsVal = startChars || leftChars;
9840
+ const rightCharsVal = endChars || rightChars;
8909
9841
 
8910
9842
  formatting.indentation = {
8911
9843
  left: leftVal ? parseInt(leftVal, 10) : undefined,
8912
9844
  right: rightVal ? parseInt(rightVal, 10) : undefined,
8913
9845
  firstLine: firstLine ? parseInt(firstLine, 10) : undefined,
8914
9846
  hanging: hanging ? parseInt(hanging, 10) : undefined,
9847
+ leftChars: leftCharsVal ? parseInt(leftCharsVal, 10) : undefined,
9848
+ rightChars: rightCharsVal ? parseInt(rightCharsVal, 10) : undefined,
9849
+ firstLineChars: firstLineChars ? parseInt(firstLineChars, 10) : undefined,
9850
+ hangingChars: hangingChars ? parseInt(hangingChars, 10) : undefined,
8915
9851
  };
8916
9852
  }
8917
9853
 
8918
- // Parse boolean properties
8919
- if (pPrXml.includes('<w:keepNext/>') || pPrXml.includes('<w:keepNext ')) {
8920
- formatting.keepNext = true;
8921
- }
8922
- if (pPrXml.includes('<w:keepLines/>') || pPrXml.includes('<w:keepLines ')) {
8923
- formatting.keepLines = true;
9854
+ // Parse CT_OnOff boolean flags per ECMA-376 §17.17.4 / §17.3.1. The previous
9855
+ // substring-only detection (`pPrXml.includes('<w:keepNext/>') ||
9856
+ // pPrXml.includes('<w:keepNext ')`) hard-coded the flag to true whenever
9857
+ // the element appeared at all — silently flipping `<w:keepNext w:val="0"/>`
9858
+ // (explicit override) into an enabled flag. Read w:val when present and
9859
+ // honour every ST_OnOff literal (1/0/true/false/on/off).
9860
+ const parseStylePPrCtOnOff = (tagName: string): boolean | undefined => {
9861
+ // extractSelfClosingTag returns the ATTRIBUTE STRING (possibly empty)
9862
+ // when found, or `undefined` when absent. Earlier this helper checked
9863
+ // `=== null` by mistake — that let the "absent" case fall through and
9864
+ // construct a garbage tag that produced `true`, silently enabling the
9865
+ // flag on every style that didn't set it.
9866
+ const el = XMLParser.extractSelfClosingTag(pPrXml, tagName);
9867
+ if (el === undefined) return undefined;
9868
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
9869
+ if (val === undefined) return true;
9870
+ return parseOnOffAttribute(val, true);
9871
+ };
9872
+
9873
+ const keepNextVal = parseStylePPrCtOnOff('w:keepNext');
9874
+ if (keepNextVal !== undefined) formatting.keepNext = keepNextVal;
9875
+
9876
+ const keepLinesVal = parseStylePPrCtOnOff('w:keepLines');
9877
+ if (keepLinesVal !== undefined) formatting.keepLines = keepLinesVal;
9878
+
9879
+ const pageBreakBeforeVal = parseStylePPrCtOnOff('w:pageBreakBefore');
9880
+ if (pageBreakBeforeVal !== undefined) formatting.pageBreakBefore = pageBreakBeforeVal;
9881
+
9882
+ // Contextual spacing per ECMA-376 Part 1 §17.3.1.9
9883
+ // "Don't add space between paragraphs of the same style"
9884
+ const contextualSpacingVal = parseStylePPrCtOnOff('w:contextualSpacing');
9885
+ if (contextualSpacingVal !== undefined) formatting.contextualSpacing = contextualSpacingVal;
9886
+
9887
+ // Remaining CT_PPrBase CT_OnOff flags per ECMA-376 Part 1 §17.3.1.
9888
+ // The main paragraph parser handles all of these; the style-level parser
9889
+ // previously dropped them (substring matches existed only for the four
9890
+ // flags above). Any style using the explicit-false form to override a
9891
+ // based-on style's enabled flag was silently losing the override.
9892
+ const widowControlVal = parseStylePPrCtOnOff('w:widowControl');
9893
+ if (widowControlVal !== undefined) formatting.widowControl = widowControlVal;
9894
+
9895
+ const suppressLineNumbersVal = parseStylePPrCtOnOff('w:suppressLineNumbers');
9896
+ if (suppressLineNumbersVal !== undefined)
9897
+ formatting.suppressLineNumbers = suppressLineNumbersVal;
9898
+
9899
+ const bidiVal = parseStylePPrCtOnOff('w:bidi');
9900
+ if (bidiVal !== undefined) formatting.bidi = bidiVal;
9901
+
9902
+ const mirrorIndentsVal = parseStylePPrCtOnOff('w:mirrorIndents');
9903
+ if (mirrorIndentsVal !== undefined) formatting.mirrorIndents = mirrorIndentsVal;
9904
+
9905
+ const adjustRightIndVal = parseStylePPrCtOnOff('w:adjustRightInd');
9906
+ if (adjustRightIndVal !== undefined) formatting.adjustRightInd = adjustRightIndVal;
9907
+
9908
+ const suppressAutoHyphensVal = parseStylePPrCtOnOff('w:suppressAutoHyphens');
9909
+ if (suppressAutoHyphensVal !== undefined)
9910
+ formatting.suppressAutoHyphens = suppressAutoHyphensVal;
9911
+
9912
+ const kinsokuVal = parseStylePPrCtOnOff('w:kinsoku');
9913
+ if (kinsokuVal !== undefined) formatting.kinsoku = kinsokuVal;
9914
+
9915
+ const wordWrapVal = parseStylePPrCtOnOff('w:wordWrap');
9916
+ if (wordWrapVal !== undefined) formatting.wordWrap = wordWrapVal;
9917
+
9918
+ const overflowPunctVal = parseStylePPrCtOnOff('w:overflowPunct');
9919
+ if (overflowPunctVal !== undefined) formatting.overflowPunct = overflowPunctVal;
9920
+
9921
+ const topLinePunctVal = parseStylePPrCtOnOff('w:topLinePunct');
9922
+ if (topLinePunctVal !== undefined) formatting.topLinePunct = topLinePunctVal;
9923
+
9924
+ const autoSpaceDEVal = parseStylePPrCtOnOff('w:autoSpaceDE');
9925
+ if (autoSpaceDEVal !== undefined) formatting.autoSpaceDE = autoSpaceDEVal;
9926
+
9927
+ const autoSpaceDNVal = parseStylePPrCtOnOff('w:autoSpaceDN');
9928
+ if (autoSpaceDNVal !== undefined) formatting.autoSpaceDN = autoSpaceDNVal;
9929
+
9930
+ const suppressOverlapVal = parseStylePPrCtOnOff('w:suppressOverlap');
9931
+ if (suppressOverlapVal !== undefined) formatting.suppressOverlap = suppressOverlapVal;
9932
+
9933
+ // Parse `w:val`-attribute string-enum children per CT_PPrBase.
9934
+ // Position #28 textDirection (ST_TextDirection), #29 textAlignment
9935
+ // (ST_TextAlignment), #30 textboxTightWrap (ST_TextboxTightWrapType).
9936
+ // The main paragraph parser handles these; the style-level parser
9937
+ // previously dropped them because the substring scan was never
9938
+ // extended past the iteration-25 CT_OnOff helper.
9939
+ const parseStylePPrValAttr = (tagName: string): string | undefined => {
9940
+ const el = XMLParser.extractSelfClosingTag(pPrXml, tagName);
9941
+ if (el === undefined) return undefined;
9942
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
9943
+ return val === undefined ? undefined : String(val);
9944
+ };
9945
+
9946
+ const textDirectionVal = parseStylePPrValAttr('w:textDirection');
9947
+ if (textDirectionVal !== undefined) {
9948
+ formatting.textDirection = textDirectionVal as ParagraphFormatting['textDirection'];
8924
9949
  }
8925
- if (pPrXml.includes('<w:pageBreakBefore/>') || pPrXml.includes('<w:pageBreakBefore ')) {
8926
- formatting.pageBreakBefore = true;
9950
+
9951
+ const textAlignmentVal = parseStylePPrValAttr('w:textAlignment');
9952
+ if (textAlignmentVal !== undefined) {
9953
+ formatting.textAlignment = textAlignmentVal as ParagraphFormatting['textAlignment'];
8927
9954
  }
8928
9955
 
8929
- // Contextual spacing per ECMA-376 Part 1 §17.3.1.8
8930
- // "Don't add space between paragraphs of the same style"
8931
- if (pPrXml.includes('<w:contextualSpacing/>') || pPrXml.includes('<w:contextualSpacing ')) {
8932
- formatting.contextualSpacing = true;
9956
+ const textboxTightWrapVal = parseStylePPrValAttr('w:textboxTightWrap');
9957
+ if (textboxTightWrapVal !== undefined) {
9958
+ formatting.textboxTightWrap = textboxTightWrapVal as ParagraphFormatting['textboxTightWrap'];
8933
9959
  }
8934
9960
 
8935
9961
  // Parse outline level (w:outlineLvl) - used for TOC generation
@@ -8945,7 +9971,29 @@ export class DocumentParser {
8945
9971
  }
8946
9972
  }
8947
9973
 
8948
- // Parse paragraph borders (w:pBdr) per ECMA-376 Part 1 §17.3.1.24
9974
+ // Parse divId (CT_PPrBase #32, §17.3.1.10) — numeric HTML div
9975
+ // association. Previously dropped on the style parser; the main
9976
+ // paragraph parser reads it at the pPrObj level but the style pPr
9977
+ // parser used string-based extraction and skipped both divId and
9978
+ // cnfStyle below.
9979
+ const divIdVal = parseStylePPrValAttr('w:divId');
9980
+ if (divIdVal !== undefined) {
9981
+ const parsedDivId = parseInt(divIdVal, 10);
9982
+ if (!isNaN(parsedDivId)) formatting.divId = parsedDivId;
9983
+ }
9984
+
9985
+ // Parse cnfStyle (CT_PPrBase #33, §17.3.1.8) — conditional formatting
9986
+ // bitmask string (12-char 0/1 sequence, e.g. "100000000100").
9987
+ const cnfStyleVal = parseStylePPrValAttr('w:cnfStyle');
9988
+ if (cnfStyleVal !== undefined) formatting.cnfStyle = cnfStyleVal;
9989
+
9990
+ // Parse paragraph borders (w:pBdr) per ECMA-376 Part 1 §17.3.1.24.
9991
+ // Covers the full CT_Border attribute set (§17.18.2): val, sz, space,
9992
+ // color, themeColor, themeTint, themeShade, shadow, frame. The style
9993
+ // *emitter* already round-trips all nine, so any style-pBdr authored
9994
+ // by Word with themed or shadow/frame attributes was silently flattened
9995
+ // here before this fix. Shadow/frame route through parseOnOffAttribute
9996
+ // so ST_OnOff literals ("on"/"off"/"1"/"0"/"true"/"false") resolve.
8949
9997
  const pBdrXml = XMLParser.extractBetweenTags(pPrXml, '<w:pBdr>', '</w:pBdr>');
8950
9998
  if (pBdrXml) {
8951
9999
  const borders: any = {};
@@ -8959,11 +10007,21 @@ export class DocumentParser {
8959
10007
  const size = XMLParser.extractAttribute(bTag, 'w:sz');
8960
10008
  const space = XMLParser.extractAttribute(bTag, 'w:space');
8961
10009
  const color = XMLParser.extractAttribute(bTag, 'w:color');
10010
+ const themeColor = XMLParser.extractAttribute(bTag, 'w:themeColor');
10011
+ const themeTint = XMLParser.extractAttribute(bTag, 'w:themeTint');
10012
+ const themeShade = XMLParser.extractAttribute(bTag, 'w:themeShade');
10013
+ const shadow = XMLParser.extractAttribute(bTag, 'w:shadow');
10014
+ const frame = XMLParser.extractAttribute(bTag, 'w:frame');
8962
10015
  const border: any = {};
8963
10016
  if (style) border.style = style;
8964
10017
  if (size) border.size = parseInt(size, 10);
8965
10018
  if (space) border.space = parseInt(space, 10);
8966
10019
  if (color) border.color = color;
10020
+ if (themeColor) border.themeColor = themeColor;
10021
+ if (themeTint) border.themeTint = themeTint;
10022
+ if (themeShade) border.themeShade = themeShade;
10023
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
10024
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
8967
10025
  if (Object.keys(border).length > 0) borders[type] = border;
8968
10026
  }
8969
10027
  }
@@ -9010,37 +10068,113 @@ export class DocumentParser {
9010
10068
  private parseRunFormattingFromXml(rPrXml: string): RunFormatting {
9011
10069
  const formatting: RunFormatting = {};
9012
10070
 
9013
- // Parse boolean properties
9014
- if (rPrXml.includes('<w:b/>') || rPrXml.includes('<w:b ')) {
9015
- formatting.bold = true;
9016
- }
9017
- if (rPrXml.includes('<w:i/>') || rPrXml.includes('<w:i ')) {
9018
- formatting.italic = true;
9019
- }
9020
- if (rPrXml.includes('<w:strike/>') || rPrXml.includes('<w:strike ')) {
9021
- formatting.strike = true;
9022
- }
9023
- if (rPrXml.includes('<w:smallCaps/>') || rPrXml.includes('<w:smallCaps ')) {
9024
- formatting.smallCaps = true;
9025
- }
9026
- if (rPrXml.includes('<w:caps/>') || rPrXml.includes('<w:caps ')) {
9027
- formatting.allCaps = true;
9028
- }
10071
+ // CT_OnOff rPr children per ECMA-376 §17.3.2. Previously detected via
10072
+ // substring-include which hard-coded the flag to `true` whenever the
10073
+ // element appeared — silently flipping `<w:b w:val="0"/>` (explicit
10074
+ // override of a based-on style's bold) into an enabled flag, and
10075
+ // never setting the field to `false` for legitimate overrides.
10076
+ // Mirrors the pPr `parseStylePPrCtOnOff` helper introduced in
10077
+ // iteration 25 / 26.
10078
+ const parseStyleRPrCtOnOff = (tagName: string): boolean | undefined => {
10079
+ const el = XMLParser.extractSelfClosingTag(rPrXml, tagName);
10080
+ if (el === undefined) return undefined;
10081
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
10082
+ if (val === undefined) return true;
10083
+ return parseOnOffAttribute(val, true);
10084
+ };
10085
+
10086
+ const boldVal = parseStyleRPrCtOnOff('w:b');
10087
+ if (boldVal !== undefined) formatting.bold = boldVal;
10088
+
10089
+ const italicVal = parseStyleRPrCtOnOff('w:i');
10090
+ if (italicVal !== undefined) formatting.italic = italicVal;
10091
+
10092
+ const strikeVal = parseStyleRPrCtOnOff('w:strike');
10093
+ if (strikeVal !== undefined) formatting.strike = strikeVal;
10094
+
10095
+ const smallCapsVal = parseStyleRPrCtOnOff('w:smallCaps');
10096
+ if (smallCapsVal !== undefined) formatting.smallCaps = smallCapsVal;
10097
+
10098
+ const allCapsVal = parseStyleRPrCtOnOff('w:caps');
10099
+ if (allCapsVal !== undefined) formatting.allCaps = allCapsVal;
10100
+
10101
+ // Extended CT_OnOff run children per ECMA-376 §17.3.2. The style-level
10102
+ // rPr parser previously dropped all of these silently, so character
10103
+ // styles setting dstrike, outline, shadow, emboss, imprint, rtl,
10104
+ // vanish, noProof, snapToGrid, specVanish, webHidden, or complex-script
10105
+ // variants (bCs / iCs / cs) lost their overrides on programmatic save.
10106
+ const boldCsVal = parseStyleRPrCtOnOff('w:bCs');
10107
+ if (boldCsVal !== undefined) formatting.complexScriptBold = boldCsVal;
10108
+
10109
+ const italicCsVal = parseStyleRPrCtOnOff('w:iCs');
10110
+ if (italicCsVal !== undefined) formatting.complexScriptItalic = italicCsVal;
10111
+
10112
+ const csVal = parseStyleRPrCtOnOff('w:cs');
10113
+ if (csVal !== undefined) formatting.complexScript = csVal;
10114
+
10115
+ const dstrikeVal = parseStyleRPrCtOnOff('w:dstrike');
10116
+ if (dstrikeVal !== undefined) formatting.dstrike = dstrikeVal;
10117
+
10118
+ const outlineVal = parseStyleRPrCtOnOff('w:outline');
10119
+ if (outlineVal !== undefined) formatting.outline = outlineVal;
10120
+
10121
+ const shadowVal = parseStyleRPrCtOnOff('w:shadow');
10122
+ if (shadowVal !== undefined) formatting.shadow = shadowVal;
10123
+
10124
+ const embossVal = parseStyleRPrCtOnOff('w:emboss');
10125
+ if (embossVal !== undefined) formatting.emboss = embossVal;
10126
+
10127
+ const imprintVal = parseStyleRPrCtOnOff('w:imprint');
10128
+ if (imprintVal !== undefined) formatting.imprint = imprintVal;
9029
10129
 
9030
- // Parse underline — all attributes per ECMA-376 §17.3.2.40
10130
+ const rtlVal = parseStyleRPrCtOnOff('w:rtl');
10131
+ if (rtlVal !== undefined) formatting.rtl = rtlVal;
10132
+
10133
+ const vanishVal = parseStyleRPrCtOnOff('w:vanish');
10134
+ if (vanishVal !== undefined) formatting.vanish = vanishVal;
10135
+
10136
+ const noProofVal = parseStyleRPrCtOnOff('w:noProof');
10137
+ if (noProofVal !== undefined) formatting.noProof = noProofVal;
10138
+
10139
+ const snapToGridVal = parseStyleRPrCtOnOff('w:snapToGrid');
10140
+ if (snapToGridVal !== undefined) formatting.snapToGrid = snapToGridVal;
10141
+
10142
+ const specVanishVal = parseStyleRPrCtOnOff('w:specVanish');
10143
+ if (specVanishVal !== undefined) formatting.specVanish = specVanishVal;
10144
+
10145
+ const webHiddenVal = parseStyleRPrCtOnOff('w:webHidden');
10146
+ if (webHiddenVal !== undefined) formatting.webHidden = webHiddenVal;
10147
+
10148
+ // Parse underline — all attributes per ECMA-376 §17.3.2.40.
10149
+ // Whitelist covers the full ST_Underline enumeration (18 values);
10150
+ // unknown / out-of-spec values fall through to `underline = true`
10151
+ // (underline enabled with default style) to match the main parser.
9031
10152
  const uElement = XMLParser.extractSelfClosingTag(rPrXml, 'w:u');
9032
10153
  if (uElement) {
9033
10154
  const uTag = `<w:u${uElement}`;
9034
10155
  const uVal = XMLParser.extractAttribute(uTag, 'w:val');
9035
- if (
9036
- uVal === 'single' ||
9037
- uVal === 'double' ||
9038
- uVal === 'thick' ||
9039
- uVal === 'dotted' ||
9040
- uVal === 'dash' ||
9041
- uVal === 'none'
9042
- ) {
9043
- formatting.underline = uVal;
10156
+ const ST_UNDERLINE = new Set<string>([
10157
+ 'single',
10158
+ 'words',
10159
+ 'double',
10160
+ 'thick',
10161
+ 'dotted',
10162
+ 'dottedHeavy',
10163
+ 'dash',
10164
+ 'dashedHeavy',
10165
+ 'dashLong',
10166
+ 'dashLongHeavy',
10167
+ 'dotDash',
10168
+ 'dashDotHeavy',
10169
+ 'dotDotDash',
10170
+ 'dashDotDotHeavy',
10171
+ 'wave',
10172
+ 'wavyHeavy',
10173
+ 'wavyDouble',
10174
+ 'none',
10175
+ ]);
10176
+ if (uVal !== undefined && ST_UNDERLINE.has(String(uVal))) {
10177
+ formatting.underline = String(uVal) as RunFormatting['underline'];
9044
10178
  } else {
9045
10179
  formatting.underline = true;
9046
10180
  }
@@ -9048,7 +10182,8 @@ export class DocumentParser {
9048
10182
  if (uColor) formatting.underlineColor = uColor;
9049
10183
  const uThemeColor = XMLParser.extractAttribute(uTag, 'w:themeColor');
9050
10184
  if (uThemeColor) {
9051
- formatting.underlineThemeColor = uThemeColor as import('../elements/Run').ThemeColorValue;
10185
+ formatting.underlineThemeColor =
10186
+ uThemeColor as import('../elements/Run.js').ThemeColorValue;
9052
10187
  }
9053
10188
  const uThemeTint = XMLParser.extractAttribute(uTag, 'w:themeTint');
9054
10189
  if (uThemeTint) formatting.underlineThemeTint = parseInt(uThemeTint, 16);
@@ -9115,17 +10250,25 @@ export class DocumentParser {
9115
10250
  }
9116
10251
  }
9117
10252
 
9118
- // Parse color (w:color) — all attributes per ECMA-376 §17.3.2.6
10253
+ // Parse color (w:color) — all attributes per ECMA-376 §17.3.2.6 / ST_HexColor
10254
+ // per §17.18.38. `w:val="auto"` is a valid ST_HexColorAuto sentinel that
10255
+ // tells Word to use the automatic/window text color; the previous parser
10256
+ // dropped it (only storing non-auto hex values), so a style-level rPr with
10257
+ // `<w:color w:val="auto"/>` silently lost that marker on round-trip and
10258
+ // the emitter defaulted to `"000000"` — changing the rendering of any
10259
+ // style that relied on the auto fallback. Preserve the literal "auto" so
10260
+ // it survives through emission. (Matches the object-format parser path
10261
+ // for direct-run rPr at parseRunFromObject line ~5210.)
9119
10262
  const colorElement = XMLParser.extractSelfClosingTag(rPrXml, 'w:color');
9120
10263
  if (colorElement) {
9121
10264
  const colorTag = `<w:color${colorElement}`;
9122
10265
  const val = XMLParser.extractAttribute(colorTag, 'w:val');
9123
- if (val && val !== 'auto') {
10266
+ if (val) {
9124
10267
  formatting.color = val;
9125
10268
  }
9126
10269
  const themeColor = XMLParser.extractAttribute(colorTag, 'w:themeColor');
9127
10270
  if (themeColor) {
9128
- formatting.themeColor = themeColor as import('../elements/Run').ThemeColorValue;
10271
+ formatting.themeColor = themeColor as import('../elements/Run.js').ThemeColorValue;
9129
10272
  }
9130
10273
  const themeTint = XMLParser.extractAttribute(colorTag, 'w:themeTint');
9131
10274
  if (themeTint) {
@@ -9190,6 +10333,186 @@ export class DocumentParser {
9190
10333
  formatting.shading = shading;
9191
10334
  }
9192
10335
 
10336
+ // Character spacing (w:spacing §17.3.2.35, ST_SignedTwipsMeasure) —
10337
+ // previously dropped on the style parser; 0 and negative values are
10338
+ // valid per spec.
10339
+ const spacingEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:spacing');
10340
+ if (spacingEl !== undefined) {
10341
+ const val = XMLParser.extractAttribute(`<w:spacing${spacingEl}`, 'w:val');
10342
+ if (val !== undefined) {
10343
+ const n = parseInt(String(val), 10);
10344
+ if (!isNaN(n)) formatting.characterSpacing = n;
10345
+ }
10346
+ }
10347
+
10348
+ // Vertical position (w:position §17.3.2.31, ST_SignedHpsMeasure).
10349
+ // 0 = baseline (explicit reset); negative = lowered; positive = raised.
10350
+ const positionEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:position');
10351
+ if (positionEl !== undefined) {
10352
+ const val = XMLParser.extractAttribute(`<w:position${positionEl}`, 'w:val');
10353
+ if (val !== undefined) {
10354
+ const n = parseInt(String(val), 10);
10355
+ if (!isNaN(n)) formatting.position = n;
10356
+ }
10357
+ }
10358
+
10359
+ // Kerning threshold (w:kern §17.3.2.20, ST_HpsMeasure). 0 = kern at
10360
+ // every size (no minimum font-size threshold).
10361
+ const kernEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:kern');
10362
+ if (kernEl !== undefined) {
10363
+ const val = XMLParser.extractAttribute(`<w:kern${kernEl}`, 'w:val');
10364
+ if (val !== undefined) {
10365
+ const n = parseInt(String(val), 10);
10366
+ if (!isNaN(n)) formatting.kerning = n;
10367
+ }
10368
+ }
10369
+
10370
+ // Language (w:lang §17.3.2.20, CT_Language). Single val → plain string;
10371
+ // multi-script (eastAsia and/or bidi present) → LanguageConfig object.
10372
+ const langEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:lang');
10373
+ if (langEl !== undefined) {
10374
+ const langTag = `<w:lang${langEl}`;
10375
+ const val = XMLParser.extractAttribute(langTag, 'w:val');
10376
+ const eastAsia = XMLParser.extractAttribute(langTag, 'w:eastAsia');
10377
+ const bidi = XMLParser.extractAttribute(langTag, 'w:bidi');
10378
+ if (eastAsia || bidi) {
10379
+ formatting.language = {
10380
+ val: val ? String(val) : undefined,
10381
+ eastAsia: eastAsia ? String(eastAsia) : undefined,
10382
+ bidi: bidi ? String(bidi) : undefined,
10383
+ };
10384
+ } else if (val) {
10385
+ formatting.language = String(val);
10386
+ }
10387
+ }
10388
+
10389
+ // Horizontal scaling (w:w §17.3.2.43, ST_TextScale — percentage,
10390
+ // min 1 per spec, so 0 is not valid and we keep a truthy check).
10391
+ const scaleEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:w');
10392
+ if (scaleEl !== undefined) {
10393
+ const val = XMLParser.extractAttribute(`<w:w${scaleEl}`, 'w:val');
10394
+ if (val) {
10395
+ const n = parseInt(String(val), 10);
10396
+ if (!isNaN(n)) formatting.scaling = n;
10397
+ }
10398
+ }
10399
+
10400
+ // Emphasis mark (w:em §17.3.2.13, ST_Em — "dot"/"comma"/"circle"/
10401
+ // "underDot"/"none"). Commonly paired with East Asian typography.
10402
+ const emEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:em');
10403
+ if (emEl !== undefined) {
10404
+ const val = XMLParser.extractAttribute(`<w:em${emEl}`, 'w:val');
10405
+ if (val) {
10406
+ formatting.emphasis = String(val) as RunFormatting['emphasis'];
10407
+ }
10408
+ }
10409
+
10410
+ // Animated text effect (w:effect §17.3.2.12, ST_TextEffect —
10411
+ // "blinkBackground"/"lights"/"antsBlack"/"antsRed"/"shimmer"/"sparkle"/
10412
+ // "none"). Legacy feature but still valid per schema.
10413
+ const effectEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:effect');
10414
+ if (effectEl !== undefined) {
10415
+ const val = XMLParser.extractAttribute(`<w:effect${effectEl}`, 'w:val');
10416
+ if (val) {
10417
+ formatting.effect = String(val) as RunFormatting['effect'];
10418
+ }
10419
+ }
10420
+
10421
+ // Text border (w:bdr §17.3.2.5) — character/run border. Full CT_Border
10422
+ // attribute set (§17.18.2): val / sz / space / color / themeColor /
10423
+ // themeTint / themeShade / shadow / frame.
10424
+ const bdrEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:bdr');
10425
+ if (bdrEl !== undefined) {
10426
+ const bdrTag = `<w:bdr${bdrEl}`;
10427
+ const border: {
10428
+ style?: string;
10429
+ size?: number;
10430
+ color?: string;
10431
+ space?: number;
10432
+ themeColor?: string;
10433
+ themeTint?: string;
10434
+ themeShade?: string;
10435
+ shadow?: boolean;
10436
+ frame?: boolean;
10437
+ } = {};
10438
+ const val = XMLParser.extractAttribute(bdrTag, 'w:val');
10439
+ if (val) border.style = String(val);
10440
+ const sz = XMLParser.extractAttribute(bdrTag, 'w:sz');
10441
+ if (sz !== undefined) {
10442
+ const n = parseInt(String(sz), 10);
10443
+ if (!isNaN(n)) border.size = n;
10444
+ }
10445
+ const color = XMLParser.extractAttribute(bdrTag, 'w:color');
10446
+ if (color) border.color = String(color);
10447
+ const space = XMLParser.extractAttribute(bdrTag, 'w:space');
10448
+ if (space !== undefined) {
10449
+ const n = parseInt(String(space), 10);
10450
+ if (!isNaN(n)) border.space = n;
10451
+ }
10452
+ const themeColor = XMLParser.extractAttribute(bdrTag, 'w:themeColor');
10453
+ if (themeColor) border.themeColor = String(themeColor);
10454
+ const themeTint = XMLParser.extractAttribute(bdrTag, 'w:themeTint');
10455
+ if (themeTint) border.themeTint = String(themeTint);
10456
+ const themeShade = XMLParser.extractAttribute(bdrTag, 'w:themeShade');
10457
+ if (themeShade) border.themeShade = String(themeShade);
10458
+ const shadow = XMLParser.extractAttribute(bdrTag, 'w:shadow');
10459
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
10460
+ const frame = XMLParser.extractAttribute(bdrTag, 'w:frame');
10461
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
10462
+ if (Object.keys(border).length > 0) {
10463
+ formatting.border = border as RunFormatting['border'];
10464
+ }
10465
+ }
10466
+
10467
+ // Manual run width (w:fitText §17.3.2.15). Value is twips; 0 is
10468
+ // technically representable as "explicit zero" — use `!== undefined`.
10469
+ const fitTextEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:fitText');
10470
+ if (fitTextEl !== undefined) {
10471
+ const val = XMLParser.extractAttribute(`<w:fitText${fitTextEl}`, 'w:val');
10472
+ if (val !== undefined) {
10473
+ const n = parseInt(String(val), 10);
10474
+ if (!isNaN(n)) formatting.fitText = n;
10475
+ }
10476
+ }
10477
+
10478
+ // East Asian layout (w:eastAsianLayout §17.3.2.10) — combined
10479
+ // characters / vertical text / compression attributes.
10480
+ const ealEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:eastAsianLayout');
10481
+ if (ealEl !== undefined) {
10482
+ const ealTag = `<w:eastAsianLayout${ealEl}`;
10483
+ const layout: Partial<{
10484
+ id: number;
10485
+ vert: boolean;
10486
+ vertCompress: boolean;
10487
+ combine: boolean;
10488
+ combineBrackets: 'none' | 'round' | 'square' | 'angle' | 'curly';
10489
+ }> = {};
10490
+ const id = XMLParser.extractAttribute(ealTag, 'w:id');
10491
+ if (id !== undefined) {
10492
+ const n = Number(id);
10493
+ if (!isNaN(n)) layout.id = n;
10494
+ }
10495
+ const vert = XMLParser.extractAttribute(ealTag, 'w:vert');
10496
+ if (vert !== undefined && parseOnOffAttribute(vert, true)) layout.vert = true;
10497
+ const vertCompress = XMLParser.extractAttribute(ealTag, 'w:vertCompress');
10498
+ if (vertCompress !== undefined && parseOnOffAttribute(vertCompress, true))
10499
+ layout.vertCompress = true;
10500
+ const combine = XMLParser.extractAttribute(ealTag, 'w:combine');
10501
+ if (combine !== undefined && parseOnOffAttribute(combine, true)) layout.combine = true;
10502
+ const combineBrackets = XMLParser.extractAttribute(ealTag, 'w:combineBrackets');
10503
+ if (combineBrackets) {
10504
+ layout.combineBrackets = String(combineBrackets) as
10505
+ | 'none'
10506
+ | 'round'
10507
+ | 'square'
10508
+ | 'angle'
10509
+ | 'curly';
10510
+ }
10511
+ if (Object.keys(layout).length > 0) {
10512
+ formatting.eastAsianLayout = layout as RunFormatting['eastAsianLayout'];
10513
+ }
10514
+ }
10515
+
9193
10516
  return formatting;
9194
10517
  }
9195
10518
 
@@ -9200,8 +10523,8 @@ export class DocumentParser {
9200
10523
  */
9201
10524
  private parseTableStyleProperties(
9202
10525
  styleXml: string
9203
- ): import('../formatting/Style').TableStyleProperties {
9204
- const tableStyle: import('../formatting/Style').TableStyleProperties = {};
10526
+ ): import('../formatting/Style.js').TableStyleProperties {
10527
+ const tableStyle: import('../formatting/Style.js').TableStyleProperties = {};
9205
10528
 
9206
10529
  // Parse tblPr (table properties)
9207
10530
  const tblPrXml = XMLParser.extractBetweenTags(styleXml, '<w:tblPr>', '</w:tblPr>');
@@ -9254,8 +10577,8 @@ export class DocumentParser {
9254
10577
  */
9255
10578
  private parseTableFormattingFromXml(
9256
10579
  tblPrXml: string
9257
- ): import('../formatting/Style').TableStyleFormatting {
9258
- const formatting: import('../formatting/Style').TableStyleFormatting = {};
10580
+ ): import('../formatting/Style.js').TableStyleFormatting {
10581
+ const formatting: import('../formatting/Style.js').TableStyleFormatting = {};
9259
10582
 
9260
10583
  // Parse indent (w:tblInd) — preserve w:type per ECMA-376 ST_TblWidth
9261
10584
  if (tblPrXml.includes('<w:tblInd')) {
@@ -9268,18 +10591,27 @@ export class DocumentParser {
9268
10591
  }
9269
10592
  const type = XMLParser.extractAttribute(tblIndTag, 'w:type');
9270
10593
  if (type) {
9271
- formatting.indentType = type as import('../elements/Table').TableWidthType;
10594
+ formatting.indentType = type as import('../elements/Table.js').TableWidthType;
9272
10595
  }
9273
10596
  }
9274
10597
  }
9275
10598
 
9276
- // Parse alignment
10599
+ // Parse alignment — ST_JcTable has 5 values (start, end, center, left,
10600
+ // right) per ECMA-376 §17.18.45. The whitelist previously only accepted
10601
+ // the three legacy LTR-centric values, silently dropping `start` / `end`
10602
+ // (the bidi-aware defaults a modern authoring tool emits).
9277
10603
  if (tblPrXml.includes('<w:jc')) {
9278
10604
  const tag = XMLParser.extractSelfClosingTag(tblPrXml, 'w:jc');
9279
10605
  if (tag) {
9280
10606
  const val = XMLParser.extractAttribute(`<w:jc${tag}`, 'w:val');
9281
- if (val === 'left' || val === 'center' || val === 'right') {
9282
- formatting.alignment = val;
10607
+ if (
10608
+ val === 'left' ||
10609
+ val === 'center' ||
10610
+ val === 'right' ||
10611
+ val === 'start' ||
10612
+ val === 'end'
10613
+ ) {
10614
+ formatting.alignment = val as import('../formatting/Style.js').TableAlignment;
9283
10615
  }
9284
10616
  }
9285
10617
  }
@@ -9320,8 +10652,8 @@ export class DocumentParser {
9320
10652
  */
9321
10653
  private parseTableCellFormattingFromXml(
9322
10654
  tcPrXml: string
9323
- ): import('../formatting/Style').TableCellStyleFormatting {
9324
- const formatting: import('../formatting/Style').TableCellStyleFormatting = {};
10655
+ ): import('../formatting/Style.js').TableCellStyleFormatting {
10656
+ const formatting: import('../formatting/Style.js').TableCellStyleFormatting = {};
9325
10657
 
9326
10658
  // Parse borders
9327
10659
  const bordersXml = XMLParser.extractBetweenTags(tcPrXml, '<w:tcBorders>', '</w:tcBorders>');
@@ -9329,7 +10661,7 @@ export class DocumentParser {
9329
10661
  formatting.borders = this.parseBordersFromXml(
9330
10662
  bordersXml,
9331
10663
  true
9332
- ) as import('../formatting/Style').CellBorders;
10664
+ ) as import('../formatting/Style.js').CellBorders;
9333
10665
  }
9334
10666
 
9335
10667
  // Parse shading
@@ -9343,12 +10675,15 @@ export class DocumentParser {
9343
10675
  formatting.margins = this.parseCellMarginsFromXml(marginXml);
9344
10676
  }
9345
10677
 
9346
- // Parse vertical alignment
10678
+ // Parse vertical alignment — ST_VerticalJc has four values
10679
+ // (top / center / both / bottom) per ECMA-376 §17.18.101. Previously
10680
+ // the whitelist only accepted the first three, silently dropping
10681
+ // `<w:vAlign w:val="both"/>` on cell styles.
9347
10682
  if (tcPrXml.includes('<w:vAlign')) {
9348
10683
  const tag = XMLParser.extractSelfClosingTag(tcPrXml, 'w:vAlign');
9349
10684
  if (tag) {
9350
10685
  const val = XMLParser.extractAttribute(`<w:vAlign${tag}`, 'w:val');
9351
- if (val === 'top' || val === 'center' || val === 'bottom') {
10686
+ if (val === 'top' || val === 'center' || val === 'both' || val === 'bottom') {
9352
10687
  formatting.verticalAlignment = val;
9353
10688
  }
9354
10689
  }
@@ -9362,8 +10697,8 @@ export class DocumentParser {
9362
10697
  */
9363
10698
  private parseTableRowFormattingFromXml(
9364
10699
  trPrXml: string
9365
- ): import('../formatting/Style').TableRowStyleFormatting {
9366
- const formatting: import('../formatting/Style').TableRowStyleFormatting = {};
10700
+ ): import('../formatting/Style.js').TableRowStyleFormatting {
10701
+ const formatting: import('../formatting/Style.js').TableRowStyleFormatting = {};
9367
10702
 
9368
10703
  // Parse height
9369
10704
  if (trPrXml.includes('<w:trHeight')) {
@@ -9380,15 +10715,25 @@ export class DocumentParser {
9380
10715
  }
9381
10716
  }
9382
10717
 
9383
- // Parse cantSplit
9384
- if (trPrXml.includes('<w:cantSplit/>') || trPrXml.includes('<w:cantSplit ')) {
9385
- formatting.cantSplit = true;
9386
- }
10718
+ // Parse cantSplit / tblHeader — both OnOffOnlyType (§17.4.6, §17.4.50).
10719
+ // Previous substring-include detection hard-coded the flag to `true`
10720
+ // whenever the element appeared, silently flipping an explicit-off
10721
+ // override (e.g., a tblStylePr conditional un-splitting a header row)
10722
+ // into an enabled flag. Reuse parseOnOffAttribute so bare, "on", and
10723
+ // "off" all map correctly, and so absent stays undefined.
10724
+ const parseTrPrOnOffOnly = (tagName: string): boolean | undefined => {
10725
+ const el = XMLParser.extractSelfClosingTag(trPrXml, tagName);
10726
+ if (el === undefined) return undefined;
10727
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
10728
+ if (val === undefined) return true;
10729
+ return parseOnOffAttribute(val, true);
10730
+ };
9387
10731
 
9388
- // Parse tblHeader (isHeader)
9389
- if (trPrXml.includes('<w:tblHeader/>') || trPrXml.includes('<w:tblHeader ')) {
9390
- formatting.isHeader = true;
9391
- }
10732
+ const cantSplitVal = parseTrPrOnOffOnly('w:cantSplit');
10733
+ if (cantSplitVal !== undefined) formatting.cantSplit = cantSplitVal;
10734
+
10735
+ const tblHeaderVal = parseTrPrOnOffOnly('w:tblHeader');
10736
+ if (tblHeaderVal !== undefined) formatting.isHeader = tblHeaderVal;
9392
10737
 
9393
10738
  return formatting;
9394
10739
  }
@@ -9398,8 +10743,8 @@ export class DocumentParser {
9398
10743
  */
9399
10744
  private parseConditionalFormattingFromXml(
9400
10745
  styleXml: string
9401
- ): import('../formatting/Style').ConditionalTableFormatting[] | undefined {
9402
- const conditionalFormatting: import('../formatting/Style').ConditionalTableFormatting[] = [];
10746
+ ): import('../formatting/Style.js').ConditionalTableFormatting[] | undefined {
10747
+ const conditionalFormatting: import('../formatting/Style.js').ConditionalTableFormatting[] = [];
9403
10748
 
9404
10749
  // Find all tblStylePr elements
9405
10750
  let searchFrom = 0;
@@ -9415,8 +10760,8 @@ export class DocumentParser {
9415
10760
  // Extract type attribute
9416
10761
  const typeAttr = XMLParser.extractAttribute(tblStylePrXml, 'w:type');
9417
10762
  if (typeAttr) {
9418
- const conditional: import('../formatting/Style').ConditionalTableFormatting = {
9419
- type: typeAttr as import('../formatting/Style').ConditionalFormattingType,
10763
+ const conditional: import('../formatting/Style.js').ConditionalTableFormatting = {
10764
+ type: typeAttr as import('../formatting/Style.js').ConditionalFormattingType,
9420
10765
  };
9421
10766
 
9422
10767
  // Parse pPr
@@ -9466,29 +10811,58 @@ export class DocumentParser {
9466
10811
  private parseBordersFromXml(
9467
10812
  bordersXml: string,
9468
10813
  includeDiagonals: boolean
9469
- ): import('../formatting/Style').TableBorders | import('../formatting/Style').CellBorders {
10814
+ ): import('../formatting/Style.js').TableBorders | import('../formatting/Style.js').CellBorders {
9470
10815
  const borders: any = {};
9471
10816
 
10817
+ // Local helper so both the main-side loop and the diagonal loop share
10818
+ // the full CT_Border attribute set (§17.18.2): val / sz / space / color
10819
+ // / themeColor / themeTint / themeShade / shadow / frame. Previously
10820
+ // this parser only extracted the four "basic" attrs, so themed borders
10821
+ // and shadow/frame flags on page/table/cell borders were silently
10822
+ // dropped on every load → save round-trip.
10823
+ const parseBorderAttrs = (
10824
+ type: string
10825
+ ): import('../formatting/Style.js').BorderProperties | null => {
10826
+ const tag = XMLParser.extractSelfClosingTag(bordersXml, `w:${type}`);
10827
+ if (!tag) return null;
10828
+ const ref = `<w:${type}${tag}`;
10829
+ const border: import('../formatting/Style.js').BorderProperties = {};
10830
+ const style = XMLParser.extractAttribute(ref, 'w:val');
10831
+ const size = XMLParser.extractAttribute(ref, 'w:sz');
10832
+ const space = XMLParser.extractAttribute(ref, 'w:space');
10833
+ const color = XMLParser.extractAttribute(ref, 'w:color');
10834
+ const themeColor = XMLParser.extractAttribute(ref, 'w:themeColor');
10835
+ const themeTint = XMLParser.extractAttribute(ref, 'w:themeTint');
10836
+ const themeShade = XMLParser.extractAttribute(ref, 'w:themeShade');
10837
+ const shadow = XMLParser.extractAttribute(ref, 'w:shadow');
10838
+ const frame = XMLParser.extractAttribute(ref, 'w:frame');
10839
+ if (style) border.style = style as any;
10840
+ if (size) border.size = parseInt(size, 10);
10841
+ if (space) border.space = parseInt(space, 10);
10842
+ if (color) border.color = color;
10843
+ if (themeColor) (border as any).themeColor = themeColor;
10844
+ if (themeTint) (border as any).themeTint = themeTint;
10845
+ if (themeShade) (border as any).themeShade = themeShade;
10846
+ if (shadow !== undefined) (border as any).shadow = parseOnOffAttribute(shadow, true);
10847
+ if (frame !== undefined) (border as any).frame = parseOnOffAttribute(frame, true);
10848
+ return Object.keys(border).length > 0 ? border : null;
10849
+ };
10850
+
10851
+ // Per ECMA-376 §17.4.40 CT_TblBorders and §17.4.66 CT_TcBorders the
10852
+ // left / right borders have bidi-aware aliases `w:start` / `w:end`.
10853
+ // Modern authoring tools (Word 2013+, Google Docs) emit the bidi-
10854
+ // aware form by default — prefer those over the legacy `w:left` /
10855
+ // `w:right` so bidi-authored tables round-trip their side borders
10856
+ // (the internal model stores under the left/right keys, matching
10857
+ // the emitter).
9472
10858
  const borderTypes = ['top', 'bottom', 'left', 'right', 'insideH', 'insideV'];
9473
10859
  for (const type of borderTypes) {
9474
- if (bordersXml.includes(`<w:${type}`)) {
9475
- const tag = XMLParser.extractSelfClosingTag(bordersXml, `w:${type}`);
9476
- if (tag) {
9477
- const style = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:val');
9478
- const size = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:sz');
9479
- const space = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:space');
9480
- const color = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:color');
9481
-
9482
- const border: import('../formatting/Style').BorderProperties = {};
9483
- if (style) border.style = style as any;
9484
- if (size) border.size = parseInt(size, 10);
9485
- if (space) border.space = parseInt(space, 10);
9486
- if (color) border.color = color;
9487
-
9488
- if (Object.keys(border).length > 0) {
9489
- borders[type] = border;
9490
- }
9491
- }
10860
+ // For left/right: prefer bidi-aware start/end alias if present.
10861
+ const alias = type === 'left' ? 'start' : type === 'right' ? 'end' : type;
10862
+ const tagNameToRead = bordersXml.includes(`<w:${alias}`) ? alias : type;
10863
+ if (bordersXml.includes(`<w:${tagNameToRead}`)) {
10864
+ const border = parseBorderAttrs(tagNameToRead);
10865
+ if (border) borders[type] = border;
9492
10866
  }
9493
10867
  }
9494
10868
 
@@ -9497,23 +10871,8 @@ export class DocumentParser {
9497
10871
  const diagonalTypes = ['tl2br', 'tr2bl'];
9498
10872
  for (const type of diagonalTypes) {
9499
10873
  if (bordersXml.includes(`<w:${type}`)) {
9500
- const tag = XMLParser.extractSelfClosingTag(bordersXml, `w:${type}`);
9501
- if (tag) {
9502
- const style = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:val');
9503
- const size = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:sz');
9504
- const space = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:space');
9505
- const color = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:color');
9506
-
9507
- const border: import('../formatting/Style').BorderProperties = {};
9508
- if (style) border.style = style as any;
9509
- if (size) border.size = parseInt(size, 10);
9510
- if (space) border.space = parseInt(space, 10);
9511
- if (color) border.color = color;
9512
-
9513
- if (Object.keys(border).length > 0) {
9514
- borders[type] = border;
9515
- }
9516
- }
10874
+ const border = parseBorderAttrs(type);
10875
+ if (border) borders[type] = border;
9517
10876
  }
9518
10877
  }
9519
10878
  }
@@ -9526,16 +10885,25 @@ export class DocumentParser {
9526
10885
  * Extracts all 9 ECMA-376 shading attributes including theme colors.
9527
10886
  */
9528
10887
  private parseShadingFromObj(shd: any): ShadingConfig | undefined {
10888
+ // Per ECMA-376 §17.3.1.32 CT_Shd, every string-typed attribute
10889
+ // (ST_UcharHexNumber tint/shade, ST_ThemeColor theme refs,
10890
+ // ST_HexColor fill/color, ST_Shd pattern) can be purely numeric in
10891
+ // hex form — "80", "00", "FF", "80000000", etc. XMLParser's
10892
+ // `parseAttributeValue: true` coerces purely-digit hex strings like
10893
+ // "80" to the JS number 80, violating the `string` type on
10894
+ // ShadingConfig. Previously stored values leaked downstream as
10895
+ // numbers (e.g. `.toUpperCase()` would throw); cast every attribute
10896
+ // through `String(...)` so the declared-type contract holds.
9529
10897
  const shading: ShadingConfig = {};
9530
- if (shd['@_w:val']) shading.pattern = shd['@_w:val'];
9531
- if (shd['@_w:fill']) shading.fill = shd['@_w:fill'];
9532
- if (shd['@_w:color']) shading.color = shd['@_w:color'];
9533
- if (shd['@_w:themeFill']) shading.themeFill = shd['@_w:themeFill'];
9534
- if (shd['@_w:themeColor']) shading.themeColor = shd['@_w:themeColor'];
9535
- if (shd['@_w:themeFillTint']) shading.themeFillTint = shd['@_w:themeFillTint'];
9536
- if (shd['@_w:themeFillShade']) shading.themeFillShade = shd['@_w:themeFillShade'];
9537
- if (shd['@_w:themeTint']) shading.themeTint = shd['@_w:themeTint'];
9538
- if (shd['@_w:themeShade']) shading.themeShade = shd['@_w:themeShade'];
10898
+ if (shd['@_w:val']) shading.pattern = String(shd['@_w:val']) as ShadingConfig['pattern'];
10899
+ if (shd['@_w:fill']) shading.fill = String(shd['@_w:fill']);
10900
+ if (shd['@_w:color']) shading.color = String(shd['@_w:color']);
10901
+ if (shd['@_w:themeFill']) shading.themeFill = String(shd['@_w:themeFill']);
10902
+ if (shd['@_w:themeColor']) shading.themeColor = String(shd['@_w:themeColor']);
10903
+ if (shd['@_w:themeFillTint']) shading.themeFillTint = String(shd['@_w:themeFillTint']);
10904
+ if (shd['@_w:themeFillShade']) shading.themeFillShade = String(shd['@_w:themeFillShade']);
10905
+ if (shd['@_w:themeTint']) shading.themeTint = String(shd['@_w:themeTint']);
10906
+ if (shd['@_w:themeShade']) shading.themeShade = String(shd['@_w:themeShade']);
9539
10907
  return Object.keys(shading).length > 0 ? shading : undefined;
9540
10908
  }
9541
10909
 
@@ -9576,8 +10944,8 @@ export class DocumentParser {
9576
10944
  */
9577
10945
  private parseCellMarginsFromXml(
9578
10946
  marginXml: string
9579
- ): import('../formatting/Style').CellMargins | undefined {
9580
- const margins: import('../formatting/Style').CellMargins = {};
10947
+ ): import('../formatting/Style.js').CellMargins | undefined {
10948
+ const margins: import('../formatting/Style.js').CellMargins = {};
9581
10949
 
9582
10950
  // Parse top and bottom directly
9583
10951
  for (const type of ['top', 'bottom'] as const) {
@@ -9842,23 +11210,23 @@ export class DocumentParser {
9842
11210
  imageManager: ImageManager
9843
11211
  ): Promise<{
9844
11212
  headers: {
9845
- header: import('../elements/Header').Header;
11213
+ header: import('../elements/Header.js').Header;
9846
11214
  relationshipId: string;
9847
11215
  filename: string;
9848
11216
  }[];
9849
11217
  footers: {
9850
- footer: import('../elements/Footer').Footer;
11218
+ footer: import('../elements/Footer.js').Footer;
9851
11219
  relationshipId: string;
9852
11220
  filename: string;
9853
11221
  }[];
9854
11222
  }> {
9855
11223
  const headers: {
9856
- header: import('../elements/Header').Header;
11224
+ header: import('../elements/Header.js').Header;
9857
11225
  relationshipId: string;
9858
11226
  filename: string;
9859
11227
  }[] = [];
9860
11228
  const footers: {
9861
- footer: import('../elements/Footer').Footer;
11229
+ footer: import('../elements/Footer.js').Footer;
9862
11230
  relationshipId: string;
9863
11231
  filename: string;
9864
11232
  }[] = [];
@@ -9872,7 +11240,7 @@ export class DocumentParser {
9872
11240
  // Parse headers
9873
11241
  // Track already-parsed headers by rId to avoid creating duplicates
9874
11242
  // when multiple section property types (default, first, even) reference the same header file
9875
- const parsedHeadersByRId = new Map<string, import('../elements/Header').Header>();
11243
+ const parsedHeadersByRId = new Map<string, import('../elements/Header.js').Header>();
9876
11244
 
9877
11245
  if (sectionProps.headers) {
9878
11246
  for (const [type, rId] of Object.entries(sectionProps.headers)) {
@@ -9935,7 +11303,7 @@ export class DocumentParser {
9935
11303
  // Parse footers
9936
11304
  // Track already-parsed footers by rId to avoid creating duplicates
9937
11305
  // when multiple section property types (default, first, even) reference the same footer file
9938
- const parsedFootersByRId = new Map<string, import('../elements/Footer').Footer>();
11306
+ const parsedFootersByRId = new Map<string, import('../elements/Footer.js').Footer>();
9939
11307
 
9940
11308
  if (sectionProps.footers) {
9941
11309
  for (const [type, rId] of Object.entries(sectionProps.footers)) {
@@ -10124,29 +11492,42 @@ export class DocumentParser {
10124
11492
 
10125
11493
  // Table-level properties (w:tblPr context)
10126
11494
  if (propsObj['w:tblStyle']) {
10127
- result.style = propsObj['w:tblStyle']['@_w:val'] || '';
10128
- }
10129
- // tblpPr (floating table position)
11495
+ // w:tblStyle w:val is ST_String (§17.7.4.62). XMLParser coerces
11496
+ // purely-numeric style IDs (e.g. "2025") to numbers; cast so the
11497
+ // declared `string` contract holds on tracked-change history.
11498
+ const v = propsObj['w:tblStyle']['@_w:val'];
11499
+ result.style = v !== undefined && v !== null ? String(v) : '';
11500
+ }
11501
+ // tblpPr (floating table position) — mirror main-path zero-value
11502
+ // preservation. The tblPrChange emitter re-emits position via
11503
+ // `!== undefined`, so dropping zero-valued tracked "previous"
11504
+ // positions here lost them silently on round-trip.
10130
11505
  if (propsObj['w:tblpPr']) {
10131
11506
  const tblpPr = propsObj['w:tblpPr'];
10132
11507
  const pos: any = {};
10133
- if (tblpPr['@_w:tblpX']) pos.x = parseInt(tblpPr['@_w:tblpX'], 10);
10134
- if (tblpPr['@_w:tblpY']) pos.y = parseInt(tblpPr['@_w:tblpY'], 10);
11508
+ if (isExplicitlySet(tblpPr['@_w:tblpX'])) pos.x = safeParseInt(tblpPr['@_w:tblpX']);
11509
+ if (isExplicitlySet(tblpPr['@_w:tblpY'])) pos.y = safeParseInt(tblpPr['@_w:tblpY']);
10135
11510
  if (tblpPr['@_w:horzAnchor']) pos.horizontalAnchor = tblpPr['@_w:horzAnchor'];
10136
11511
  if (tblpPr['@_w:vertAnchor']) pos.verticalAnchor = tblpPr['@_w:vertAnchor'];
10137
- if (tblpPr['@_w:leftFromText']) pos.leftFromText = parseInt(tblpPr['@_w:leftFromText'], 10);
10138
- if (tblpPr['@_w:rightFromText'])
10139
- pos.rightFromText = parseInt(tblpPr['@_w:rightFromText'], 10);
10140
- if (tblpPr['@_w:topFromText']) pos.topFromText = parseInt(tblpPr['@_w:topFromText'], 10);
10141
- if (tblpPr['@_w:bottomFromText'])
10142
- pos.bottomFromText = parseInt(tblpPr['@_w:bottomFromText'], 10);
11512
+ if (isExplicitlySet(tblpPr['@_w:leftFromText'])) {
11513
+ pos.leftFromText = safeParseInt(tblpPr['@_w:leftFromText']);
11514
+ }
11515
+ if (isExplicitlySet(tblpPr['@_w:rightFromText'])) {
11516
+ pos.rightFromText = safeParseInt(tblpPr['@_w:rightFromText']);
11517
+ }
11518
+ if (isExplicitlySet(tblpPr['@_w:topFromText'])) {
11519
+ pos.topFromText = safeParseInt(tblpPr['@_w:topFromText']);
11520
+ }
11521
+ if (isExplicitlySet(tblpPr['@_w:bottomFromText'])) {
11522
+ pos.bottomFromText = safeParseInt(tblpPr['@_w:bottomFromText']);
11523
+ }
10143
11524
  if (Object.keys(pos).length > 0) result.position = pos;
10144
11525
  }
10145
11526
  if (propsObj['w:tblOverlap']) {
10146
11527
  result.overlap = propsObj['w:tblOverlap']['@_w:val'];
10147
11528
  }
10148
11529
  if (propsObj['w:bidiVisual']) {
10149
- result.bidiVisual = true;
11530
+ result.bidiVisual = parseOoxmlBoolean(propsObj['w:bidiVisual']);
10150
11531
  }
10151
11532
  if (propsObj['w:tblStyleRowBandSize']) {
10152
11533
  result.tblStyleRowBandSize = parseInt(
@@ -10192,21 +11573,55 @@ export class DocumentParser {
10192
11573
  const borders: any = {};
10193
11574
  const bordersObj = propsObj['w:tblBorders'];
10194
11575
  for (const side of ['top', 'bottom', 'left', 'right', 'insideH', 'insideV']) {
10195
- if (bordersObj[`w:${side}`]) {
10196
- borders[side] = this.parseBorderElement(bordersObj[`w:${side}`]);
11576
+ // Prefer bidi-aware w:start/w:end aliases over legacy w:left/
11577
+ // w:right (ECMA-376 §17.4.40 CT_TblBorders). Same pattern as
11578
+ // the main table borders parser — the bidi-aware form is the
11579
+ // preferred modern spelling.
11580
+ const aliasKey = side === 'left' ? 'w:start' : side === 'right' ? 'w:end' : undefined;
11581
+ const borderObj = (aliasKey && bordersObj[aliasKey]) || bordersObj[`w:${side}`];
11582
+ if (borderObj) {
11583
+ borders[side] = this.parseBorderElement(borderObj);
10197
11584
  }
10198
11585
  }
10199
11586
  if (Object.keys(borders).length > 0) result.borders = borders;
10200
11587
  }
11588
+ // tblLook per ECMA-376 §17.4.57 — supports both hex-string format
11589
+ // (w:val="04A0") AND the expanded individual-attribute form
11590
+ // (firstRow/lastRow/firstColumn/lastColumn/noHBand/noVBand).
11591
+ // Word often emits the expanded form (no w:val) inside *PrChange
11592
+ // previous-properties; the hex-only read silently collapsed every
11593
+ // flag to "0000" on round-trip.
10201
11594
  if (propsObj['w:tblLook']) {
10202
11595
  const look = propsObj['w:tblLook'];
10203
- result.tblLook = look['@_w:val'] || '0000';
11596
+ if (look['@_w:val']) {
11597
+ result.tblLook = String(look['@_w:val']);
11598
+ } else {
11599
+ const attrIsOn = (name: string): boolean => {
11600
+ const v = look[name];
11601
+ if (v === undefined) return false;
11602
+ return parseOoxmlBoolean({ '@_w:val': v });
11603
+ };
11604
+ let value = 0;
11605
+ if (attrIsOn('@_w:firstRow')) value |= 0x0020;
11606
+ if (attrIsOn('@_w:lastRow')) value |= 0x0040;
11607
+ if (attrIsOn('@_w:firstColumn')) value |= 0x0080;
11608
+ if (attrIsOn('@_w:lastColumn')) value |= 0x0100;
11609
+ if (attrIsOn('@_w:noHBand')) value |= 0x0200;
11610
+ if (attrIsOn('@_w:noVBand')) value |= 0x0400;
11611
+ result.tblLook = value.toString(16).toUpperCase().padStart(4, '0');
11612
+ }
10204
11613
  }
10205
11614
  if (propsObj['w:tblCaption']) {
10206
- result.caption = propsObj['w:tblCaption']['@_w:val'];
11615
+ // w:tblCaption w:val is ST_String (§17.4.62). Cast through
11616
+ // String() so purely-numeric caption text round-trips as a
11617
+ // string inside the tracked-change previousProperties.
11618
+ const v = propsObj['w:tblCaption']['@_w:val'];
11619
+ result.caption = v !== undefined && v !== null ? String(v) : undefined;
10207
11620
  }
10208
11621
  if (propsObj['w:tblDescription']) {
10209
- result.description = propsObj['w:tblDescription']['@_w:val'];
11622
+ // w:tblDescription w:val is ST_String (§17.4.63).
11623
+ const v = propsObj['w:tblDescription']['@_w:val'];
11624
+ result.description = v !== undefined && v !== null ? String(v) : undefined;
10210
11625
  }
10211
11626
 
10212
11627
  // Row-level properties (w:trPr context) — all CT_TrPr elements
@@ -10237,14 +11652,15 @@ export class DocumentParser {
10237
11652
  const rule = propsObj['w:trHeight']['@_w:hRule'];
10238
11653
  if (rule) result.heightRule = rule;
10239
11654
  }
11655
+ // Row CT_OnOff — honour w:val per ECMA-376 §17.17.4 (ST_OnOff)
10240
11656
  if (propsObj['w:tblHeader']) {
10241
- result.isHeader = true;
11657
+ result.isHeader = parseOoxmlBoolean(propsObj['w:tblHeader']);
10242
11658
  }
10243
11659
  if (propsObj['w:cantSplit']) {
10244
- result.cantSplit = true;
11660
+ result.cantSplit = parseOoxmlBoolean(propsObj['w:cantSplit']);
10245
11661
  }
10246
11662
  if (propsObj['w:hidden']) {
10247
- result.hidden = true;
11663
+ result.hidden = parseOoxmlBoolean(propsObj['w:hidden']);
10248
11664
  }
10249
11665
 
10250
11666
  // Cell-level properties (w:tcPr context) — all CT_TcPr elements
@@ -10265,14 +11681,19 @@ export class DocumentParser {
10265
11681
  const borders: any = {};
10266
11682
  const bordersObj = propsObj['w:tcBorders'];
10267
11683
  for (const side of ['top', 'bottom', 'left', 'right', 'tl2br', 'tr2bl']) {
10268
- if (bordersObj[`w:${side}`]) {
10269
- borders[side] = this.parseBorderElement(bordersObj[`w:${side}`]);
11684
+ // Prefer bidi-aware w:start/w:end aliases for left/right
11685
+ // (ECMA-376 §17.4.66 CT_TcBorders). Diagonals (tl2br/tr2bl)
11686
+ // have no bidi aliases.
11687
+ const aliasKey = side === 'left' ? 'w:start' : side === 'right' ? 'w:end' : undefined;
11688
+ const borderObj = (aliasKey && bordersObj[aliasKey]) || bordersObj[`w:${side}`];
11689
+ if (borderObj) {
11690
+ borders[side] = this.parseBorderElement(borderObj);
10270
11691
  }
10271
11692
  }
10272
11693
  if (Object.keys(borders).length > 0) result.borders = borders;
10273
11694
  }
10274
11695
  if (propsObj['w:noWrap']) {
10275
- result.noWrap = true;
11696
+ result.noWrap = parseOoxmlBoolean(propsObj['w:noWrap']);
10276
11697
  }
10277
11698
  if (propsObj['w:tcMar']) {
10278
11699
  const tcMar = propsObj['w:tcMar'];
@@ -10289,13 +11710,13 @@ export class DocumentParser {
10289
11710
  result.textDirection = propsObj['w:textDirection']['@_w:val'];
10290
11711
  }
10291
11712
  if (propsObj['w:tcFitText']) {
10292
- result.fitText = true;
11713
+ result.fitText = parseOoxmlBoolean(propsObj['w:tcFitText']);
10293
11714
  }
10294
11715
  if (propsObj['w:vAlign']) {
10295
11716
  result.verticalAlignment = propsObj['w:vAlign']['@_w:val'];
10296
11717
  }
10297
11718
  if (propsObj['w:hideMark']) {
10298
- result.hideMark = true;
11719
+ result.hideMark = parseOoxmlBoolean(propsObj['w:hideMark']);
10299
11720
  }
10300
11721
  if (propsObj['w:cnfStyle']) {
10301
11722
  result.cnfStyle = propsObj['w:cnfStyle']['@_w:val'];
@@ -10325,6 +11746,79 @@ export class DocumentParser {
10325
11746
  if (!sectPrXml) return {};
10326
11747
  const result: Record<string, any> = {};
10327
11748
 
11749
+ // Footnote properties (w:footnotePr) per §17.11.9. The main sectPr parser
11750
+ // reads these, and the emitter supports prev.footnotePr, but the
11751
+ // sectPrChange parser previously dropped them entirely — tracked history
11752
+ // of changes to footnote numbering format / position / start / restart
11753
+ // was lost on every round-trip.
11754
+ const footnotePrElements = XMLParser.extractElements(sectPrXml, 'w:footnotePr');
11755
+ if (footnotePrElements.length > 0 && footnotePrElements[0]) {
11756
+ const fnPr = footnotePrElements[0];
11757
+ const fnObj: any = {};
11758
+ const posElements = XMLParser.extractElements(fnPr, 'w:pos');
11759
+ if (posElements[0]) {
11760
+ const pos = XMLParser.extractAttribute(posElements[0], 'w:val');
11761
+ if (pos) fnObj.position = pos;
11762
+ }
11763
+ const numFmtElements = XMLParser.extractElements(fnPr, 'w:numFmt');
11764
+ if (numFmtElements[0]) {
11765
+ const fmt = XMLParser.extractAttribute(numFmtElements[0], 'w:val');
11766
+ if (fmt) fnObj.numberFormat = fmt;
11767
+ }
11768
+ const numStartElements = XMLParser.extractElements(fnPr, 'w:numStart');
11769
+ if (numStartElements[0]) {
11770
+ const start = XMLParser.extractAttribute(numStartElements[0], 'w:val');
11771
+ if (start !== undefined) fnObj.startNumber = parseInt(String(start), 10);
11772
+ }
11773
+ const numRestartElements = XMLParser.extractElements(fnPr, 'w:numRestart');
11774
+ if (numRestartElements[0]) {
11775
+ const restart = XMLParser.extractAttribute(numRestartElements[0], 'w:val');
11776
+ if (restart) fnObj.restart = restart;
11777
+ }
11778
+ if (Object.keys(fnObj).length > 0) result.footnotePr = fnObj;
11779
+ }
11780
+
11781
+ // Endnote properties (w:endnotePr) per §17.11.5 — mirror of footnotePr.
11782
+ const endnotePrElements = XMLParser.extractElements(sectPrXml, 'w:endnotePr');
11783
+ if (endnotePrElements.length > 0 && endnotePrElements[0]) {
11784
+ const enPr = endnotePrElements[0];
11785
+ const enObj: any = {};
11786
+ const posElements = XMLParser.extractElements(enPr, 'w:pos');
11787
+ if (posElements[0]) {
11788
+ const pos = XMLParser.extractAttribute(posElements[0], 'w:val');
11789
+ if (pos) enObj.position = pos;
11790
+ }
11791
+ const numFmtElements = XMLParser.extractElements(enPr, 'w:numFmt');
11792
+ if (numFmtElements[0]) {
11793
+ const fmt = XMLParser.extractAttribute(numFmtElements[0], 'w:val');
11794
+ if (fmt) enObj.numberFormat = fmt;
11795
+ }
11796
+ const numStartElements = XMLParser.extractElements(enPr, 'w:numStart');
11797
+ if (numStartElements[0]) {
11798
+ const start = XMLParser.extractAttribute(numStartElements[0], 'w:val');
11799
+ if (start !== undefined) enObj.startNumber = parseInt(String(start), 10);
11800
+ }
11801
+ const numRestartElements = XMLParser.extractElements(enPr, 'w:numRestart');
11802
+ if (numRestartElements[0]) {
11803
+ const restart = XMLParser.extractAttribute(numRestartElements[0], 'w:val');
11804
+ if (restart) enObj.restart = restart;
11805
+ }
11806
+ if (Object.keys(enObj).length > 0) result.endnotePr = enObj;
11807
+ }
11808
+
11809
+ // Paper source (w:paperSrc) per §17.6.12 CT_PaperSource — first-page / other
11810
+ // paper tray selection. Both attributes optional per schema.
11811
+ const paperSrcElements = XMLParser.extractElements(sectPrXml, 'w:paperSrc');
11812
+ if (paperSrcElements.length > 0 && paperSrcElements[0]) {
11813
+ const ps = paperSrcElements[0];
11814
+ const psObj: any = {};
11815
+ const first = XMLParser.extractAttribute(ps, 'w:first');
11816
+ if (first !== undefined) psObj.first = parseInt(String(first), 10);
11817
+ const other = XMLParser.extractAttribute(ps, 'w:other');
11818
+ if (other !== undefined) psObj.other = parseInt(String(other), 10);
11819
+ if (Object.keys(psObj).length > 0) result.paperSource = psObj;
11820
+ }
11821
+
10328
11822
  // Page size
10329
11823
  const pgSzElements = XMLParser.extractElements(sectPrXml, 'w:pgSz');
10330
11824
  if (pgSzElements.length > 0 && pgSzElements[0]) {
@@ -10343,7 +11837,10 @@ export class DocumentParser {
10343
11837
  }
10344
11838
  }
10345
11839
 
10346
- // Margins
11840
+ // Margins — full CT_PageMar attribute set (§17.6.11) including w:gutter
11841
+ // (the book-binding margin). Previously gutter was dropped on sectPrChange
11842
+ // history, so any tracked change to a binding-gutter value lost the
11843
+ // previous value on round-trip.
10347
11844
  const pgMarElements = XMLParser.extractElements(sectPrXml, 'w:pgMar');
10348
11845
  if (pgMarElements.length > 0 && pgMarElements[0]) {
10349
11846
  const pgMar = pgMarElements[0];
@@ -10360,6 +11857,8 @@ export class DocumentParser {
10360
11857
  if (header) margins.header = parseInt(header, 10);
10361
11858
  const footer = XMLParser.extractAttribute(pgMar, 'w:footer');
10362
11859
  if (footer) margins.footer = parseInt(footer, 10);
11860
+ const gutter = XMLParser.extractAttribute(pgMar, 'w:gutter');
11861
+ if (gutter) margins.gutter = parseInt(gutter, 10);
10363
11862
  if (Object.keys(margins).length > 0) result.margins = margins;
10364
11863
  }
10365
11864
 
@@ -10386,7 +11885,13 @@ export class DocumentParser {
10386
11885
  if (Object.keys(lnObj).length > 0) result.lineNumbering = lnObj;
10387
11886
  }
10388
11887
 
10389
- // Page numbering
11888
+ // Page numbering — full CT_PageNumber attribute set (§17.6.12):
11889
+ // fmt / start / chapStyle / chapSep. Previously only fmt+start were read,
11890
+ // so tracked-change history of chapter-numbering edits (e.g. switching
11891
+ // from "Heading 1" to "Heading 2" as the chapter marker, or changing the
11892
+ // chapter separator from hyphen to emDash) lost the previous values.
11893
+ // The Section.ts emitter stores chapStyle / chapSep as top-level
11894
+ // properties rather than on pageNumbering, so expose them the same way.
10390
11895
  const pgNumElements = XMLParser.extractElements(sectPrXml, 'w:pgNumType');
10391
11896
  if (pgNumElements.length > 0 && pgNumElements[0]) {
10392
11897
  const pn = pgNumElements[0];
@@ -10396,6 +11901,12 @@ export class DocumentParser {
10396
11901
  const fmt = XMLParser.extractAttribute(pn, 'w:fmt');
10397
11902
  if (fmt) pnObj.format = fmt;
10398
11903
  if (Object.keys(pnObj).length > 0) result.pageNumbering = pnObj;
11904
+ // Mirror the main-sectPr parser: chapStyle / chapSep live at the root
11905
+ // of the section properties, not inside pageNumbering.
11906
+ const chapStyle = XMLParser.extractAttribute(pn, 'w:chapStyle');
11907
+ if (chapStyle !== undefined) result.chapStyle = parseInt(String(chapStyle), 10);
11908
+ const chapSep = XMLParser.extractAttribute(pn, 'w:chapSep');
11909
+ if (chapSep) result.chapSep = chapSep;
10399
11910
  }
10400
11911
 
10401
11912
  // Columns
@@ -10404,16 +11915,58 @@ export class DocumentParser {
10404
11915
  const cols = colsElements[0];
10405
11916
  const num = XMLParser.extractAttribute(cols, 'w:num');
10406
11917
  const space = XMLParser.extractAttribute(cols, 'w:space');
11918
+ // Full CT_Columns attribute set (§17.6.4): num / space / equalWidth / sep
11919
+ // plus the child <w:col w:w="..." w:space="..."/> entries for per-column
11920
+ // widths. Previously only num+space were read, so sectPrChange history of
11921
+ // a columns-layout change dropped equalWidth, the separator line, and
11922
+ // the entire custom column-width / per-column-space configuration.
11923
+ const equalWidth = XMLParser.extractAttribute(cols, 'w:equalWidth');
11924
+ const sep = XMLParser.extractAttribute(cols, 'w:sep');
11925
+
11926
+ // Extract individual <w:col> children for non-equal-width layouts.
11927
+ const colChildElements = XMLParser.extractElements(cols, 'w:col');
11928
+ const columnWidths: number[] = [];
11929
+ const columnSpaces: number[] = [];
11930
+ let hasColumnSpaces = false;
11931
+ for (const col of colChildElements) {
11932
+ const width = XMLParser.extractAttribute(col, 'w:w');
11933
+ if (width) columnWidths.push(parseInt(width, 10));
11934
+ const colSpace = XMLParser.extractAttribute(col, 'w:space');
11935
+ if (colSpace) {
11936
+ columnSpaces.push(parseInt(colSpace, 10));
11937
+ hasColumnSpaces = true;
11938
+ } else {
11939
+ columnSpaces.push(0);
11940
+ }
11941
+ }
11942
+
10407
11943
  if (num) {
10408
11944
  result.columns = {
10409
11945
  count: parseInt(num, 10),
10410
11946
  space: space ? parseInt(space, 10) : undefined,
11947
+ equalWidth: equalWidth ? parseOnOffAttribute(equalWidth) : undefined,
11948
+ separator: sep ? parseOnOffAttribute(sep) : undefined,
11949
+ columnWidths: columnWidths.length > 0 ? columnWidths : undefined,
11950
+ columnSpaces: hasColumnSpaces ? columnSpaces : undefined,
10411
11951
  };
10412
11952
  }
10413
11953
  }
10414
11954
 
10415
- // Form protection
10416
- if (sectPrXml.includes('<w:formProt')) result.formProt = true;
11955
+ // CT_OnOff sectPr flags — honour w:val per ECMA-376 §17.17.4 (ST_OnOff).
11956
+ // Previously these used substring `.includes()`, which both ignored w:val
11957
+ // (flipping explicit false to true) and could false-positive on prefix
11958
+ // matches (e.g. "<w:bidi" inside "<w:bidiVisual"). Use extractElements +
11959
+ // extractAttribute + parseOnOffAttribute instead.
11960
+ const parseSectCtOnOff = (tagName: string): boolean | undefined => {
11961
+ const els = XMLParser.extractElements(sectPrXml, tagName);
11962
+ if (els.length === 0 || !els[0]) return undefined;
11963
+ const v = XMLParser.extractAttribute(els[0], 'w:val');
11964
+ return parseOnOffAttribute(v, true);
11965
+ };
11966
+
11967
+ // Form protection (w:formProt) — CT_OnOff
11968
+ const formProtVal = parseSectCtOnOff('w:formProt');
11969
+ if (formProtVal !== undefined) result.formProt = formProtVal;
10417
11970
 
10418
11971
  // Vertical alignment
10419
11972
  const vAlignElements = XMLParser.extractElements(sectPrXml, 'w:vAlign');
@@ -10422,11 +11975,13 @@ export class DocumentParser {
10422
11975
  if (val) result.verticalAlignment = val;
10423
11976
  }
10424
11977
 
10425
- // Suppress endnotes
10426
- if (sectPrXml.includes('<w:noEndnote')) result.noEndnote = true;
11978
+ // Suppress endnotes (w:noEndnote) — CT_OnOff
11979
+ const noEndnoteVal = parseSectCtOnOff('w:noEndnote');
11980
+ if (noEndnoteVal !== undefined) result.noEndnote = noEndnoteVal;
10427
11981
 
10428
- // Title page
10429
- if (sectPrXml.includes('<w:titlePg')) result.titlePage = true;
11982
+ // Title page (w:titlePg) — CT_OnOff
11983
+ const titlePgVal = parseSectCtOnOff('w:titlePg');
11984
+ if (titlePgVal !== undefined) result.titlePage = titlePgVal;
10430
11985
 
10431
11986
  // Text direction
10432
11987
  const textDirElements = XMLParser.extractElements(sectPrXml, 'w:textDirection');
@@ -10435,11 +11990,13 @@ export class DocumentParser {
10435
11990
  if (val) result.textDirection = val;
10436
11991
  }
10437
11992
 
10438
- // Bidi section
10439
- if (sectPrXml.includes('<w:bidi')) result.bidi = true;
11993
+ // Bidi section (w:bidi) — CT_OnOff
11994
+ const bidiVal = parseSectCtOnOff('w:bidi');
11995
+ if (bidiVal !== undefined) result.bidi = bidiVal;
10440
11996
 
10441
- // RTL gutter
10442
- if (sectPrXml.includes('<w:rtlGutter')) result.rtlGutter = true;
11997
+ // RTL gutter (w:rtlGutter) — CT_OnOff
11998
+ const rtlGutterVal = parseSectCtOnOff('w:rtlGutter');
11999
+ if (rtlGutterVal !== undefined) result.rtlGutter = rtlGutterVal;
10443
12000
 
10444
12001
  // Document grid
10445
12002
  const docGridElements = XMLParser.extractElements(sectPrXml, 'w:docGrid');
@@ -10455,6 +12012,64 @@ export class DocumentParser {
10455
12012
  if (Object.keys(dgObj).length > 0) result.docGrid = dgObj;
10456
12013
  }
10457
12014
 
12015
+ // Page borders (w:pgBorders) per ECMA-376 §17.6.10. The main sectPr parser
12016
+ // reads these, but the sectPrChange previous-sectPr parser previously
12017
+ // didn't — so a tracked-change history of page-border edits lost the
12018
+ // entire "previous" border configuration (style, color, themeColor,
12019
+ // themeTint, themeShade, shadow, frame) every round-trip. The emitter
12020
+ // supports prev.pageBorders already; this is the missing parser half.
12021
+ const pgBordersElements = XMLParser.extractElements(sectPrXml, 'w:pgBorders');
12022
+ if (pgBordersElements.length > 0 && pgBordersElements[0]) {
12023
+ const pgBordersXml = pgBordersElements[0];
12024
+ const pageBorders: any = {};
12025
+ const offsetFrom = XMLParser.extractAttribute(pgBordersXml, 'w:offsetFrom');
12026
+ if (offsetFrom) pageBorders.offsetFrom = offsetFrom;
12027
+ const display = XMLParser.extractAttribute(pgBordersXml, 'w:display');
12028
+ if (display) pageBorders.display = display;
12029
+ const zOrder = XMLParser.extractAttribute(pgBordersXml, 'w:zOrder');
12030
+ if (zOrder) pageBorders.zOrder = zOrder;
12031
+
12032
+ // Per-side border parser mirrors the main-sectPr logic — full CT_Border
12033
+ // attribute set including themed colors and shadow/frame flags.
12034
+ const parsePrevBorder = (sideXml: string): any | undefined => {
12035
+ if (!sideXml) return undefined;
12036
+ const border: any = {};
12037
+ const val = XMLParser.extractAttribute(sideXml, 'w:val');
12038
+ if (val) border.style = val;
12039
+ const sz = XMLParser.extractAttribute(sideXml, 'w:sz');
12040
+ if (sz) border.size = parseInt(sz, 10);
12041
+ const color = XMLParser.extractAttribute(sideXml, 'w:color');
12042
+ if (color) border.color = color;
12043
+ const space = XMLParser.extractAttribute(sideXml, 'w:space');
12044
+ if (space) border.space = parseInt(space, 10);
12045
+ const shadow = XMLParser.extractAttribute(sideXml, 'w:shadow');
12046
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
12047
+ const frame = XMLParser.extractAttribute(sideXml, 'w:frame');
12048
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
12049
+ const themeColor = XMLParser.extractAttribute(sideXml, 'w:themeColor');
12050
+ if (themeColor) border.themeColor = themeColor;
12051
+ const themeTint = XMLParser.extractAttribute(sideXml, 'w:themeTint');
12052
+ if (themeTint) border.themeTint = themeTint;
12053
+ const themeShade = XMLParser.extractAttribute(sideXml, 'w:themeShade');
12054
+ if (themeShade) border.themeShade = themeShade;
12055
+ const artId = XMLParser.extractAttribute(sideXml, 'w:id');
12056
+ if (artId) border.artId = parseInt(artId, 10);
12057
+ return Object.keys(border).length > 0 ? border : undefined;
12058
+ };
12059
+
12060
+ for (const side of ['top', 'left', 'bottom', 'right']) {
12061
+ const sideElements = XMLParser.extractElements(pgBordersXml, `w:${side}`);
12062
+ if (sideElements.length > 0 && sideElements[0]) {
12063
+ const border = parsePrevBorder(sideElements[0]);
12064
+ if (border) pageBorders[side] = border;
12065
+ }
12066
+ }
12067
+
12068
+ if (Object.keys(pageBorders).length > 0) {
12069
+ result.pageBorders = pageBorders;
12070
+ }
12071
+ }
12072
+
10458
12073
  return result;
10459
12074
  }
10460
12075
  }