docxmlater 10.4.1 → 11.0.6

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 +411 -638
  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 +74 -67
  6. package/dist/core/Document.d.ts.map +1 -1
  7. package/dist/core/Document.js +605 -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 +2059 -1073
  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 +8 -3
  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 +8763 -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 +34 -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 +35 -8
  570. package/src/constants/legacyCompatFlags.ts +1 -1
  571. package/src/core/Document.ts +461 -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 +2187 -617
  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 +13 -4
  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)
@@ -1289,6 +1349,11 @@ export class DocumentParser {
1289
1349
  imageManager
1290
1350
  );
1291
1351
  if (imageRun) {
1352
+ // Preserve the parent run's w:rPr (rFonts, noProof, b, etc.)
1353
+ // — without this, ImageRun.toXML() emits <w:r><w:drawing/></w:r>
1354
+ // and Word recalculates line height with the default font,
1355
+ // shifting the image and clipping it into adjacent cells.
1356
+ this.parseRunPropertiesFromObject(runObj['w:rPr'], imageRun);
1292
1357
  paragraph.addRun(imageRun);
1293
1358
  }
1294
1359
  }
@@ -1533,7 +1598,7 @@ export class DocumentParser {
1533
1598
  } = { revision: null, bookmarkStarts: [], bookmarkEnds: [] };
1534
1599
  try {
1535
1600
  // Map XML tag to RevisionType
1536
- let revisionType: import('../elements/Revision').RevisionType;
1601
+ let revisionType: import('../elements/Revision.js').RevisionType;
1537
1602
  switch (tagName) {
1538
1603
  case 'w:ins':
1539
1604
  revisionType = 'insert';
@@ -1581,7 +1646,7 @@ export class DocumentParser {
1581
1646
  const runXmls = XMLParser.extractElements(xmlWithoutHyperlinks, 'w:r');
1582
1647
 
1583
1648
  // Use RevisionContent to hold both Run and Hyperlink objects
1584
- const content: import('../elements/RevisionContent').RevisionContent[] = [];
1649
+ const content: import('../elements/RevisionContent.js').RevisionContent[] = [];
1585
1650
 
1586
1651
  // Parse standalone runs (not inside hyperlinks)
1587
1652
  for (const runXml of runXmls) {
@@ -1727,8 +1792,8 @@ export class DocumentParser {
1727
1792
  const id = parseInt(idAttr, 10);
1728
1793
  const date = dateAttr ? new Date(dateAttr) : new Date();
1729
1794
  const parentId = parentIdAttr ? parseInt(parentIdAttr, 10) : undefined;
1730
- // Per ECMA-376, w:done="1" or "true" indicates resolved
1731
- const done = doneAttr === '1' || doneAttr === 'true';
1795
+ // Per ECMA-376 §17.17.4, w:done is ST_OnOff accept 1/0/true/false/on/off
1796
+ const done = parseOnOffAttribute(doneAttr);
1732
1797
 
1733
1798
  // Parse content (runs from paragraphs within the comment)
1734
1799
  const runs: Run[] = [];
@@ -1921,12 +1986,20 @@ export class DocumentParser {
1921
1986
  // Parse optional column range for table bookmarks (ECMA-376 §17.16.5)
1922
1987
  const colFirstAttr = XMLParser.extractAttribute(bookmarkXml, 'w:colFirst');
1923
1988
  const colLastAttr = XMLParser.extractAttribute(bookmarkXml, 'w:colLast');
1989
+ // Parse optional w:displacedByCustomXml per CT_MarkupRange (§17.13.5).
1990
+ // Without this the attribute was dropped on load, so any Word document
1991
+ // with custom-XML-displaced bookmarks lost the disambiguator even
1992
+ // though the model now supports round-tripping it.
1993
+ const displacedAttr = XMLParser.extractAttribute(bookmarkXml, 'w:displacedByCustomXml');
1994
+ const displacedByCustomXml =
1995
+ displacedAttr === 'next' || displacedAttr === 'prev' ? displacedAttr : undefined;
1924
1996
  const bookmark = new Bookmark({
1925
1997
  name: nameAttr,
1926
1998
  id: id,
1927
1999
  skipNormalization: true,
1928
2000
  colFirst: colFirstAttr ? parseInt(colFirstAttr, 10) : undefined,
1929
2001
  colLast: colLastAttr ? parseInt(colLastAttr, 10) : undefined,
2002
+ displacedByCustomXml,
1930
2003
  });
1931
2004
 
1932
2005
  // Register with BookmarkManager to enable hasBookmark() checks
@@ -1969,12 +2042,22 @@ export class DocumentParser {
1969
2042
 
1970
2043
  const id = parseInt(idAttr, 10);
1971
2044
 
2045
+ // CT_MarkupRange (§17.13.5) also permits w:displacedByCustomXml on
2046
+ // the end marker. Previously dropped on load, so a Word document
2047
+ // whose bookmark-end was displaced across a custom-XML node lost
2048
+ // the disambiguator even though the Bookmark model already emits
2049
+ // it from toEndXML().
2050
+ const displacedAttr = XMLParser.extractAttribute(bookmarkXml, 'w:displacedByCustomXml');
2051
+ const displacedByCustomXml =
2052
+ displacedAttr === 'next' || displacedAttr === 'prev' ? displacedAttr : undefined;
2053
+
1972
2054
  // Create a placeholder bookmark for the end marker
1973
2055
  // The name doesn't matter for bookmarkEnd as it only uses the ID
1974
2056
  const bookmark = new Bookmark({
1975
2057
  name: `_end_${id}`,
1976
2058
  id: id,
1977
2059
  skipNormalization: true,
2060
+ displacedByCustomXml,
1978
2061
  });
1979
2062
 
1980
2063
  return bookmark;
@@ -1996,12 +2079,23 @@ export class DocumentParser {
1996
2079
  try {
1997
2080
  const paragraph = new Paragraph();
1998
2081
 
1999
- // Parse w14:paraId and w14:textId attributes from paragraph element (Word 2010+)
2000
- const paraId = paraObj['w14:paraId'];
2082
+ // Parse w14:paraId and w14:textId attributes from paragraph element
2083
+ // (Word 2010+, ST_LongHexNumber 8-char hex). XMLParser keys
2084
+ // attributes under the @_ prefix and may numeric-coerce purely-
2085
+ // digit hex strings like "00000001" to the number 1 — normalise
2086
+ // back to 8-char uppercase hex so the output passes strict
2087
+ // validation. The prior code used the un-prefixed element-shaped
2088
+ // keys and always saw `undefined`.
2089
+ const normaliseHexId = (raw: unknown): string | undefined => {
2090
+ if (raw === undefined || raw === null) return undefined;
2091
+ const asStr = typeof raw === 'number' ? raw.toString(16) : String(raw);
2092
+ return asStr.toUpperCase().padStart(8, '0');
2093
+ };
2094
+ const paraId = normaliseHexId(paraObj['@_w14:paraId']);
2001
2095
  if (paraId) {
2002
2096
  paragraph.formatting.paraId = paraId;
2003
2097
  }
2004
- const textId = paraObj['w14:textId'];
2098
+ const textId = normaliseHexId(paraObj['@_w14:textId']);
2005
2099
  if (textId) {
2006
2100
  paragraph.formatting.textId = textId;
2007
2101
  }
@@ -2034,6 +2128,7 @@ export class DocumentParser {
2034
2128
  imageManager
2035
2129
  );
2036
2130
  if (imageRun) {
2131
+ this.parseRunPropertiesFromObject(child['w:rPr'], imageRun);
2037
2132
  paragraph.addRun(imageRun);
2038
2133
  }
2039
2134
  }
@@ -2095,6 +2190,7 @@ export class DocumentParser {
2095
2190
  imageManager
2096
2191
  );
2097
2192
  if (imageRun) {
2193
+ this.parseRunPropertiesFromObject(child['w:rPr'], imageRun);
2098
2194
  paragraph.addRun(imageRun);
2099
2195
  }
2100
2196
  }
@@ -2171,6 +2267,22 @@ export class DocumentParser {
2171
2267
  // Extract the formatting and set it as paragraph mark properties
2172
2268
  paragraph.setParagraphMarkFormatting(tempRun.getFormatting());
2173
2269
 
2270
+ // Transfer w:rPrChange (CT_ParaRPrChange, §17.3.1.30) from the
2271
+ // temp run onto the paragraph's formatting. Without this the
2272
+ // paragraph-mark rPrChange is silently dropped because
2273
+ // `tempRun.getFormatting()` exposes RunFormatting fields only —
2274
+ // `propertyChangeRevision` is a separate field on Run that was
2275
+ // previously discarded along with the temp run.
2276
+ const rPrChangeRev = tempRun.getPropertyChangeRevision();
2277
+ if (rPrChangeRev) {
2278
+ paragraph.formatting.paragraphMarkRunPropertiesChange = {
2279
+ id: rPrChangeRev.id,
2280
+ author: rPrChangeRev.author,
2281
+ date: rPrChangeRev.date,
2282
+ previousProperties: rPrChangeRev.previousProperties,
2283
+ };
2284
+ }
2285
+
2174
2286
  // Parse paragraph mark deletion tracking (w:del in w:pPr/w:rPr)
2175
2287
  // Per ECMA-376 Part 1 §17.13.5.14 - indicates the paragraph mark was deleted
2176
2288
  if (rPrObj['w:del']) {
@@ -2210,9 +2322,14 @@ export class DocumentParser {
2210
2322
  paragraph.setAlignment(pPrObj['w:jc']['@_w:val']);
2211
2323
  }
2212
2324
 
2213
- // Style
2214
- if (pPrObj['w:pStyle']?.['@_w:val']) {
2215
- paragraph.setStyle(pPrObj['w:pStyle']['@_w:val']);
2325
+ // Style (w:pStyle per ECMA-376 §17.3.1.27 — `w:val` is ST_String
2326
+ // referencing a style ID). Cast via String(...) so purely-numeric
2327
+ // style IDs that XMLParser's `parseAttributeValue: true` coerces to
2328
+ // JS numbers (e.g., a custom styleId of "1") survive as strings,
2329
+ // matching the `style?: string` field contract on
2330
+ // ParagraphFormatting.
2331
+ if (pPrObj['w:pStyle']?.['@_w:val'] !== undefined) {
2332
+ paragraph.setStyle(String(pPrObj['w:pStyle']['@_w:val']));
2216
2333
  }
2217
2334
 
2218
2335
  // Indentation
@@ -2231,6 +2348,24 @@ export class DocumentParser {
2231
2348
  // Parse hanging indent per ECMA-376 Part 1 §17.3.1.17
2232
2349
  if (isExplicitlySet(ind['@_w:hanging']))
2233
2350
  paragraph.setHangingIndent(safeParseInt(ind['@_w:hanging']));
2351
+
2352
+ // CJK character-unit indentation attributes per ECMA-376 §17.3.1.12.
2353
+ // start/endChars are bidi-aware alternatives to left/rightChars; collapse
2354
+ // them onto the leftChars/rightChars fields the same way the twips parser
2355
+ // collapses w:start → left. Values are ST_DecimalNumber (hundredths of a
2356
+ // character unit), and 0 is a legitimate value — use isExplicitlySet so
2357
+ // number-0 from XMLParser.parseAttributeValue is preserved.
2358
+ if (!paragraph.formatting.indentation) paragraph.formatting.indentation = {};
2359
+ const leftCharsVal = ind['@_w:startChars'] ?? ind['@_w:leftChars'];
2360
+ const rightCharsVal = ind['@_w:endChars'] ?? ind['@_w:rightChars'];
2361
+ if (isExplicitlySet(leftCharsVal))
2362
+ paragraph.formatting.indentation.leftChars = safeParseInt(leftCharsVal);
2363
+ if (isExplicitlySet(rightCharsVal))
2364
+ paragraph.formatting.indentation.rightChars = safeParseInt(rightCharsVal);
2365
+ if (isExplicitlySet(ind['@_w:firstLineChars']))
2366
+ paragraph.formatting.indentation.firstLineChars = safeParseInt(ind['@_w:firstLineChars']);
2367
+ if (isExplicitlySet(ind['@_w:hangingChars']))
2368
+ paragraph.formatting.indentation.hangingChars = safeParseInt(ind['@_w:hangingChars']);
2234
2369
  }
2235
2370
 
2236
2371
  // Spacing (ECMA-376 §17.3.1.33 — 8 attributes)
@@ -2251,14 +2386,13 @@ export class DocumentParser {
2251
2386
  paragraph.formatting.spacing.beforeLines = safeParseInt(spacing['@_w:beforeLines']);
2252
2387
  if (isExplicitlySet(spacing['@_w:afterLines']))
2253
2388
  paragraph.formatting.spacing.afterLines = safeParseInt(spacing['@_w:afterLines']);
2389
+ // ST_OnOff per ECMA-376 §17.17.4 — accept 1/0/true/false/on/off
2254
2390
  const beforeAuto = spacing['@_w:beforeAutospacing'];
2255
2391
  if (beforeAuto !== undefined)
2256
- paragraph.formatting.spacing.beforeAutospacing =
2257
- String(beforeAuto) === '1' || String(beforeAuto) === 'true';
2392
+ paragraph.formatting.spacing.beforeAutospacing = parseOnOffAttribute(beforeAuto);
2258
2393
  const afterAuto = spacing['@_w:afterAutospacing'];
2259
2394
  if (afterAuto !== undefined)
2260
- paragraph.formatting.spacing.afterAutospacing =
2261
- String(afterAuto) === '1' || String(afterAuto) === 'true';
2395
+ paragraph.formatting.spacing.afterAutospacing = parseOnOffAttribute(afterAuto);
2262
2396
  }
2263
2397
 
2264
2398
  // Keep properties — preserve explicit val="0" to override style inheritance
@@ -2306,7 +2440,12 @@ export class DocumentParser {
2306
2440
  const pBdr = pPrObj['w:pBdr'];
2307
2441
  const borders: any = {};
2308
2442
 
2309
- // Helper function to parse border definition
2443
+ // Helper function to parse border definition.
2444
+ // Covers the full CT_Border attribute set per ECMA-376 §17.18.2:
2445
+ // w:val, w:sz, w:color, w:space, w:themeColor, w:themeTint,
2446
+ // w:themeShade, w:shadow, w:frame. The last two are ST_OnOff —
2447
+ // route through parseOnOffAttribute so "off"/"false"/"0"/"on"
2448
+ // all resolve correctly even after XMLParser numeric coercion.
2310
2449
  const parseBorder = (borderObj: any): any => {
2311
2450
  if (!borderObj) return undefined;
2312
2451
  const border: any = {};
@@ -2315,6 +2454,15 @@ export class DocumentParser {
2315
2454
  if (borderObj['@_w:color']) border.color = borderObj['@_w:color'];
2316
2455
  if (borderObj['@_w:space'] !== undefined)
2317
2456
  border.space = safeParseInt(borderObj['@_w:space']);
2457
+ if (borderObj['@_w:themeColor']) border.themeColor = String(borderObj['@_w:themeColor']);
2458
+ if (borderObj['@_w:themeTint']) border.themeTint = String(borderObj['@_w:themeTint']);
2459
+ if (borderObj['@_w:themeShade']) border.themeShade = String(borderObj['@_w:themeShade']);
2460
+ if (borderObj['@_w:shadow'] !== undefined) {
2461
+ border.shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
2462
+ }
2463
+ if (borderObj['@_w:frame'] !== undefined) {
2464
+ border.frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
2465
+ }
2318
2466
  return Object.keys(border).length > 0 ? border : undefined;
2319
2467
  };
2320
2468
 
@@ -2353,7 +2501,15 @@ export class DocumentParser {
2353
2501
 
2354
2502
  for (const tabObj of tabElements) {
2355
2503
  const tab: any = {};
2356
- if (tabObj['@_w:pos']) tab.position = parseInt(tabObj['@_w:pos'], 10);
2504
+ // w:pos is REQUIRED per §17.3.1.38 and is ST_SignedTwipsMeasure — 0 and
2505
+ // negative values are both valid. Use `!== undefined` so that XMLParser's
2506
+ // parseAttributeValue coercion of "0" to number 0 doesn't silently drop
2507
+ // tabs at the left margin (the previous `if (tabObj['@_w:pos'])` truthy
2508
+ // check turned pos=0 into an invisible tab-loss bug).
2509
+ if (tabObj['@_w:pos'] !== undefined) {
2510
+ const parsed = parseInt(String(tabObj['@_w:pos']), 10);
2511
+ if (!isNaN(parsed)) tab.position = parsed;
2512
+ }
2357
2513
  if (tabObj['@_w:val']) tab.val = tabObj['@_w:val'];
2358
2514
  if (tabObj['@_w:leader']) tab.leader = tabObj['@_w:leader'];
2359
2515
 
@@ -2369,19 +2525,10 @@ export class DocumentParser {
2369
2525
 
2370
2526
  // Widow control per ECMA-376 Part 1 §17.3.1.40
2371
2527
  if (pPrObj['w:widowControl'] !== undefined) {
2372
- const widowControlVal = pPrObj['w:widowControl']?.['@_w:val'];
2373
- // Parse w:val attribute - can be "0"/"1" or "false"/"true"
2374
- if (
2375
- widowControlVal === '0' ||
2376
- widowControlVal === 'false' ||
2377
- widowControlVal === false ||
2378
- widowControlVal === 0
2379
- ) {
2380
- paragraph.setWidowControl(false);
2381
- } else {
2382
- // If w:val is "1", "true", true, 1, or undefined (element present without val), default to true
2383
- paragraph.setWidowControl(true);
2384
- }
2528
+ // Delegate to parseOoxmlBoolean so every ST_OnOff literal — including
2529
+ // "off" / "on" resolves correctly. The previous bespoke check missed
2530
+ // "off", silently flipping explicit-off to explicit-on.
2531
+ paragraph.setWidowControl(parseOoxmlBoolean(pPrObj['w:widowControl']));
2385
2532
  }
2386
2533
 
2387
2534
  // Outline level per ECMA-376 Part 1 §17.3.1.19
@@ -2397,15 +2544,11 @@ export class DocumentParser {
2397
2544
  paragraph.setSuppressLineNumbers(parseOoxmlBoolean(pPrObj['w:suppressLineNumbers']));
2398
2545
  }
2399
2546
 
2400
- // Bidirectional layout per ECMA-376 Part 1 §17.3.1.6
2547
+ // Bidirectional layout per ECMA-376 Part 1 §17.3.1.6 — delegate to
2548
+ // parseOoxmlBoolean so "off"/"on" literals resolve correctly (the
2549
+ // previous bespoke check missed them).
2401
2550
  if (pPrObj['w:bidi'] !== undefined) {
2402
- const bidiVal = pPrObj['w:bidi']?.['@_w:val'];
2403
- if (bidiVal === '0' || bidiVal === 'false' || bidiVal === false || bidiVal === 0) {
2404
- paragraph.setBidi(false);
2405
- } else {
2406
- // Default is true when element present without val attribute or val="1"
2407
- paragraph.setBidi(true);
2408
- }
2551
+ paragraph.setBidi(parseOoxmlBoolean(pPrObj['w:bidi']));
2409
2552
  }
2410
2553
 
2411
2554
  // Text direction per ECMA-376 Part 1 §17.3.1.36
@@ -2423,20 +2566,10 @@ export class DocumentParser {
2423
2566
  paragraph.setMirrorIndents(parseOoxmlBoolean(pPrObj['w:mirrorIndents']));
2424
2567
  }
2425
2568
 
2426
- // Auto-adjust right indent per ECMA-376 Part 1 §17.3.1.1
2569
+ // Auto-adjust right indent per ECMA-376 Part 1 §17.3.1.1 — delegate to
2570
+ // parseOoxmlBoolean so "off"/"on" literals resolve correctly.
2427
2571
  if (pPrObj['w:adjustRightInd'] !== undefined) {
2428
- const adjustRightIndVal = pPrObj['w:adjustRightInd']?.['@_w:val'];
2429
- if (
2430
- adjustRightIndVal === '0' ||
2431
- adjustRightIndVal === 'false' ||
2432
- adjustRightIndVal === false ||
2433
- adjustRightIndVal === 0
2434
- ) {
2435
- paragraph.setAdjustRightInd(false);
2436
- } else {
2437
- // Default is true when element present without val attribute or val="1"
2438
- paragraph.setAdjustRightInd(true);
2439
- }
2572
+ paragraph.setAdjustRightInd(parseOoxmlBoolean(pPrObj['w:adjustRightInd']));
2440
2573
  }
2441
2574
 
2442
2575
  // Text frame properties per ECMA-376 Part 1 §17.3.1.11
@@ -2510,11 +2643,16 @@ export class DocumentParser {
2510
2643
  }
2511
2644
  }
2512
2645
 
2513
- // HTML div ID per ECMA-376 Part 1 §17.3.1.9
2646
+ // HTML div ID per ECMA-376 Part 1 §17.3.1.10 (CT_DivId). `w:val` is
2647
+ // ST_DecimalNumber — 0 is a valid ID referencing the first div in
2648
+ // web settings. XMLParser coerces `"0"` to the number 0, and the
2649
+ // previous `if (divIdVal)` truthy check silently dropped it, breaking
2650
+ // the paragraph's link to div index 0 on every round-trip.
2514
2651
  if (pPrObj['w:divId']) {
2515
2652
  const divIdVal = pPrObj['w:divId']?.['@_w:val'];
2516
- if (divIdVal) {
2517
- paragraph.setDivId(parseInt(divIdVal, 10));
2653
+ if (isExplicitlySet(divIdVal)) {
2654
+ const parsed = safeParseInt(divIdVal);
2655
+ if (!isNaN(parsed)) paragraph.setDivId(parsed);
2518
2656
  }
2519
2657
  }
2520
2658
 
@@ -2529,13 +2667,27 @@ export class DocumentParser {
2529
2667
  }
2530
2668
  }
2531
2669
 
2532
- // Paragraph property change tracking per ECMA-376 Part 1 §17.3.1.27
2670
+ // Paragraph property change tracking per ECMA-376 Part 1 §17.3.1.27.
2671
+ // CT_TrackChange attributes — `w:id` (ST_DecimalNumber, required),
2672
+ // `w:author` (ST_String, required), `w:date` (ST_DateTime, optional).
2673
+ // XMLParser coerces `w:id="0"` to the number 0; the previous
2674
+ // `if (changeObj['@_w:id'])` truthy gate silently dropped id=0,
2675
+ // producing `<w:pPrChange w:author="…" w:date="…"/>` on emission —
2676
+ // missing the required `w:id` and failing strict validation. The
2677
+ // sibling `trPrChange` / `tblPrChange` / `tcPrChange` / `sectPrChange`
2678
+ // parsers already use `|| '0'` or `!== undefined` for the same reason.
2533
2679
  if (pPrObj['w:pPrChange']) {
2534
2680
  const changeObj = pPrObj['w:pPrChange'];
2535
2681
  const change: any = {};
2536
- if (changeObj['@_w:author']) change.author = String(changeObj['@_w:author']);
2537
- if (changeObj['@_w:date']) change.date = String(changeObj['@_w:date']);
2538
- if (changeObj['@_w:id']) change.id = String(changeObj['@_w:id']);
2682
+ if (changeObj['@_w:author'] !== undefined) {
2683
+ change.author = String(changeObj['@_w:author']);
2684
+ }
2685
+ if (changeObj['@_w:date'] !== undefined) {
2686
+ change.date = String(changeObj['@_w:date']);
2687
+ }
2688
+ if (changeObj['@_w:id'] !== undefined) {
2689
+ change.id = String(changeObj['@_w:id']);
2690
+ }
2539
2691
 
2540
2692
  // Parse child w:pPr for previousProperties to preserve tracked change history
2541
2693
  if (changeObj['w:pPr']) {
@@ -2566,7 +2718,11 @@ export class DocumentParser {
2566
2718
  }
2567
2719
 
2568
2720
  // Parse previous indentation
2569
- // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to w:left/w:right
2721
+ // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to w:left/w:right.
2722
+ // Also parse the six CJK character-unit variants (ST_DecimalNumber) per §17.3.1.12;
2723
+ // these round-trip alongside the twips so Word's rendering of the tracked "previous"
2724
+ // state stays locale-accurate for CJK-authored documents. Matches the iteration-21
2725
+ // fix on the main-path parser.
2570
2726
  if (prevPPr['w:ind']) {
2571
2727
  const ind = prevPPr['w:ind'];
2572
2728
  previousProperties.indentation = {};
@@ -2578,6 +2734,18 @@ export class DocumentParser {
2578
2734
  previousProperties.indentation.firstLine = parseInt(ind['@_w:firstLine'], 10);
2579
2735
  if (ind['@_w:hanging'] !== undefined)
2580
2736
  previousProperties.indentation.hanging = parseInt(ind['@_w:hanging'], 10);
2737
+ // CJK character-unit variants. startChars/endChars collapse onto
2738
+ // leftChars/rightChars (same pattern as the twips variants).
2739
+ const leftCharsVal = ind['@_w:startChars'] ?? ind['@_w:leftChars'];
2740
+ const rightCharsVal = ind['@_w:endChars'] ?? ind['@_w:rightChars'];
2741
+ if (leftCharsVal !== undefined)
2742
+ previousProperties.indentation.leftChars = parseInt(leftCharsVal, 10);
2743
+ if (rightCharsVal !== undefined)
2744
+ previousProperties.indentation.rightChars = parseInt(rightCharsVal, 10);
2745
+ if (ind['@_w:firstLineChars'] !== undefined)
2746
+ previousProperties.indentation.firstLineChars = parseInt(ind['@_w:firstLineChars'], 10);
2747
+ if (ind['@_w:hangingChars'] !== undefined)
2748
+ previousProperties.indentation.hangingChars = parseInt(ind['@_w:hangingChars'], 10);
2581
2749
  }
2582
2750
 
2583
2751
  // Parse previous alignment
@@ -2601,48 +2769,51 @@ export class DocumentParser {
2601
2769
  previousProperties.spacing.beforeLines = parseInt(spacing['@_w:beforeLines'], 10);
2602
2770
  if (spacing['@_w:afterLines'] !== undefined)
2603
2771
  previousProperties.spacing.afterLines = parseInt(spacing['@_w:afterLines'], 10);
2772
+ // ST_OnOff per ECMA-376 §17.17.4 — accept 1/0/true/false/on/off
2604
2773
  const beforeAuto = spacing['@_w:beforeAutospacing'];
2605
2774
  if (beforeAuto !== undefined)
2606
- previousProperties.spacing.beforeAutospacing =
2607
- String(beforeAuto) === '1' || String(beforeAuto) === 'true';
2775
+ previousProperties.spacing.beforeAutospacing = parseOnOffAttribute(beforeAuto);
2608
2776
  const afterAuto = spacing['@_w:afterAutospacing'];
2609
2777
  if (afterAuto !== undefined)
2610
- previousProperties.spacing.afterAutospacing =
2611
- String(afterAuto) === '1' || String(afterAuto) === 'true';
2778
+ previousProperties.spacing.afterAutospacing = parseOnOffAttribute(afterAuto);
2612
2779
  }
2613
2780
 
2614
- // Parse previous keepNext/keepLines/pageBreakBefore
2781
+ // CT_OnOff properties per ECMA-376 §17.17.4 — accept "1"/"0"/"true"/"false"/"on"/"off"
2782
+ // plus the number forms produced by fast-xml-parser's parseAttributeValue. Using
2783
+ // parseOoxmlBoolean() keeps pPrChange round-trips consistent with the main pPr parser;
2784
+ // the previous `!== '0'` pattern silently flipped "false", "off", and the numeric 0.
2615
2785
  if (prevPPr['w:keepNext']) {
2616
- previousProperties.keepNext = prevPPr['w:keepNext']['@_w:val'] !== '0';
2786
+ previousProperties.keepNext = parseOoxmlBoolean(prevPPr['w:keepNext']);
2617
2787
  }
2618
2788
  if (prevPPr['w:keepLines']) {
2619
- previousProperties.keepLines = prevPPr['w:keepLines']['@_w:val'] !== '0';
2789
+ previousProperties.keepLines = parseOoxmlBoolean(prevPPr['w:keepLines']);
2620
2790
  }
2621
2791
  if (prevPPr['w:pageBreakBefore']) {
2622
- previousProperties.pageBreakBefore = prevPPr['w:pageBreakBefore']['@_w:val'] !== '0';
2792
+ previousProperties.pageBreakBefore = parseOoxmlBoolean(prevPPr['w:pageBreakBefore']);
2623
2793
  }
2624
2794
 
2625
2795
  // === Extended paragraph property parsing per ECMA-376 Part 1 §17.3.1 ===
2626
2796
 
2627
2797
  // Parse widowControl (w:widowControl) - orphan/widow control
2628
2798
  if (prevPPr['w:widowControl']) {
2629
- previousProperties.widowControl = prevPPr['w:widowControl']['@_w:val'] !== '0';
2799
+ previousProperties.widowControl = parseOoxmlBoolean(prevPPr['w:widowControl']);
2630
2800
  }
2631
2801
 
2632
2802
  // Parse suppressAutoHyphens (w:suppressAutoHyphens)
2633
2803
  if (prevPPr['w:suppressAutoHyphens']) {
2634
- previousProperties.suppressAutoHyphens =
2635
- prevPPr['w:suppressAutoHyphens']['@_w:val'] !== '0';
2804
+ previousProperties.suppressAutoHyphens = parseOoxmlBoolean(
2805
+ prevPPr['w:suppressAutoHyphens']
2806
+ );
2636
2807
  }
2637
2808
 
2638
2809
  // Parse contextualSpacing (w:contextualSpacing)
2639
2810
  if (prevPPr['w:contextualSpacing']) {
2640
- previousProperties.contextualSpacing = prevPPr['w:contextualSpacing']['@_w:val'] !== '0';
2811
+ previousProperties.contextualSpacing = parseOoxmlBoolean(prevPPr['w:contextualSpacing']);
2641
2812
  }
2642
2813
 
2643
2814
  // Parse mirrorIndents (w:mirrorIndents)
2644
2815
  if (prevPPr['w:mirrorIndents']) {
2645
- previousProperties.mirrorIndents = prevPPr['w:mirrorIndents']['@_w:val'] !== '0';
2816
+ previousProperties.mirrorIndents = parseOoxmlBoolean(prevPPr['w:mirrorIndents']);
2646
2817
  }
2647
2818
 
2648
2819
  // Parse outlineLevel (w:outlineLvl @w:val)
@@ -2650,40 +2821,106 @@ export class DocumentParser {
2650
2821
  previousProperties.outlineLevel = parseInt(prevPPr['w:outlineLvl']['@_w:val'], 10);
2651
2822
  }
2652
2823
 
2824
+ // Parse previous text frame properties (w:framePr) per ECMA-376
2825
+ // Part 1 §17.3.1.11 CT_FramePr. The pPrChange emitter already
2826
+ // rebuilds every framePr attribute (see Paragraph.ts §3634), but
2827
+ // the parser never read them — so a tracked change to any
2828
+ // frame property (drop-cap, text-box positioning, wrap mode,
2829
+ // anchor lock…) silently lost the previous state on round-trip.
2830
+ if (prevPPr['w:framePr']) {
2831
+ const framePr = prevPPr['w:framePr'];
2832
+ const frameProps: any = {};
2833
+ if (isExplicitlySet(framePr['@_w:w'])) frameProps.w = safeParseInt(framePr['@_w:w']);
2834
+ if (isExplicitlySet(framePr['@_w:h'])) frameProps.h = safeParseInt(framePr['@_w:h']);
2835
+ if (framePr['@_w:hRule']) frameProps.hRule = String(framePr['@_w:hRule']);
2836
+ if (isExplicitlySet(framePr['@_w:x'])) frameProps.x = safeParseInt(framePr['@_w:x']);
2837
+ if (isExplicitlySet(framePr['@_w:y'])) frameProps.y = safeParseInt(framePr['@_w:y']);
2838
+ if (framePr['@_w:xAlign']) frameProps.xAlign = String(framePr['@_w:xAlign']);
2839
+ if (framePr['@_w:yAlign']) frameProps.yAlign = String(framePr['@_w:yAlign']);
2840
+ if (framePr['@_w:hAnchor']) frameProps.hAnchor = String(framePr['@_w:hAnchor']);
2841
+ if (framePr['@_w:vAnchor']) frameProps.vAnchor = String(framePr['@_w:vAnchor']);
2842
+ if (isExplicitlySet(framePr['@_w:hSpace'])) {
2843
+ frameProps.hSpace = safeParseInt(framePr['@_w:hSpace']);
2844
+ }
2845
+ if (isExplicitlySet(framePr['@_w:vSpace'])) {
2846
+ frameProps.vSpace = safeParseInt(framePr['@_w:vSpace']);
2847
+ }
2848
+ if (framePr['@_w:wrap']) frameProps.wrap = String(framePr['@_w:wrap']);
2849
+ if (framePr['@_w:dropCap']) frameProps.dropCap = String(framePr['@_w:dropCap']);
2850
+ if (isExplicitlySet(framePr['@_w:lines'])) {
2851
+ frameProps.lines = safeParseInt(framePr['@_w:lines']);
2852
+ }
2853
+ if (isExplicitlySet(framePr['@_w:anchorLock'])) {
2854
+ frameProps.anchorLock = parseOnOffAttribute(String(framePr['@_w:anchorLock']), true);
2855
+ }
2856
+ if (Object.keys(frameProps).length > 0) {
2857
+ previousProperties.framePr = frameProps;
2858
+ }
2859
+ }
2860
+
2653
2861
  // Parse bidi (w:bidi) - right-to-left paragraph
2654
2862
  if (prevPPr['w:bidi']) {
2655
- previousProperties.bidi = prevPPr['w:bidi']['@_w:val'] !== '0';
2863
+ previousProperties.bidi = parseOoxmlBoolean(prevPPr['w:bidi']);
2656
2864
  }
2657
2865
 
2658
2866
  // Parse suppressLineNumbers (w:suppressLineNumbers)
2659
2867
  if (prevPPr['w:suppressLineNumbers']) {
2660
- previousProperties.suppressLineNumbers =
2661
- prevPPr['w:suppressLineNumbers']['@_w:val'] !== '0';
2868
+ previousProperties.suppressLineNumbers = parseOoxmlBoolean(
2869
+ prevPPr['w:suppressLineNumbers']
2870
+ );
2662
2871
  }
2663
2872
 
2664
2873
  // Parse adjustRightInd (w:adjustRightInd)
2665
2874
  if (prevPPr['w:adjustRightInd']) {
2666
- previousProperties.adjustRightInd = prevPPr['w:adjustRightInd']['@_w:val'] !== '0';
2875
+ previousProperties.adjustRightInd = parseOoxmlBoolean(prevPPr['w:adjustRightInd']);
2667
2876
  }
2668
2877
 
2669
2878
  // Parse snapToGrid (w:snapToGrid)
2670
2879
  if (prevPPr['w:snapToGrid']) {
2671
- previousProperties.snapToGrid = prevPPr['w:snapToGrid']['@_w:val'] !== '0';
2880
+ previousProperties.snapToGrid = parseOoxmlBoolean(prevPPr['w:snapToGrid']);
2672
2881
  }
2673
2882
 
2674
2883
  // Parse wordWrap (w:wordWrap)
2675
2884
  if (prevPPr['w:wordWrap']) {
2676
- previousProperties.wordWrap = prevPPr['w:wordWrap']['@_w:val'] !== '0';
2885
+ previousProperties.wordWrap = parseOoxmlBoolean(prevPPr['w:wordWrap']);
2677
2886
  }
2678
2887
 
2679
2888
  // Parse autoSpaceDE (w:autoSpaceDE) - East Asian/numeric spacing
2680
2889
  if (prevPPr['w:autoSpaceDE']) {
2681
- previousProperties.autoSpaceDE = prevPPr['w:autoSpaceDE']['@_w:val'] !== '0';
2890
+ previousProperties.autoSpaceDE = parseOoxmlBoolean(prevPPr['w:autoSpaceDE']);
2682
2891
  }
2683
2892
 
2684
2893
  // Parse autoSpaceDN (w:autoSpaceDN) - East Asian/Western spacing
2685
2894
  if (prevPPr['w:autoSpaceDN']) {
2686
- previousProperties.autoSpaceDN = prevPPr['w:autoSpaceDN']['@_w:val'] !== '0';
2895
+ previousProperties.autoSpaceDN = parseOoxmlBoolean(prevPPr['w:autoSpaceDN']);
2896
+ }
2897
+
2898
+ // Parse kinsoku / overflowPunct / topLinePunct / suppressOverlap —
2899
+ // CJK typography CT_OnOff flags. The Paragraph pPrChange generator
2900
+ // already emits these in the previous-properties block, but the
2901
+ // parser was missing the read side, so tracked paragraph-property
2902
+ // revisions that recorded any of these four flags were silently
2903
+ // dropped on load → save. Uses `parseOoxmlBoolean` to honour every
2904
+ // ST_OnOff literal (bare, 1/0, true/false, on/off).
2905
+ if (prevPPr['w:kinsoku']) {
2906
+ (previousProperties as { kinsoku?: boolean }).kinsoku = parseOoxmlBoolean(
2907
+ prevPPr['w:kinsoku']
2908
+ );
2909
+ }
2910
+ if (prevPPr['w:overflowPunct']) {
2911
+ (previousProperties as { overflowPunct?: boolean }).overflowPunct = parseOoxmlBoolean(
2912
+ prevPPr['w:overflowPunct']
2913
+ );
2914
+ }
2915
+ if (prevPPr['w:topLinePunct']) {
2916
+ (previousProperties as { topLinePunct?: boolean }).topLinePunct = parseOoxmlBoolean(
2917
+ prevPPr['w:topLinePunct']
2918
+ );
2919
+ }
2920
+ if (prevPPr['w:suppressOverlap']) {
2921
+ (previousProperties as { suppressOverlap?: boolean }).suppressOverlap = parseOoxmlBoolean(
2922
+ prevPPr['w:suppressOverlap']
2923
+ );
2687
2924
  }
2688
2925
 
2689
2926
  // Parse textDirection (w:textDirection @w:val)
@@ -2696,23 +2933,68 @@ export class DocumentParser {
2696
2933
  previousProperties.textAlignment = String(prevPPr['w:textAlignment']['@_w:val']);
2697
2934
  }
2698
2935
 
2936
+ // Parse previous divId (w:divId) per ECMA-376 §17.3.1.10 —
2937
+ // ST_DecimalNumber referencing a web-settings div. Zero is a
2938
+ // legal ID (first div). XMLParser coerces `"0"` to number 0, so
2939
+ // gate via `isExplicitlySet` to preserve divId=0 on tracked
2940
+ // previous state. The pPrChange emitter (Paragraph.ts §3915)
2941
+ // re-emits prev.divId via `!== undefined`.
2942
+ if (prevPPr['w:divId']?.['@_w:val'] !== undefined) {
2943
+ const rawDivId = prevPPr['w:divId']['@_w:val'];
2944
+ const parsedDivId = safeParseInt(rawDivId);
2945
+ if (!isNaN(parsedDivId)) {
2946
+ previousProperties.divId = parsedDivId;
2947
+ }
2948
+ }
2949
+
2950
+ // Parse previous cnfStyle (w:cnfStyle) per ECMA-376 §17.3.1.8 —
2951
+ // 12-character bitmask identifying which conditional-formatting
2952
+ // flags from the parent table style apply. XMLParser coerces
2953
+ // purely-numeric hex strings, but the custom parseValue keeps
2954
+ // 7+-digit strings as-is (so 12-char bitmasks survive); use
2955
+ // String + padStart to defensively normalise any shorter form.
2956
+ if (prevPPr['w:cnfStyle']?.['@_w:val'] !== undefined) {
2957
+ previousProperties.cnfStyle = String(prevPPr['w:cnfStyle']['@_w:val']).padStart(12, '0');
2958
+ }
2959
+
2699
2960
  // Parse paragraph borders (w:pBdr) per ECMA-376 Part 1 §17.3.1.24
2961
+ // Previous versions stored the attribute values under the wrong
2962
+ // field names (`val`/`sz` instead of `style`/`size`) — the
2963
+ // paragraph emitter reads `style`/`size`, so every tracked
2964
+ // previous border collapsed to `<w:top w:val="nil"/>` on
2965
+ // round-trip. The CT_Border attribute coverage here now matches
2966
+ // the main parser (§17.18.2): all nine attrs, with shadow/frame
2967
+ // routed through parseOnOffAttribute so ST_OnOff literals
2968
+ // ("on"/"off"/"true"/"false") resolve correctly.
2700
2969
  if (prevPPr['w:pBdr']) {
2701
2970
  const pBdr = prevPPr['w:pBdr'];
2702
2971
  previousProperties.borders = {};
2703
2972
 
2704
2973
  const parseBorder = (borderObj: any) => {
2705
2974
  if (!borderObj) return undefined;
2706
- return {
2707
- val: borderObj['@_w:val'],
2708
- sz: borderObj['@_w:sz'] !== undefined ? parseInt(borderObj['@_w:sz'], 10) : undefined,
2709
- space:
2710
- borderObj['@_w:space'] !== undefined
2711
- ? parseInt(borderObj['@_w:space'], 10)
2712
- : undefined,
2713
- color: borderObj['@_w:color'],
2714
- themeColor: borderObj['@_w:themeColor'],
2715
- };
2975
+ const border: any = {};
2976
+ if (borderObj['@_w:val']) border.style = borderObj['@_w:val'];
2977
+ if (borderObj['@_w:sz'] !== undefined) border.size = safeParseInt(borderObj['@_w:sz']);
2978
+ if (borderObj['@_w:space'] !== undefined) {
2979
+ border.space = safeParseInt(borderObj['@_w:space']);
2980
+ }
2981
+ if (borderObj['@_w:color']) border.color = borderObj['@_w:color'];
2982
+ if (borderObj['@_w:themeColor']) {
2983
+ border.themeColor = String(borderObj['@_w:themeColor']);
2984
+ }
2985
+ if (borderObj['@_w:themeTint']) {
2986
+ border.themeTint = String(borderObj['@_w:themeTint']);
2987
+ }
2988
+ if (borderObj['@_w:themeShade']) {
2989
+ border.themeShade = String(borderObj['@_w:themeShade']);
2990
+ }
2991
+ if (borderObj['@_w:shadow'] !== undefined) {
2992
+ border.shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
2993
+ }
2994
+ if (borderObj['@_w:frame'] !== undefined) {
2995
+ border.frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
2996
+ }
2997
+ return Object.keys(border).length > 0 ? border : undefined;
2716
2998
  };
2717
2999
 
2718
3000
  if (pBdr['w:top']) previousProperties.borders.top = parseBorder(pBdr['w:top']);
@@ -3873,11 +4155,15 @@ export class DocumentParser {
3873
4155
  return XMLBuilder.unescapeXml(String(node));
3874
4156
  };
3875
4157
 
3876
- const parseBooleanAttr = (value: any): boolean | undefined => {
4158
+ // Field-character attributes (w:dirty, w:fldLock, w:lock on w:fldChar) are
4159
+ // ST_OnOff per ECMA-376 §17.16.18. Delegate to parseOnOffAttribute so every
4160
+ // literal is honoured — the previous inline check missed "on" (silently
4161
+ // coerced to false) and was tighter than the spec requires.
4162
+ const parseBooleanAttr = (value: unknown): boolean | undefined => {
3877
4163
  if (value === undefined || value === null) {
3878
4164
  return undefined;
3879
4165
  }
3880
- return value === '1' || value === 1 || value === true || value === 'true';
4166
+ return parseOnOffAttribute(value);
3881
4167
  };
3882
4168
 
3883
4169
  // Parse w:ffData from a fldChar object (form field data per ECMA-376 §17.16.17)
@@ -3892,15 +4178,13 @@ export class DocumentParser {
3892
4178
  if (ffDataObj['w:name']?.['@_w:val'] !== undefined) {
3893
4179
  ffd.name = String(ffDataObj['w:name']['@_w:val']);
3894
4180
  }
3895
- // w:enabled (presence = true, w:val="0" = false)
4181
+ // w:enabled — CT_OnOff per ECMA-376 §17.16.11; presence = true, w:val honours ST_OnOff
3896
4182
  if (ffDataObj['w:enabled'] !== undefined) {
3897
- const enabledVal = ffDataObj['w:enabled']?.['@_w:val'];
3898
- ffd.enabled = enabledVal === '0' || enabledVal === 0 ? false : true;
4183
+ ffd.enabled = parseOoxmlBoolean(ffDataObj['w:enabled']);
3899
4184
  }
3900
- // w:calcOnExit
4185
+ // w:calcOnExit — CT_OnOff per ECMA-376 §17.16.4; presence = true, w:val honours ST_OnOff
3901
4186
  if (ffDataObj['w:calcOnExit'] !== undefined) {
3902
- const calcVal = ffDataObj['w:calcOnExit']?.['@_w:val'];
3903
- ffd.calcOnExit = calcVal === '1' || calcVal === 1 || calcVal === true;
4187
+ ffd.calcOnExit = parseOoxmlBoolean(ffDataObj['w:calcOnExit']);
3904
4188
  }
3905
4189
  // w:helpText
3906
4190
  if (ffDataObj['w:helpText']?.['@_w:val'] !== undefined) {
@@ -3936,13 +4220,14 @@ export class DocumentParser {
3936
4220
  if (ffDataObj['w:checkBox'] !== undefined) {
3937
4221
  const cb: XmlNode = ffDataObj['w:checkBox'];
3938
4222
  const checkBox: FormFieldCheckBox = { type: 'checkBox' };
3939
- if (cb['w:default']?.['@_w:val'] !== undefined) {
3940
- checkBox.defaultChecked =
3941
- cb['w:default']['@_w:val'] === '1' || cb['w:default']['@_w:val'] === 1;
4223
+ // w:default / w:checked are CT_OnOff per ECMA-376 §17.16.18 —
4224
+ // honour every ST_OnOff literal ("true"/"false"/"1"/"0"/"on"/"off")
4225
+ // and treat a bare self-closing element as true.
4226
+ if (cb['w:default'] !== undefined) {
4227
+ checkBox.defaultChecked = parseOoxmlBoolean(cb['w:default']);
3942
4228
  }
3943
- if (cb['w:checked']?.['@_w:val'] !== undefined) {
3944
- checkBox.checked =
3945
- cb['w:checked']['@_w:val'] === '1' || cb['w:checked']['@_w:val'] === 1;
4229
+ if (cb['w:checked'] !== undefined) {
4230
+ checkBox.checked = parseOoxmlBoolean(cb['w:checked']);
3946
4231
  }
3947
4232
  if (cb['w:size']?.['@_w:val'] !== undefined) {
3948
4233
  checkBox.size = Number(cb['w:size']['@_w:val']);
@@ -4146,30 +4431,47 @@ export class DocumentParser {
4146
4431
  content.push({ type: 'annotationRef' });
4147
4432
  break;
4148
4433
 
4149
- // Footnote reference (w:footnoteReference) per ECMA-376 Part 1 §17.11.13
4434
+ // Footnote reference (w:footnoteReference) per ECMA-376 Part 1 §17.11.13.
4435
+ // w:customMarkFollows is ST_OnOff — honour every literal via parseOnOffAttribute.
4150
4436
  case 'w:footnoteReference': {
4151
4437
  const fnRefElements = toArray(runObj['w:footnoteReference']);
4152
4438
  const fnRef = fnRefElements[elementIndex] || fnRefElements[0];
4153
4439
  const fnId = fnRef?.['@_w:id'];
4440
+ const fnCustomMark = fnRef?.['@_w:customMarkFollows'];
4154
4441
  content.push({
4155
4442
  type: 'footnoteReference',
4156
4443
  footnoteId: fnId !== undefined ? parseInt(fnId, 10) : undefined,
4444
+ customMarkFollows:
4445
+ fnCustomMark !== undefined ? parseOnOffAttribute(fnCustomMark) : undefined,
4157
4446
  });
4158
4447
  break;
4159
4448
  }
4160
4449
 
4161
- // Endnote reference (w:endnoteReference) per ECMA-376 Part 1 §17.11.2
4450
+ // Endnote reference (w:endnoteReference) per ECMA-376 Part 1 §17.11.2.
4451
+ // Same ST_OnOff treatment for w:customMarkFollows.
4162
4452
  case 'w:endnoteReference': {
4163
4453
  const enRefElements = toArray(runObj['w:endnoteReference']);
4164
4454
  const enRef = enRefElements[elementIndex] || enRefElements[0];
4165
4455
  const enId = enRef?.['@_w:id'];
4456
+ const enCustomMark = enRef?.['@_w:customMarkFollows'];
4166
4457
  content.push({
4167
4458
  type: 'endnoteReference',
4168
4459
  endnoteId: enId !== undefined ? parseInt(enId, 10) : undefined,
4460
+ customMarkFollows:
4461
+ enCustomMark !== undefined ? parseOnOffAttribute(enCustomMark) : undefined,
4169
4462
  });
4170
4463
  break;
4171
4464
  }
4172
4465
 
4466
+ // Auto-numbered marks INSIDE a footnote/endnote body per
4467
+ // ECMA-376 §17.11.14 / §17.11.3. Empty self-closing elements.
4468
+ case 'w:footnoteRef':
4469
+ content.push({ type: 'footnoteRef' });
4470
+ break;
4471
+ case 'w:endnoteRef':
4472
+ content.push({ type: 'endnoteRef' });
4473
+ break;
4474
+
4173
4475
  case 'w:dayShort':
4174
4476
  content.push({ type: 'dayShort' });
4175
4477
  break;
@@ -4355,14 +4657,18 @@ export class DocumentParser {
4355
4657
  if (runObj['w:annotationRef'] !== undefined) {
4356
4658
  content.push({ type: 'annotationRef' });
4357
4659
  }
4358
- // Footnote/endnote reference fallback
4660
+ // Footnote/endnote reference fallback. w:customMarkFollows is ST_OnOff
4661
+ // per ECMA-376 §17.11.13 / §17.11.2 — honour every literal.
4359
4662
  if (runObj['w:footnoteReference'] !== undefined) {
4360
4663
  const fnRefElements = toArray(runObj['w:footnoteReference']);
4361
4664
  for (const fnRef of fnRefElements) {
4362
4665
  const fnId = fnRef?.['@_w:id'];
4666
+ const fnCustomMark = fnRef?.['@_w:customMarkFollows'];
4363
4667
  content.push({
4364
4668
  type: 'footnoteReference',
4365
4669
  footnoteId: fnId !== undefined ? parseInt(fnId, 10) : undefined,
4670
+ customMarkFollows:
4671
+ fnCustomMark !== undefined ? parseOnOffAttribute(fnCustomMark) : undefined,
4366
4672
  });
4367
4673
  }
4368
4674
  }
@@ -4370,12 +4676,22 @@ export class DocumentParser {
4370
4676
  const enRefElements = toArray(runObj['w:endnoteReference']);
4371
4677
  for (const enRef of enRefElements) {
4372
4678
  const enId = enRef?.['@_w:id'];
4679
+ const enCustomMark = enRef?.['@_w:customMarkFollows'];
4373
4680
  content.push({
4374
4681
  type: 'endnoteReference',
4375
4682
  endnoteId: enId !== undefined ? parseInt(enId, 10) : undefined,
4683
+ customMarkFollows:
4684
+ enCustomMark !== undefined ? parseOnOffAttribute(enCustomMark) : undefined,
4376
4685
  });
4377
4686
  }
4378
4687
  }
4688
+ // Auto-numbered marks INSIDE a footnote/endnote body — empty elements.
4689
+ if (runObj['w:footnoteRef'] !== undefined) {
4690
+ content.push({ type: 'footnoteRef' });
4691
+ }
4692
+ if (runObj['w:endnoteRef'] !== undefined) {
4693
+ content.push({ type: 'endnoteRef' });
4694
+ }
4379
4695
  if (runObj['w:dayShort'] !== undefined) {
4380
4696
  content.push({ type: 'dayShort' });
4381
4697
  }
@@ -4474,12 +4790,40 @@ export class DocumentParser {
4474
4790
  : [hyperlinkObj['w:bookmarkStart']];
4475
4791
  for (const bs of bookmarkStarts) {
4476
4792
  const id = bs['@_w:id'];
4477
- const name = bs['@_w:name'];
4793
+ // w:name is ST_String per §17.16.5 CT_Bookmark. XMLParser
4794
+ // coerces purely-numeric bookmark names ("12345") to JS
4795
+ // numbers; cast so Bookmark.name holds the declared string
4796
+ // type contract (parent parsers already do the same —
4797
+ // iter 125 toOptString helper).
4798
+ const rawName = bs['@_w:name'];
4799
+ const name =
4800
+ rawName === undefined || rawName === null || rawName === ''
4801
+ ? undefined
4802
+ : String(rawName);
4478
4803
  if (id !== undefined && name) {
4804
+ // CT_Bookmark per ECMA-376 §17.16.5: the object-form parser
4805
+ // must carry the same four "markup" attributes that the
4806
+ // XML-string bookmarkStart parser handles — colFirst/colLast
4807
+ // (table-column-scoped bookmarks) and displacedByCustomXml
4808
+ // (custom-XML boundary disambiguator). Previously dropped
4809
+ // whenever a hyperlink wrapped a bookmark, so inline
4810
+ // hyperlinks anchored to table-column bookmarks lost their
4811
+ // column range on round-trip.
4812
+ const rawColFirst = bs['@_w:colFirst'];
4813
+ const rawColLast = bs['@_w:colLast'];
4814
+ const rawDisplaced = bs['@_w:displacedByCustomXml'];
4815
+ const colFirst =
4816
+ rawColFirst === undefined ? undefined : parseInt(String(rawColFirst), 10);
4817
+ const colLast = rawColLast === undefined ? undefined : parseInt(String(rawColLast), 10);
4818
+ const displacedByCustomXml =
4819
+ rawDisplaced === 'next' || rawDisplaced === 'prev' ? rawDisplaced : undefined;
4479
4820
  const bookmark = new Bookmark({
4480
4821
  name: name,
4481
4822
  id: typeof id === 'number' ? id : parseInt(id, 10),
4482
4823
  skipNormalization: true,
4824
+ colFirst: Number.isNaN(colFirst as number) ? undefined : colFirst,
4825
+ colLast: Number.isNaN(colLast as number) ? undefined : colLast,
4826
+ displacedByCustomXml,
4483
4827
  });
4484
4828
  result.bookmarkStarts.push(bookmark);
4485
4829
  // Also register with BookmarkManager
@@ -4501,23 +4845,47 @@ export class DocumentParser {
4501
4845
  for (const be of bookmarkEnds) {
4502
4846
  const id = be['@_w:id'];
4503
4847
  if (id !== undefined) {
4848
+ // CT_MarkupRange per ECMA-376 §17.13.5 — preserve
4849
+ // w:displacedByCustomXml on bookmarkEnd when a custom-XML
4850
+ // boundary forced the marker to be displaced.
4851
+ const rawDisplaced = be['@_w:displacedByCustomXml'];
4852
+ const displacedByCustomXml =
4853
+ rawDisplaced === 'next' || rawDisplaced === 'prev' ? rawDisplaced : undefined;
4504
4854
  const bookmark = new Bookmark({
4505
4855
  name: `_end_${id}`,
4506
4856
  id: typeof id === 'number' ? id : parseInt(id, 10),
4507
4857
  skipNormalization: true,
4858
+ displacedByCustomXml,
4508
4859
  });
4509
4860
  result.bookmarkEnds.push(bookmark);
4510
4861
  }
4511
4862
  }
4512
4863
  }
4513
4864
 
4514
- // Extract hyperlink attributes
4515
- const relationshipId = hyperlinkObj['@_r:id'];
4516
- const anchor = hyperlinkObj['@_w:anchor'];
4517
- const tooltip = hyperlinkObj['@_w:tooltip'];
4518
- const tgtFrame = hyperlinkObj['@_w:tgtFrame'];
4519
- const history = hyperlinkObj['@_w:history'];
4520
- const docLocation = hyperlinkObj['@_w:docLocation'];
4865
+ // Extract hyperlink attributes. Per ECMA-376 §17.16.22 CT_Hyperlink,
4866
+ // w:anchor / w:tooltip / w:tgtFrame / w:docLocation / r:id are all
4867
+ // ST_String. XMLParser's `parseAttributeValue: true` coerces
4868
+ // purely-numeric strings (e.g., a bookmark name like "12345") to
4869
+ // JS numbers — cast via String(...) so downstream `Hyperlink`
4870
+ // storage and string-method callers see the declared `string`
4871
+ // type contract.
4872
+ const toOptString = (v: unknown): string | undefined =>
4873
+ v === undefined || v === null ? undefined : String(v);
4874
+ const relationshipId = toOptString(hyperlinkObj['@_r:id']);
4875
+ const anchor = toOptString(hyperlinkObj['@_w:anchor']);
4876
+ const tooltip = toOptString(hyperlinkObj['@_w:tooltip']);
4877
+ const tgtFrame = toOptString(hyperlinkObj['@_w:tgtFrame']);
4878
+ // w:history is CT_OnOff per ECMA-376 §17.16.22 — honour every
4879
+ // ST_OnOff literal ("1"/"0"/"true"/"false"/"on"/"off") and every
4880
+ // XMLParser-coerced form (number 0/1, boolean). The Hyperlink
4881
+ // serializer accepts a string, so normalise to the canonical
4882
+ // "1"/"0" form. Without this, `w:history="0"` or `w:history="false"`
4883
+ // coerced to falsy values and the emitter's truthy check dropped
4884
+ // the attribute on round-trip.
4885
+ const rawHistory = hyperlinkObj['@_w:history'];
4886
+ const history =
4887
+ rawHistory === undefined ? undefined : parseOnOffAttribute(rawHistory) ? '1' : '0';
4888
+ const docLocation = toOptString(hyperlinkObj['@_w:docLocation']);
4521
4889
 
4522
4890
  // Parse runs inside the hyperlink
4523
4891
  const runs = hyperlinkObj['w:r'];
@@ -4815,8 +5183,20 @@ export class DocumentParser {
4815
5183
  }
4816
5184
 
4817
5185
  // Extract field type from instruction (first word)
4818
- const typeMatch = instruction.trim().match(/^(\w+)/);
4819
- const type = (typeMatch?.[1] || 'PAGE') as import('../elements/Field').FieldType;
5186
+ const typeMatch = String(instruction)
5187
+ .trim()
5188
+ .match(/^(\w+)/);
5189
+ const type = (typeMatch?.[1] || 'PAGE') as import('../elements/Field.js').FieldType;
5190
+
5191
+ // CT_SimpleField (§17.16.16) carries two ST_OnOff attributes besides
5192
+ // the required w:instr — w:fldLock (update lock) and w:dirty
5193
+ // (cached-result staleness). Previously neither was parsed, so
5194
+ // Word's "update field" indicator and "lock field" flag were
5195
+ // silently cleared on every load → save round-trip.
5196
+ const fldLockRaw = fieldObj['@_w:fldLock'];
5197
+ const dirtyRaw = fieldObj['@_w:dirty'];
5198
+ const fldLock = fldLockRaw !== undefined ? parseOnOffAttribute(fldLockRaw) : undefined;
5199
+ const dirty = dirtyRaw !== undefined ? parseOnOffAttribute(dirtyRaw) : undefined;
4820
5200
 
4821
5201
  // Parse run formatting from w:rPr if present
4822
5202
  let formatting: RunFormatting | undefined;
@@ -4829,8 +5209,10 @@ export class DocumentParser {
4829
5209
  // Create field with instruction
4830
5210
  const field = Field.create({
4831
5211
  type,
4832
- instruction,
5212
+ instruction: String(instruction),
4833
5213
  formatting,
5214
+ fldLock,
5215
+ dirty,
4834
5216
  });
4835
5217
 
4836
5218
  return field;
@@ -4848,15 +5230,25 @@ export class DocumentParser {
4848
5230
  private parseRunPropertiesFromObject(rPrObj: any, run: Run): void {
4849
5231
  if (!rPrObj) return;
4850
5232
 
4851
- // Parse character style reference (w:rStyle) per ECMA-376 Part 1 §17.3.2.36
5233
+ // Parse character style reference (w:rStyle) per ECMA-376 Part 1
5234
+ // §17.3.2.36 — `w:val` is ST_String referencing a style ID. Cast
5235
+ // via String(...) so a purely-numeric style ID (e.g., "1") that
5236
+ // XMLParser coerces to the number 1 survives as the string "1",
5237
+ // matching the `characterStyle?: string` field contract on
5238
+ // RunFormatting.
4852
5239
  if (rPrObj['w:rStyle']) {
4853
5240
  const styleId = rPrObj['w:rStyle']['@_w:val'];
4854
- if (styleId) {
4855
- run.setCharacterStyle(styleId);
5241
+ if (styleId !== undefined && styleId !== null && styleId !== '') {
5242
+ run.setCharacterStyle(String(styleId));
4856
5243
  }
4857
5244
  }
4858
5245
 
4859
- // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.5
5246
+ // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.5 — CT_Border
5247
+ // §17.18.2 attribute set: val / sz / space / color / themeColor /
5248
+ // themeTint / themeShade / shadow / frame. Previously only the first
5249
+ // four were read, so themed character borders lost their theme linkage
5250
+ // on round-trip. The emitter (Run.generateRunPropertiesXML) handles
5251
+ // all nine since iteration 79.
4860
5252
  if (rPrObj['w:bdr']) {
4861
5253
  const bdr = rPrObj['w:bdr'];
4862
5254
  const border: any = {};
@@ -4864,6 +5256,20 @@ export class DocumentParser {
4864
5256
  if (bdr['@_w:sz']) border.size = parseInt(bdr['@_w:sz'], 10);
4865
5257
  if (bdr['@_w:color']) border.color = bdr['@_w:color'];
4866
5258
  if (bdr['@_w:space']) border.space = parseInt(bdr['@_w:space'], 10);
5259
+ // Per ECMA-376 §17.18.82 CT_Border: themeTint / themeShade are
5260
+ // ST_UcharHexNumber (2-char hex). XMLParser coerces purely-digit
5261
+ // hex strings like "80" / "50" to JS numbers; cast via String(...)
5262
+ // so the declared `string` contract on the model holds for any
5263
+ // downstream code that calls string methods (.toUpperCase(), etc.).
5264
+ if (bdr['@_w:themeColor']) border.themeColor = String(bdr['@_w:themeColor']);
5265
+ if (bdr['@_w:themeTint']) border.themeTint = String(bdr['@_w:themeTint']);
5266
+ if (bdr['@_w:themeShade']) border.themeShade = String(bdr['@_w:themeShade']);
5267
+ if (bdr['@_w:shadow'] !== undefined) {
5268
+ border.shadow = parseOnOffAttribute(String(bdr['@_w:shadow']), true);
5269
+ }
5270
+ if (bdr['@_w:frame'] !== undefined) {
5271
+ border.frame = parseOnOffAttribute(String(bdr['@_w:frame']), true);
5272
+ }
4867
5273
  if (Object.keys(border).length > 0) {
4868
5274
  run.setBorder(border);
4869
5275
  }
@@ -4883,26 +5289,30 @@ export class DocumentParser {
4883
5289
  if (val) run.setEmphasis(val);
4884
5290
  }
4885
5291
 
4886
- // Parse boolean text effects — use parseOoxmlBoolean to correctly handle w:val="0"/"false"
4887
- // Per ECMA-376, <w:xxx/> or <w:xxx w:val="1"/> = true; <w:xxx w:val="0"/> = false (explicit off)
4888
- if (parseOoxmlBoolean(rPrObj['w:outline'])) run.setOutline(true);
4889
- if (parseOoxmlBoolean(rPrObj['w:shadow'])) run.setShadow(true);
4890
- if (parseOoxmlBoolean(rPrObj['w:emboss'])) run.setEmboss(true);
4891
- if (parseOoxmlBoolean(rPrObj['w:imprint'])) run.setImprint(true);
4892
- if (parseOoxmlBoolean(rPrObj['w:noProof'])) run.setNoProof(true);
5292
+ // CT_OnOff text effects — presence + w:val both matter. Use `!== undefined`
5293
+ // to detect presence, then parseOoxmlBoolean() for the value, so an explicit
5294
+ // `<w:outline w:val="0"/>` override of a style-inherited true is preserved
5295
+ // (not silently dropped into "inherit"). Applies to all OnOffType rPr flags
5296
+ // per ECMA-376 §17.3.2.
5297
+ if (rPrObj['w:outline'] !== undefined) run.setOutline(parseOoxmlBoolean(rPrObj['w:outline']));
5298
+ if (rPrObj['w:shadow'] !== undefined) run.setShadow(parseOoxmlBoolean(rPrObj['w:shadow']));
5299
+ if (rPrObj['w:emboss'] !== undefined) run.setEmboss(parseOoxmlBoolean(rPrObj['w:emboss']));
5300
+ if (rPrObj['w:imprint'] !== undefined) run.setImprint(parseOoxmlBoolean(rPrObj['w:imprint']));
5301
+ if (rPrObj['w:noProof'] !== undefined) run.setNoProof(parseOoxmlBoolean(rPrObj['w:noProof']));
4893
5302
  // snapToGrid: default when absent is true (§17.3.2.34), so explicit val="0" must be preserved
4894
5303
  if (rPrObj['w:snapToGrid'] !== undefined) {
4895
5304
  run.setSnapToGrid(parseOoxmlBoolean(rPrObj['w:snapToGrid']));
4896
5305
  }
4897
- if (parseOoxmlBoolean(rPrObj['w:vanish'])) run.setVanish(true);
4898
- if (parseOoxmlBoolean(rPrObj['w:specVanish'])) run.setSpecVanish(true);
5306
+ if (rPrObj['w:vanish'] !== undefined) run.setVanish(parseOoxmlBoolean(rPrObj['w:vanish']));
5307
+ if (rPrObj['w:specVanish'] !== undefined)
5308
+ run.setSpecVanish(parseOoxmlBoolean(rPrObj['w:specVanish']));
4899
5309
 
4900
5310
  // Boolean properties - use parseOoxmlBoolean helper
4901
5311
  // Per ECMA-376: <w:b/> or <w:b w:val="1"/> or <w:b w:val="true"/> means true
4902
5312
  // <w:b w:val="0"/> or <w:b w:val="false"/> means false (omit from document)
4903
5313
 
4904
5314
  // Parse RTL text (w:rtl) per ECMA-376 Part 1 §17.3.2.30
4905
- if (parseOoxmlBoolean(rPrObj['w:rtl'])) run.setRTL(true);
5315
+ if (rPrObj['w:rtl'] !== undefined) run.setRTL(parseOoxmlBoolean(rPrObj['w:rtl']));
4906
5316
 
4907
5317
  // b, bCs, i, iCs: preserve explicit val="0" to override style-inherited formatting
4908
5318
  if (rPrObj['w:b'] !== undefined) run.setBold(parseOoxmlBoolean(rPrObj['w:b']));
@@ -4919,11 +5329,12 @@ export class DocumentParser {
4919
5329
  run.setSmallCaps(parseOoxmlBoolean(rPrObj['w:smallCaps']));
4920
5330
  if (rPrObj['w:caps'] !== undefined) run.setAllCaps(parseOoxmlBoolean(rPrObj['w:caps']));
4921
5331
 
4922
- // Parse complex script flag (w:cs) per ECMA-376 Part 1 §17.3.2.7
4923
- if (parseOoxmlBoolean(rPrObj['w:cs'])) run.setComplexScript(true);
5332
+ // Parse complex script flag (w:cs) per ECMA-376 Part 1 §17.3.2.7 — CT_OnOff
5333
+ if (rPrObj['w:cs'] !== undefined) run.setComplexScript(parseOoxmlBoolean(rPrObj['w:cs']));
4924
5334
 
4925
- // Parse web hidden (w:webHidden) per ECMA-376 Part 1 §17.3.2.44
4926
- if (parseOoxmlBoolean(rPrObj['w:webHidden'])) run.setWebHidden(true);
5335
+ // Parse web hidden (w:webHidden) per ECMA-376 Part 1 §17.3.2.44 — CT_OnOff
5336
+ if (rPrObj['w:webHidden'] !== undefined)
5337
+ run.setWebHidden(parseOoxmlBoolean(rPrObj['w:webHidden']));
4927
5338
 
4928
5339
  if (rPrObj['w:u']) {
4929
5340
  // XMLParser adds @_ prefix to attributes
@@ -4943,28 +5354,37 @@ export class DocumentParser {
4943
5354
  );
4944
5355
  }
4945
5356
 
4946
- // Parse character spacing (w:spacing) per ECMA-376 Part 1 §17.3.2.33
5357
+ // Parse character spacing (w:spacing) per ECMA-376 Part 1 §17.3.2.35.
5358
+ // ST_SignedTwipsMeasure — 0 and negative values are valid (default /
5359
+ // tighter spacing). XMLParser.parseAttributeValue coerces "0" to number 0,
5360
+ // which is falsy — so the previous `if (val)` truthy check silently dropped
5361
+ // explicit zero / baseline-reset formatting on every run that used it.
5362
+ // Matches the rPrChange parser below which already uses `!== undefined`.
4947
5363
  if (rPrObj['w:spacing']) {
4948
5364
  const val = rPrObj['w:spacing']['@_w:val'];
4949
- if (val) run.setCharacterSpacing(parseInt(val, 10));
5365
+ if (val !== undefined) run.setCharacterSpacing(parseInt(String(val), 10));
4950
5366
  }
4951
5367
 
4952
- // Parse horizontal scaling (w:w) per ECMA-376 Part 1 §17.3.2.43
5368
+ // Parse horizontal scaling (w:w) per ECMA-376 Part 1 §17.3.2.43.
5369
+ // ST_TextScale — min 1 per schema, so value 0 is not spec-valid; keep
5370
+ // truthy check as a mild sanity guard against malformed sources.
4953
5371
  if (rPrObj['w:w']) {
4954
5372
  const val = rPrObj['w:w']['@_w:val'];
4955
- if (val) run.setScaling(parseInt(val, 10));
5373
+ if (val) run.setScaling(parseInt(String(val), 10));
4956
5374
  }
4957
5375
 
4958
- // Parse vertical position (w:position) per ECMA-376 Part 1 §17.3.2.31
5376
+ // Parse vertical position (w:position) per ECMA-376 Part 1 §17.3.2.31.
5377
+ // ST_SignedHpsMeasure — 0 = baseline (default / explicit reset).
4959
5378
  if (rPrObj['w:position']) {
4960
5379
  const val = rPrObj['w:position']['@_w:val'];
4961
- if (val) run.setPosition(parseInt(val, 10));
5380
+ if (val !== undefined) run.setPosition(parseInt(String(val), 10));
4962
5381
  }
4963
5382
 
4964
- // Parse kerning (w:kern) per ECMA-376 Part 1 §17.3.2.20
5383
+ // Parse kerning (w:kern) per ECMA-376 Part 1 §17.3.2.20.
5384
+ // ST_HpsMeasure — 0 means "kern at every size" (no minimum threshold).
4965
5385
  if (rPrObj['w:kern']) {
4966
5386
  const val = rPrObj['w:kern']['@_w:val'];
4967
- if (val) run.setKerning(parseInt(val, 10));
5387
+ if (val !== undefined) run.setKerning(parseInt(String(val), 10));
4968
5388
  }
4969
5389
 
4970
5390
  // Parse language (w:lang) per ECMA-376 Part 1 §17.3.2.20 (CT_Language)
@@ -4984,14 +5404,27 @@ export class DocumentParser {
4984
5404
  }
4985
5405
  }
4986
5406
 
4987
- // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1 §17.3.2.10
5407
+ // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1
5408
+ // §17.3.2.10 CT_EastAsianLayout. `w:vert` / `w:vertCompress` /
5409
+ // `w:combine` are ST_OnOff attributes — route through
5410
+ // parseOnOffAttribute so every literal ("1"/"0"/"true"/"false"/
5411
+ // "on"/"off") resolves correctly. The previous truthy gate both
5412
+ // dropped explicit false (`w:vert="0"` → coerced 0 → undefined) AND
5413
+ // wrongly marked `w:vert="off"` as true (non-empty string is truthy
5414
+ // without parsing).
4988
5415
  if (rPrObj['w:eastAsianLayout']) {
4989
5416
  const layoutObj = rPrObj['w:eastAsianLayout'];
4990
5417
  const layout: any = {};
4991
5418
  if (layoutObj['@_w:id'] !== undefined) layout.id = Number(layoutObj['@_w:id']);
4992
- if (layoutObj['@_w:vert']) layout.vert = true;
4993
- if (layoutObj['@_w:vertCompress']) layout.vertCompress = true;
4994
- if (layoutObj['@_w:combine']) layout.combine = true;
5419
+ if (layoutObj['@_w:vert'] !== undefined) {
5420
+ layout.vert = parseOnOffAttribute(String(layoutObj['@_w:vert']), true);
5421
+ }
5422
+ if (layoutObj['@_w:vertCompress'] !== undefined) {
5423
+ layout.vertCompress = parseOnOffAttribute(String(layoutObj['@_w:vertCompress']), true);
5424
+ }
5425
+ if (layoutObj['@_w:combine'] !== undefined) {
5426
+ layout.combine = parseOnOffAttribute(String(layoutObj['@_w:combine']), true);
5427
+ }
4995
5428
  if (layoutObj['@_w:combineBrackets'])
4996
5429
  layout.combineBrackets = layoutObj['@_w:combineBrackets'];
4997
5430
 
@@ -5021,17 +5454,23 @@ export class DocumentParser {
5021
5454
 
5022
5455
  if (rPrObj['w:rFonts']) {
5023
5456
  const rFonts = rPrObj['w:rFonts'];
5024
- if (rFonts['@_w:ascii']) run.setFont(rFonts['@_w:ascii']);
5457
+ // Per ECMA-376 §17.3.2.26 CT_Fonts, all four literal-font
5458
+ // attributes (ascii/hAnsi/eastAsia/cs) are ST_String. XMLParser
5459
+ // coerces purely-numeric font names ("2010", etc.) to JS
5460
+ // numbers; cast through String() so RunFormatting's
5461
+ // declared-string font fields keep their type contract.
5462
+ if (rFonts['@_w:ascii'] !== undefined) run.setFont(String(rFonts['@_w:ascii']));
5025
5463
  // Parse additional font variants per ECMA-376 Part 1 §17.3.2.26
5026
- if (rFonts['@_w:hAnsi']) run.setFontHAnsi(rFonts['@_w:hAnsi']);
5027
- if (rFonts['@_w:eastAsia']) run.setFontEastAsia(rFonts['@_w:eastAsia']);
5028
- if (rFonts['@_w:cs']) run.setFontCs(rFonts['@_w:cs']);
5029
- if (rFonts['@_w:hint']) run.setFontHint(rFonts['@_w:hint']);
5464
+ if (rFonts['@_w:hAnsi'] !== undefined) run.setFontHAnsi(String(rFonts['@_w:hAnsi']));
5465
+ if (rFonts['@_w:eastAsia'] !== undefined) run.setFontEastAsia(String(rFonts['@_w:eastAsia']));
5466
+ if (rFonts['@_w:cs'] !== undefined) run.setFontCs(String(rFonts['@_w:cs']));
5467
+ if (rFonts['@_w:hint']) run.setFontHint(String(rFonts['@_w:hint']));
5030
5468
  // Parse theme font references per ECMA-376 Part 1 §17.3.2.26
5031
- if (rFonts['@_w:asciiTheme']) run.setFontAsciiTheme(rFonts['@_w:asciiTheme']);
5032
- if (rFonts['@_w:hAnsiTheme']) run.setFontHAnsiTheme(rFonts['@_w:hAnsiTheme']);
5033
- if (rFonts['@_w:eastAsiaTheme']) run.setFontEastAsiaTheme(rFonts['@_w:eastAsiaTheme']);
5034
- if (rFonts['@_w:cstheme']) run.setFontCsTheme(rFonts['@_w:cstheme']);
5469
+ if (rFonts['@_w:asciiTheme']) run.setFontAsciiTheme(String(rFonts['@_w:asciiTheme']));
5470
+ if (rFonts['@_w:hAnsiTheme']) run.setFontHAnsiTheme(String(rFonts['@_w:hAnsiTheme']));
5471
+ if (rFonts['@_w:eastAsiaTheme'])
5472
+ run.setFontEastAsiaTheme(String(rFonts['@_w:eastAsiaTheme']));
5473
+ if (rFonts['@_w:cstheme']) run.setFontCsTheme(String(rFonts['@_w:cstheme']));
5035
5474
  }
5036
5475
 
5037
5476
  if (rPrObj['w:sz']) {
@@ -5099,7 +5538,7 @@ export class DocumentParser {
5099
5538
  // This records what the run formatting was BEFORE a change was made
5100
5539
  if (rPrObj['w:rPrChange']) {
5101
5540
  const changeObj = rPrObj['w:rPrChange'];
5102
- const propChange: import('../elements/PropertyChangeTypes').RunPropertyChange = {
5541
+ const propChange: import('../elements/PropertyChangeTypes.js').RunPropertyChange = {
5103
5542
  id: changeObj['@_w:id'] !== undefined ? parseInt(String(changeObj['@_w:id']), 10) : 0,
5104
5543
  author: changeObj['@_w:author'] ? String(changeObj['@_w:author']) : '',
5105
5544
  date: changeObj['@_w:date'] ? new Date(String(changeObj['@_w:date'])) : new Date(),
@@ -5109,7 +5548,7 @@ export class DocumentParser {
5109
5548
  // Parse previous run properties from child w:rPr element
5110
5549
  if (changeObj['w:rPr']) {
5111
5550
  const prevRPr = changeObj['w:rPr'];
5112
- const prevProps: Partial<import('../elements/Run').RunFormatting> = {};
5551
+ const prevProps: Partial<import('../elements/Run.js').RunFormatting> = {};
5113
5552
 
5114
5553
  // Parse previous bold
5115
5554
  if (prevRPr['w:b']) {
@@ -5121,10 +5560,22 @@ export class DocumentParser {
5121
5560
  prevProps.italic = parseOoxmlBoolean(prevRPr['w:i']);
5122
5561
  }
5123
5562
 
5124
- // Parse previous underline
5563
+ // Parse previous underline — CT_Underline per §17.3.2.40 has `val`
5564
+ // plus color / themeColor / themeTint / themeShade. Main rPr parser
5565
+ // reads all of them; rPrChange previously only read `val`, so
5566
+ // underline color metadata on tracked "previous" state was dropped.
5125
5567
  if (prevRPr['w:u']) {
5126
- const uVal = prevRPr['w:u']['@_w:val'];
5568
+ const uObj = prevRPr['w:u'];
5569
+ const uVal = uObj['@_w:val'];
5127
5570
  prevProps.underline = uVal || true;
5571
+ if (uObj['@_w:color']) prevProps.underlineColor = uObj['@_w:color'];
5572
+ if (uObj['@_w:themeColor']) prevProps.underlineThemeColor = uObj['@_w:themeColor'];
5573
+ if (uObj['@_w:themeTint'] !== undefined) {
5574
+ prevProps.underlineThemeTint = parseInt(String(uObj['@_w:themeTint']), 16);
5575
+ }
5576
+ if (uObj['@_w:themeShade'] !== undefined) {
5577
+ prevProps.underlineThemeShade = parseInt(String(uObj['@_w:themeShade']), 16);
5578
+ }
5128
5579
  }
5129
5580
 
5130
5581
  // Parse previous strikethrough
@@ -5133,13 +5584,28 @@ export class DocumentParser {
5133
5584
  }
5134
5585
 
5135
5586
  // Parse previous font (all w:rFonts attributes per ECMA-376 Part 1 §17.3.2.26)
5587
+ // including theme font references (asciiTheme/hAnsiTheme/eastAsiaTheme/
5588
+ // cstheme). Previously only the literal-font attributes were read, so
5589
+ // rPrChange tracked history of theme-font changes lost the theme linkage
5590
+ // on round-trip — a paragraph whose "previous" font was a theme
5591
+ // reference (e.g. w:asciiTheme="minorHAnsi") silently dropped it.
5136
5592
  if (prevRPr['w:rFonts']) {
5137
5593
  const rFonts = prevRPr['w:rFonts'];
5138
- if (rFonts['@_w:ascii']) prevProps.font = rFonts['@_w:ascii'];
5139
- if (rFonts['@_w:hAnsi']) prevProps.fontHAnsi = rFonts['@_w:hAnsi'];
5140
- if (rFonts['@_w:eastAsia']) prevProps.fontEastAsia = rFonts['@_w:eastAsia'];
5141
- if (rFonts['@_w:cs']) prevProps.fontCs = rFonts['@_w:cs'];
5142
- if (rFonts['@_w:hint']) prevProps.fontHint = rFonts['@_w:hint'];
5594
+ // Mirror the main-path String() casts on rPrChange
5595
+ // previous-font reads — ECMA-376 §17.3.2.26 CT_Fonts declares
5596
+ // ascii/hAnsi/eastAsia/cs as ST_String, so purely-numeric
5597
+ // font names must survive round-trip as strings here too.
5598
+ if (rFonts['@_w:ascii'] !== undefined) prevProps.font = String(rFonts['@_w:ascii']);
5599
+ if (rFonts['@_w:hAnsi'] !== undefined) prevProps.fontHAnsi = String(rFonts['@_w:hAnsi']);
5600
+ if (rFonts['@_w:eastAsia'] !== undefined)
5601
+ prevProps.fontEastAsia = String(rFonts['@_w:eastAsia']);
5602
+ if (rFonts['@_w:cs'] !== undefined) prevProps.fontCs = String(rFonts['@_w:cs']);
5603
+ if (rFonts['@_w:hint']) prevProps.fontHint = String(rFonts['@_w:hint']);
5604
+ if (rFonts['@_w:asciiTheme']) prevProps.fontAsciiTheme = String(rFonts['@_w:asciiTheme']);
5605
+ if (rFonts['@_w:hAnsiTheme']) prevProps.fontHAnsiTheme = String(rFonts['@_w:hAnsiTheme']);
5606
+ if (rFonts['@_w:eastAsiaTheme'])
5607
+ prevProps.fontEastAsiaTheme = String(rFonts['@_w:eastAsiaTheme']);
5608
+ if (rFonts['@_w:cstheme']) prevProps.fontCsTheme = String(rFonts['@_w:cstheme']);
5143
5609
  }
5144
5610
 
5145
5611
  // Parse previous size (half-points to points)
@@ -5337,17 +5803,33 @@ export class DocumentParser {
5337
5803
  }
5338
5804
  }
5339
5805
 
5340
- // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.4
5341
- // Maps to TextBorder interface: style, size, color, space
5806
+ // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.5 — full
5807
+ // CT_Border attribute set for rPrChange previous-properties fidelity.
5342
5808
  if (prevRPr['w:bdr']) {
5343
5809
  const bdrObj = prevRPr['w:bdr'];
5344
- prevProps.border = {
5345
- style: bdrObj['@_w:val'] as import('../elements/Run').TextBorderStyle,
5810
+ const tb: import('../elements/Run.js').TextBorder = {
5811
+ style: bdrObj['@_w:val'] as import('../elements/Run.js').TextBorderStyle,
5346
5812
  size: bdrObj['@_w:sz'] !== undefined ? safeParseInt(bdrObj['@_w:sz']) : undefined,
5347
5813
  space:
5348
5814
  bdrObj['@_w:space'] !== undefined ? safeParseInt(bdrObj['@_w:space']) : undefined,
5349
5815
  color: bdrObj['@_w:color'],
5350
5816
  };
5817
+ // String(...) cast: XMLParser coerces "80"/"50" hex to numbers
5818
+ // — preserve the declared string contract on the model.
5819
+ if (bdrObj['@_w:themeColor']) {
5820
+ tb.themeColor = String(
5821
+ bdrObj['@_w:themeColor']
5822
+ ) as import('../elements/Run.js').ThemeColorValue;
5823
+ }
5824
+ if (bdrObj['@_w:themeTint']) tb.themeTint = String(bdrObj['@_w:themeTint']);
5825
+ if (bdrObj['@_w:themeShade']) tb.themeShade = String(bdrObj['@_w:themeShade']);
5826
+ if (bdrObj['@_w:shadow'] !== undefined) {
5827
+ tb.shadow = parseOnOffAttribute(String(bdrObj['@_w:shadow']), true);
5828
+ }
5829
+ if (bdrObj['@_w:frame'] !== undefined) {
5830
+ tb.frame = parseOnOffAttribute(String(bdrObj['@_w:frame']), true);
5831
+ }
5832
+ prevProps.border = tb;
5351
5833
  }
5352
5834
 
5353
5835
  // Parse character shading (w:shd) per ECMA-376 Part 1 §17.3.2.32
@@ -5358,24 +5840,54 @@ export class DocumentParser {
5358
5840
  }
5359
5841
  }
5360
5842
 
5361
- // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1 §17.3.2.10
5843
+ // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1
5844
+ // §17.3.2.10 CT_EastAsianLayout. Parity-fix with the main rPr
5845
+ // parser: route the three ST_OnOff attributes through
5846
+ // parseOnOffAttribute so every literal — including "0"
5847
+ // (explicit-false override) and "off" — resolves correctly. The
5848
+ // previous truthy gate both dropped explicit-false (XMLParser
5849
+ // coerces "0" to number 0 → falsy → undefined) and wrongly
5850
+ // coerced "off" to true.
5362
5851
  if (prevRPr['w:eastAsianLayout']) {
5363
5852
  const eaObj = prevRPr['w:eastAsianLayout'];
5364
5853
  prevProps.eastAsianLayout = {
5365
5854
  id: eaObj['@_w:id'] !== undefined ? safeParseInt(eaObj['@_w:id']) : undefined,
5366
- combine: eaObj['@_w:combine']
5367
- ? parseOoxmlBoolean({ '@_w:val': eaObj['@_w:combine'] })
5368
- : undefined,
5855
+ combine:
5856
+ eaObj['@_w:combine'] !== undefined
5857
+ ? parseOnOffAttribute(String(eaObj['@_w:combine']), true)
5858
+ : undefined,
5369
5859
  combineBrackets: eaObj['@_w:combineBrackets'],
5370
- vert: eaObj['@_w:vert']
5371
- ? parseOoxmlBoolean({ '@_w:val': eaObj['@_w:vert'] })
5372
- : undefined,
5373
- vertCompress: eaObj['@_w:vertCompress']
5374
- ? parseOoxmlBoolean({ '@_w:val': eaObj['@_w:vertCompress'] })
5375
- : undefined,
5860
+ vert:
5861
+ eaObj['@_w:vert'] !== undefined
5862
+ ? parseOnOffAttribute(String(eaObj['@_w:vert']), true)
5863
+ : undefined,
5864
+ vertCompress:
5865
+ eaObj['@_w:vertCompress'] !== undefined
5866
+ ? parseOnOffAttribute(String(eaObj['@_w:vertCompress']), true)
5867
+ : undefined,
5376
5868
  };
5377
5869
  }
5378
5870
 
5871
+ // Collect w14: namespace elements from the previous rPr for
5872
+ // passthrough (Word 2010+ text effects: w14:textOutline,
5873
+ // w14:shadow, w14:reflection, w14:glow, w14:ligatures,
5874
+ // w14:numForm, w14:numSpacing, w14:cntxtAlts, w14:stylisticSets).
5875
+ // The main rPr parser already collects these and the rPrChange
5876
+ // emitter (via generateRunPropertiesXML line 3130) re-emits
5877
+ // prevProps.rawW14Properties, but the rPrChange parser never
5878
+ // captured them — so tracked changes to any w14 text effect
5879
+ // silently lost the previous state on load → save.
5880
+ const prevRawW14: string[] = [];
5881
+ for (const key of Object.keys(prevRPr)) {
5882
+ if (key.startsWith('w14:')) {
5883
+ const rawXml = this.objectToXml({ [key]: prevRPr[key] });
5884
+ if (rawXml) prevRawW14.push(rawXml);
5885
+ }
5886
+ }
5887
+ if (prevRawW14.length > 0) {
5888
+ (prevProps as { rawW14Properties?: string[] }).rawW14Properties = prevRawW14;
5889
+ }
5890
+
5379
5891
  propChange.previousProperties = prevProps;
5380
5892
  }
5381
5893
 
@@ -5446,8 +5958,15 @@ export class DocumentParser {
5446
5958
  let docPrId = 1;
5447
5959
  let hidden = false;
5448
5960
  if (docPrObj) {
5449
- name = docPrObj['@_name'] || 'image';
5450
- description = docPrObj['@_descr'] || 'Image';
5961
+ // wp:docPr @name and @descr are xsd:string per ECMA-376
5962
+ // §20.4.2.5 CT_NonVisualDrawingProps. XMLParser coerces
5963
+ // purely-numeric values ("2010") to JS numbers; cast through
5964
+ // String() so Image.name / Image.description keep the declared
5965
+ // string contract (matches the @_title handling below).
5966
+ const rawName = docPrObj['@_name'];
5967
+ name = rawName !== undefined && rawName !== null ? String(rawName) : 'image';
5968
+ const rawDescr = docPrObj['@_descr'];
5969
+ description = rawDescr !== undefined && rawDescr !== null ? String(rawDescr) : 'Image';
5451
5970
  if (docPrObj['@_title']) {
5452
5971
  title = String(docPrObj['@_title']);
5453
5972
  }
@@ -5776,7 +6295,7 @@ export class DocumentParser {
5776
6295
  );
5777
6296
 
5778
6297
  // Create image from buffer with all properties
5779
- const { Image: ImageClass } = await import('../elements/Image');
6298
+ const { Image: ImageClass } = await import('../elements/Image.js');
5780
6299
  const image = await ImageClass.create({
5781
6300
  source: imageData,
5782
6301
  width,
@@ -6137,12 +6656,36 @@ export class DocumentParser {
6137
6656
  */
6138
6657
  private parseBorderElement(borderObj: any): TableBorder | undefined {
6139
6658
  if (!borderObj) return undefined;
6659
+ // Extract the full CT_Border attribute set per ECMA-376 §17.18.2:
6660
+ // val (required) / sz / space / color / themeColor / themeTint /
6661
+ // themeShade / shadow / frame. Previously the last five were silently
6662
+ // dropped on load, so themed borders and shadow/frame flags were lost
6663
+ // on every round-trip.
6140
6664
  const border: TableBorder = {
6141
6665
  style: (borderObj['@_w:val'] || 'single') as TableBorder['style'],
6142
6666
  };
6143
6667
  if (borderObj['@_w:sz'] !== undefined) border.size = safeParseInt(borderObj['@_w:sz']);
6144
6668
  if (borderObj['@_w:space'] !== undefined) border.space = safeParseInt(borderObj['@_w:space']);
6145
- if (borderObj['@_w:color']) border.color = borderObj['@_w:color'];
6669
+ if (borderObj['@_w:color']) border.color = String(borderObj['@_w:color']);
6670
+ // String(...) cast: themeTint / themeShade are ST_UcharHexNumber
6671
+ // (2-char hex) declared as `string` on the model. XMLParser coerces
6672
+ // purely-digit hex like "80"/"50" to numbers — cast to preserve
6673
+ // the type contract.
6674
+ if (borderObj['@_w:themeColor']) {
6675
+ (border as any).themeColor = String(borderObj['@_w:themeColor']);
6676
+ }
6677
+ if (borderObj['@_w:themeTint']) {
6678
+ (border as any).themeTint = String(borderObj['@_w:themeTint']);
6679
+ }
6680
+ if (borderObj['@_w:themeShade']) {
6681
+ (border as any).themeShade = String(borderObj['@_w:themeShade']);
6682
+ }
6683
+ if (borderObj['@_w:shadow'] !== undefined) {
6684
+ (border as any).shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
6685
+ }
6686
+ if (borderObj['@_w:frame'] !== undefined) {
6687
+ (border as any).frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
6688
+ }
6146
6689
  return border;
6147
6690
  }
6148
6691
 
@@ -6188,8 +6731,10 @@ export class DocumentParser {
6188
6731
  const gridChange = TableGridChange.create(
6189
6732
  safeParseInt(changeObj['@_w:id'], 0),
6190
6733
  prevWidths,
6191
- changeObj['@_w:author'] || undefined,
6192
- changeObj['@_w:date'] ? new Date(changeObj['@_w:date']) : undefined
6734
+ changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : undefined,
6735
+ changeObj['@_w:date'] !== undefined
6736
+ ? new Date(String(changeObj['@_w:date']))
6737
+ : undefined
6193
6738
  );
6194
6739
  table.setTblGridChange(gridChange);
6195
6740
  }
@@ -6284,53 +6829,90 @@ export class DocumentParser {
6284
6829
  private parseTablePropertiesFromObject(tblPrObj: any, table: Table): void {
6285
6830
  if (!tblPrObj) return;
6286
6831
 
6287
- // Parse table style reference (w:tblStyle)
6832
+ // Parse table style reference (w:tblStyle) per ECMA-376 §17.7.4.62.
6833
+ // w:val is ST_String — cast through String() so purely-numeric
6834
+ // custom style IDs ("2025", "1", …) don't leak as JS numbers
6835
+ // through XMLParser's parseAttributeValue coercion into the
6836
+ // string-typed `formatting.style` field.
6288
6837
  if (tblPrObj['w:tblStyle']) {
6289
6838
  const styleId = tblPrObj['w:tblStyle']['@_w:val'];
6290
- if (styleId) {
6291
- table.setStyle(styleId);
6292
- }
6293
- }
6294
-
6295
- // Parse table look flags (w:tblLook) - for conditional formatting
6296
- // Supports both hex string format (w:val="04A0") and individual attributes
6839
+ if (styleId !== undefined && styleId !== null && styleId !== '') {
6840
+ table.setStyle(String(styleId));
6841
+ }
6842
+ }
6843
+
6844
+ // Parse table look flags (w:tblLook) per ECMA-376 §17.4.57 supports both
6845
+ // hex-string format (w:val="04A0") AND individual ST_OnOff attributes
6846
+ // (firstRow/lastRow/firstColumn/lastColumn/noHBand/noVBand).
6847
+ //
6848
+ // XMLParser.parseToObject runs with `parseAttributeValue: true` by default,
6849
+ // so `"1"` coerces to the number `1` and `"true"` to the boolean `true`.
6850
+ // The previous `=== '1'` strict-string comparison missed both coerced
6851
+ // forms, silently flipping every individually-set flag to OFF and
6852
+ // producing `tblLook="0000"` for every Word-authored document whose
6853
+ // tblLook used the expanded attribute syntax. Route each attribute
6854
+ // through `parseOoxmlBoolean` (attribute form) so string/number/boolean
6855
+ // representations all resolve correctly.
6297
6856
  if (tblPrObj['w:tblLook']) {
6298
6857
  const look = tblPrObj['w:tblLook'];
6299
6858
  if (look['@_w:val']) {
6300
6859
  // Hex string format
6301
6860
  table.setTblLook(look['@_w:val']);
6302
6861
  } else {
6303
- // Individual attribute format - construct hex value
6304
- // Per ECMA-376 §17.4.57: bit5=firstRow, bit6=lastRow, bit7=firstCol, bit8=lastCol, bit9=noHBand, bit10=noVBand
6862
+ // Individual attribute format construct hex value.
6863
+ // Bits per §17.4.57: firstRow=0x0020, lastRow=0x0040, firstCol=0x0080,
6864
+ // lastCol=0x0100, noHBand=0x0200, noVBand=0x0400.
6865
+ const attrIsOn = (name: string): boolean => {
6866
+ const v = look[name];
6867
+ if (v === undefined) return false;
6868
+ // parseOoxmlBoolean accepts the value wrapped as `{'@_w:val': v}` —
6869
+ // handles string "1"/"0"/"true"/"false"/"on"/"off", number 1/0,
6870
+ // and boolean true/false uniformly.
6871
+ return parseOoxmlBoolean({ '@_w:val': v });
6872
+ };
6305
6873
  let value = 0;
6306
- if (look['@_w:firstRow'] === '1') value |= 0x0020;
6307
- if (look['@_w:lastRow'] === '1') value |= 0x0040;
6308
- if (look['@_w:firstColumn'] === '1') value |= 0x0080;
6309
- if (look['@_w:lastColumn'] === '1') value |= 0x0100;
6310
- if (look['@_w:noHBand'] === '1') value |= 0x0200;
6311
- if (look['@_w:noVBand'] === '1') value |= 0x0400;
6874
+ if (attrIsOn('@_w:firstRow')) value |= 0x0020;
6875
+ if (attrIsOn('@_w:lastRow')) value |= 0x0040;
6876
+ if (attrIsOn('@_w:firstColumn')) value |= 0x0080;
6877
+ if (attrIsOn('@_w:lastColumn')) value |= 0x0100;
6878
+ if (attrIsOn('@_w:noHBand')) value |= 0x0200;
6879
+ if (attrIsOn('@_w:noVBand')) value |= 0x0400;
6312
6880
  table.setTblLook(value.toString(16).toUpperCase().padStart(4, '0'));
6313
6881
  }
6314
6882
  }
6315
6883
 
6316
- // Parse table positioning (tblpPr) - for floating tables
6884
+ // Parse table positioning (tblpPr) - for floating tables.
6885
+ // Per ECMA-376 §17.4.52 CT_TblPPr, the six numeric attributes
6886
+ // (tblpX/tblpY/leftFromText/rightFromText/topFromText/bottomFromText)
6887
+ // are ST_SignedTwipsMeasure / ST_TwipsMeasure where 0 is a valid
6888
+ // value (e.g. float table anchored exactly at the anchor point).
6889
+ // XMLParser coerces "0" to the number 0 (falsy), so the previous
6890
+ // truthy gate silently dropped zero-offset positions. Table's
6891
+ // emitter uses `!== undefined`, so the asymmetry lost zeroes on
6892
+ // round-trip. Route each numeric read through isExplicitlySet +
6893
+ // safeParseInt.
6317
6894
  if (tblPrObj['w:tblpPr']) {
6318
6895
  const tblpPr = tblPrObj['w:tblpPr'];
6319
6896
  const position: any = {};
6320
6897
 
6321
- if (tblpPr['@_w:tblpX']) position.x = parseInt(tblpPr['@_w:tblpX'], 10);
6322
- if (tblpPr['@_w:tblpY']) position.y = parseInt(tblpPr['@_w:tblpY'], 10);
6898
+ if (isExplicitlySet(tblpPr['@_w:tblpX'])) position.x = safeParseInt(tblpPr['@_w:tblpX']);
6899
+ if (isExplicitlySet(tblpPr['@_w:tblpY'])) position.y = safeParseInt(tblpPr['@_w:tblpY']);
6323
6900
  if (tblpPr['@_w:horzAnchor']) position.horizontalAnchor = tblpPr['@_w:horzAnchor'];
6324
6901
  if (tblpPr['@_w:vertAnchor']) position.verticalAnchor = tblpPr['@_w:vertAnchor'];
6325
6902
  if (tblpPr['@_w:tblpXSpec']) position.horizontalAlignment = tblpPr['@_w:tblpXSpec'];
6326
6903
  if (tblpPr['@_w:tblpYSpec']) position.verticalAlignment = tblpPr['@_w:tblpYSpec'];
6327
- if (tblpPr['@_w:leftFromText'])
6328
- position.leftFromText = parseInt(tblpPr['@_w:leftFromText'], 10);
6329
- if (tblpPr['@_w:rightFromText'])
6330
- position.rightFromText = parseInt(tblpPr['@_w:rightFromText'], 10);
6331
- if (tblpPr['@_w:topFromText']) position.topFromText = parseInt(tblpPr['@_w:topFromText'], 10);
6332
- if (tblpPr['@_w:bottomFromText'])
6333
- position.bottomFromText = parseInt(tblpPr['@_w:bottomFromText'], 10);
6904
+ if (isExplicitlySet(tblpPr['@_w:leftFromText'])) {
6905
+ position.leftFromText = safeParseInt(tblpPr['@_w:leftFromText']);
6906
+ }
6907
+ if (isExplicitlySet(tblpPr['@_w:rightFromText'])) {
6908
+ position.rightFromText = safeParseInt(tblpPr['@_w:rightFromText']);
6909
+ }
6910
+ if (isExplicitlySet(tblpPr['@_w:topFromText'])) {
6911
+ position.topFromText = safeParseInt(tblpPr['@_w:topFromText']);
6912
+ }
6913
+ if (isExplicitlySet(tblpPr['@_w:bottomFromText'])) {
6914
+ position.bottomFromText = safeParseInt(tblpPr['@_w:bottomFromText']);
6915
+ }
6334
6916
 
6335
6917
  if (Object.keys(position).length > 0) {
6336
6918
  table.setPosition(position);
@@ -6343,9 +6925,9 @@ export class DocumentParser {
6343
6925
  table.setOverlap(val === 'overlap');
6344
6926
  }
6345
6927
 
6346
- // Parse bidirectional visual layout
6928
+ // Parse bidirectional visual layout — CT_OnOff, honour w:val per ECMA-376 §17.17.4
6347
6929
  if (tblPrObj['w:bidiVisual']) {
6348
- table.setBidiVisual(true);
6930
+ table.setBidiVisual(parseOoxmlBoolean(tblPrObj['w:bidiVisual']));
6349
6931
  }
6350
6932
 
6351
6933
  // Parse table width — always set when w:tblW is present, including w:w="0" w:type="auto"
@@ -6358,24 +6940,37 @@ export class DocumentParser {
6358
6940
  table.setWidthType(widthType);
6359
6941
  }
6360
6942
 
6361
- // Parse table caption
6943
+ // Parse table caption — ST_String per §17.4.62. Cast through
6944
+ // String() so a purely-numeric caption ("42") is preserved as a
6945
+ // string in `formatting.caption` rather than a JS number.
6362
6946
  if (tblPrObj['w:tblCaption']) {
6363
6947
  const caption = tblPrObj['w:tblCaption']['@_w:val'];
6364
- if (caption) table.setCaption(caption);
6948
+ if (caption !== undefined && caption !== null && caption !== '') {
6949
+ table.setCaption(String(caption));
6950
+ }
6365
6951
  }
6366
6952
 
6367
- // Parse table description
6953
+ // Parse table description — ST_String per §17.4.63.
6368
6954
  if (tblPrObj['w:tblDescription']) {
6369
6955
  const description = tblPrObj['w:tblDescription']['@_w:val'];
6370
- if (description) table.setDescription(description);
6956
+ if (description !== undefined && description !== null && description !== '') {
6957
+ table.setDescription(String(description));
6958
+ }
6371
6959
  }
6372
6960
 
6373
- // Parse cell spacing
6961
+ // Parse table-level cell spacing (w:tblCellSpacing) per ECMA-376
6962
+ // §17.4.44 CT_TblCellSpacing. w:w is ST_MeasurementOrPercent; 0 is
6963
+ // a legal "explicit zero spacing" value (overrides any style-level
6964
+ // inherited tblCellSpacing). The emitter uses `!== undefined`, so
6965
+ // the previous `spacing > 0` gate created a parser/emitter
6966
+ // asymmetry: a tracked table-property change recording a *previous*
6967
+ // state of `<w:tblCellSpacing w:w="0" …/>` lost the override on
6968
+ // every round-trip.
6374
6969
  if (tblPrObj['w:tblCellSpacing']) {
6375
- const spacing = parseInt(tblPrObj['w:tblCellSpacing']['@_w:w'] || '0', 10);
6376
- const spacingType = tblPrObj['w:tblCellSpacing']['@_w:type'] || 'dxa';
6377
- if (spacing > 0) {
6378
- table.setCellSpacing(spacing);
6970
+ const rawW = tblPrObj['w:tblCellSpacing']['@_w:w'];
6971
+ if (isExplicitlySet(rawW)) {
6972
+ table.setCellSpacing(safeParseInt(rawW));
6973
+ const spacingType = tblPrObj['w:tblCellSpacing']['@_w:type'] || 'dxa';
6379
6974
  table.setCellSpacingType(spacingType);
6380
6975
  }
6381
6976
  }
@@ -6394,7 +6989,7 @@ export class DocumentParser {
6394
6989
  table.setIndent(indentVal);
6395
6990
  const indentType = tblPrObj['w:tblInd']['@_w:type'];
6396
6991
  if (indentType) {
6397
- table.setIndentType(indentType as import('../elements/Table').TableWidthType);
6992
+ table.setIndentType(indentType as import('../elements/Table.js').TableWidthType);
6398
6993
  }
6399
6994
  }
6400
6995
 
@@ -6460,15 +7055,26 @@ export class DocumentParser {
6460
7055
  }
6461
7056
  }
6462
7057
 
6463
- // Parse table borders (w:tblBorders) per ECMA-376 Part 1 §17.4.40
7058
+ // Parse table borders (w:tblBorders) per ECMA-376 Part 1 §17.4.40.
7059
+ // left / right have bidi-aware aliases `w:start` / `w:end` (the
7060
+ // preferred spelling in modern Word-authored documents). Prefer
7061
+ // them when present, falling back to the legacy names — the
7062
+ // internal model stores under `left` / `right`, matching the
7063
+ // emitter. Without this fallback, any table whose side borders
7064
+ // were authored with the bidi-aware form silently lost those
7065
+ // borders on every round-trip (the emitter would replace them
7066
+ // with absent w:left/w:right, and the parser would never revive
7067
+ // the w:start/w:end it dropped).
6464
7068
  if (tblPrObj['w:tblBorders']) {
6465
7069
  const bordersObj = tblPrObj['w:tblBorders'];
6466
- const borders: import('../elements/Table').TableBorders = {};
7070
+ const borders: import('../elements/Table.js').TableBorders = {};
6467
7071
 
6468
7072
  if (bordersObj['w:top']) borders.top = this.parseBorderElement(bordersObj['w:top']);
6469
7073
  if (bordersObj['w:bottom']) borders.bottom = this.parseBorderElement(bordersObj['w:bottom']);
6470
- if (bordersObj['w:left']) borders.left = this.parseBorderElement(bordersObj['w:left']);
6471
- if (bordersObj['w:right']) borders.right = this.parseBorderElement(bordersObj['w:right']);
7074
+ const leftBorder = bordersObj['w:start'] ?? bordersObj['w:left'];
7075
+ if (leftBorder) borders.left = this.parseBorderElement(leftBorder);
7076
+ const rightBorder = bordersObj['w:end'] ?? bordersObj['w:right'];
7077
+ if (rightBorder) borders.right = this.parseBorderElement(rightBorder);
6472
7078
  if (bordersObj['w:insideH'])
6473
7079
  borders.insideH = this.parseBorderElement(bordersObj['w:insideH']);
6474
7080
  if (bordersObj['w:insideV'])
@@ -6484,8 +7090,8 @@ export class DocumentParser {
6484
7090
  const changeObj = tblPrObj['w:tblPrChange'];
6485
7091
  table.setTblPrChange({
6486
7092
  id: String(changeObj['@_w:id'] || '0'),
6487
- author: changeObj['@_w:author'] || '',
6488
- date: changeObj['@_w:date'] || '',
7093
+ author: changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : '',
7094
+ date: changeObj['@_w:date'] !== undefined ? String(changeObj['@_w:date']) : '',
6489
7095
  previousProperties: this.parseGenericPreviousProperties(changeObj['w:tblPr']),
6490
7096
  });
6491
7097
  }
@@ -6562,14 +7168,20 @@ export class DocumentParser {
6562
7168
  private parseTableRowPropertiesFromObject(trPrObj: any, row: TableRow): void {
6563
7169
  if (!trPrObj) return;
6564
7170
 
6565
- // Parse row height (w:trHeight) per ECMA-376 Part 1 §17.4.81
6566
- // Per §17.18.33 (ST_HeightRule), when w:hRule is absent the default is "auto"
7171
+ // Parse row height (w:trHeight) per ECMA-376 Part 1 §17.4.81.
7172
+ // w:val is ST_TwipsMeasure; zero is a valid value and, combined
7173
+ // with w:hRule="exact", represents a hidden / collapsed row.
7174
+ // XMLParser coerces "0" to the number 0 (falsy), and the previous
7175
+ // `heightVal > 0` gate silently dropped explicit zero-height rows
7176
+ // even though the emitter (TableRow.ts §914) preserves them via
7177
+ // `!== undefined`. Route through isExplicitlySet so zero survives.
7178
+ // Per §17.18.33 (ST_HeightRule), when w:hRule is absent the
7179
+ // default is "auto".
6567
7180
  if (trPrObj['w:trHeight']) {
6568
- const heightVal = parseInt(trPrObj['w:trHeight']['@_w:val'] || '0', 10);
7181
+ const rawVal = trPrObj['w:trHeight']['@_w:val'];
6569
7182
  const heightRule = trPrObj['w:trHeight']['@_w:hRule'];
6570
- if (heightVal > 0) {
6571
- // Set height without defaulting hRule — setHeight defaults to 'atLeast'
6572
- // so we set height first, then override the rule only if explicitly present
7183
+ if (isExplicitlySet(rawVal)) {
7184
+ const heightVal = safeParseInt(rawVal);
6573
7185
  row.setHeight(heightVal);
6574
7186
  if (heightRule) {
6575
7187
  row.setHeightRule(heightRule);
@@ -6581,14 +7193,14 @@ export class DocumentParser {
6581
7193
  }
6582
7194
  }
6583
7195
 
6584
- // Parse table header row (w:tblHeader) per ECMA-376 Part 1 §17.4.49
7196
+ // Parse table header row (w:tblHeader) per ECMA-376 Part 1 §17.4.49 — CT_OnOff
6585
7197
  if (trPrObj['w:tblHeader']) {
6586
- row.setHeader(true);
7198
+ row.setHeader(parseOoxmlBoolean(trPrObj['w:tblHeader']));
6587
7199
  }
6588
7200
 
6589
- // Parse can't split (w:cantSplit) per ECMA-376 Part 1 §17.4.5
7201
+ // Parse can't split (w:cantSplit) per ECMA-376 Part 1 §17.4.5 — CT_OnOff
6590
7202
  if (trPrObj['w:cantSplit']) {
6591
- row.setCantSplit(true);
7203
+ row.setCantSplit(parseOoxmlBoolean(trPrObj['w:cantSplit']));
6592
7204
  }
6593
7205
 
6594
7206
  // Parse row justification (w:jc) per ECMA-376 Part 1 §17.4.79
@@ -6599,9 +7211,9 @@ export class DocumentParser {
6599
7211
  }
6600
7212
  }
6601
7213
 
6602
- // Parse hidden (w:hidden) per ECMA-376 Part 1 §17.4.23
7214
+ // Parse hidden (w:hidden) per ECMA-376 Part 1 §17.4.23 — CT_OnOff
6603
7215
  if (trPrObj['w:hidden']) {
6604
- row.setHidden(true);
7216
+ row.setHidden(parseOoxmlBoolean(trPrObj['w:hidden']));
6605
7217
  }
6606
7218
 
6607
7219
  // Parse grid before (w:gridBefore) per ECMA-376 Part 1 §17.4.15
@@ -6620,30 +7232,36 @@ export class DocumentParser {
6620
7232
  }
6621
7233
  }
6622
7234
 
6623
- // Parse width before (w:wBefore) per ECMA-376 Part 1 §17.4.83
7235
+ // Parse width before (w:wBefore) per ECMA-376 Part 1 §17.4.83.
7236
+ // w:w is ST_TblWidth; 0 paired with w:type="auto" is the idiomatic
7237
+ // "no width" form, and explicit 0 in dxa twips can override an
7238
+ // inherited wBefore. Previous `w > 0` gate silently dropped both.
6624
7239
  if (trPrObj['w:wBefore']) {
6625
- const w = parseInt(trPrObj['w:wBefore']['@_w:w'] || '0', 10);
6626
- const type = trPrObj['w:wBefore']['@_w:type'] || 'dxa';
6627
- if (w > 0) {
6628
- row.setWBefore(w, type);
7240
+ const rawW = trPrObj['w:wBefore']['@_w:w'];
7241
+ if (isExplicitlySet(rawW)) {
7242
+ const type = (trPrObj['w:wBefore']['@_w:type'] as string | undefined) || 'dxa';
7243
+ row.setWBefore(safeParseInt(rawW), type);
6629
7244
  }
6630
7245
  }
6631
7246
 
6632
- // Parse width after (w:wAfter) per ECMA-376 Part 1 §17.4.82
7247
+ // Parse width after (w:wAfter) per ECMA-376 Part 1 §17.4.82 — same
7248
+ // ST_TblWidth semantics as wBefore.
6633
7249
  if (trPrObj['w:wAfter']) {
6634
- const w = parseInt(trPrObj['w:wAfter']['@_w:w'] || '0', 10);
6635
- const type = trPrObj['w:wAfter']['@_w:type'] || 'dxa';
6636
- if (w > 0) {
6637
- row.setWAfter(w, type);
7250
+ const rawW = trPrObj['w:wAfter']['@_w:w'];
7251
+ if (isExplicitlySet(rawW)) {
7252
+ const type = (trPrObj['w:wAfter']['@_w:type'] as string | undefined) || 'dxa';
7253
+ row.setWAfter(safeParseInt(rawW), type);
6638
7254
  }
6639
7255
  }
6640
7256
 
6641
- // Parse row-level cell spacing (w:tblCellSpacing)
7257
+ // Parse row-level cell spacing (w:tblCellSpacing). Zero is a valid
7258
+ // override — "explicitly no extra spacing" on a row overriding a
7259
+ // non-zero table-level tblCellSpacing.
6642
7260
  if (trPrObj['w:tblCellSpacing']) {
6643
- const w = parseInt(trPrObj['w:tblCellSpacing']['@_w:w'] || '0', 10);
6644
- const type = trPrObj['w:tblCellSpacing']['@_w:type'] || 'dxa';
6645
- if (w > 0) {
6646
- row.setRowCellSpacing(w, type);
7261
+ const rawW = trPrObj['w:tblCellSpacing']['@_w:w'];
7262
+ if (isExplicitlySet(rawW)) {
7263
+ const type = (trPrObj['w:tblCellSpacing']['@_w:type'] as string | undefined) || 'dxa';
7264
+ row.setRowCellSpacing(safeParseInt(rawW), type);
6647
7265
  }
6648
7266
  }
6649
7267
 
@@ -6655,11 +7273,39 @@ export class DocumentParser {
6655
7273
  }
6656
7274
  }
6657
7275
 
6658
- // Parse divId (w:divId) per ECMA-376 Part 1 §17.4.9
7276
+ // Parse divId (w:divId) per ECMA-376 Part 1 §17.4.9. `w:val` is
7277
+ // ST_DecimalNumber; 0 is a valid reference to the first div in web
7278
+ // settings. The previous `val > 0` gate silently dropped it on load.
6659
7279
  if (trPrObj['w:divId']) {
6660
- const val = parseInt(trPrObj['w:divId']['@_w:val'] || '0', 10);
6661
- if (val > 0) {
6662
- row.setDivId(val);
7280
+ const rawVal = trPrObj['w:divId']['@_w:val'];
7281
+ if (isExplicitlySet(rawVal)) {
7282
+ const parsed = safeParseInt(rawVal);
7283
+ if (!isNaN(parsed)) row.setDivId(parsed);
7284
+ }
7285
+ }
7286
+
7287
+ // Parse tracked row insertion / deletion (CT_TrackChange inside CT_TrPr)
7288
+ // per ECMA-376 Part 1 §17.13.5.19 (ins) / §17.13.5.14 (del). These mark
7289
+ // the entire row as a tracked revision; a previous version silently
7290
+ // dropped both markers on load → save because the parser skipped them.
7291
+ if (trPrObj['w:ins']) {
7292
+ const insObj = Array.isArray(trPrObj['w:ins']) ? trPrObj['w:ins'][0] : trPrObj['w:ins'];
7293
+ if (insObj && typeof insObj === 'object') {
7294
+ row.setRowInsertion({
7295
+ id: String(insObj['@_w:id'] ?? '0'),
7296
+ author: String(insObj['@_w:author'] ?? ''),
7297
+ date: String(insObj['@_w:date'] ?? ''),
7298
+ });
7299
+ }
7300
+ }
7301
+ if (trPrObj['w:del']) {
7302
+ const delObj = Array.isArray(trPrObj['w:del']) ? trPrObj['w:del'][0] : trPrObj['w:del'];
7303
+ if (delObj && typeof delObj === 'object') {
7304
+ row.setRowDeletion({
7305
+ id: String(delObj['@_w:id'] ?? '0'),
7306
+ author: String(delObj['@_w:author'] ?? ''),
7307
+ date: String(delObj['@_w:date'] ?? ''),
7308
+ });
6663
7309
  }
6664
7310
  }
6665
7311
 
@@ -6668,8 +7314,8 @@ export class DocumentParser {
6668
7314
  const changeObj = trPrObj['w:trPrChange'];
6669
7315
  row.setTrPrChange({
6670
7316
  id: String(changeObj['@_w:id'] || '0'),
6671
- author: changeObj['@_w:author'] || '',
6672
- date: changeObj['@_w:date'] || '',
7317
+ author: changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : '',
7318
+ date: changeObj['@_w:date'] !== undefined ? String(changeObj['@_w:date']) : '',
6673
7319
  previousProperties: this.parseGenericPreviousProperties(changeObj['w:trPr']),
6674
7320
  });
6675
7321
  }
@@ -6685,11 +7331,15 @@ export class DocumentParser {
6685
7331
 
6686
7332
  const exceptions: any = {};
6687
7333
 
6688
- // Parse table width exception (w:tblW)
7334
+ // Parse table width exception (w:tblW). The `val > 0` gate previously
7335
+ // dropped both w:w="0" (explicit zero-width override, valid when
7336
+ // paired with w:type="nil"/"auto") and negative overrides. Route
7337
+ // through isExplicitlySet + safeParseInt so zero and negative widths
7338
+ // round-trip.
6689
7339
  if (tblPrExObj['w:tblW']) {
6690
- const widthVal = parseInt(tblPrExObj['w:tblW']['@_w:w'] || '0', 10);
6691
- if (widthVal > 0) {
6692
- exceptions.width = widthVal;
7340
+ const rawW = tblPrExObj['w:tblW']['@_w:w'];
7341
+ if (isExplicitlySet(rawW)) {
7342
+ exceptions.width = safeParseInt(rawW);
6693
7343
  }
6694
7344
  }
6695
7345
 
@@ -6701,19 +7351,26 @@ export class DocumentParser {
6701
7351
  }
6702
7352
  }
6703
7353
 
6704
- // Parse cell spacing exception (w:tblCellSpacing)
7354
+ // Parse cell spacing exception (w:tblCellSpacing). Zero-value
7355
+ // override is valid (= "explicit no cell spacing" on a row that
7356
+ // would otherwise inherit non-zero spacing from the table-level
7357
+ // tblCellSpacing).
6705
7358
  if (tblPrExObj['w:tblCellSpacing']) {
6706
- const val = parseInt(tblPrExObj['w:tblCellSpacing']['@_w:w'] || '0', 10);
6707
- if (val > 0) {
6708
- exceptions.cellSpacing = val;
7359
+ const rawW = tblPrExObj['w:tblCellSpacing']['@_w:w'];
7360
+ if (isExplicitlySet(rawW)) {
7361
+ exceptions.cellSpacing = safeParseInt(rawW);
6709
7362
  }
6710
7363
  }
6711
7364
 
6712
- // Parse table indentation exception (w:tblInd)
7365
+ // Parse table indentation exception (w:tblInd). Per ECMA-376
7366
+ // §17.4.62 CT_TblWidth, w:w is ST_MeasurementOrPercent — 0 is a
7367
+ // legal "reset" value and negative values indicate an outdent (table
7368
+ // hanging into the page margin). The previous `val > 0` check
7369
+ // silently dropped both.
6713
7370
  if (tblPrExObj['w:tblInd']) {
6714
- const val = parseInt(tblPrExObj['w:tblInd']['@_w:w'] || '0', 10);
6715
- if (val > 0) {
6716
- exceptions.indentation = val;
7371
+ const rawW = tblPrExObj['w:tblInd']['@_w:w'];
7372
+ if (isExplicitlySet(rawW)) {
7373
+ exceptions.indentation = safeParseInt(rawW);
6717
7374
  }
6718
7375
  }
6719
7376
 
@@ -6744,15 +7401,40 @@ export class DocumentParser {
6744
7401
  const borderNames = ['top', 'bottom', 'left', 'right', 'insideH', 'insideV'];
6745
7402
 
6746
7403
  for (const name of borderNames) {
6747
- const borderKey = `w:${name}`;
6748
- if (bordersObj[borderKey]) {
6749
- const borderObj = bordersObj[borderKey];
7404
+ // Prefer bidi-aware `w:start`/`w:end` aliases over legacy `w:left`/
7405
+ // `w:right` per ECMA-376 §17.4.40 CT_TblBorders. Modern Word-
7406
+ // authored documents emit the bidi-aware form by default; the
7407
+ // internal model stores under the legacy keys to match the emitter.
7408
+ const aliasKey = name === 'left' ? 'w:start' : name === 'right' ? 'w:end' : undefined;
7409
+ const borderObj = (aliasKey && bordersObj[aliasKey]) || bordersObj[`w:${name}`];
7410
+ if (borderObj) {
6750
7411
  borders[name] = {};
6751
7412
 
7413
+ // Full CT_Border attribute set (§17.18.2) — previously only the four
7414
+ // basic attrs were read, so tblPrEx borders lost themed-color linkage
7415
+ // on every round-trip.
6752
7416
  if (borderObj['@_w:val']) borders[name].style = borderObj['@_w:val'];
6753
7417
  if (borderObj['@_w:sz']) borders[name].size = parseInt(borderObj['@_w:sz'], 10);
6754
7418
  if (borderObj['@_w:space']) borders[name].space = parseInt(borderObj['@_w:space'], 10);
6755
- if (borderObj['@_w:color']) borders[name].color = borderObj['@_w:color'];
7419
+ if (borderObj['@_w:color']) borders[name].color = String(borderObj['@_w:color']);
7420
+ // String(...) cast: themeTint / themeShade are ST_UcharHexNumber
7421
+ // (2-char hex). XMLParser coerces purely-digit hex to numbers —
7422
+ // cast so the string contract on the model is preserved.
7423
+ if (borderObj['@_w:themeColor']) {
7424
+ borders[name].themeColor = String(borderObj['@_w:themeColor']);
7425
+ }
7426
+ if (borderObj['@_w:themeTint']) {
7427
+ borders[name].themeTint = String(borderObj['@_w:themeTint']);
7428
+ }
7429
+ if (borderObj['@_w:themeShade']) {
7430
+ borders[name].themeShade = String(borderObj['@_w:themeShade']);
7431
+ }
7432
+ if (borderObj['@_w:shadow'] !== undefined) {
7433
+ borders[name].shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
7434
+ }
7435
+ if (borderObj['@_w:frame'] !== undefined) {
7436
+ borders[name].frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
7437
+ }
6756
7438
  }
6757
7439
  }
6758
7440
 
@@ -6773,12 +7455,26 @@ export class DocumentParser {
6773
7455
  // Parse cell properties (w:tcPr) per ECMA-376 Part 1 §17.4.42
6774
7456
  const tcPr = cellObj['w:tcPr'];
6775
7457
  if (tcPr) {
6776
- // Parse cell width (w:tcW) with type per ECMA-376 Part 1 §17.4.81
7458
+ // Parse cell width (w:tcW) with type per ECMA-376 Part 1 §17.4.72
7459
+ // CT_TblWidth — w:w is ST_MeasurementOrPercent, w:type is
7460
+ // ST_TblWidth. Zero is a legal explicit override:
7461
+ // - `w:w="0" w:type="auto"` is the idiomatic "size to content"
7462
+ // form (also the default when w:tcW is absent).
7463
+ // - `w:w="0" w:type="dxa"` / `"pct"` / `"nil"` explicitly
7464
+ // override an inherited non-zero width back to zero.
7465
+ // The emitter at TableCell.ts:1353 uses `!== undefined`, so the
7466
+ // previous `widthVal > 0 || widthType === 'auto'` gate created a
7467
+ // parser/emitter asymmetry — any cell with an explicit zero
7468
+ // override in a non-auto width type silently reinherited the
7469
+ // style-level width on every round-trip.
6777
7470
  if (tcPr['w:tcW']) {
6778
- const widthVal = parseInt(tcPr['w:tcW']['@_w:w'] || '0', 10);
6779
- const widthType = tcPr['w:tcW']['@_w:type'] || 'dxa';
6780
- if (widthVal > 0 || widthType === 'auto') {
6781
- cell.setWidthType(widthVal, widthType);
7471
+ const rawW = tcPr['w:tcW']['@_w:w'];
7472
+ if (isExplicitlySet(rawW)) {
7473
+ const widthType = (tcPr['w:tcW']['@_w:type'] as string | undefined) || 'dxa';
7474
+ cell.setWidthType(
7475
+ safeParseInt(rawW),
7476
+ widthType as import('../elements/TableCell.js').CellWidthType
7477
+ );
6782
7478
  }
6783
7479
  }
6784
7480
 
@@ -6790,7 +7486,11 @@ export class DocumentParser {
6790
7486
  }
6791
7487
  }
6792
7488
 
6793
- // Parse cell borders (w:tcBorders)
7489
+ // Parse cell borders (w:tcBorders) per ECMA-376 Part 1 §17.4.66.
7490
+ // Supports both legacy LTR names (w:left / w:right) and bidi-
7491
+ // aware aliases (w:start / w:end). Prefer w:start / w:end when
7492
+ // present. Includes diagonal borders (w:tl2br / w:tr2bl) which
7493
+ // are cell-specific.
6794
7494
  if (tcPr['w:tcBorders']) {
6795
7495
  const bordersObj = tcPr['w:tcBorders'];
6796
7496
  const borders: any = {};
@@ -6798,8 +7498,10 @@ export class DocumentParser {
6798
7498
  if (bordersObj['w:top']) borders.top = this.parseBorderElement(bordersObj['w:top']);
6799
7499
  if (bordersObj['w:bottom'])
6800
7500
  borders.bottom = this.parseBorderElement(bordersObj['w:bottom']);
6801
- if (bordersObj['w:left']) borders.left = this.parseBorderElement(bordersObj['w:left']);
6802
- if (bordersObj['w:right']) borders.right = this.parseBorderElement(bordersObj['w:right']);
7501
+ const leftBorder = bordersObj['w:start'] ?? bordersObj['w:left'];
7502
+ if (leftBorder) borders.left = this.parseBorderElement(leftBorder);
7503
+ const rightBorder = bordersObj['w:end'] ?? bordersObj['w:right'];
7504
+ if (rightBorder) borders.right = this.parseBorderElement(rightBorder);
6803
7505
  if (bordersObj['w:tl2br']) borders.tl2br = this.parseBorderElement(bordersObj['w:tl2br']);
6804
7506
  if (bordersObj['w:tr2bl']) borders.tr2bl = this.parseBorderElement(bordersObj['w:tr2bl']);
6805
7507
 
@@ -6842,10 +7544,15 @@ export class DocumentParser {
6842
7544
  }
6843
7545
  }
6844
7546
 
6845
- // Parse vertical alignment (w:vAlign)
7547
+ // Parse vertical alignment (w:vAlign) per ECMA-376 §17.4.83.
7548
+ // ST_VerticalJc has four values (§17.18.101): top, center, both,
7549
+ // bottom. The previous whitelist dropped "both" silently — the
7550
+ // style-level parser accepts it, so the asymmetry truncated cell
7551
+ // vertical alignment on cells using the "both" (justified)
7552
+ // vertical alignment on load.
6846
7553
  if (tcPr['w:vAlign']) {
6847
7554
  const valign = tcPr['w:vAlign']['@_w:val'];
6848
- if (valign && (valign === 'top' || valign === 'center' || valign === 'bottom')) {
7555
+ if (valign === 'top' || valign === 'center' || valign === 'both' || valign === 'bottom') {
6849
7556
  cell.setVerticalAlignment(valign);
6850
7557
  }
6851
7558
  }
@@ -6866,14 +7573,14 @@ export class DocumentParser {
6866
7573
  }
6867
7574
  }
6868
7575
 
6869
- // Parse no wrap (w:noWrap) per ECMA-376 Part 1 §17.4.34
7576
+ // Parse no wrap (w:noWrap) per ECMA-376 Part 1 §17.4.34 — CT_OnOff
6870
7577
  if (tcPr['w:noWrap']) {
6871
- cell.setNoWrap(true);
7578
+ cell.setNoWrap(parseOoxmlBoolean(tcPr['w:noWrap']));
6872
7579
  }
6873
7580
 
6874
- // Parse hide mark (w:hideMark) per ECMA-376 Part 1 §17.4.24
7581
+ // Parse hide mark (w:hideMark) per ECMA-376 Part 1 §17.4.24 — CT_OnOff
6875
7582
  if (tcPr['w:hideMark']) {
6876
- cell.setHideMark(true);
7583
+ cell.setHideMark(parseOoxmlBoolean(tcPr['w:hideMark']));
6877
7584
  }
6878
7585
 
6879
7586
  // Parse headers (w:headers) per ECMA-376 Part 1 §17.4.26
@@ -6884,9 +7591,9 @@ export class DocumentParser {
6884
7591
  }
6885
7592
  }
6886
7593
 
6887
- // Parse fit text (w:tcFitText) per ECMA-376 Part 1 §17.4.68
7594
+ // Parse fit text (w:tcFitText) per ECMA-376 Part 1 §17.4.68 — CT_OnOff
6888
7595
  if (tcPr['w:tcFitText']) {
6889
- cell.setFitText(true);
7596
+ cell.setFitText(parseOoxmlBoolean(tcPr['w:tcFitText']));
6890
7597
  }
6891
7598
 
6892
7599
  // Parse vertical merge (w:vMerge) per ECMA-376 Part 1 §17.4.85
@@ -6913,10 +7620,11 @@ export class DocumentParser {
6913
7620
  // Parse table cell insertion marker (w:cellIns) per ECMA-376 Part 1 §17.13.5.5
6914
7621
  if (tcPr['w:cellIns']) {
6915
7622
  const cellIns = tcPr['w:cellIns'];
6916
- const id = parseInt(cellIns['@_w:id'] || '0', 10);
6917
- const author = cellIns['@_w:author'] || 'Unknown';
7623
+ const id = parseInt(String(cellIns['@_w:id'] ?? '0'), 10);
7624
+ const author =
7625
+ cellIns['@_w:author'] !== undefined ? String(cellIns['@_w:author']) : 'Unknown';
6918
7626
  const dateAttr = cellIns['@_w:date'];
6919
- const date = dateAttr ? new Date(dateAttr) : new Date();
7627
+ const date = dateAttr !== undefined ? new Date(String(dateAttr)) : new Date();
6920
7628
 
6921
7629
  const revision = new Revision({
6922
7630
  id,
@@ -6931,10 +7639,11 @@ export class DocumentParser {
6931
7639
  // Parse table cell deletion marker (w:cellDel) per ECMA-376 Part 1 §17.13.5.6
6932
7640
  if (tcPr['w:cellDel']) {
6933
7641
  const cellDel = tcPr['w:cellDel'];
6934
- const id = parseInt(cellDel['@_w:id'] || '0', 10);
6935
- const author = cellDel['@_w:author'] || 'Unknown';
7642
+ const id = parseInt(String(cellDel['@_w:id'] ?? '0'), 10);
7643
+ const author =
7644
+ cellDel['@_w:author'] !== undefined ? String(cellDel['@_w:author']) : 'Unknown';
6936
7645
  const dateAttr = cellDel['@_w:date'];
6937
- const date = dateAttr ? new Date(dateAttr) : new Date();
7646
+ const date = dateAttr !== undefined ? new Date(String(dateAttr)) : new Date();
6938
7647
 
6939
7648
  const revision = new Revision({
6940
7649
  id,
@@ -6949,10 +7658,11 @@ export class DocumentParser {
6949
7658
  // Parse table cell merge marker (w:cellMerge) per ECMA-376 Part 1 §17.13.5.4
6950
7659
  if (tcPr['w:cellMerge']) {
6951
7660
  const cellMerge = tcPr['w:cellMerge'];
6952
- const id = parseInt(cellMerge['@_w:id'] || '0', 10);
6953
- const author = cellMerge['@_w:author'] || 'Unknown';
7661
+ const id = parseInt(String(cellMerge['@_w:id'] ?? '0'), 10);
7662
+ const author =
7663
+ cellMerge['@_w:author'] !== undefined ? String(cellMerge['@_w:author']) : 'Unknown';
6954
7664
  const dateAttr = cellMerge['@_w:date'];
6955
- const date = dateAttr ? new Date(dateAttr) : new Date();
7665
+ const date = dateAttr !== undefined ? new Date(String(dateAttr)) : new Date();
6956
7666
  const vMergeAttr = cellMerge['@_w:vMerge'];
6957
7667
  const vMergeOrigAttr = cellMerge['@_w:vMergeOrig'];
6958
7668
  // ST_AnnotationVMerge uses "rest"/"cont" but API uses "restart"/"continue"
@@ -6977,8 +7687,8 @@ export class DocumentParser {
6977
7687
  const changeObj = tcPr['w:tcPrChange'];
6978
7688
  cell.setTcPrChange({
6979
7689
  id: String(changeObj['@_w:id'] || '0'),
6980
- author: changeObj['@_w:author'] || '',
6981
- date: changeObj['@_w:date'] || '',
7690
+ author: changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : '',
7691
+ date: changeObj['@_w:date'] !== undefined ? String(changeObj['@_w:date']) : '',
6982
7692
  previousProperties: this.parseGenericPreviousProperties(changeObj['w:tcPr']),
6983
7693
  });
6984
7694
  }
@@ -7226,28 +7936,40 @@ export class DocumentParser {
7226
7936
  // Parse SDT properties (sdtPr)
7227
7937
  const sdtPr = sdtObj['w:sdtPr'];
7228
7938
  if (sdtPr) {
7229
- // Parse ID
7939
+ // Parse `<w:id w:val="…"/>` per ECMA-376 §17.5.2.18. `w:val` is
7940
+ // ST_DecimalNumber (xsd:integer) — 0 is legal. XMLParser coerces
7941
+ // `"0"` to the number `0`, so the previous truthy gate silently
7942
+ // dropped w:id=0 on every load → save cycle. The emitter uses
7943
+ // `!== undefined`, creating a parser/emitter asymmetry.
7230
7944
  const idElement = sdtPr['w:id'];
7231
- if (idElement?.['@_w:val']) {
7232
- properties.id = parseInt(idElement['@_w:val'], 10);
7945
+ if (isExplicitlySet(idElement?.['@_w:val'])) {
7946
+ const parsed = safeParseInt(idElement['@_w:val']);
7947
+ if (!isNaN(parsed)) properties.id = parsed;
7233
7948
  }
7234
7949
 
7235
- // Parse tag
7950
+ // Parse `<w:tag w:val="…"/>` per ECMA-376 §17.5.2.34. `w:val`
7951
+ // is ST_String — any string is legal, including numeric-looking
7952
+ // strings like "123" that XMLParser coerces to the number 123.
7953
+ // Cast via `String(…)` so the tag round-trips as text rather
7954
+ // than leaking a JS number into a `tag?: string` field.
7236
7955
  const tagElement = sdtPr['w:tag'];
7237
- if (tagElement?.['@_w:val']) {
7238
- properties.tag = tagElement['@_w:val'];
7956
+ if (tagElement?.['@_w:val'] !== undefined) {
7957
+ properties.tag = String(tagElement['@_w:val']);
7239
7958
  }
7240
7959
 
7241
- // Parse lock
7960
+ // Parse lock — ST_Lock enum: "sdtLocked" / "contentLocked" /
7961
+ // "sdtContentLocked" / "unlocked". Always a non-numeric string,
7962
+ // so no XMLParser coercion concern; truthy check fine.
7242
7963
  const lockElement = sdtPr['w:lock'];
7243
7964
  if (lockElement?.['@_w:val']) {
7244
7965
  properties.lock = lockElement['@_w:val'];
7245
7966
  }
7246
7967
 
7247
- // Parse alias
7968
+ // Parse alias — ST_String. Same numeric-coercion concern as
7969
+ // `w:tag`; cast via `String(…)`.
7248
7970
  const aliasElement = sdtPr['w:alias'];
7249
- if (aliasElement?.['@_w:val']) {
7250
- properties.alias = aliasElement['@_w:val'];
7971
+ if (aliasElement?.['@_w:val'] !== undefined) {
7972
+ properties.alias = String(aliasElement['@_w:val']);
7251
7973
  }
7252
7974
 
7253
7975
  // Parse control type from various elements
@@ -7256,9 +7978,18 @@ export class DocumentParser {
7256
7978
  } else if (sdtPr['w:text']) {
7257
7979
  properties.controlType = 'plainText';
7258
7980
  const textElement = sdtPr['w:text'];
7981
+ // w:multiLine is an OPTIONAL ST_OnOff attribute per ECMA-376
7982
+ // §17.5.2.33 CT_SdtText. Only record a value when the source
7983
+ // actually set it — otherwise leave the field undefined so
7984
+ // the emitter (which uses `!== undefined`) preserves the
7985
+ // "attribute absent" state on round-trip. Previously the
7986
+ // parser unconditionally stored `false` for any absent
7987
+ // attribute, then the emitter wrote `w:multiLine="0"` —
7988
+ // adding spec-noise that wasn't in the source.
7989
+ const rawMultiLine = textElement?.['@_w:multiLine'];
7259
7990
  properties.plainText = {
7260
7991
  multiLine:
7261
- textElement?.['@_w:multiLine'] === '1' || textElement?.['@_w:multiLine'] === 'true',
7992
+ rawMultiLine === undefined ? undefined : parseOnOffAttribute(String(rawMultiLine)),
7262
7993
  };
7263
7994
  } else if (sdtPr['w:comboBox']) {
7264
7995
  properties.controlType = 'comboBox';
@@ -7286,14 +8017,11 @@ export class DocumentParser {
7286
8017
  } else if (sdtPr['w14:checkbox']) {
7287
8018
  properties.controlType = 'checkbox';
7288
8019
  const checkboxElement = sdtPr['w14:checkbox'];
7289
- // Handle both string and numeric values from XML parser
7290
- const checkedVal = checkboxElement?.['w14:checked']?.['@_w14:val'];
8020
+ // <w14:checked> is CT_OnOff in the Word 2010+ extension namespace.
8021
+ // Honour every ST_OnOff literal ("1"/"0"/"true"/"false"/"on"/"off")
8022
+ // and treat a bare self-closing `<w14:checked/>` as true.
7291
8023
  properties.checkbox = {
7292
- checked:
7293
- checkedVal === 1 ||
7294
- checkedVal === '1' ||
7295
- checkedVal === true ||
7296
- checkedVal === 'true',
8024
+ checked: parseOoxmlBoolean(checkboxElement?.['w14:checked'], '@_w14:val'),
7297
8025
  checkedState: String(checkboxElement?.['w14:checkedState']?.['@_w14:val'] ?? ''),
7298
8026
  uncheckedState: String(checkboxElement?.['w14:uncheckedState']?.['@_w14:val'] ?? ''),
7299
8027
  };
@@ -7343,11 +8071,10 @@ export class DocumentParser {
7343
8071
  };
7344
8072
  }
7345
8073
 
7346
- // Parse showing placeholder flag (w:showingPlcHdr)
8074
+ // Parse showing placeholder flag (w:showingPlcHdr) — CT_OnOff per ECMA-376 §17.5.2.40
7347
8075
  const showingPlcHdr = sdtPr['w:showingPlcHdr'];
7348
8076
  if (showingPlcHdr) {
7349
- const val = showingPlcHdr['@_w:val'];
7350
- properties.showingPlcHdr = val === '1' || val === 'true' || val === true;
8077
+ properties.showingPlcHdr = parseOoxmlBoolean(showingPlcHdr);
7351
8078
  }
7352
8079
  }
7353
8080
 
@@ -7887,7 +8614,25 @@ export class DocumentParser {
7887
8614
  }
7888
8615
 
7889
8616
  /**
7890
- * Helper to parse list items for combo box / dropdown
8617
+ * Helper to parse list items for combo box / dropdown per ECMA-376
8618
+ * Part 1 §17.5.2.13 CT_SdtListItem. `w:value` is required; both
8619
+ * `w:displayText` and `w:value` are ST_String so any string
8620
+ * (including the empty string) is legal.
8621
+ *
8622
+ * The previous truthy gate dropped legitimate list items whenever:
8623
+ * - `w:value="0"` / `w:value="123"` — XMLParser coerces numeric
8624
+ * strings to numbers; `0` fails the truthy check entirely, and
8625
+ * storing a raw number instead of a string breaks the `ListItem`
8626
+ * `value: string` contract downstream.
8627
+ * - `w:displayText=""` — empty displayText is legal (e.g. a
8628
+ * separator / blank choice); the gate dropped it.
8629
+ * The fix:
8630
+ * - Gate on presence (`!== undefined`), not truthiness.
8631
+ * - Coerce both attributes to `String(…)` so numeric-coerced
8632
+ * attribute values serialise back to their original textual form.
8633
+ * - Default missing `w:displayText` to the stringified `w:value`
8634
+ * (the idiomatic Word fallback when authors author list items
8635
+ * with only a value attribute).
7891
8636
  */
7892
8637
  private parseListItems(element: any): any {
7893
8638
  const items: any[] = [];
@@ -7895,17 +8640,18 @@ export class DocumentParser {
7895
8640
  const itemArray = Array.isArray(listItems) ? listItems : listItems ? [listItems] : [];
7896
8641
 
7897
8642
  for (const item of itemArray) {
7898
- if (item['@_w:displayText'] && item['@_w:value']) {
7899
- items.push({
7900
- displayText: item['@_w:displayText'],
7901
- value: item['@_w:value'],
7902
- });
7903
- }
8643
+ const rawValue = item['@_w:value'];
8644
+ if (rawValue === undefined) continue; // w:value is required by the schema
8645
+ const value = String(rawValue);
8646
+ const rawDisplay = item['@_w:displayText'];
8647
+ const displayText = rawDisplay === undefined ? value : String(rawDisplay);
8648
+ items.push({ displayText, value });
7904
8649
  }
7905
8650
 
8651
+ const rawLast = element?.['@_w:lastValue'];
7906
8652
  return {
7907
8653
  items,
7908
- lastValue: element?.['@_w:lastValue'],
8654
+ lastValue: rawLast === undefined ? undefined : String(rawLast),
7909
8655
  };
7910
8656
  }
7911
8657
 
@@ -8381,12 +9127,21 @@ export class DocumentParser {
8381
9127
  if (color) border.color = color;
8382
9128
  const space = XMLParser.extractAttribute(sideXml, 'w:space');
8383
9129
  if (space) border.space = parseInt(space.toString(), 10);
9130
+ // w:shadow and w:frame are ST_OnOff per ECMA-376 §17.17.4.
9131
+ // Use `!== undefined` gating so explicit-false survives round-trip
9132
+ // (previous code only stored `true`, silently dropping `w:shadow="0"`).
8384
9133
  const shadow = XMLParser.extractAttribute(sideXml, 'w:shadow');
8385
- if (shadow === '1' || shadow === 'true') border.shadow = true;
9134
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
8386
9135
  const frame = XMLParser.extractAttribute(sideXml, 'w:frame');
8387
- if (frame === '1' || frame === 'true') border.frame = true;
9136
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
8388
9137
  const themeColor = XMLParser.extractAttribute(sideXml, 'w:themeColor');
8389
9138
  if (themeColor) border.themeColor = themeColor;
9139
+ // Theme tint / shade per §17.18.82 — CT_TopBorder/CT_BottomBorder extend
9140
+ // CT_Border so inherit the full themed-color attribute set.
9141
+ const themeTint = XMLParser.extractAttribute(sideXml, 'w:themeTint');
9142
+ if (themeTint) border.themeTint = themeTint;
9143
+ const themeShade = XMLParser.extractAttribute(sideXml, 'w:themeShade');
9144
+ if (themeShade) border.themeShade = themeShade;
8390
9145
  const artId = XMLParser.extractAttribute(sideXml, 'w:id');
8391
9146
  if (artId) border.artId = parseInt(artId.toString(), 10);
8392
9147
  return Object.keys(border).length > 0 ? border : undefined;
@@ -8407,7 +9162,14 @@ export class DocumentParser {
8407
9162
  }
8408
9163
  }
8409
9164
 
8410
- // Parse columns (enhanced with separator and custom widths)
9165
+ // Parse columns per ECMA-376 §17.6.4 CT_Columns. Every attribute
9166
+ // (num / sep / space / equalWidth) is optional with spec-defined
9167
+ // defaults — num defaults to 1, equalWidth to true, sep to false,
9168
+ // space to 720 twips. The previous `if (num)` gate silently dropped
9169
+ // every `<w:cols>` that relied on the default num=1 (e.g. a bare
9170
+ // `<w:cols w:sep="1" w:space="720"/>` specifying a single column
9171
+ // with a separator), which is the exact form Word emits when the
9172
+ // user toggles the column separator without changing column count.
8411
9173
  const colsElements = XMLParser.extractElements(sectPr, 'w:cols');
8412
9174
  if (colsElements.length > 0) {
8413
9175
  const cols = colsElements[0];
@@ -8436,19 +9198,23 @@ export class DocumentParser {
8436
9198
  }
8437
9199
  }
8438
9200
 
8439
- // Helper to handle boolean conversion (XMLParser may return string or number)
8440
- const toBool = (val: any) => val === '1' || val === 1 || val === 'true' || val === true;
8441
-
8442
- if (num) {
8443
- sectionProps.columns = {
8444
- count: parseInt(num.toString(), 10),
8445
- space: space ? parseInt(space.toString(), 10) : undefined,
8446
- equalWidth: equalWidth ? toBool(equalWidth) : undefined,
8447
- separator: sep ? toBool(sep) : undefined,
8448
- columnWidths: columnWidths.length > 0 ? columnWidths : undefined,
8449
- columnSpaces: hasColumnSpaces ? columnSpaces : undefined,
8450
- };
8451
- }
9201
+ // Spec default for num is 1; fall back to column-count from
9202
+ // child `<w:col>` children when available (the expanded per-column
9203
+ // form), otherwise to the literal default.
9204
+ const count = num
9205
+ ? parseInt(num.toString(), 10)
9206
+ : columnWidths.length > 0
9207
+ ? columnWidths.length
9208
+ : 1;
9209
+
9210
+ sectionProps.columns = {
9211
+ count,
9212
+ space: space ? parseInt(space.toString(), 10) : undefined,
9213
+ equalWidth: equalWidth ? parseOnOffAttribute(equalWidth) : undefined,
9214
+ separator: sep ? parseOnOffAttribute(sep) : undefined,
9215
+ columnWidths: columnWidths.length > 0 ? columnWidths : undefined,
9216
+ columnSpaces: hasColumnSpaces ? columnSpaces : undefined,
9217
+ };
8452
9218
  }
8453
9219
  }
8454
9220
 
@@ -8489,9 +9255,13 @@ export class DocumentParser {
8489
9255
  }
8490
9256
  }
8491
9257
 
8492
- // Parse title page flag
8493
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:titlePg')) {
8494
- sectionProps.titlePage = true;
9258
+ // Parse title page flag (w:titlePg) — CT_OnOff per ECMA-376 §17.6.23;
9259
+ // honour w:val so an explicit `w:val="0"` override of an inherited
9260
+ // true is not silently flipped to true.
9261
+ const titlePgEls = XMLParser.extractElements(sectPr, 'w:titlePg');
9262
+ if (titlePgEls.length > 0 && titlePgEls[0]) {
9263
+ const v = XMLParser.extractAttribute(titlePgEls[0], 'w:val');
9264
+ sectionProps.titlePage = parseOnOffAttribute(v, true);
8495
9265
  }
8496
9266
 
8497
9267
  // Parse header references
@@ -8574,14 +9344,18 @@ export class DocumentParser {
8574
9344
  }
8575
9345
  }
8576
9346
 
8577
- // Parse bidi (right-to-left section layout)
8578
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:bidi')) {
8579
- sectionProps.bidi = true;
9347
+ // Parse bidi (w:bidi) — CT_OnOff per ECMA-376 §17.6.1 (RTL section)
9348
+ const bidiEls = XMLParser.extractElements(sectPr, 'w:bidi');
9349
+ if (bidiEls.length > 0 && bidiEls[0]) {
9350
+ const v = XMLParser.extractAttribute(bidiEls[0], 'w:val');
9351
+ sectionProps.bidi = parseOnOffAttribute(v, true);
8580
9352
  }
8581
9353
 
8582
- // Parse RTL gutter
8583
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:rtlGutter')) {
8584
- sectionProps.rtlGutter = true;
9354
+ // Parse RTL gutter (w:rtlGutter) — CT_OnOff per ECMA-376 §17.6.16
9355
+ const rtlGutterEls = XMLParser.extractElements(sectPr, 'w:rtlGutter');
9356
+ if (rtlGutterEls.length > 0 && rtlGutterEls[0]) {
9357
+ const v = XMLParser.extractAttribute(rtlGutterEls[0], 'w:val');
9358
+ sectionProps.rtlGutter = parseOnOffAttribute(v, true);
8585
9359
  }
8586
9360
 
8587
9361
  // Parse document grid (w:docGrid)
@@ -8659,14 +9433,18 @@ export class DocumentParser {
8659
9433
  if (Object.keys(props).length > 0) sectionProps.endnotePr = props;
8660
9434
  }
8661
9435
 
8662
- // Parse noEndnote
8663
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:noEndnote')) {
8664
- sectionProps.noEndnote = true;
9436
+ // Parse noEndnote (w:noEndnote) — CT_OnOff per ECMA-376 §17.11.14
9437
+ const noEndEls = XMLParser.extractElements(sectPr, 'w:noEndnote');
9438
+ if (noEndEls.length > 0 && noEndEls[0]) {
9439
+ const v = XMLParser.extractAttribute(noEndEls[0], 'w:val');
9440
+ sectionProps.noEndnote = parseOnOffAttribute(v, true);
8665
9441
  }
8666
9442
 
8667
- // Parse form protection
8668
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:formProt')) {
8669
- sectionProps.formProt = true;
9443
+ // Parse form protection (w:formProt) — CT_OnOff per ECMA-376 §17.6.8
9444
+ const formProtEls = XMLParser.extractElements(sectPr, 'w:formProt');
9445
+ if (formProtEls.length > 0 && formProtEls[0]) {
9446
+ const v = XMLParser.extractAttribute(formProtEls[0], 'w:val');
9447
+ sectionProps.formProt = parseOnOffAttribute(v, true);
8670
9448
  }
8671
9449
 
8672
9450
  // Parse printer settings (w:printerSettings r:id)
@@ -8789,34 +9567,45 @@ export class DocumentParser {
8789
9567
  runFormatting = this.parseRunFormattingFromXml(rPrXml);
8790
9568
  }
8791
9569
 
8792
- // Parse metadata properties (Phase 5.3)
8793
- // qFormat - Quick style gallery
8794
- const qFormat = styleXml.includes('<w:qFormat/>') || styleXml.includes('<w:qFormat ');
8795
-
8796
- // semiHidden - Hide from recommended list
8797
- const semiHidden = styleXml.includes('<w:semiHidden/>') || styleXml.includes('<w:semiHidden ');
8798
-
8799
- // unhideWhenUsed - Auto-show when applied
8800
- const unhideWhenUsed =
8801
- styleXml.includes('<w:unhideWhenUsed/>') || styleXml.includes('<w:unhideWhenUsed ');
8802
-
8803
- // locked - Prevent modification
8804
- const locked = styleXml.includes('<w:locked/>') || styleXml.includes('<w:locked ');
8805
-
8806
- // personal - User-specific style
8807
- const personal = styleXml.includes('<w:personal/>') || styleXml.includes('<w:personal ');
8808
-
8809
- // personalCompose - Style for composing new messages
8810
- const personalCompose =
8811
- styleXml.includes('<w:personalCompose/>') || styleXml.includes('<w:personalCompose ');
8812
-
8813
- // personalReply - Style for replying to messages
8814
- const personalReply =
8815
- styleXml.includes('<w:personalReply/>') || styleXml.includes('<w:personalReply ');
9570
+ // Parse metadata CT_OnOff flags per ECMA-376 §17.7.4 (OnOffType bindings).
9571
+ // Each flag honours `w:val` so an explicit `<w:qFormat w:val="0"/>` override
9572
+ // of a based-on style's qFormat=true round-trips as `false`. The old code
9573
+ // detected presence via `styleXml.includes('<w:qFormat/>')` which ignored
9574
+ // w:val entirely and flipped any explicit-false to true.
9575
+ const parseStyleOnOffFlag = (tagName: string): boolean | undefined => {
9576
+ const els = XMLParser.extractElements(styleXml, tagName);
9577
+ if (els.length === 0 || !els[0]) return undefined;
9578
+ const v = XMLParser.extractAttribute(els[0], 'w:val');
9579
+ return parseOnOffAttribute(v, true);
9580
+ };
8816
9581
 
8817
- // autoRedefine - Update style from formatting
8818
- const autoRedefine =
8819
- styleXml.includes('<w:autoRedefine/>') || styleXml.includes('<w:autoRedefine ');
9582
+ const qFormat = parseStyleOnOffFlag('w:qFormat');
9583
+ const semiHidden = parseStyleOnOffFlag('w:semiHidden');
9584
+ const unhideWhenUsed = parseStyleOnOffFlag('w:unhideWhenUsed');
9585
+ const locked = parseStyleOnOffFlag('w:locked');
9586
+ const personal = parseStyleOnOffFlag('w:personal');
9587
+ const personalCompose = parseStyleOnOffFlag('w:personalCompose');
9588
+ const personalReply = parseStyleOnOffFlag('w:personalReply');
9589
+ const autoRedefine = parseStyleOnOffFlag('w:autoRedefine');
9590
+ // `<w:hidden>` (CT_Style §17.7.4, OnOffOnlyType) — completely hide the
9591
+ // style. Previously not modeled; now round-trips as `properties.hidden`.
9592
+ const hidden = parseStyleOnOffFlag('w:hidden');
9593
+
9594
+ // `<w:rsid w:val="HEX"/>` (CT_Style §17.7.4, CT_LongHexNumber §17.18.50) —
9595
+ // revision-save ID stamp identifying the session in which this style
9596
+ // definition was last edited. Schema position: between `personalReply`
9597
+ // and `pPr`. Previously dropped entirely on parse, now preserved on
9598
+ // StyleProperties so round-trips stay faithful.
9599
+ let styleRsid: string | undefined;
9600
+ if (styleXml.includes('<w:rsid')) {
9601
+ const rsidTag = XMLParser.extractSelfClosingTag(styleXml, 'w:rsid');
9602
+ if (rsidTag) {
9603
+ const v = XMLParser.extractAttribute(`<w:rsid${rsidTag}`, 'w:val');
9604
+ if (v && v.length > 0) {
9605
+ styleRsid = v;
9606
+ }
9607
+ }
9608
+ }
8820
9609
 
8821
9610
  // uiPriority - Sort order
8822
9611
  let uiPriority: number | undefined;
@@ -8855,7 +9644,7 @@ export class DocumentParser {
8855
9644
  }
8856
9645
 
8857
9646
  // Parse table style properties (Phase 5.1)
8858
- let tableStyle: import('../formatting/Style').TableStyleProperties | undefined;
9647
+ let tableStyle: import('../formatting/Style.js').TableStyleProperties | undefined;
8859
9648
  if (typeAttr === 'table') {
8860
9649
  tableStyle = this.parseTableStyleProperties(styleXml);
8861
9650
  }
@@ -8867,21 +9656,27 @@ export class DocumentParser {
8867
9656
  type: typeAttr,
8868
9657
  basedOn,
8869
9658
  next,
8870
- isDefault: defaultAttr === '1' || defaultAttr === 'true',
8871
- customStyle: customStyleAttr === '1' || customStyleAttr === 'true',
9659
+ // w:default and w:customStyle are ST_OnOff per ECMA-376 §17.17.4
9660
+ isDefault: parseOnOffAttribute(defaultAttr),
9661
+ customStyle: parseOnOffAttribute(customStyleAttr),
8872
9662
  paragraphFormatting,
8873
9663
  numPr: styleNumPr,
8874
9664
  runFormatting,
8875
9665
  tableStyle,
8876
- // Metadata properties (Phase 5.3)
8877
- qFormat: qFormat || undefined,
8878
- semiHidden: semiHidden || undefined,
8879
- unhideWhenUsed: unhideWhenUsed || undefined,
8880
- locked: locked || undefined,
8881
- personal: personal || undefined,
8882
- personalCompose: personalCompose || undefined,
8883
- personalReply: personalReply || undefined,
8884
- autoRedefine: autoRedefine || undefined,
9666
+ // Metadata CT_OnOff flags (ECMA-376 §17.7.4). parseStyleOnOffFlag returns
9667
+ // undefined when the element is absent, or the actual boolean (true/false)
9668
+ // when present — preserve both "explicit false" (override) and "absent"
9669
+ // (inherit) faithfully through the Style properties record.
9670
+ qFormat,
9671
+ semiHidden,
9672
+ hidden,
9673
+ unhideWhenUsed,
9674
+ locked,
9675
+ personal,
9676
+ personalCompose,
9677
+ personalReply,
9678
+ rsid: styleRsid,
9679
+ autoRedefine,
8885
9680
  uiPriority,
8886
9681
  link,
8887
9682
  aliases,
@@ -8898,6 +9693,86 @@ export class DocumentParser {
8898
9693
  private parseParagraphFormattingFromXml(pPrXml: string): ParagraphFormatting {
8899
9694
  const formatting: ParagraphFormatting = {};
8900
9695
 
9696
+ // Parse framePr (text frame properties) per ECMA-376 Part 1 §17.3.1.11 —
9697
+ // CT_FramePr is a CT_PPrBase child (#5, between pageBreakBefore and
9698
+ // widowControl). Each attribute is independently optional; numeric
9699
+ // attributes (w/h/x/y/hSpace/vSpace/lines) may legitimately be zero
9700
+ // so use explicit string presence rather than truthy checks.
9701
+ const framePrTag = XMLParser.extractSelfClosingTag(pPrXml, 'w:framePr');
9702
+ if (framePrTag) {
9703
+ const fpStr = `<w:framePr${framePrTag}`;
9704
+ const frameProps: NonNullable<ParagraphFormatting['framePr']> = {};
9705
+ const wAttr = XMLParser.extractAttribute(fpStr, 'w:w');
9706
+ if (wAttr !== undefined) frameProps.w = parseInt(wAttr, 10);
9707
+ const hAttr = XMLParser.extractAttribute(fpStr, 'w:h');
9708
+ if (hAttr !== undefined) frameProps.h = parseInt(hAttr, 10);
9709
+ const hRule = XMLParser.extractAttribute(fpStr, 'w:hRule');
9710
+ if (hRule === 'auto' || hRule === 'atLeast' || hRule === 'exact') {
9711
+ frameProps.hRule = hRule;
9712
+ }
9713
+ const xAttr = XMLParser.extractAttribute(fpStr, 'w:x');
9714
+ if (xAttr !== undefined) frameProps.x = parseInt(xAttr, 10);
9715
+ const yAttr = XMLParser.extractAttribute(fpStr, 'w:y');
9716
+ if (yAttr !== undefined) frameProps.y = parseInt(yAttr, 10);
9717
+ const xAlign = XMLParser.extractAttribute(fpStr, 'w:xAlign');
9718
+ if (
9719
+ xAlign === 'left' ||
9720
+ xAlign === 'center' ||
9721
+ xAlign === 'right' ||
9722
+ xAlign === 'inside' ||
9723
+ xAlign === 'outside'
9724
+ ) {
9725
+ frameProps.xAlign = xAlign;
9726
+ }
9727
+ const yAlign = XMLParser.extractAttribute(fpStr, 'w:yAlign');
9728
+ if (
9729
+ yAlign === 'top' ||
9730
+ yAlign === 'center' ||
9731
+ yAlign === 'bottom' ||
9732
+ yAlign === 'inline' ||
9733
+ yAlign === 'inside' ||
9734
+ yAlign === 'outside'
9735
+ ) {
9736
+ frameProps.yAlign = yAlign;
9737
+ }
9738
+ const hAnchor = XMLParser.extractAttribute(fpStr, 'w:hAnchor');
9739
+ if (hAnchor === 'page' || hAnchor === 'margin' || hAnchor === 'text') {
9740
+ frameProps.hAnchor = hAnchor;
9741
+ }
9742
+ const vAnchor = XMLParser.extractAttribute(fpStr, 'w:vAnchor');
9743
+ if (vAnchor === 'page' || vAnchor === 'margin' || vAnchor === 'text') {
9744
+ frameProps.vAnchor = vAnchor;
9745
+ }
9746
+ const hSpace = XMLParser.extractAttribute(fpStr, 'w:hSpace');
9747
+ if (hSpace !== undefined) frameProps.hSpace = parseInt(hSpace, 10);
9748
+ const vSpace = XMLParser.extractAttribute(fpStr, 'w:vSpace');
9749
+ if (vSpace !== undefined) frameProps.vSpace = parseInt(vSpace, 10);
9750
+ const wrap = XMLParser.extractAttribute(fpStr, 'w:wrap');
9751
+ if (
9752
+ wrap === 'around' ||
9753
+ wrap === 'auto' ||
9754
+ wrap === 'none' ||
9755
+ wrap === 'notBeside' ||
9756
+ wrap === 'through' ||
9757
+ wrap === 'tight'
9758
+ ) {
9759
+ frameProps.wrap = wrap;
9760
+ }
9761
+ const dropCap = XMLParser.extractAttribute(fpStr, 'w:dropCap');
9762
+ if (dropCap === 'none' || dropCap === 'drop' || dropCap === 'margin') {
9763
+ frameProps.dropCap = dropCap;
9764
+ }
9765
+ const lines = XMLParser.extractAttribute(fpStr, 'w:lines');
9766
+ if (lines !== undefined) frameProps.lines = parseInt(lines, 10);
9767
+ const anchorLock = XMLParser.extractAttribute(fpStr, 'w:anchorLock');
9768
+ if (anchorLock !== undefined) {
9769
+ frameProps.anchorLock = parseOnOffAttribute(anchorLock, true);
9770
+ }
9771
+ if (Object.keys(frameProps).length > 0) {
9772
+ formatting.framePr = frameProps;
9773
+ }
9774
+ }
9775
+
8901
9776
  // Parse alignment (w:jc)
8902
9777
  const jcElement = XMLParser.extractSelfClosingTag(pPrXml, 'w:jc');
8903
9778
  if (jcElement) {
@@ -8937,15 +9812,17 @@ export class DocumentParser {
8937
9812
  lineRule: validatedLineRule,
8938
9813
  beforeLines: beforeLines ? parseInt(beforeLines, 10) : undefined,
8939
9814
  afterLines: afterLines ? parseInt(afterLines, 10) : undefined,
8940
- beforeAutospacing: beforeAutosp
8941
- ? beforeAutosp === '1' || beforeAutosp === 'true'
8942
- : undefined,
8943
- afterAutospacing: afterAutosp ? afterAutosp === '1' || afterAutosp === 'true' : undefined,
9815
+ // ST_OnOff per ECMA-376 §17.17.4 — accept 1/0/true/false/on/off
9816
+ beforeAutospacing: beforeAutosp ? parseOnOffAttribute(beforeAutosp) : undefined,
9817
+ afterAutospacing: afterAutosp ? parseOnOffAttribute(afterAutosp) : undefined,
8944
9818
  };
8945
9819
  }
8946
9820
 
8947
9821
  // Parse indentation (w:ind)
8948
- // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to w:left/w:right
9822
+ // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to
9823
+ // w:left/w:right. §17.3.1.12 also defines six CJK character-unit variants
9824
+ // (ST_DecimalNumber) — parse those alongside so styles authored in CJK
9825
+ // locales preserve their character-unit indent spec through round-trip.
8949
9826
  const indElement = XMLParser.extractSelfClosingTag(pPrXml, 'w:ind');
8950
9827
  if (indElement) {
8951
9828
  const indTag = `<w:ind${indElement}`;
@@ -8955,33 +9832,137 @@ export class DocumentParser {
8955
9832
  const right = XMLParser.extractAttribute(indTag, 'w:right');
8956
9833
  const firstLine = XMLParser.extractAttribute(indTag, 'w:firstLine');
8957
9834
  const hanging = XMLParser.extractAttribute(indTag, 'w:hanging');
9835
+ // CJK character-unit variants. startChars/endChars collapse to
9836
+ // leftChars/rightChars (same bidi-aware rule as the twips pair).
9837
+ const startChars = XMLParser.extractAttribute(indTag, 'w:startChars');
9838
+ const leftChars = XMLParser.extractAttribute(indTag, 'w:leftChars');
9839
+ const endChars = XMLParser.extractAttribute(indTag, 'w:endChars');
9840
+ const rightChars = XMLParser.extractAttribute(indTag, 'w:rightChars');
9841
+ const firstLineChars = XMLParser.extractAttribute(indTag, 'w:firstLineChars');
9842
+ const hangingChars = XMLParser.extractAttribute(indTag, 'w:hangingChars');
8958
9843
 
8959
9844
  const leftVal = start || left;
8960
9845
  const rightVal = end || right;
9846
+ const leftCharsVal = startChars || leftChars;
9847
+ const rightCharsVal = endChars || rightChars;
8961
9848
 
8962
9849
  formatting.indentation = {
8963
9850
  left: leftVal ? parseInt(leftVal, 10) : undefined,
8964
9851
  right: rightVal ? parseInt(rightVal, 10) : undefined,
8965
9852
  firstLine: firstLine ? parseInt(firstLine, 10) : undefined,
8966
9853
  hanging: hanging ? parseInt(hanging, 10) : undefined,
9854
+ leftChars: leftCharsVal ? parseInt(leftCharsVal, 10) : undefined,
9855
+ rightChars: rightCharsVal ? parseInt(rightCharsVal, 10) : undefined,
9856
+ firstLineChars: firstLineChars ? parseInt(firstLineChars, 10) : undefined,
9857
+ hangingChars: hangingChars ? parseInt(hangingChars, 10) : undefined,
8967
9858
  };
8968
9859
  }
8969
9860
 
8970
- // Parse boolean properties
8971
- if (pPrXml.includes('<w:keepNext/>') || pPrXml.includes('<w:keepNext ')) {
8972
- formatting.keepNext = true;
8973
- }
8974
- if (pPrXml.includes('<w:keepLines/>') || pPrXml.includes('<w:keepLines ')) {
8975
- formatting.keepLines = true;
9861
+ // Parse CT_OnOff boolean flags per ECMA-376 §17.17.4 / §17.3.1. The previous
9862
+ // substring-only detection (`pPrXml.includes('<w:keepNext/>') ||
9863
+ // pPrXml.includes('<w:keepNext ')`) hard-coded the flag to true whenever
9864
+ // the element appeared at all — silently flipping `<w:keepNext w:val="0"/>`
9865
+ // (explicit override) into an enabled flag. Read w:val when present and
9866
+ // honour every ST_OnOff literal (1/0/true/false/on/off).
9867
+ const parseStylePPrCtOnOff = (tagName: string): boolean | undefined => {
9868
+ // extractSelfClosingTag returns the ATTRIBUTE STRING (possibly empty)
9869
+ // when found, or `undefined` when absent. Earlier this helper checked
9870
+ // `=== null` by mistake — that let the "absent" case fall through and
9871
+ // construct a garbage tag that produced `true`, silently enabling the
9872
+ // flag on every style that didn't set it.
9873
+ const el = XMLParser.extractSelfClosingTag(pPrXml, tagName);
9874
+ if (el === undefined) return undefined;
9875
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
9876
+ if (val === undefined) return true;
9877
+ return parseOnOffAttribute(val, true);
9878
+ };
9879
+
9880
+ const keepNextVal = parseStylePPrCtOnOff('w:keepNext');
9881
+ if (keepNextVal !== undefined) formatting.keepNext = keepNextVal;
9882
+
9883
+ const keepLinesVal = parseStylePPrCtOnOff('w:keepLines');
9884
+ if (keepLinesVal !== undefined) formatting.keepLines = keepLinesVal;
9885
+
9886
+ const pageBreakBeforeVal = parseStylePPrCtOnOff('w:pageBreakBefore');
9887
+ if (pageBreakBeforeVal !== undefined) formatting.pageBreakBefore = pageBreakBeforeVal;
9888
+
9889
+ // Contextual spacing per ECMA-376 Part 1 §17.3.1.9
9890
+ // "Don't add space between paragraphs of the same style"
9891
+ const contextualSpacingVal = parseStylePPrCtOnOff('w:contextualSpacing');
9892
+ if (contextualSpacingVal !== undefined) formatting.contextualSpacing = contextualSpacingVal;
9893
+
9894
+ // Remaining CT_PPrBase CT_OnOff flags per ECMA-376 Part 1 §17.3.1.
9895
+ // The main paragraph parser handles all of these; the style-level parser
9896
+ // previously dropped them (substring matches existed only for the four
9897
+ // flags above). Any style using the explicit-false form to override a
9898
+ // based-on style's enabled flag was silently losing the override.
9899
+ const widowControlVal = parseStylePPrCtOnOff('w:widowControl');
9900
+ if (widowControlVal !== undefined) formatting.widowControl = widowControlVal;
9901
+
9902
+ const suppressLineNumbersVal = parseStylePPrCtOnOff('w:suppressLineNumbers');
9903
+ if (suppressLineNumbersVal !== undefined)
9904
+ formatting.suppressLineNumbers = suppressLineNumbersVal;
9905
+
9906
+ const bidiVal = parseStylePPrCtOnOff('w:bidi');
9907
+ if (bidiVal !== undefined) formatting.bidi = bidiVal;
9908
+
9909
+ const mirrorIndentsVal = parseStylePPrCtOnOff('w:mirrorIndents');
9910
+ if (mirrorIndentsVal !== undefined) formatting.mirrorIndents = mirrorIndentsVal;
9911
+
9912
+ const adjustRightIndVal = parseStylePPrCtOnOff('w:adjustRightInd');
9913
+ if (adjustRightIndVal !== undefined) formatting.adjustRightInd = adjustRightIndVal;
9914
+
9915
+ const suppressAutoHyphensVal = parseStylePPrCtOnOff('w:suppressAutoHyphens');
9916
+ if (suppressAutoHyphensVal !== undefined)
9917
+ formatting.suppressAutoHyphens = suppressAutoHyphensVal;
9918
+
9919
+ const kinsokuVal = parseStylePPrCtOnOff('w:kinsoku');
9920
+ if (kinsokuVal !== undefined) formatting.kinsoku = kinsokuVal;
9921
+
9922
+ const wordWrapVal = parseStylePPrCtOnOff('w:wordWrap');
9923
+ if (wordWrapVal !== undefined) formatting.wordWrap = wordWrapVal;
9924
+
9925
+ const overflowPunctVal = parseStylePPrCtOnOff('w:overflowPunct');
9926
+ if (overflowPunctVal !== undefined) formatting.overflowPunct = overflowPunctVal;
9927
+
9928
+ const topLinePunctVal = parseStylePPrCtOnOff('w:topLinePunct');
9929
+ if (topLinePunctVal !== undefined) formatting.topLinePunct = topLinePunctVal;
9930
+
9931
+ const autoSpaceDEVal = parseStylePPrCtOnOff('w:autoSpaceDE');
9932
+ if (autoSpaceDEVal !== undefined) formatting.autoSpaceDE = autoSpaceDEVal;
9933
+
9934
+ const autoSpaceDNVal = parseStylePPrCtOnOff('w:autoSpaceDN');
9935
+ if (autoSpaceDNVal !== undefined) formatting.autoSpaceDN = autoSpaceDNVal;
9936
+
9937
+ const suppressOverlapVal = parseStylePPrCtOnOff('w:suppressOverlap');
9938
+ if (suppressOverlapVal !== undefined) formatting.suppressOverlap = suppressOverlapVal;
9939
+
9940
+ // Parse `w:val`-attribute string-enum children per CT_PPrBase.
9941
+ // Position #28 textDirection (ST_TextDirection), #29 textAlignment
9942
+ // (ST_TextAlignment), #30 textboxTightWrap (ST_TextboxTightWrapType).
9943
+ // The main paragraph parser handles these; the style-level parser
9944
+ // previously dropped them because the substring scan was never
9945
+ // extended past the iteration-25 CT_OnOff helper.
9946
+ const parseStylePPrValAttr = (tagName: string): string | undefined => {
9947
+ const el = XMLParser.extractSelfClosingTag(pPrXml, tagName);
9948
+ if (el === undefined) return undefined;
9949
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
9950
+ return val === undefined ? undefined : String(val);
9951
+ };
9952
+
9953
+ const textDirectionVal = parseStylePPrValAttr('w:textDirection');
9954
+ if (textDirectionVal !== undefined) {
9955
+ formatting.textDirection = textDirectionVal as ParagraphFormatting['textDirection'];
8976
9956
  }
8977
- if (pPrXml.includes('<w:pageBreakBefore/>') || pPrXml.includes('<w:pageBreakBefore ')) {
8978
- formatting.pageBreakBefore = true;
9957
+
9958
+ const textAlignmentVal = parseStylePPrValAttr('w:textAlignment');
9959
+ if (textAlignmentVal !== undefined) {
9960
+ formatting.textAlignment = textAlignmentVal as ParagraphFormatting['textAlignment'];
8979
9961
  }
8980
9962
 
8981
- // Contextual spacing per ECMA-376 Part 1 §17.3.1.8
8982
- // "Don't add space between paragraphs of the same style"
8983
- if (pPrXml.includes('<w:contextualSpacing/>') || pPrXml.includes('<w:contextualSpacing ')) {
8984
- formatting.contextualSpacing = true;
9963
+ const textboxTightWrapVal = parseStylePPrValAttr('w:textboxTightWrap');
9964
+ if (textboxTightWrapVal !== undefined) {
9965
+ formatting.textboxTightWrap = textboxTightWrapVal as ParagraphFormatting['textboxTightWrap'];
8985
9966
  }
8986
9967
 
8987
9968
  // Parse outline level (w:outlineLvl) - used for TOC generation
@@ -8997,7 +9978,29 @@ export class DocumentParser {
8997
9978
  }
8998
9979
  }
8999
9980
 
9000
- // Parse paragraph borders (w:pBdr) per ECMA-376 Part 1 §17.3.1.24
9981
+ // Parse divId (CT_PPrBase #32, §17.3.1.10) — numeric HTML div
9982
+ // association. Previously dropped on the style parser; the main
9983
+ // paragraph parser reads it at the pPrObj level but the style pPr
9984
+ // parser used string-based extraction and skipped both divId and
9985
+ // cnfStyle below.
9986
+ const divIdVal = parseStylePPrValAttr('w:divId');
9987
+ if (divIdVal !== undefined) {
9988
+ const parsedDivId = parseInt(divIdVal, 10);
9989
+ if (!isNaN(parsedDivId)) formatting.divId = parsedDivId;
9990
+ }
9991
+
9992
+ // Parse cnfStyle (CT_PPrBase #33, §17.3.1.8) — conditional formatting
9993
+ // bitmask string (12-char 0/1 sequence, e.g. "100000000100").
9994
+ const cnfStyleVal = parseStylePPrValAttr('w:cnfStyle');
9995
+ if (cnfStyleVal !== undefined) formatting.cnfStyle = cnfStyleVal;
9996
+
9997
+ // Parse paragraph borders (w:pBdr) per ECMA-376 Part 1 §17.3.1.24.
9998
+ // Covers the full CT_Border attribute set (§17.18.2): val, sz, space,
9999
+ // color, themeColor, themeTint, themeShade, shadow, frame. The style
10000
+ // *emitter* already round-trips all nine, so any style-pBdr authored
10001
+ // by Word with themed or shadow/frame attributes was silently flattened
10002
+ // here before this fix. Shadow/frame route through parseOnOffAttribute
10003
+ // so ST_OnOff literals ("on"/"off"/"1"/"0"/"true"/"false") resolve.
9001
10004
  const pBdrXml = XMLParser.extractBetweenTags(pPrXml, '<w:pBdr>', '</w:pBdr>');
9002
10005
  if (pBdrXml) {
9003
10006
  const borders: any = {};
@@ -9011,11 +10014,21 @@ export class DocumentParser {
9011
10014
  const size = XMLParser.extractAttribute(bTag, 'w:sz');
9012
10015
  const space = XMLParser.extractAttribute(bTag, 'w:space');
9013
10016
  const color = XMLParser.extractAttribute(bTag, 'w:color');
10017
+ const themeColor = XMLParser.extractAttribute(bTag, 'w:themeColor');
10018
+ const themeTint = XMLParser.extractAttribute(bTag, 'w:themeTint');
10019
+ const themeShade = XMLParser.extractAttribute(bTag, 'w:themeShade');
10020
+ const shadow = XMLParser.extractAttribute(bTag, 'w:shadow');
10021
+ const frame = XMLParser.extractAttribute(bTag, 'w:frame');
9014
10022
  const border: any = {};
9015
10023
  if (style) border.style = style;
9016
10024
  if (size) border.size = parseInt(size, 10);
9017
10025
  if (space) border.space = parseInt(space, 10);
9018
10026
  if (color) border.color = color;
10027
+ if (themeColor) border.themeColor = themeColor;
10028
+ if (themeTint) border.themeTint = themeTint;
10029
+ if (themeShade) border.themeShade = themeShade;
10030
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
10031
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
9019
10032
  if (Object.keys(border).length > 0) borders[type] = border;
9020
10033
  }
9021
10034
  }
@@ -9062,37 +10075,113 @@ export class DocumentParser {
9062
10075
  private parseRunFormattingFromXml(rPrXml: string): RunFormatting {
9063
10076
  const formatting: RunFormatting = {};
9064
10077
 
9065
- // Parse boolean properties
9066
- if (rPrXml.includes('<w:b/>') || rPrXml.includes('<w:b ')) {
9067
- formatting.bold = true;
9068
- }
9069
- if (rPrXml.includes('<w:i/>') || rPrXml.includes('<w:i ')) {
9070
- formatting.italic = true;
9071
- }
9072
- if (rPrXml.includes('<w:strike/>') || rPrXml.includes('<w:strike ')) {
9073
- formatting.strike = true;
9074
- }
9075
- if (rPrXml.includes('<w:smallCaps/>') || rPrXml.includes('<w:smallCaps ')) {
9076
- formatting.smallCaps = true;
9077
- }
9078
- if (rPrXml.includes('<w:caps/>') || rPrXml.includes('<w:caps ')) {
9079
- formatting.allCaps = true;
9080
- }
10078
+ // CT_OnOff rPr children per ECMA-376 §17.3.2. Previously detected via
10079
+ // substring-include which hard-coded the flag to `true` whenever the
10080
+ // element appeared — silently flipping `<w:b w:val="0"/>` (explicit
10081
+ // override of a based-on style's bold) into an enabled flag, and
10082
+ // never setting the field to `false` for legitimate overrides.
10083
+ // Mirrors the pPr `parseStylePPrCtOnOff` helper introduced in
10084
+ // iteration 25 / 26.
10085
+ const parseStyleRPrCtOnOff = (tagName: string): boolean | undefined => {
10086
+ const el = XMLParser.extractSelfClosingTag(rPrXml, tagName);
10087
+ if (el === undefined) return undefined;
10088
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
10089
+ if (val === undefined) return true;
10090
+ return parseOnOffAttribute(val, true);
10091
+ };
10092
+
10093
+ const boldVal = parseStyleRPrCtOnOff('w:b');
10094
+ if (boldVal !== undefined) formatting.bold = boldVal;
9081
10095
 
9082
- // Parse underline — all attributes per ECMA-376 §17.3.2.40
10096
+ const italicVal = parseStyleRPrCtOnOff('w:i');
10097
+ if (italicVal !== undefined) formatting.italic = italicVal;
10098
+
10099
+ const strikeVal = parseStyleRPrCtOnOff('w:strike');
10100
+ if (strikeVal !== undefined) formatting.strike = strikeVal;
10101
+
10102
+ const smallCapsVal = parseStyleRPrCtOnOff('w:smallCaps');
10103
+ if (smallCapsVal !== undefined) formatting.smallCaps = smallCapsVal;
10104
+
10105
+ const allCapsVal = parseStyleRPrCtOnOff('w:caps');
10106
+ if (allCapsVal !== undefined) formatting.allCaps = allCapsVal;
10107
+
10108
+ // Extended CT_OnOff run children per ECMA-376 §17.3.2. The style-level
10109
+ // rPr parser previously dropped all of these silently, so character
10110
+ // styles setting dstrike, outline, shadow, emboss, imprint, rtl,
10111
+ // vanish, noProof, snapToGrid, specVanish, webHidden, or complex-script
10112
+ // variants (bCs / iCs / cs) lost their overrides on programmatic save.
10113
+ const boldCsVal = parseStyleRPrCtOnOff('w:bCs');
10114
+ if (boldCsVal !== undefined) formatting.complexScriptBold = boldCsVal;
10115
+
10116
+ const italicCsVal = parseStyleRPrCtOnOff('w:iCs');
10117
+ if (italicCsVal !== undefined) formatting.complexScriptItalic = italicCsVal;
10118
+
10119
+ const csVal = parseStyleRPrCtOnOff('w:cs');
10120
+ if (csVal !== undefined) formatting.complexScript = csVal;
10121
+
10122
+ const dstrikeVal = parseStyleRPrCtOnOff('w:dstrike');
10123
+ if (dstrikeVal !== undefined) formatting.dstrike = dstrikeVal;
10124
+
10125
+ const outlineVal = parseStyleRPrCtOnOff('w:outline');
10126
+ if (outlineVal !== undefined) formatting.outline = outlineVal;
10127
+
10128
+ const shadowVal = parseStyleRPrCtOnOff('w:shadow');
10129
+ if (shadowVal !== undefined) formatting.shadow = shadowVal;
10130
+
10131
+ const embossVal = parseStyleRPrCtOnOff('w:emboss');
10132
+ if (embossVal !== undefined) formatting.emboss = embossVal;
10133
+
10134
+ const imprintVal = parseStyleRPrCtOnOff('w:imprint');
10135
+ if (imprintVal !== undefined) formatting.imprint = imprintVal;
10136
+
10137
+ const rtlVal = parseStyleRPrCtOnOff('w:rtl');
10138
+ if (rtlVal !== undefined) formatting.rtl = rtlVal;
10139
+
10140
+ const vanishVal = parseStyleRPrCtOnOff('w:vanish');
10141
+ if (vanishVal !== undefined) formatting.vanish = vanishVal;
10142
+
10143
+ const noProofVal = parseStyleRPrCtOnOff('w:noProof');
10144
+ if (noProofVal !== undefined) formatting.noProof = noProofVal;
10145
+
10146
+ const snapToGridVal = parseStyleRPrCtOnOff('w:snapToGrid');
10147
+ if (snapToGridVal !== undefined) formatting.snapToGrid = snapToGridVal;
10148
+
10149
+ const specVanishVal = parseStyleRPrCtOnOff('w:specVanish');
10150
+ if (specVanishVal !== undefined) formatting.specVanish = specVanishVal;
10151
+
10152
+ const webHiddenVal = parseStyleRPrCtOnOff('w:webHidden');
10153
+ if (webHiddenVal !== undefined) formatting.webHidden = webHiddenVal;
10154
+
10155
+ // Parse underline — all attributes per ECMA-376 §17.3.2.40.
10156
+ // Whitelist covers the full ST_Underline enumeration (18 values);
10157
+ // unknown / out-of-spec values fall through to `underline = true`
10158
+ // (underline enabled with default style) to match the main parser.
9083
10159
  const uElement = XMLParser.extractSelfClosingTag(rPrXml, 'w:u');
9084
10160
  if (uElement) {
9085
10161
  const uTag = `<w:u${uElement}`;
9086
10162
  const uVal = XMLParser.extractAttribute(uTag, 'w:val');
9087
- if (
9088
- uVal === 'single' ||
9089
- uVal === 'double' ||
9090
- uVal === 'thick' ||
9091
- uVal === 'dotted' ||
9092
- uVal === 'dash' ||
9093
- uVal === 'none'
9094
- ) {
9095
- formatting.underline = uVal;
10163
+ const ST_UNDERLINE = new Set<string>([
10164
+ 'single',
10165
+ 'words',
10166
+ 'double',
10167
+ 'thick',
10168
+ 'dotted',
10169
+ 'dottedHeavy',
10170
+ 'dash',
10171
+ 'dashedHeavy',
10172
+ 'dashLong',
10173
+ 'dashLongHeavy',
10174
+ 'dotDash',
10175
+ 'dashDotHeavy',
10176
+ 'dotDotDash',
10177
+ 'dashDotDotHeavy',
10178
+ 'wave',
10179
+ 'wavyHeavy',
10180
+ 'wavyDouble',
10181
+ 'none',
10182
+ ]);
10183
+ if (uVal !== undefined && ST_UNDERLINE.has(String(uVal))) {
10184
+ formatting.underline = String(uVal) as RunFormatting['underline'];
9096
10185
  } else {
9097
10186
  formatting.underline = true;
9098
10187
  }
@@ -9100,7 +10189,8 @@ export class DocumentParser {
9100
10189
  if (uColor) formatting.underlineColor = uColor;
9101
10190
  const uThemeColor = XMLParser.extractAttribute(uTag, 'w:themeColor');
9102
10191
  if (uThemeColor) {
9103
- formatting.underlineThemeColor = uThemeColor as import('../elements/Run').ThemeColorValue;
10192
+ formatting.underlineThemeColor =
10193
+ uThemeColor as import('../elements/Run.js').ThemeColorValue;
9104
10194
  }
9105
10195
  const uThemeTint = XMLParser.extractAttribute(uTag, 'w:themeTint');
9106
10196
  if (uThemeTint) formatting.underlineThemeTint = parseInt(uThemeTint, 16);
@@ -9167,17 +10257,25 @@ export class DocumentParser {
9167
10257
  }
9168
10258
  }
9169
10259
 
9170
- // Parse color (w:color) — all attributes per ECMA-376 §17.3.2.6
10260
+ // Parse color (w:color) — all attributes per ECMA-376 §17.3.2.6 / ST_HexColor
10261
+ // per §17.18.38. `w:val="auto"` is a valid ST_HexColorAuto sentinel that
10262
+ // tells Word to use the automatic/window text color; the previous parser
10263
+ // dropped it (only storing non-auto hex values), so a style-level rPr with
10264
+ // `<w:color w:val="auto"/>` silently lost that marker on round-trip and
10265
+ // the emitter defaulted to `"000000"` — changing the rendering of any
10266
+ // style that relied on the auto fallback. Preserve the literal "auto" so
10267
+ // it survives through emission. (Matches the object-format parser path
10268
+ // for direct-run rPr at parseRunFromObject line ~5210.)
9171
10269
  const colorElement = XMLParser.extractSelfClosingTag(rPrXml, 'w:color');
9172
10270
  if (colorElement) {
9173
10271
  const colorTag = `<w:color${colorElement}`;
9174
10272
  const val = XMLParser.extractAttribute(colorTag, 'w:val');
9175
- if (val && val !== 'auto') {
10273
+ if (val) {
9176
10274
  formatting.color = val;
9177
10275
  }
9178
10276
  const themeColor = XMLParser.extractAttribute(colorTag, 'w:themeColor');
9179
10277
  if (themeColor) {
9180
- formatting.themeColor = themeColor as import('../elements/Run').ThemeColorValue;
10278
+ formatting.themeColor = themeColor as import('../elements/Run.js').ThemeColorValue;
9181
10279
  }
9182
10280
  const themeTint = XMLParser.extractAttribute(colorTag, 'w:themeTint');
9183
10281
  if (themeTint) {
@@ -9242,6 +10340,186 @@ export class DocumentParser {
9242
10340
  formatting.shading = shading;
9243
10341
  }
9244
10342
 
10343
+ // Character spacing (w:spacing §17.3.2.35, ST_SignedTwipsMeasure) —
10344
+ // previously dropped on the style parser; 0 and negative values are
10345
+ // valid per spec.
10346
+ const spacingEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:spacing');
10347
+ if (spacingEl !== undefined) {
10348
+ const val = XMLParser.extractAttribute(`<w:spacing${spacingEl}`, 'w:val');
10349
+ if (val !== undefined) {
10350
+ const n = parseInt(String(val), 10);
10351
+ if (!isNaN(n)) formatting.characterSpacing = n;
10352
+ }
10353
+ }
10354
+
10355
+ // Vertical position (w:position §17.3.2.31, ST_SignedHpsMeasure).
10356
+ // 0 = baseline (explicit reset); negative = lowered; positive = raised.
10357
+ const positionEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:position');
10358
+ if (positionEl !== undefined) {
10359
+ const val = XMLParser.extractAttribute(`<w:position${positionEl}`, 'w:val');
10360
+ if (val !== undefined) {
10361
+ const n = parseInt(String(val), 10);
10362
+ if (!isNaN(n)) formatting.position = n;
10363
+ }
10364
+ }
10365
+
10366
+ // Kerning threshold (w:kern §17.3.2.20, ST_HpsMeasure). 0 = kern at
10367
+ // every size (no minimum font-size threshold).
10368
+ const kernEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:kern');
10369
+ if (kernEl !== undefined) {
10370
+ const val = XMLParser.extractAttribute(`<w:kern${kernEl}`, 'w:val');
10371
+ if (val !== undefined) {
10372
+ const n = parseInt(String(val), 10);
10373
+ if (!isNaN(n)) formatting.kerning = n;
10374
+ }
10375
+ }
10376
+
10377
+ // Language (w:lang §17.3.2.20, CT_Language). Single val → plain string;
10378
+ // multi-script (eastAsia and/or bidi present) → LanguageConfig object.
10379
+ const langEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:lang');
10380
+ if (langEl !== undefined) {
10381
+ const langTag = `<w:lang${langEl}`;
10382
+ const val = XMLParser.extractAttribute(langTag, 'w:val');
10383
+ const eastAsia = XMLParser.extractAttribute(langTag, 'w:eastAsia');
10384
+ const bidi = XMLParser.extractAttribute(langTag, 'w:bidi');
10385
+ if (eastAsia || bidi) {
10386
+ formatting.language = {
10387
+ val: val ? String(val) : undefined,
10388
+ eastAsia: eastAsia ? String(eastAsia) : undefined,
10389
+ bidi: bidi ? String(bidi) : undefined,
10390
+ };
10391
+ } else if (val) {
10392
+ formatting.language = String(val);
10393
+ }
10394
+ }
10395
+
10396
+ // Horizontal scaling (w:w §17.3.2.43, ST_TextScale — percentage,
10397
+ // min 1 per spec, so 0 is not valid and we keep a truthy check).
10398
+ const scaleEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:w');
10399
+ if (scaleEl !== undefined) {
10400
+ const val = XMLParser.extractAttribute(`<w:w${scaleEl}`, 'w:val');
10401
+ if (val) {
10402
+ const n = parseInt(String(val), 10);
10403
+ if (!isNaN(n)) formatting.scaling = n;
10404
+ }
10405
+ }
10406
+
10407
+ // Emphasis mark (w:em §17.3.2.13, ST_Em — "dot"/"comma"/"circle"/
10408
+ // "underDot"/"none"). Commonly paired with East Asian typography.
10409
+ const emEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:em');
10410
+ if (emEl !== undefined) {
10411
+ const val = XMLParser.extractAttribute(`<w:em${emEl}`, 'w:val');
10412
+ if (val) {
10413
+ formatting.emphasis = String(val) as RunFormatting['emphasis'];
10414
+ }
10415
+ }
10416
+
10417
+ // Animated text effect (w:effect §17.3.2.12, ST_TextEffect —
10418
+ // "blinkBackground"/"lights"/"antsBlack"/"antsRed"/"shimmer"/"sparkle"/
10419
+ // "none"). Legacy feature but still valid per schema.
10420
+ const effectEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:effect');
10421
+ if (effectEl !== undefined) {
10422
+ const val = XMLParser.extractAttribute(`<w:effect${effectEl}`, 'w:val');
10423
+ if (val) {
10424
+ formatting.effect = String(val) as RunFormatting['effect'];
10425
+ }
10426
+ }
10427
+
10428
+ // Text border (w:bdr §17.3.2.5) — character/run border. Full CT_Border
10429
+ // attribute set (§17.18.2): val / sz / space / color / themeColor /
10430
+ // themeTint / themeShade / shadow / frame.
10431
+ const bdrEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:bdr');
10432
+ if (bdrEl !== undefined) {
10433
+ const bdrTag = `<w:bdr${bdrEl}`;
10434
+ const border: {
10435
+ style?: string;
10436
+ size?: number;
10437
+ color?: string;
10438
+ space?: number;
10439
+ themeColor?: string;
10440
+ themeTint?: string;
10441
+ themeShade?: string;
10442
+ shadow?: boolean;
10443
+ frame?: boolean;
10444
+ } = {};
10445
+ const val = XMLParser.extractAttribute(bdrTag, 'w:val');
10446
+ if (val) border.style = String(val);
10447
+ const sz = XMLParser.extractAttribute(bdrTag, 'w:sz');
10448
+ if (sz !== undefined) {
10449
+ const n = parseInt(String(sz), 10);
10450
+ if (!isNaN(n)) border.size = n;
10451
+ }
10452
+ const color = XMLParser.extractAttribute(bdrTag, 'w:color');
10453
+ if (color) border.color = String(color);
10454
+ const space = XMLParser.extractAttribute(bdrTag, 'w:space');
10455
+ if (space !== undefined) {
10456
+ const n = parseInt(String(space), 10);
10457
+ if (!isNaN(n)) border.space = n;
10458
+ }
10459
+ const themeColor = XMLParser.extractAttribute(bdrTag, 'w:themeColor');
10460
+ if (themeColor) border.themeColor = String(themeColor);
10461
+ const themeTint = XMLParser.extractAttribute(bdrTag, 'w:themeTint');
10462
+ if (themeTint) border.themeTint = String(themeTint);
10463
+ const themeShade = XMLParser.extractAttribute(bdrTag, 'w:themeShade');
10464
+ if (themeShade) border.themeShade = String(themeShade);
10465
+ const shadow = XMLParser.extractAttribute(bdrTag, 'w:shadow');
10466
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
10467
+ const frame = XMLParser.extractAttribute(bdrTag, 'w:frame');
10468
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
10469
+ if (Object.keys(border).length > 0) {
10470
+ formatting.border = border as RunFormatting['border'];
10471
+ }
10472
+ }
10473
+
10474
+ // Manual run width (w:fitText §17.3.2.15). Value is twips; 0 is
10475
+ // technically representable as "explicit zero" — use `!== undefined`.
10476
+ const fitTextEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:fitText');
10477
+ if (fitTextEl !== undefined) {
10478
+ const val = XMLParser.extractAttribute(`<w:fitText${fitTextEl}`, 'w:val');
10479
+ if (val !== undefined) {
10480
+ const n = parseInt(String(val), 10);
10481
+ if (!isNaN(n)) formatting.fitText = n;
10482
+ }
10483
+ }
10484
+
10485
+ // East Asian layout (w:eastAsianLayout §17.3.2.10) — combined
10486
+ // characters / vertical text / compression attributes.
10487
+ const ealEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:eastAsianLayout');
10488
+ if (ealEl !== undefined) {
10489
+ const ealTag = `<w:eastAsianLayout${ealEl}`;
10490
+ const layout: Partial<{
10491
+ id: number;
10492
+ vert: boolean;
10493
+ vertCompress: boolean;
10494
+ combine: boolean;
10495
+ combineBrackets: 'none' | 'round' | 'square' | 'angle' | 'curly';
10496
+ }> = {};
10497
+ const id = XMLParser.extractAttribute(ealTag, 'w:id');
10498
+ if (id !== undefined) {
10499
+ const n = Number(id);
10500
+ if (!isNaN(n)) layout.id = n;
10501
+ }
10502
+ const vert = XMLParser.extractAttribute(ealTag, 'w:vert');
10503
+ if (vert !== undefined && parseOnOffAttribute(vert, true)) layout.vert = true;
10504
+ const vertCompress = XMLParser.extractAttribute(ealTag, 'w:vertCompress');
10505
+ if (vertCompress !== undefined && parseOnOffAttribute(vertCompress, true))
10506
+ layout.vertCompress = true;
10507
+ const combine = XMLParser.extractAttribute(ealTag, 'w:combine');
10508
+ if (combine !== undefined && parseOnOffAttribute(combine, true)) layout.combine = true;
10509
+ const combineBrackets = XMLParser.extractAttribute(ealTag, 'w:combineBrackets');
10510
+ if (combineBrackets) {
10511
+ layout.combineBrackets = String(combineBrackets) as
10512
+ | 'none'
10513
+ | 'round'
10514
+ | 'square'
10515
+ | 'angle'
10516
+ | 'curly';
10517
+ }
10518
+ if (Object.keys(layout).length > 0) {
10519
+ formatting.eastAsianLayout = layout as RunFormatting['eastAsianLayout'];
10520
+ }
10521
+ }
10522
+
9245
10523
  return formatting;
9246
10524
  }
9247
10525
 
@@ -9252,8 +10530,8 @@ export class DocumentParser {
9252
10530
  */
9253
10531
  private parseTableStyleProperties(
9254
10532
  styleXml: string
9255
- ): import('../formatting/Style').TableStyleProperties {
9256
- const tableStyle: import('../formatting/Style').TableStyleProperties = {};
10533
+ ): import('../formatting/Style.js').TableStyleProperties {
10534
+ const tableStyle: import('../formatting/Style.js').TableStyleProperties = {};
9257
10535
 
9258
10536
  // Parse tblPr (table properties)
9259
10537
  const tblPrXml = XMLParser.extractBetweenTags(styleXml, '<w:tblPr>', '</w:tblPr>');
@@ -9306,8 +10584,8 @@ export class DocumentParser {
9306
10584
  */
9307
10585
  private parseTableFormattingFromXml(
9308
10586
  tblPrXml: string
9309
- ): import('../formatting/Style').TableStyleFormatting {
9310
- const formatting: import('../formatting/Style').TableStyleFormatting = {};
10587
+ ): import('../formatting/Style.js').TableStyleFormatting {
10588
+ const formatting: import('../formatting/Style.js').TableStyleFormatting = {};
9311
10589
 
9312
10590
  // Parse indent (w:tblInd) — preserve w:type per ECMA-376 ST_TblWidth
9313
10591
  if (tblPrXml.includes('<w:tblInd')) {
@@ -9320,18 +10598,27 @@ export class DocumentParser {
9320
10598
  }
9321
10599
  const type = XMLParser.extractAttribute(tblIndTag, 'w:type');
9322
10600
  if (type) {
9323
- formatting.indentType = type as import('../elements/Table').TableWidthType;
10601
+ formatting.indentType = type as import('../elements/Table.js').TableWidthType;
9324
10602
  }
9325
10603
  }
9326
10604
  }
9327
10605
 
9328
- // Parse alignment
10606
+ // Parse alignment — ST_JcTable has 5 values (start, end, center, left,
10607
+ // right) per ECMA-376 §17.18.45. The whitelist previously only accepted
10608
+ // the three legacy LTR-centric values, silently dropping `start` / `end`
10609
+ // (the bidi-aware defaults a modern authoring tool emits).
9329
10610
  if (tblPrXml.includes('<w:jc')) {
9330
10611
  const tag = XMLParser.extractSelfClosingTag(tblPrXml, 'w:jc');
9331
10612
  if (tag) {
9332
10613
  const val = XMLParser.extractAttribute(`<w:jc${tag}`, 'w:val');
9333
- if (val === 'left' || val === 'center' || val === 'right') {
9334
- formatting.alignment = val;
10614
+ if (
10615
+ val === 'left' ||
10616
+ val === 'center' ||
10617
+ val === 'right' ||
10618
+ val === 'start' ||
10619
+ val === 'end'
10620
+ ) {
10621
+ formatting.alignment = val as import('../formatting/Style.js').TableAlignment;
9335
10622
  }
9336
10623
  }
9337
10624
  }
@@ -9372,8 +10659,8 @@ export class DocumentParser {
9372
10659
  */
9373
10660
  private parseTableCellFormattingFromXml(
9374
10661
  tcPrXml: string
9375
- ): import('../formatting/Style').TableCellStyleFormatting {
9376
- const formatting: import('../formatting/Style').TableCellStyleFormatting = {};
10662
+ ): import('../formatting/Style.js').TableCellStyleFormatting {
10663
+ const formatting: import('../formatting/Style.js').TableCellStyleFormatting = {};
9377
10664
 
9378
10665
  // Parse borders
9379
10666
  const bordersXml = XMLParser.extractBetweenTags(tcPrXml, '<w:tcBorders>', '</w:tcBorders>');
@@ -9381,7 +10668,7 @@ export class DocumentParser {
9381
10668
  formatting.borders = this.parseBordersFromXml(
9382
10669
  bordersXml,
9383
10670
  true
9384
- ) as import('../formatting/Style').CellBorders;
10671
+ ) as import('../formatting/Style.js').CellBorders;
9385
10672
  }
9386
10673
 
9387
10674
  // Parse shading
@@ -9395,12 +10682,15 @@ export class DocumentParser {
9395
10682
  formatting.margins = this.parseCellMarginsFromXml(marginXml);
9396
10683
  }
9397
10684
 
9398
- // Parse vertical alignment
10685
+ // Parse vertical alignment — ST_VerticalJc has four values
10686
+ // (top / center / both / bottom) per ECMA-376 §17.18.101. Previously
10687
+ // the whitelist only accepted the first three, silently dropping
10688
+ // `<w:vAlign w:val="both"/>` on cell styles.
9399
10689
  if (tcPrXml.includes('<w:vAlign')) {
9400
10690
  const tag = XMLParser.extractSelfClosingTag(tcPrXml, 'w:vAlign');
9401
10691
  if (tag) {
9402
10692
  const val = XMLParser.extractAttribute(`<w:vAlign${tag}`, 'w:val');
9403
- if (val === 'top' || val === 'center' || val === 'bottom') {
10693
+ if (val === 'top' || val === 'center' || val === 'both' || val === 'bottom') {
9404
10694
  formatting.verticalAlignment = val;
9405
10695
  }
9406
10696
  }
@@ -9414,8 +10704,8 @@ export class DocumentParser {
9414
10704
  */
9415
10705
  private parseTableRowFormattingFromXml(
9416
10706
  trPrXml: string
9417
- ): import('../formatting/Style').TableRowStyleFormatting {
9418
- const formatting: import('../formatting/Style').TableRowStyleFormatting = {};
10707
+ ): import('../formatting/Style.js').TableRowStyleFormatting {
10708
+ const formatting: import('../formatting/Style.js').TableRowStyleFormatting = {};
9419
10709
 
9420
10710
  // Parse height
9421
10711
  if (trPrXml.includes('<w:trHeight')) {
@@ -9432,15 +10722,25 @@ export class DocumentParser {
9432
10722
  }
9433
10723
  }
9434
10724
 
9435
- // Parse cantSplit
9436
- if (trPrXml.includes('<w:cantSplit/>') || trPrXml.includes('<w:cantSplit ')) {
9437
- formatting.cantSplit = true;
9438
- }
10725
+ // Parse cantSplit / tblHeader — both OnOffOnlyType (§17.4.6, §17.4.50).
10726
+ // Previous substring-include detection hard-coded the flag to `true`
10727
+ // whenever the element appeared, silently flipping an explicit-off
10728
+ // override (e.g., a tblStylePr conditional un-splitting a header row)
10729
+ // into an enabled flag. Reuse parseOnOffAttribute so bare, "on", and
10730
+ // "off" all map correctly, and so absent stays undefined.
10731
+ const parseTrPrOnOffOnly = (tagName: string): boolean | undefined => {
10732
+ const el = XMLParser.extractSelfClosingTag(trPrXml, tagName);
10733
+ if (el === undefined) return undefined;
10734
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
10735
+ if (val === undefined) return true;
10736
+ return parseOnOffAttribute(val, true);
10737
+ };
9439
10738
 
9440
- // Parse tblHeader (isHeader)
9441
- if (trPrXml.includes('<w:tblHeader/>') || trPrXml.includes('<w:tblHeader ')) {
9442
- formatting.isHeader = true;
9443
- }
10739
+ const cantSplitVal = parseTrPrOnOffOnly('w:cantSplit');
10740
+ if (cantSplitVal !== undefined) formatting.cantSplit = cantSplitVal;
10741
+
10742
+ const tblHeaderVal = parseTrPrOnOffOnly('w:tblHeader');
10743
+ if (tblHeaderVal !== undefined) formatting.isHeader = tblHeaderVal;
9444
10744
 
9445
10745
  return formatting;
9446
10746
  }
@@ -9450,8 +10750,8 @@ export class DocumentParser {
9450
10750
  */
9451
10751
  private parseConditionalFormattingFromXml(
9452
10752
  styleXml: string
9453
- ): import('../formatting/Style').ConditionalTableFormatting[] | undefined {
9454
- const conditionalFormatting: import('../formatting/Style').ConditionalTableFormatting[] = [];
10753
+ ): import('../formatting/Style.js').ConditionalTableFormatting[] | undefined {
10754
+ const conditionalFormatting: import('../formatting/Style.js').ConditionalTableFormatting[] = [];
9455
10755
 
9456
10756
  // Find all tblStylePr elements
9457
10757
  let searchFrom = 0;
@@ -9467,8 +10767,8 @@ export class DocumentParser {
9467
10767
  // Extract type attribute
9468
10768
  const typeAttr = XMLParser.extractAttribute(tblStylePrXml, 'w:type');
9469
10769
  if (typeAttr) {
9470
- const conditional: import('../formatting/Style').ConditionalTableFormatting = {
9471
- type: typeAttr as import('../formatting/Style').ConditionalFormattingType,
10770
+ const conditional: import('../formatting/Style.js').ConditionalTableFormatting = {
10771
+ type: typeAttr as import('../formatting/Style.js').ConditionalFormattingType,
9472
10772
  };
9473
10773
 
9474
10774
  // Parse pPr
@@ -9518,29 +10818,58 @@ export class DocumentParser {
9518
10818
  private parseBordersFromXml(
9519
10819
  bordersXml: string,
9520
10820
  includeDiagonals: boolean
9521
- ): import('../formatting/Style').TableBorders | import('../formatting/Style').CellBorders {
10821
+ ): import('../formatting/Style.js').TableBorders | import('../formatting/Style.js').CellBorders {
9522
10822
  const borders: any = {};
9523
10823
 
10824
+ // Local helper so both the main-side loop and the diagonal loop share
10825
+ // the full CT_Border attribute set (§17.18.2): val / sz / space / color
10826
+ // / themeColor / themeTint / themeShade / shadow / frame. Previously
10827
+ // this parser only extracted the four "basic" attrs, so themed borders
10828
+ // and shadow/frame flags on page/table/cell borders were silently
10829
+ // dropped on every load → save round-trip.
10830
+ const parseBorderAttrs = (
10831
+ type: string
10832
+ ): import('../formatting/Style.js').BorderProperties | null => {
10833
+ const tag = XMLParser.extractSelfClosingTag(bordersXml, `w:${type}`);
10834
+ if (!tag) return null;
10835
+ const ref = `<w:${type}${tag}`;
10836
+ const border: import('../formatting/Style.js').BorderProperties = {};
10837
+ const style = XMLParser.extractAttribute(ref, 'w:val');
10838
+ const size = XMLParser.extractAttribute(ref, 'w:sz');
10839
+ const space = XMLParser.extractAttribute(ref, 'w:space');
10840
+ const color = XMLParser.extractAttribute(ref, 'w:color');
10841
+ const themeColor = XMLParser.extractAttribute(ref, 'w:themeColor');
10842
+ const themeTint = XMLParser.extractAttribute(ref, 'w:themeTint');
10843
+ const themeShade = XMLParser.extractAttribute(ref, 'w:themeShade');
10844
+ const shadow = XMLParser.extractAttribute(ref, 'w:shadow');
10845
+ const frame = XMLParser.extractAttribute(ref, 'w:frame');
10846
+ if (style) border.style = style as any;
10847
+ if (size) border.size = parseInt(size, 10);
10848
+ if (space) border.space = parseInt(space, 10);
10849
+ if (color) border.color = color;
10850
+ if (themeColor) (border as any).themeColor = themeColor;
10851
+ if (themeTint) (border as any).themeTint = themeTint;
10852
+ if (themeShade) (border as any).themeShade = themeShade;
10853
+ if (shadow !== undefined) (border as any).shadow = parseOnOffAttribute(shadow, true);
10854
+ if (frame !== undefined) (border as any).frame = parseOnOffAttribute(frame, true);
10855
+ return Object.keys(border).length > 0 ? border : null;
10856
+ };
10857
+
10858
+ // Per ECMA-376 §17.4.40 CT_TblBorders and §17.4.66 CT_TcBorders the
10859
+ // left / right borders have bidi-aware aliases `w:start` / `w:end`.
10860
+ // Modern authoring tools (Word 2013+, Google Docs) emit the bidi-
10861
+ // aware form by default — prefer those over the legacy `w:left` /
10862
+ // `w:right` so bidi-authored tables round-trip their side borders
10863
+ // (the internal model stores under the left/right keys, matching
10864
+ // the emitter).
9524
10865
  const borderTypes = ['top', 'bottom', 'left', 'right', 'insideH', 'insideV'];
9525
10866
  for (const type of borderTypes) {
9526
- if (bordersXml.includes(`<w:${type}`)) {
9527
- const tag = XMLParser.extractSelfClosingTag(bordersXml, `w:${type}`);
9528
- if (tag) {
9529
- const style = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:val');
9530
- const size = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:sz');
9531
- const space = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:space');
9532
- const color = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:color');
9533
-
9534
- const border: import('../formatting/Style').BorderProperties = {};
9535
- if (style) border.style = style as any;
9536
- if (size) border.size = parseInt(size, 10);
9537
- if (space) border.space = parseInt(space, 10);
9538
- if (color) border.color = color;
9539
-
9540
- if (Object.keys(border).length > 0) {
9541
- borders[type] = border;
9542
- }
9543
- }
10867
+ // For left/right: prefer bidi-aware start/end alias if present.
10868
+ const alias = type === 'left' ? 'start' : type === 'right' ? 'end' : type;
10869
+ const tagNameToRead = bordersXml.includes(`<w:${alias}`) ? alias : type;
10870
+ if (bordersXml.includes(`<w:${tagNameToRead}`)) {
10871
+ const border = parseBorderAttrs(tagNameToRead);
10872
+ if (border) borders[type] = border;
9544
10873
  }
9545
10874
  }
9546
10875
 
@@ -9549,23 +10878,8 @@ export class DocumentParser {
9549
10878
  const diagonalTypes = ['tl2br', 'tr2bl'];
9550
10879
  for (const type of diagonalTypes) {
9551
10880
  if (bordersXml.includes(`<w:${type}`)) {
9552
- const tag = XMLParser.extractSelfClosingTag(bordersXml, `w:${type}`);
9553
- if (tag) {
9554
- const style = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:val');
9555
- const size = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:sz');
9556
- const space = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:space');
9557
- const color = XMLParser.extractAttribute(`<w:${type}${tag}`, 'w:color');
9558
-
9559
- const border: import('../formatting/Style').BorderProperties = {};
9560
- if (style) border.style = style as any;
9561
- if (size) border.size = parseInt(size, 10);
9562
- if (space) border.space = parseInt(space, 10);
9563
- if (color) border.color = color;
9564
-
9565
- if (Object.keys(border).length > 0) {
9566
- borders[type] = border;
9567
- }
9568
- }
10881
+ const border = parseBorderAttrs(type);
10882
+ if (border) borders[type] = border;
9569
10883
  }
9570
10884
  }
9571
10885
  }
@@ -9578,16 +10892,25 @@ export class DocumentParser {
9578
10892
  * Extracts all 9 ECMA-376 shading attributes including theme colors.
9579
10893
  */
9580
10894
  private parseShadingFromObj(shd: any): ShadingConfig | undefined {
10895
+ // Per ECMA-376 §17.3.1.32 CT_Shd, every string-typed attribute
10896
+ // (ST_UcharHexNumber tint/shade, ST_ThemeColor theme refs,
10897
+ // ST_HexColor fill/color, ST_Shd pattern) can be purely numeric in
10898
+ // hex form — "80", "00", "FF", "80000000", etc. XMLParser's
10899
+ // `parseAttributeValue: true` coerces purely-digit hex strings like
10900
+ // "80" to the JS number 80, violating the `string` type on
10901
+ // ShadingConfig. Previously stored values leaked downstream as
10902
+ // numbers (e.g. `.toUpperCase()` would throw); cast every attribute
10903
+ // through `String(...)` so the declared-type contract holds.
9581
10904
  const shading: ShadingConfig = {};
9582
- if (shd['@_w:val']) shading.pattern = shd['@_w:val'];
9583
- if (shd['@_w:fill']) shading.fill = shd['@_w:fill'];
9584
- if (shd['@_w:color']) shading.color = shd['@_w:color'];
9585
- if (shd['@_w:themeFill']) shading.themeFill = shd['@_w:themeFill'];
9586
- if (shd['@_w:themeColor']) shading.themeColor = shd['@_w:themeColor'];
9587
- if (shd['@_w:themeFillTint']) shading.themeFillTint = shd['@_w:themeFillTint'];
9588
- if (shd['@_w:themeFillShade']) shading.themeFillShade = shd['@_w:themeFillShade'];
9589
- if (shd['@_w:themeTint']) shading.themeTint = shd['@_w:themeTint'];
9590
- if (shd['@_w:themeShade']) shading.themeShade = shd['@_w:themeShade'];
10905
+ if (shd['@_w:val']) shading.pattern = String(shd['@_w:val']) as ShadingConfig['pattern'];
10906
+ if (shd['@_w:fill']) shading.fill = String(shd['@_w:fill']);
10907
+ if (shd['@_w:color']) shading.color = String(shd['@_w:color']);
10908
+ if (shd['@_w:themeFill']) shading.themeFill = String(shd['@_w:themeFill']);
10909
+ if (shd['@_w:themeColor']) shading.themeColor = String(shd['@_w:themeColor']);
10910
+ if (shd['@_w:themeFillTint']) shading.themeFillTint = String(shd['@_w:themeFillTint']);
10911
+ if (shd['@_w:themeFillShade']) shading.themeFillShade = String(shd['@_w:themeFillShade']);
10912
+ if (shd['@_w:themeTint']) shading.themeTint = String(shd['@_w:themeTint']);
10913
+ if (shd['@_w:themeShade']) shading.themeShade = String(shd['@_w:themeShade']);
9591
10914
  return Object.keys(shading).length > 0 ? shading : undefined;
9592
10915
  }
9593
10916
 
@@ -9628,8 +10951,8 @@ export class DocumentParser {
9628
10951
  */
9629
10952
  private parseCellMarginsFromXml(
9630
10953
  marginXml: string
9631
- ): import('../formatting/Style').CellMargins | undefined {
9632
- const margins: import('../formatting/Style').CellMargins = {};
10954
+ ): import('../formatting/Style.js').CellMargins | undefined {
10955
+ const margins: import('../formatting/Style.js').CellMargins = {};
9633
10956
 
9634
10957
  // Parse top and bottom directly
9635
10958
  for (const type of ['top', 'bottom'] as const) {
@@ -9894,23 +11217,23 @@ export class DocumentParser {
9894
11217
  imageManager: ImageManager
9895
11218
  ): Promise<{
9896
11219
  headers: {
9897
- header: import('../elements/Header').Header;
11220
+ header: import('../elements/Header.js').Header;
9898
11221
  relationshipId: string;
9899
11222
  filename: string;
9900
11223
  }[];
9901
11224
  footers: {
9902
- footer: import('../elements/Footer').Footer;
11225
+ footer: import('../elements/Footer.js').Footer;
9903
11226
  relationshipId: string;
9904
11227
  filename: string;
9905
11228
  }[];
9906
11229
  }> {
9907
11230
  const headers: {
9908
- header: import('../elements/Header').Header;
11231
+ header: import('../elements/Header.js').Header;
9909
11232
  relationshipId: string;
9910
11233
  filename: string;
9911
11234
  }[] = [];
9912
11235
  const footers: {
9913
- footer: import('../elements/Footer').Footer;
11236
+ footer: import('../elements/Footer.js').Footer;
9914
11237
  relationshipId: string;
9915
11238
  filename: string;
9916
11239
  }[] = [];
@@ -9924,7 +11247,7 @@ export class DocumentParser {
9924
11247
  // Parse headers
9925
11248
  // Track already-parsed headers by rId to avoid creating duplicates
9926
11249
  // when multiple section property types (default, first, even) reference the same header file
9927
- const parsedHeadersByRId = new Map<string, import('../elements/Header').Header>();
11250
+ const parsedHeadersByRId = new Map<string, import('../elements/Header.js').Header>();
9928
11251
 
9929
11252
  if (sectionProps.headers) {
9930
11253
  for (const [type, rId] of Object.entries(sectionProps.headers)) {
@@ -9987,7 +11310,7 @@ export class DocumentParser {
9987
11310
  // Parse footers
9988
11311
  // Track already-parsed footers by rId to avoid creating duplicates
9989
11312
  // when multiple section property types (default, first, even) reference the same footer file
9990
- const parsedFootersByRId = new Map<string, import('../elements/Footer').Footer>();
11313
+ const parsedFootersByRId = new Map<string, import('../elements/Footer.js').Footer>();
9991
11314
 
9992
11315
  if (sectionProps.footers) {
9993
11316
  for (const [type, rId] of Object.entries(sectionProps.footers)) {
@@ -10176,29 +11499,42 @@ export class DocumentParser {
10176
11499
 
10177
11500
  // Table-level properties (w:tblPr context)
10178
11501
  if (propsObj['w:tblStyle']) {
10179
- result.style = propsObj['w:tblStyle']['@_w:val'] || '';
10180
- }
10181
- // tblpPr (floating table position)
11502
+ // w:tblStyle w:val is ST_String (§17.7.4.62). XMLParser coerces
11503
+ // purely-numeric style IDs (e.g. "2025") to numbers; cast so the
11504
+ // declared `string` contract holds on tracked-change history.
11505
+ const v = propsObj['w:tblStyle']['@_w:val'];
11506
+ result.style = v !== undefined && v !== null ? String(v) : '';
11507
+ }
11508
+ // tblpPr (floating table position) — mirror main-path zero-value
11509
+ // preservation. The tblPrChange emitter re-emits position via
11510
+ // `!== undefined`, so dropping zero-valued tracked "previous"
11511
+ // positions here lost them silently on round-trip.
10182
11512
  if (propsObj['w:tblpPr']) {
10183
11513
  const tblpPr = propsObj['w:tblpPr'];
10184
11514
  const pos: any = {};
10185
- if (tblpPr['@_w:tblpX']) pos.x = parseInt(tblpPr['@_w:tblpX'], 10);
10186
- if (tblpPr['@_w:tblpY']) pos.y = parseInt(tblpPr['@_w:tblpY'], 10);
11515
+ if (isExplicitlySet(tblpPr['@_w:tblpX'])) pos.x = safeParseInt(tblpPr['@_w:tblpX']);
11516
+ if (isExplicitlySet(tblpPr['@_w:tblpY'])) pos.y = safeParseInt(tblpPr['@_w:tblpY']);
10187
11517
  if (tblpPr['@_w:horzAnchor']) pos.horizontalAnchor = tblpPr['@_w:horzAnchor'];
10188
11518
  if (tblpPr['@_w:vertAnchor']) pos.verticalAnchor = tblpPr['@_w:vertAnchor'];
10189
- if (tblpPr['@_w:leftFromText']) pos.leftFromText = parseInt(tblpPr['@_w:leftFromText'], 10);
10190
- if (tblpPr['@_w:rightFromText'])
10191
- pos.rightFromText = parseInt(tblpPr['@_w:rightFromText'], 10);
10192
- if (tblpPr['@_w:topFromText']) pos.topFromText = parseInt(tblpPr['@_w:topFromText'], 10);
10193
- if (tblpPr['@_w:bottomFromText'])
10194
- pos.bottomFromText = parseInt(tblpPr['@_w:bottomFromText'], 10);
11519
+ if (isExplicitlySet(tblpPr['@_w:leftFromText'])) {
11520
+ pos.leftFromText = safeParseInt(tblpPr['@_w:leftFromText']);
11521
+ }
11522
+ if (isExplicitlySet(tblpPr['@_w:rightFromText'])) {
11523
+ pos.rightFromText = safeParseInt(tblpPr['@_w:rightFromText']);
11524
+ }
11525
+ if (isExplicitlySet(tblpPr['@_w:topFromText'])) {
11526
+ pos.topFromText = safeParseInt(tblpPr['@_w:topFromText']);
11527
+ }
11528
+ if (isExplicitlySet(tblpPr['@_w:bottomFromText'])) {
11529
+ pos.bottomFromText = safeParseInt(tblpPr['@_w:bottomFromText']);
11530
+ }
10195
11531
  if (Object.keys(pos).length > 0) result.position = pos;
10196
11532
  }
10197
11533
  if (propsObj['w:tblOverlap']) {
10198
11534
  result.overlap = propsObj['w:tblOverlap']['@_w:val'];
10199
11535
  }
10200
11536
  if (propsObj['w:bidiVisual']) {
10201
- result.bidiVisual = true;
11537
+ result.bidiVisual = parseOoxmlBoolean(propsObj['w:bidiVisual']);
10202
11538
  }
10203
11539
  if (propsObj['w:tblStyleRowBandSize']) {
10204
11540
  result.tblStyleRowBandSize = parseInt(
@@ -10244,21 +11580,55 @@ export class DocumentParser {
10244
11580
  const borders: any = {};
10245
11581
  const bordersObj = propsObj['w:tblBorders'];
10246
11582
  for (const side of ['top', 'bottom', 'left', 'right', 'insideH', 'insideV']) {
10247
- if (bordersObj[`w:${side}`]) {
10248
- borders[side] = this.parseBorderElement(bordersObj[`w:${side}`]);
11583
+ // Prefer bidi-aware w:start/w:end aliases over legacy w:left/
11584
+ // w:right (ECMA-376 §17.4.40 CT_TblBorders). Same pattern as
11585
+ // the main table borders parser — the bidi-aware form is the
11586
+ // preferred modern spelling.
11587
+ const aliasKey = side === 'left' ? 'w:start' : side === 'right' ? 'w:end' : undefined;
11588
+ const borderObj = (aliasKey && bordersObj[aliasKey]) || bordersObj[`w:${side}`];
11589
+ if (borderObj) {
11590
+ borders[side] = this.parseBorderElement(borderObj);
10249
11591
  }
10250
11592
  }
10251
11593
  if (Object.keys(borders).length > 0) result.borders = borders;
10252
11594
  }
11595
+ // tblLook per ECMA-376 §17.4.57 — supports both hex-string format
11596
+ // (w:val="04A0") AND the expanded individual-attribute form
11597
+ // (firstRow/lastRow/firstColumn/lastColumn/noHBand/noVBand).
11598
+ // Word often emits the expanded form (no w:val) inside *PrChange
11599
+ // previous-properties; the hex-only read silently collapsed every
11600
+ // flag to "0000" on round-trip.
10253
11601
  if (propsObj['w:tblLook']) {
10254
11602
  const look = propsObj['w:tblLook'];
10255
- result.tblLook = look['@_w:val'] || '0000';
11603
+ if (look['@_w:val']) {
11604
+ result.tblLook = String(look['@_w:val']);
11605
+ } else {
11606
+ const attrIsOn = (name: string): boolean => {
11607
+ const v = look[name];
11608
+ if (v === undefined) return false;
11609
+ return parseOoxmlBoolean({ '@_w:val': v });
11610
+ };
11611
+ let value = 0;
11612
+ if (attrIsOn('@_w:firstRow')) value |= 0x0020;
11613
+ if (attrIsOn('@_w:lastRow')) value |= 0x0040;
11614
+ if (attrIsOn('@_w:firstColumn')) value |= 0x0080;
11615
+ if (attrIsOn('@_w:lastColumn')) value |= 0x0100;
11616
+ if (attrIsOn('@_w:noHBand')) value |= 0x0200;
11617
+ if (attrIsOn('@_w:noVBand')) value |= 0x0400;
11618
+ result.tblLook = value.toString(16).toUpperCase().padStart(4, '0');
11619
+ }
10256
11620
  }
10257
11621
  if (propsObj['w:tblCaption']) {
10258
- result.caption = propsObj['w:tblCaption']['@_w:val'];
11622
+ // w:tblCaption w:val is ST_String (§17.4.62). Cast through
11623
+ // String() so purely-numeric caption text round-trips as a
11624
+ // string inside the tracked-change previousProperties.
11625
+ const v = propsObj['w:tblCaption']['@_w:val'];
11626
+ result.caption = v !== undefined && v !== null ? String(v) : undefined;
10259
11627
  }
10260
11628
  if (propsObj['w:tblDescription']) {
10261
- result.description = propsObj['w:tblDescription']['@_w:val'];
11629
+ // w:tblDescription w:val is ST_String (§17.4.63).
11630
+ const v = propsObj['w:tblDescription']['@_w:val'];
11631
+ result.description = v !== undefined && v !== null ? String(v) : undefined;
10262
11632
  }
10263
11633
 
10264
11634
  // Row-level properties (w:trPr context) — all CT_TrPr elements
@@ -10289,14 +11659,15 @@ export class DocumentParser {
10289
11659
  const rule = propsObj['w:trHeight']['@_w:hRule'];
10290
11660
  if (rule) result.heightRule = rule;
10291
11661
  }
11662
+ // Row CT_OnOff — honour w:val per ECMA-376 §17.17.4 (ST_OnOff)
10292
11663
  if (propsObj['w:tblHeader']) {
10293
- result.isHeader = true;
11664
+ result.isHeader = parseOoxmlBoolean(propsObj['w:tblHeader']);
10294
11665
  }
10295
11666
  if (propsObj['w:cantSplit']) {
10296
- result.cantSplit = true;
11667
+ result.cantSplit = parseOoxmlBoolean(propsObj['w:cantSplit']);
10297
11668
  }
10298
11669
  if (propsObj['w:hidden']) {
10299
- result.hidden = true;
11670
+ result.hidden = parseOoxmlBoolean(propsObj['w:hidden']);
10300
11671
  }
10301
11672
 
10302
11673
  // Cell-level properties (w:tcPr context) — all CT_TcPr elements
@@ -10317,14 +11688,19 @@ export class DocumentParser {
10317
11688
  const borders: any = {};
10318
11689
  const bordersObj = propsObj['w:tcBorders'];
10319
11690
  for (const side of ['top', 'bottom', 'left', 'right', 'tl2br', 'tr2bl']) {
10320
- if (bordersObj[`w:${side}`]) {
10321
- borders[side] = this.parseBorderElement(bordersObj[`w:${side}`]);
11691
+ // Prefer bidi-aware w:start/w:end aliases for left/right
11692
+ // (ECMA-376 §17.4.66 CT_TcBorders). Diagonals (tl2br/tr2bl)
11693
+ // have no bidi aliases.
11694
+ const aliasKey = side === 'left' ? 'w:start' : side === 'right' ? 'w:end' : undefined;
11695
+ const borderObj = (aliasKey && bordersObj[aliasKey]) || bordersObj[`w:${side}`];
11696
+ if (borderObj) {
11697
+ borders[side] = this.parseBorderElement(borderObj);
10322
11698
  }
10323
11699
  }
10324
11700
  if (Object.keys(borders).length > 0) result.borders = borders;
10325
11701
  }
10326
11702
  if (propsObj['w:noWrap']) {
10327
- result.noWrap = true;
11703
+ result.noWrap = parseOoxmlBoolean(propsObj['w:noWrap']);
10328
11704
  }
10329
11705
  if (propsObj['w:tcMar']) {
10330
11706
  const tcMar = propsObj['w:tcMar'];
@@ -10341,13 +11717,13 @@ export class DocumentParser {
10341
11717
  result.textDirection = propsObj['w:textDirection']['@_w:val'];
10342
11718
  }
10343
11719
  if (propsObj['w:tcFitText']) {
10344
- result.fitText = true;
11720
+ result.fitText = parseOoxmlBoolean(propsObj['w:tcFitText']);
10345
11721
  }
10346
11722
  if (propsObj['w:vAlign']) {
10347
11723
  result.verticalAlignment = propsObj['w:vAlign']['@_w:val'];
10348
11724
  }
10349
11725
  if (propsObj['w:hideMark']) {
10350
- result.hideMark = true;
11726
+ result.hideMark = parseOoxmlBoolean(propsObj['w:hideMark']);
10351
11727
  }
10352
11728
  if (propsObj['w:cnfStyle']) {
10353
11729
  result.cnfStyle = propsObj['w:cnfStyle']['@_w:val'];
@@ -10377,6 +11753,79 @@ export class DocumentParser {
10377
11753
  if (!sectPrXml) return {};
10378
11754
  const result: Record<string, any> = {};
10379
11755
 
11756
+ // Footnote properties (w:footnotePr) per §17.11.9. The main sectPr parser
11757
+ // reads these, and the emitter supports prev.footnotePr, but the
11758
+ // sectPrChange parser previously dropped them entirely — tracked history
11759
+ // of changes to footnote numbering format / position / start / restart
11760
+ // was lost on every round-trip.
11761
+ const footnotePrElements = XMLParser.extractElements(sectPrXml, 'w:footnotePr');
11762
+ if (footnotePrElements.length > 0 && footnotePrElements[0]) {
11763
+ const fnPr = footnotePrElements[0];
11764
+ const fnObj: any = {};
11765
+ const posElements = XMLParser.extractElements(fnPr, 'w:pos');
11766
+ if (posElements[0]) {
11767
+ const pos = XMLParser.extractAttribute(posElements[0], 'w:val');
11768
+ if (pos) fnObj.position = pos;
11769
+ }
11770
+ const numFmtElements = XMLParser.extractElements(fnPr, 'w:numFmt');
11771
+ if (numFmtElements[0]) {
11772
+ const fmt = XMLParser.extractAttribute(numFmtElements[0], 'w:val');
11773
+ if (fmt) fnObj.numberFormat = fmt;
11774
+ }
11775
+ const numStartElements = XMLParser.extractElements(fnPr, 'w:numStart');
11776
+ if (numStartElements[0]) {
11777
+ const start = XMLParser.extractAttribute(numStartElements[0], 'w:val');
11778
+ if (start !== undefined) fnObj.startNumber = parseInt(String(start), 10);
11779
+ }
11780
+ const numRestartElements = XMLParser.extractElements(fnPr, 'w:numRestart');
11781
+ if (numRestartElements[0]) {
11782
+ const restart = XMLParser.extractAttribute(numRestartElements[0], 'w:val');
11783
+ if (restart) fnObj.restart = restart;
11784
+ }
11785
+ if (Object.keys(fnObj).length > 0) result.footnotePr = fnObj;
11786
+ }
11787
+
11788
+ // Endnote properties (w:endnotePr) per §17.11.5 — mirror of footnotePr.
11789
+ const endnotePrElements = XMLParser.extractElements(sectPrXml, 'w:endnotePr');
11790
+ if (endnotePrElements.length > 0 && endnotePrElements[0]) {
11791
+ const enPr = endnotePrElements[0];
11792
+ const enObj: any = {};
11793
+ const posElements = XMLParser.extractElements(enPr, 'w:pos');
11794
+ if (posElements[0]) {
11795
+ const pos = XMLParser.extractAttribute(posElements[0], 'w:val');
11796
+ if (pos) enObj.position = pos;
11797
+ }
11798
+ const numFmtElements = XMLParser.extractElements(enPr, 'w:numFmt');
11799
+ if (numFmtElements[0]) {
11800
+ const fmt = XMLParser.extractAttribute(numFmtElements[0], 'w:val');
11801
+ if (fmt) enObj.numberFormat = fmt;
11802
+ }
11803
+ const numStartElements = XMLParser.extractElements(enPr, 'w:numStart');
11804
+ if (numStartElements[0]) {
11805
+ const start = XMLParser.extractAttribute(numStartElements[0], 'w:val');
11806
+ if (start !== undefined) enObj.startNumber = parseInt(String(start), 10);
11807
+ }
11808
+ const numRestartElements = XMLParser.extractElements(enPr, 'w:numRestart');
11809
+ if (numRestartElements[0]) {
11810
+ const restart = XMLParser.extractAttribute(numRestartElements[0], 'w:val');
11811
+ if (restart) enObj.restart = restart;
11812
+ }
11813
+ if (Object.keys(enObj).length > 0) result.endnotePr = enObj;
11814
+ }
11815
+
11816
+ // Paper source (w:paperSrc) per §17.6.12 CT_PaperSource — first-page / other
11817
+ // paper tray selection. Both attributes optional per schema.
11818
+ const paperSrcElements = XMLParser.extractElements(sectPrXml, 'w:paperSrc');
11819
+ if (paperSrcElements.length > 0 && paperSrcElements[0]) {
11820
+ const ps = paperSrcElements[0];
11821
+ const psObj: any = {};
11822
+ const first = XMLParser.extractAttribute(ps, 'w:first');
11823
+ if (first !== undefined) psObj.first = parseInt(String(first), 10);
11824
+ const other = XMLParser.extractAttribute(ps, 'w:other');
11825
+ if (other !== undefined) psObj.other = parseInt(String(other), 10);
11826
+ if (Object.keys(psObj).length > 0) result.paperSource = psObj;
11827
+ }
11828
+
10380
11829
  // Page size
10381
11830
  const pgSzElements = XMLParser.extractElements(sectPrXml, 'w:pgSz');
10382
11831
  if (pgSzElements.length > 0 && pgSzElements[0]) {
@@ -10395,7 +11844,10 @@ export class DocumentParser {
10395
11844
  }
10396
11845
  }
10397
11846
 
10398
- // Margins
11847
+ // Margins — full CT_PageMar attribute set (§17.6.11) including w:gutter
11848
+ // (the book-binding margin). Previously gutter was dropped on sectPrChange
11849
+ // history, so any tracked change to a binding-gutter value lost the
11850
+ // previous value on round-trip.
10399
11851
  const pgMarElements = XMLParser.extractElements(sectPrXml, 'w:pgMar');
10400
11852
  if (pgMarElements.length > 0 && pgMarElements[0]) {
10401
11853
  const pgMar = pgMarElements[0];
@@ -10412,6 +11864,8 @@ export class DocumentParser {
10412
11864
  if (header) margins.header = parseInt(header, 10);
10413
11865
  const footer = XMLParser.extractAttribute(pgMar, 'w:footer');
10414
11866
  if (footer) margins.footer = parseInt(footer, 10);
11867
+ const gutter = XMLParser.extractAttribute(pgMar, 'w:gutter');
11868
+ if (gutter) margins.gutter = parseInt(gutter, 10);
10415
11869
  if (Object.keys(margins).length > 0) result.margins = margins;
10416
11870
  }
10417
11871
 
@@ -10438,7 +11892,13 @@ export class DocumentParser {
10438
11892
  if (Object.keys(lnObj).length > 0) result.lineNumbering = lnObj;
10439
11893
  }
10440
11894
 
10441
- // Page numbering
11895
+ // Page numbering — full CT_PageNumber attribute set (§17.6.12):
11896
+ // fmt / start / chapStyle / chapSep. Previously only fmt+start were read,
11897
+ // so tracked-change history of chapter-numbering edits (e.g. switching
11898
+ // from "Heading 1" to "Heading 2" as the chapter marker, or changing the
11899
+ // chapter separator from hyphen to emDash) lost the previous values.
11900
+ // The Section.ts emitter stores chapStyle / chapSep as top-level
11901
+ // properties rather than on pageNumbering, so expose them the same way.
10442
11902
  const pgNumElements = XMLParser.extractElements(sectPrXml, 'w:pgNumType');
10443
11903
  if (pgNumElements.length > 0 && pgNumElements[0]) {
10444
11904
  const pn = pgNumElements[0];
@@ -10448,6 +11908,12 @@ export class DocumentParser {
10448
11908
  const fmt = XMLParser.extractAttribute(pn, 'w:fmt');
10449
11909
  if (fmt) pnObj.format = fmt;
10450
11910
  if (Object.keys(pnObj).length > 0) result.pageNumbering = pnObj;
11911
+ // Mirror the main-sectPr parser: chapStyle / chapSep live at the root
11912
+ // of the section properties, not inside pageNumbering.
11913
+ const chapStyle = XMLParser.extractAttribute(pn, 'w:chapStyle');
11914
+ if (chapStyle !== undefined) result.chapStyle = parseInt(String(chapStyle), 10);
11915
+ const chapSep = XMLParser.extractAttribute(pn, 'w:chapSep');
11916
+ if (chapSep) result.chapSep = chapSep;
10451
11917
  }
10452
11918
 
10453
11919
  // Columns
@@ -10456,16 +11922,58 @@ export class DocumentParser {
10456
11922
  const cols = colsElements[0];
10457
11923
  const num = XMLParser.extractAttribute(cols, 'w:num');
10458
11924
  const space = XMLParser.extractAttribute(cols, 'w:space');
11925
+ // Full CT_Columns attribute set (§17.6.4): num / space / equalWidth / sep
11926
+ // plus the child <w:col w:w="..." w:space="..."/> entries for per-column
11927
+ // widths. Previously only num+space were read, so sectPrChange history of
11928
+ // a columns-layout change dropped equalWidth, the separator line, and
11929
+ // the entire custom column-width / per-column-space configuration.
11930
+ const equalWidth = XMLParser.extractAttribute(cols, 'w:equalWidth');
11931
+ const sep = XMLParser.extractAttribute(cols, 'w:sep');
11932
+
11933
+ // Extract individual <w:col> children for non-equal-width layouts.
11934
+ const colChildElements = XMLParser.extractElements(cols, 'w:col');
11935
+ const columnWidths: number[] = [];
11936
+ const columnSpaces: number[] = [];
11937
+ let hasColumnSpaces = false;
11938
+ for (const col of colChildElements) {
11939
+ const width = XMLParser.extractAttribute(col, 'w:w');
11940
+ if (width) columnWidths.push(parseInt(width, 10));
11941
+ const colSpace = XMLParser.extractAttribute(col, 'w:space');
11942
+ if (colSpace) {
11943
+ columnSpaces.push(parseInt(colSpace, 10));
11944
+ hasColumnSpaces = true;
11945
+ } else {
11946
+ columnSpaces.push(0);
11947
+ }
11948
+ }
11949
+
10459
11950
  if (num) {
10460
11951
  result.columns = {
10461
11952
  count: parseInt(num, 10),
10462
11953
  space: space ? parseInt(space, 10) : undefined,
11954
+ equalWidth: equalWidth ? parseOnOffAttribute(equalWidth) : undefined,
11955
+ separator: sep ? parseOnOffAttribute(sep) : undefined,
11956
+ columnWidths: columnWidths.length > 0 ? columnWidths : undefined,
11957
+ columnSpaces: hasColumnSpaces ? columnSpaces : undefined,
10463
11958
  };
10464
11959
  }
10465
11960
  }
10466
11961
 
10467
- // Form protection
10468
- if (sectPrXml.includes('<w:formProt')) result.formProt = true;
11962
+ // CT_OnOff sectPr flags — honour w:val per ECMA-376 §17.17.4 (ST_OnOff).
11963
+ // Previously these used substring `.includes()`, which both ignored w:val
11964
+ // (flipping explicit false to true) and could false-positive on prefix
11965
+ // matches (e.g. "<w:bidi" inside "<w:bidiVisual"). Use extractElements +
11966
+ // extractAttribute + parseOnOffAttribute instead.
11967
+ const parseSectCtOnOff = (tagName: string): boolean | undefined => {
11968
+ const els = XMLParser.extractElements(sectPrXml, tagName);
11969
+ if (els.length === 0 || !els[0]) return undefined;
11970
+ const v = XMLParser.extractAttribute(els[0], 'w:val');
11971
+ return parseOnOffAttribute(v, true);
11972
+ };
11973
+
11974
+ // Form protection (w:formProt) — CT_OnOff
11975
+ const formProtVal = parseSectCtOnOff('w:formProt');
11976
+ if (formProtVal !== undefined) result.formProt = formProtVal;
10469
11977
 
10470
11978
  // Vertical alignment
10471
11979
  const vAlignElements = XMLParser.extractElements(sectPrXml, 'w:vAlign');
@@ -10474,11 +11982,13 @@ export class DocumentParser {
10474
11982
  if (val) result.verticalAlignment = val;
10475
11983
  }
10476
11984
 
10477
- // Suppress endnotes
10478
- if (sectPrXml.includes('<w:noEndnote')) result.noEndnote = true;
11985
+ // Suppress endnotes (w:noEndnote) — CT_OnOff
11986
+ const noEndnoteVal = parseSectCtOnOff('w:noEndnote');
11987
+ if (noEndnoteVal !== undefined) result.noEndnote = noEndnoteVal;
10479
11988
 
10480
- // Title page
10481
- if (sectPrXml.includes('<w:titlePg')) result.titlePage = true;
11989
+ // Title page (w:titlePg) — CT_OnOff
11990
+ const titlePgVal = parseSectCtOnOff('w:titlePg');
11991
+ if (titlePgVal !== undefined) result.titlePage = titlePgVal;
10482
11992
 
10483
11993
  // Text direction
10484
11994
  const textDirElements = XMLParser.extractElements(sectPrXml, 'w:textDirection');
@@ -10487,11 +11997,13 @@ export class DocumentParser {
10487
11997
  if (val) result.textDirection = val;
10488
11998
  }
10489
11999
 
10490
- // Bidi section
10491
- if (sectPrXml.includes('<w:bidi')) result.bidi = true;
12000
+ // Bidi section (w:bidi) — CT_OnOff
12001
+ const bidiVal = parseSectCtOnOff('w:bidi');
12002
+ if (bidiVal !== undefined) result.bidi = bidiVal;
10492
12003
 
10493
- // RTL gutter
10494
- if (sectPrXml.includes('<w:rtlGutter')) result.rtlGutter = true;
12004
+ // RTL gutter (w:rtlGutter) — CT_OnOff
12005
+ const rtlGutterVal = parseSectCtOnOff('w:rtlGutter');
12006
+ if (rtlGutterVal !== undefined) result.rtlGutter = rtlGutterVal;
10495
12007
 
10496
12008
  // Document grid
10497
12009
  const docGridElements = XMLParser.extractElements(sectPrXml, 'w:docGrid');
@@ -10507,6 +12019,64 @@ export class DocumentParser {
10507
12019
  if (Object.keys(dgObj).length > 0) result.docGrid = dgObj;
10508
12020
  }
10509
12021
 
12022
+ // Page borders (w:pgBorders) per ECMA-376 §17.6.10. The main sectPr parser
12023
+ // reads these, but the sectPrChange previous-sectPr parser previously
12024
+ // didn't — so a tracked-change history of page-border edits lost the
12025
+ // entire "previous" border configuration (style, color, themeColor,
12026
+ // themeTint, themeShade, shadow, frame) every round-trip. The emitter
12027
+ // supports prev.pageBorders already; this is the missing parser half.
12028
+ const pgBordersElements = XMLParser.extractElements(sectPrXml, 'w:pgBorders');
12029
+ if (pgBordersElements.length > 0 && pgBordersElements[0]) {
12030
+ const pgBordersXml = pgBordersElements[0];
12031
+ const pageBorders: any = {};
12032
+ const offsetFrom = XMLParser.extractAttribute(pgBordersXml, 'w:offsetFrom');
12033
+ if (offsetFrom) pageBorders.offsetFrom = offsetFrom;
12034
+ const display = XMLParser.extractAttribute(pgBordersXml, 'w:display');
12035
+ if (display) pageBorders.display = display;
12036
+ const zOrder = XMLParser.extractAttribute(pgBordersXml, 'w:zOrder');
12037
+ if (zOrder) pageBorders.zOrder = zOrder;
12038
+
12039
+ // Per-side border parser mirrors the main-sectPr logic — full CT_Border
12040
+ // attribute set including themed colors and shadow/frame flags.
12041
+ const parsePrevBorder = (sideXml: string): any | undefined => {
12042
+ if (!sideXml) return undefined;
12043
+ const border: any = {};
12044
+ const val = XMLParser.extractAttribute(sideXml, 'w:val');
12045
+ if (val) border.style = val;
12046
+ const sz = XMLParser.extractAttribute(sideXml, 'w:sz');
12047
+ if (sz) border.size = parseInt(sz, 10);
12048
+ const color = XMLParser.extractAttribute(sideXml, 'w:color');
12049
+ if (color) border.color = color;
12050
+ const space = XMLParser.extractAttribute(sideXml, 'w:space');
12051
+ if (space) border.space = parseInt(space, 10);
12052
+ const shadow = XMLParser.extractAttribute(sideXml, 'w:shadow');
12053
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
12054
+ const frame = XMLParser.extractAttribute(sideXml, 'w:frame');
12055
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
12056
+ const themeColor = XMLParser.extractAttribute(sideXml, 'w:themeColor');
12057
+ if (themeColor) border.themeColor = themeColor;
12058
+ const themeTint = XMLParser.extractAttribute(sideXml, 'w:themeTint');
12059
+ if (themeTint) border.themeTint = themeTint;
12060
+ const themeShade = XMLParser.extractAttribute(sideXml, 'w:themeShade');
12061
+ if (themeShade) border.themeShade = themeShade;
12062
+ const artId = XMLParser.extractAttribute(sideXml, 'w:id');
12063
+ if (artId) border.artId = parseInt(artId, 10);
12064
+ return Object.keys(border).length > 0 ? border : undefined;
12065
+ };
12066
+
12067
+ for (const side of ['top', 'left', 'bottom', 'right']) {
12068
+ const sideElements = XMLParser.extractElements(pgBordersXml, `w:${side}`);
12069
+ if (sideElements.length > 0 && sideElements[0]) {
12070
+ const border = parsePrevBorder(sideElements[0]);
12071
+ if (border) pageBorders[side] = border;
12072
+ }
12073
+ }
12074
+
12075
+ if (Object.keys(pageBorders).length > 0) {
12076
+ result.pageBorders = pageBorders;
12077
+ }
12078
+ }
12079
+
10510
12080
  return result;
10511
12081
  }
10512
12082
  }