docxmlater 10.4.1 → 11.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (699) hide show
  1. package/README.md +3 -3
  2. package/dist/constants/legacyCompatFlags.d.ts +1 -1
  3. package/dist/constants/legacyCompatFlags.d.ts.map +1 -1
  4. package/dist/constants/legacyCompatFlags.js.map +1 -1
  5. package/dist/core/Document.d.ts +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 +2056 -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 +2 -2
  115. package/dist/elements/ImageRun.js.map +1 -1
  116. package/dist/elements/MathElement.d.ts +1 -1
  117. package/dist/elements/MathElement.d.ts.map +1 -1
  118. package/dist/elements/MathElement.js.map +1 -1
  119. package/dist/elements/Paragraph.d.ts +34 -19
  120. package/dist/elements/Paragraph.d.ts.map +1 -1
  121. package/dist/elements/Paragraph.js +286 -231
  122. package/dist/elements/Paragraph.js.map +1 -1
  123. package/dist/elements/PreservedElement.d.ts +1 -1
  124. package/dist/elements/PreservedElement.d.ts.map +1 -1
  125. package/dist/elements/PreservedElement.js.map +1 -1
  126. package/dist/elements/PropertyChangeTypes.d.ts +2 -2
  127. package/dist/elements/PropertyChangeTypes.d.ts.map +1 -1
  128. package/dist/elements/PropertyChangeTypes.js.map +1 -1
  129. package/dist/elements/RangeMarker.d.ts +14 -1
  130. package/dist/elements/RangeMarker.d.ts.map +1 -1
  131. package/dist/elements/RangeMarker.js +46 -8
  132. package/dist/elements/RangeMarker.js.map +1 -1
  133. package/dist/elements/RegisteredBodyElement.d.ts +15 -0
  134. package/dist/elements/RegisteredBodyElement.d.ts.map +1 -0
  135. package/dist/elements/RegisteredBodyElement.js +44 -0
  136. package/dist/elements/RegisteredBodyElement.js.map +1 -0
  137. package/dist/elements/Revision.d.ts +8 -8
  138. package/dist/elements/Revision.d.ts.map +1 -1
  139. package/dist/elements/Revision.js +12 -12
  140. package/dist/elements/Revision.js.map +1 -1
  141. package/dist/elements/RevisionContent.d.ts +3 -3
  142. package/dist/elements/RevisionContent.d.ts.map +1 -1
  143. package/dist/elements/RevisionContent.js.map +1 -1
  144. package/dist/elements/RevisionManager.d.ts +2 -2
  145. package/dist/elements/RevisionManager.d.ts.map +1 -1
  146. package/dist/elements/RevisionManager.js +2 -2
  147. package/dist/elements/RevisionManager.js.map +1 -1
  148. package/dist/elements/Run.d.ts +16 -10
  149. package/dist/elements/Run.d.ts.map +1 -1
  150. package/dist/elements/Run.js +199 -173
  151. package/dist/elements/Run.js.map +1 -1
  152. package/dist/elements/Section.d.ts +4 -2
  153. package/dist/elements/Section.d.ts.map +1 -1
  154. package/dist/elements/Section.js +152 -145
  155. package/dist/elements/Section.js.map +1 -1
  156. package/dist/elements/Shape.d.ts +3 -3
  157. package/dist/elements/Shape.d.ts.map +1 -1
  158. package/dist/elements/Shape.js +12 -12
  159. package/dist/elements/Shape.js.map +1 -1
  160. package/dist/elements/StructuredDocumentTag.d.ts +3 -3
  161. package/dist/elements/StructuredDocumentTag.d.ts.map +1 -1
  162. package/dist/elements/StructuredDocumentTag.js +39 -39
  163. package/dist/elements/StructuredDocumentTag.js.map +1 -1
  164. package/dist/elements/Table.d.ts +16 -10
  165. package/dist/elements/Table.d.ts.map +1 -1
  166. package/dist/elements/Table.js +118 -89
  167. package/dist/elements/Table.js.map +1 -1
  168. package/dist/elements/TableCell.d.ts +11 -11
  169. package/dist/elements/TableCell.d.ts.map +1 -1
  170. package/dist/elements/TableCell.js +108 -78
  171. package/dist/elements/TableCell.js.map +1 -1
  172. package/dist/elements/TableGridChange.d.ts +1 -1
  173. package/dist/elements/TableGridChange.d.ts.map +1 -1
  174. package/dist/elements/TableGridChange.js +3 -3
  175. package/dist/elements/TableGridChange.js.map +1 -1
  176. package/dist/elements/TableOfContents.d.ts +1 -1
  177. package/dist/elements/TableOfContents.d.ts.map +1 -1
  178. package/dist/elements/TableOfContents.js +2 -2
  179. package/dist/elements/TableOfContents.js.map +1 -1
  180. package/dist/elements/TableOfContentsElement.d.ts +2 -2
  181. package/dist/elements/TableOfContentsElement.d.ts.map +1 -1
  182. package/dist/elements/TableOfContentsElement.js +5 -5
  183. package/dist/elements/TableOfContentsElement.js.map +1 -1
  184. package/dist/elements/TableRow.d.ts +18 -7
  185. package/dist/elements/TableRow.d.ts.map +1 -1
  186. package/dist/elements/TableRow.js +127 -74
  187. package/dist/elements/TableRow.js.map +1 -1
  188. package/dist/elements/TextBox.d.ts +4 -4
  189. package/dist/elements/TextBox.d.ts.map +1 -1
  190. package/dist/elements/TextBox.js +6 -6
  191. package/dist/elements/TextBox.js.map +1 -1
  192. package/dist/esm/constants/legacyCompatFlags.js +97 -0
  193. package/dist/esm/constants/legacyCompatFlags.js.map +1 -0
  194. package/dist/esm/constants/limits.js +36 -0
  195. package/dist/esm/constants/limits.js.map +1 -0
  196. package/dist/esm/core/Document.js +8498 -0
  197. package/dist/esm/core/Document.js.map +1 -0
  198. package/dist/esm/core/DocumentContent.js +190 -0
  199. package/dist/esm/core/DocumentContent.js.map +1 -0
  200. package/dist/esm/core/DocumentEvents.js +47 -0
  201. package/dist/esm/core/DocumentEvents.js.map +1 -0
  202. package/dist/esm/core/DocumentGenerator.js +764 -0
  203. package/dist/esm/core/DocumentGenerator.js.map +1 -0
  204. package/dist/esm/core/DocumentIdManager.js +67 -0
  205. package/dist/esm/core/DocumentIdManager.js.map +1 -0
  206. package/dist/esm/core/DocumentParser.js +8760 -0
  207. package/dist/esm/core/DocumentParser.js.map +1 -0
  208. package/dist/esm/core/DocumentValidator.js +222 -0
  209. package/dist/esm/core/DocumentValidator.js.map +1 -0
  210. package/dist/esm/core/ElementRegistry.js +24 -0
  211. package/dist/esm/core/ElementRegistry.js.map +1 -0
  212. package/dist/esm/core/Relationship.js +177 -0
  213. package/dist/esm/core/Relationship.js.map +1 -0
  214. package/dist/esm/core/RelationshipManager.js +202 -0
  215. package/dist/esm/core/RelationshipManager.js.map +1 -0
  216. package/dist/esm/elements/AlternateContent.js +19 -0
  217. package/dist/esm/elements/AlternateContent.js.map +1 -0
  218. package/dist/esm/elements/Bookmark.js +115 -0
  219. package/dist/esm/elements/Bookmark.js.map +1 -0
  220. package/dist/esm/elements/BookmarkManager.js +99 -0
  221. package/dist/esm/elements/BookmarkManager.js.map +1 -0
  222. package/dist/esm/elements/Comment.js +181 -0
  223. package/dist/esm/elements/Comment.js.map +1 -0
  224. package/dist/esm/elements/CommentManager.js +233 -0
  225. package/dist/esm/elements/CommentManager.js.map +1 -0
  226. package/dist/esm/elements/CommonTypes.js +106 -0
  227. package/dist/esm/elements/CommonTypes.js.map +1 -0
  228. package/dist/esm/elements/CustomXml.js +19 -0
  229. package/dist/esm/elements/CustomXml.js.map +1 -0
  230. package/dist/esm/elements/Endnote.js +107 -0
  231. package/dist/esm/elements/Endnote.js.map +1 -0
  232. package/dist/esm/elements/EndnoteManager.js +119 -0
  233. package/dist/esm/elements/EndnoteManager.js.map +1 -0
  234. package/dist/esm/elements/Field.js +856 -0
  235. package/dist/esm/elements/Field.js.map +1 -0
  236. package/dist/esm/elements/FieldHelpers.js +134 -0
  237. package/dist/esm/elements/FieldHelpers.js.map +1 -0
  238. package/dist/esm/elements/FontManager.js +158 -0
  239. package/dist/esm/elements/FontManager.js.map +1 -0
  240. package/dist/esm/elements/Footer.js +141 -0
  241. package/dist/esm/elements/Footer.js.map +1 -0
  242. package/dist/esm/elements/Footnote.js +107 -0
  243. package/dist/esm/elements/Footnote.js.map +1 -0
  244. package/dist/esm/elements/FootnoteManager.js +119 -0
  245. package/dist/esm/elements/FootnoteManager.js.map +1 -0
  246. package/dist/esm/elements/Header.js +141 -0
  247. package/dist/esm/elements/Header.js.map +1 -0
  248. package/dist/esm/elements/HeaderFooterManager.js +87 -0
  249. package/dist/esm/elements/HeaderFooterManager.js.map +1 -0
  250. package/dist/esm/elements/Hyperlink.js +586 -0
  251. package/dist/esm/elements/Hyperlink.js.map +1 -0
  252. package/dist/esm/elements/Image.js +1288 -0
  253. package/dist/esm/elements/Image.js.map +1 -0
  254. package/dist/esm/elements/ImageManager.js +223 -0
  255. package/dist/esm/elements/ImageManager.js.map +1 -0
  256. package/dist/esm/elements/ImageRun.js +29 -0
  257. package/dist/esm/elements/ImageRun.js.map +1 -0
  258. package/dist/esm/elements/MathElement.js +37 -0
  259. package/dist/esm/elements/MathElement.js.map +1 -0
  260. package/dist/esm/elements/Paragraph.js +2308 -0
  261. package/dist/esm/elements/Paragraph.js.map +1 -0
  262. package/dist/esm/elements/PreservedElement.js +29 -0
  263. package/dist/esm/elements/PreservedElement.js.map +1 -0
  264. package/dist/esm/elements/PropertyChangeTypes.js +53 -0
  265. package/dist/esm/elements/PropertyChangeTypes.js.map +1 -0
  266. package/dist/esm/elements/RangeMarker.js +219 -0
  267. package/dist/esm/elements/RangeMarker.js.map +1 -0
  268. package/dist/esm/elements/RegisteredBodyElement.js +40 -0
  269. package/dist/esm/elements/RegisteredBodyElement.js.map +1 -0
  270. package/dist/esm/elements/Revision.js +498 -0
  271. package/dist/esm/elements/Revision.js.map +1 -0
  272. package/dist/esm/elements/RevisionContent.js +18 -0
  273. package/dist/esm/elements/RevisionContent.js.map +1 -0
  274. package/dist/esm/elements/RevisionManager.js +486 -0
  275. package/dist/esm/elements/RevisionManager.js.map +1 -0
  276. package/dist/esm/elements/Run.js +1465 -0
  277. package/dist/esm/elements/Run.js.map +1 -0
  278. package/dist/esm/elements/Section.js +978 -0
  279. package/dist/esm/elements/Section.js.map +1 -0
  280. package/dist/esm/elements/Shape.js +493 -0
  281. package/dist/esm/elements/Shape.js.map +1 -0
  282. package/dist/esm/elements/StructuredDocumentTag.js +471 -0
  283. package/dist/esm/elements/StructuredDocumentTag.js.map +1 -0
  284. package/dist/esm/elements/Table.js +1456 -0
  285. package/dist/esm/elements/Table.js.map +1 -0
  286. package/dist/esm/elements/TableCell.js +835 -0
  287. package/dist/esm/elements/TableCell.js.map +1 -0
  288. package/dist/esm/elements/TableGridChange.js +52 -0
  289. package/dist/esm/elements/TableGridChange.js.map +1 -0
  290. package/dist/esm/elements/TableOfContents.js +389 -0
  291. package/dist/esm/elements/TableOfContents.js.map +1 -0
  292. package/dist/esm/elements/TableOfContentsElement.js +29 -0
  293. package/dist/esm/elements/TableOfContentsElement.js.map +1 -0
  294. package/dist/esm/elements/TableRow.js +555 -0
  295. package/dist/esm/elements/TableRow.js.map +1 -0
  296. package/dist/esm/elements/TextBox.js +459 -0
  297. package/dist/esm/elements/TextBox.js.map +1 -0
  298. package/dist/esm/formatting/AbstractNumbering.js +325 -0
  299. package/dist/esm/formatting/AbstractNumbering.js.map +1 -0
  300. package/dist/esm/formatting/NumberingInstance.js +150 -0
  301. package/dist/esm/formatting/NumberingInstance.js.map +1 -0
  302. package/dist/esm/formatting/NumberingLevel.js +608 -0
  303. package/dist/esm/formatting/NumberingLevel.js.map +1 -0
  304. package/dist/esm/formatting/NumberingManager.js +423 -0
  305. package/dist/esm/formatting/NumberingManager.js.map +1 -0
  306. package/dist/esm/formatting/Style.js +1151 -0
  307. package/dist/esm/formatting/Style.js.map +1 -0
  308. package/dist/esm/formatting/StylesManager.js +557 -0
  309. package/dist/esm/formatting/StylesManager.js.map +1 -0
  310. package/dist/esm/helpers/CleanupHelper.js +350 -0
  311. package/dist/esm/helpers/CleanupHelper.js.map +1 -0
  312. package/dist/esm/images/ImageOptimizer.js +161 -0
  313. package/dist/esm/images/ImageOptimizer.js.map +1 -0
  314. package/dist/esm/index.js +75 -0
  315. package/dist/esm/index.js.map +1 -0
  316. package/dist/esm/internal.js +16 -0
  317. package/dist/esm/internal.js.map +1 -0
  318. package/dist/esm/managers/DrawingManager.js +163 -0
  319. package/dist/esm/managers/DrawingManager.js.map +1 -0
  320. package/dist/esm/package.json +3 -0
  321. package/dist/esm/processors/ChangelogGenerator.js +970 -0
  322. package/dist/esm/processors/ChangelogGenerator.js.map +1 -0
  323. package/dist/esm/processors/CompatibilityUpgrader.js +130 -0
  324. package/dist/esm/processors/CompatibilityUpgrader.js.map +1 -0
  325. package/dist/esm/processors/InMemoryRevisionAcceptor.js +530 -0
  326. package/dist/esm/processors/InMemoryRevisionAcceptor.js.map +1 -0
  327. package/dist/esm/processors/MoveOperationHelper.js +57 -0
  328. package/dist/esm/processors/MoveOperationHelper.js.map +1 -0
  329. package/dist/esm/processors/RevisionAwareProcessor.js +232 -0
  330. package/dist/esm/processors/RevisionAwareProcessor.js.map +1 -0
  331. package/dist/esm/processors/RevisionWalker.js +278 -0
  332. package/dist/esm/processors/RevisionWalker.js.map +1 -0
  333. package/dist/{utils → esm/processors}/SelectiveRevisionAcceptor.js +81 -42
  334. package/dist/esm/processors/SelectiveRevisionAcceptor.js.map +1 -0
  335. package/dist/esm/processors/ShadingResolver.js +66 -0
  336. package/dist/esm/processors/ShadingResolver.js.map +1 -0
  337. package/dist/esm/processors/acceptRevisions.js +416 -0
  338. package/dist/esm/processors/acceptRevisions.js.map +1 -0
  339. package/dist/esm/processors/cnfStyleDecoder.js +89 -0
  340. package/dist/esm/processors/cnfStyleDecoder.js.map +1 -0
  341. package/dist/esm/processors/stripTrackedChanges.js +201 -0
  342. package/dist/esm/processors/stripTrackedChanges.js.map +1 -0
  343. package/dist/esm/tracking/DocumentTrackingContext.js +531 -0
  344. package/dist/esm/tracking/DocumentTrackingContext.js.map +1 -0
  345. package/dist/esm/tracking/TrackingContext.js +2 -0
  346. package/dist/esm/tracking/TrackingContext.js.map +1 -0
  347. package/dist/esm/types/compatibility-types.js +8 -0
  348. package/dist/esm/types/compatibility-types.js.map +1 -0
  349. package/dist/esm/types/document-types.js +2 -0
  350. package/dist/esm/types/document-types.js.map +1 -0
  351. package/dist/esm/types/formatting.js +2 -0
  352. package/dist/esm/types/formatting.js.map +1 -0
  353. package/dist/esm/types/list-types.js +2 -0
  354. package/dist/esm/types/list-types.js.map +1 -0
  355. package/dist/esm/types/settings-types.js +2 -0
  356. package/dist/esm/types/settings-types.js.map +1 -0
  357. package/dist/esm/types/styleConfig.js +2 -0
  358. package/dist/esm/types/styleConfig.js.map +1 -0
  359. package/dist/esm/utils/KeyedRegistry.js +32 -0
  360. package/dist/esm/utils/KeyedRegistry.js.map +1 -0
  361. package/dist/esm/utils/corruptionDetection.js +155 -0
  362. package/dist/esm/utils/corruptionDetection.js.map +1 -0
  363. package/dist/esm/utils/dateFormatting.js +4 -0
  364. package/dist/esm/utils/dateFormatting.js.map +1 -0
  365. package/dist/esm/utils/deepClone.js +40 -0
  366. package/dist/esm/utils/deepClone.js.map +1 -0
  367. package/dist/esm/utils/deepEqual.js +47 -0
  368. package/dist/esm/utils/deepEqual.js.map +1 -0
  369. package/dist/esm/utils/diagnostics.js +69 -0
  370. package/dist/esm/utils/diagnostics.js.map +1 -0
  371. package/dist/esm/utils/errorHandling.js +36 -0
  372. package/dist/esm/utils/errorHandling.js.map +1 -0
  373. package/dist/esm/utils/formatting.js +93 -0
  374. package/dist/esm/utils/formatting.js.map +1 -0
  375. package/dist/esm/utils/list-detection.js +148 -0
  376. package/dist/esm/utils/list-detection.js.map +1 -0
  377. package/dist/esm/utils/logger.js +205 -0
  378. package/dist/esm/utils/logger.js.map +1 -0
  379. package/dist/esm/utils/parsingHelpers.js +56 -0
  380. package/dist/esm/utils/parsingHelpers.js.map +1 -0
  381. package/dist/esm/utils/textDiff.js +42 -0
  382. package/dist/esm/utils/textDiff.js.map +1 -0
  383. package/dist/esm/utils/units.js +152 -0
  384. package/dist/esm/utils/units.js.map +1 -0
  385. package/dist/esm/utils/validation.js +285 -0
  386. package/dist/esm/utils/validation.js.map +1 -0
  387. package/dist/esm/utils/xmlSanitization.js +54 -0
  388. package/dist/esm/utils/xmlSanitization.js.map +1 -0
  389. package/dist/esm/validation/RevisionAutoFixer.js +340 -0
  390. package/dist/esm/validation/RevisionAutoFixer.js.map +1 -0
  391. package/dist/esm/validation/RevisionValidator.js +240 -0
  392. package/dist/esm/validation/RevisionValidator.js.map +1 -0
  393. package/dist/esm/validation/ValidationRuleRegistry.js +40 -0
  394. package/dist/esm/validation/ValidationRuleRegistry.js.map +1 -0
  395. package/dist/esm/validation/ValidationRules.js +92 -0
  396. package/dist/esm/validation/ValidationRules.js.map +1 -0
  397. package/dist/esm/validation/index.js +4 -0
  398. package/dist/esm/validation/index.js.map +1 -0
  399. package/dist/esm/xml/XMLBuilder.js +434 -0
  400. package/dist/esm/xml/XMLBuilder.js.map +1 -0
  401. package/dist/esm/xml/XMLParser.js +486 -0
  402. package/dist/esm/xml/XMLParser.js.map +1 -0
  403. package/dist/esm/zip/ZipHandler.js +298 -0
  404. package/dist/esm/zip/ZipHandler.js.map +1 -0
  405. package/dist/esm/zip/ZipReader.js +147 -0
  406. package/dist/esm/zip/ZipReader.js.map +1 -0
  407. package/dist/esm/zip/ZipWriter.js +199 -0
  408. package/dist/esm/zip/ZipWriter.js.map +1 -0
  409. package/dist/esm/zip/errors.js +43 -0
  410. package/dist/esm/zip/errors.js.map +1 -0
  411. package/dist/esm/zip/types.js +31 -0
  412. package/dist/esm/zip/types.js.map +1 -0
  413. package/dist/formatting/AbstractNumbering.d.ts +2 -2
  414. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  415. package/dist/formatting/AbstractNumbering.js +33 -33
  416. package/dist/formatting/AbstractNumbering.js.map +1 -1
  417. package/dist/formatting/NumberingInstance.d.ts +2 -2
  418. package/dist/formatting/NumberingInstance.d.ts.map +1 -1
  419. package/dist/formatting/NumberingInstance.js +7 -7
  420. package/dist/formatting/NumberingInstance.js.map +1 -1
  421. package/dist/formatting/NumberingLevel.d.ts +11 -2
  422. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  423. package/dist/formatting/NumberingLevel.js +111 -25
  424. package/dist/formatting/NumberingLevel.js.map +1 -1
  425. package/dist/formatting/NumberingManager.d.ts +4 -4
  426. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  427. package/dist/formatting/NumberingManager.js +28 -28
  428. package/dist/formatting/NumberingManager.js.map +1 -1
  429. package/dist/formatting/Style.d.ts +14 -7
  430. package/dist/formatting/Style.d.ts.map +1 -1
  431. package/dist/formatting/Style.js +309 -112
  432. package/dist/formatting/Style.js.map +1 -1
  433. package/dist/formatting/StylesManager.d.ts +2 -2
  434. package/dist/formatting/StylesManager.d.ts.map +1 -1
  435. package/dist/formatting/StylesManager.js +52 -52
  436. package/dist/formatting/StylesManager.js.map +1 -1
  437. package/dist/helpers/CleanupHelper.d.ts +1 -1
  438. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  439. package/dist/helpers/CleanupHelper.js +15 -15
  440. package/dist/helpers/CleanupHelper.js.map +1 -1
  441. package/dist/index.d.ts +81 -90
  442. package/dist/index.d.ts.map +1 -1
  443. package/dist/index.js +286 -317
  444. package/dist/index.js.map +1 -1
  445. package/dist/internal.d.ts +16 -0
  446. package/dist/internal.d.ts.map +1 -0
  447. package/dist/internal.js +42 -0
  448. package/dist/internal.js.map +1 -0
  449. package/dist/managers/DrawingManager.d.ts +3 -3
  450. package/dist/managers/DrawingManager.d.ts.map +1 -1
  451. package/dist/managers/DrawingManager.js +12 -12
  452. package/dist/managers/DrawingManager.js.map +1 -1
  453. package/dist/{utils → processors}/ChangelogGenerator.d.ts +2 -2
  454. package/dist/processors/ChangelogGenerator.d.ts.map +1 -0
  455. package/dist/{utils → processors}/ChangelogGenerator.js +2 -2
  456. package/dist/processors/ChangelogGenerator.js.map +1 -0
  457. package/dist/processors/CompatibilityUpgrader.d.ts.map +1 -0
  458. package/dist/{utils → processors}/CompatibilityUpgrader.js +10 -10
  459. package/dist/processors/CompatibilityUpgrader.js.map +1 -0
  460. package/dist/{utils → processors}/InMemoryRevisionAcceptor.d.ts +3 -3
  461. package/dist/processors/InMemoryRevisionAcceptor.d.ts.map +1 -0
  462. package/dist/{utils → processors}/InMemoryRevisionAcceptor.js +84 -27
  463. package/dist/processors/InMemoryRevisionAcceptor.js.map +1 -0
  464. package/dist/{utils → processors}/MoveOperationHelper.d.ts +4 -4
  465. package/dist/processors/MoveOperationHelper.d.ts.map +1 -0
  466. package/dist/{utils → processors}/MoveOperationHelper.js +10 -10
  467. package/dist/processors/MoveOperationHelper.js.map +1 -0
  468. package/dist/{utils → processors}/RevisionAwareProcessor.d.ts +3 -3
  469. package/dist/processors/RevisionAwareProcessor.d.ts.map +1 -0
  470. package/dist/{utils → processors}/RevisionAwareProcessor.js +2 -2
  471. package/dist/processors/RevisionAwareProcessor.js.map +1 -0
  472. package/dist/{utils → processors}/RevisionWalker.d.ts +2 -1
  473. package/dist/processors/RevisionWalker.d.ts.map +1 -0
  474. package/dist/{utils → processors}/RevisionWalker.js +28 -0
  475. package/dist/processors/RevisionWalker.js.map +1 -0
  476. package/dist/{utils → processors}/SelectiveRevisionAcceptor.d.ts +4 -3
  477. package/dist/processors/SelectiveRevisionAcceptor.d.ts.map +1 -0
  478. package/dist/processors/SelectiveRevisionAcceptor.js +402 -0
  479. package/dist/processors/SelectiveRevisionAcceptor.js.map +1 -0
  480. package/dist/processors/ShadingResolver.d.ts +6 -0
  481. package/dist/processors/ShadingResolver.d.ts.map +1 -0
  482. package/dist/{utils → processors}/ShadingResolver.js +2 -2
  483. package/dist/processors/ShadingResolver.js.map +1 -0
  484. package/dist/{utils → processors}/acceptRevisions.d.ts +1 -1
  485. package/dist/processors/acceptRevisions.d.ts.map +1 -0
  486. package/dist/{utils → processors}/acceptRevisions.js +24 -4
  487. package/dist/processors/acceptRevisions.js.map +1 -0
  488. package/dist/{utils → processors}/cnfStyleDecoder.d.ts +1 -1
  489. package/dist/processors/cnfStyleDecoder.d.ts.map +1 -0
  490. package/dist/processors/cnfStyleDecoder.js.map +1 -0
  491. package/dist/processors/stripTrackedChanges.d.ts +3 -0
  492. package/dist/processors/stripTrackedChanges.d.ts.map +1 -0
  493. package/dist/{utils → processors}/stripTrackedChanges.js +16 -6
  494. package/dist/processors/stripTrackedChanges.js.map +1 -0
  495. package/dist/tracking/DocumentTrackingContext.d.ts +4 -4
  496. package/dist/tracking/DocumentTrackingContext.d.ts.map +1 -1
  497. package/dist/tracking/DocumentTrackingContext.js +38 -43
  498. package/dist/tracking/DocumentTrackingContext.js.map +1 -1
  499. package/dist/tracking/TrackingContext.d.ts +8 -8
  500. package/dist/tracking/TrackingContext.d.ts.map +1 -1
  501. package/dist/tracking/TrackingContext.js.map +1 -1
  502. package/dist/types/document-types.d.ts +28 -0
  503. package/dist/types/document-types.d.ts.map +1 -0
  504. package/dist/types/document-types.js +3 -0
  505. package/dist/types/document-types.js.map +1 -0
  506. package/dist/types/formatting.d.ts +4 -4
  507. package/dist/types/formatting.d.ts.map +1 -1
  508. package/dist/types/formatting.js.map +1 -1
  509. package/dist/types/settings-types.d.ts +6 -0
  510. package/dist/types/settings-types.d.ts.map +1 -1
  511. package/dist/types/settings-types.js.map +1 -1
  512. package/dist/utils/KeyedRegistry.d.ts +13 -0
  513. package/dist/utils/KeyedRegistry.d.ts.map +1 -0
  514. package/dist/utils/KeyedRegistry.js +36 -0
  515. package/dist/utils/KeyedRegistry.js.map +1 -0
  516. package/dist/utils/corruptionDetection.d.ts +1 -1
  517. package/dist/utils/corruptionDetection.d.ts.map +1 -1
  518. package/dist/utils/corruptionDetection.js +4 -4
  519. package/dist/utils/corruptionDetection.js.map +1 -1
  520. package/dist/utils/deepEqual.d.ts +2 -0
  521. package/dist/utils/deepEqual.d.ts.map +1 -0
  522. package/dist/utils/deepEqual.js +50 -0
  523. package/dist/utils/deepEqual.js.map +1 -0
  524. package/dist/utils/list-detection.d.ts +2 -2
  525. package/dist/utils/list-detection.d.ts.map +1 -1
  526. package/dist/utils/list-detection.js.map +1 -1
  527. package/dist/utils/parsingHelpers.d.ts +1 -1
  528. package/dist/utils/parsingHelpers.d.ts.map +1 -1
  529. package/dist/utils/parsingHelpers.js +2 -2
  530. package/dist/utils/parsingHelpers.js.map +1 -1
  531. package/dist/utils/validation.js +7 -7
  532. package/dist/utils/validation.js.map +1 -1
  533. package/dist/utils/xmlSanitization.js +2 -2
  534. package/dist/utils/xmlSanitization.js.map +1 -1
  535. package/dist/validation/RevisionAutoFixer.d.ts +4 -4
  536. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  537. package/dist/validation/RevisionAutoFixer.js +11 -11
  538. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  539. package/dist/validation/RevisionValidator.d.ts +5 -4
  540. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  541. package/dist/validation/RevisionValidator.js +29 -30
  542. package/dist/validation/RevisionValidator.js.map +1 -1
  543. package/dist/validation/ValidationRuleRegistry.d.ts +27 -0
  544. package/dist/validation/ValidationRuleRegistry.d.ts.map +1 -0
  545. package/dist/validation/ValidationRuleRegistry.js +43 -0
  546. package/dist/validation/ValidationRuleRegistry.js.map +1 -0
  547. package/dist/validation/index.d.ts +3 -3
  548. package/dist/validation/index.d.ts.map +1 -1
  549. package/dist/validation/index.js +10 -10
  550. package/dist/validation/index.js.map +1 -1
  551. package/dist/xml/XMLBuilder.d.ts +6 -1
  552. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  553. package/dist/xml/XMLBuilder.js +11 -6
  554. package/dist/xml/XMLBuilder.js.map +1 -1
  555. package/dist/xml/XMLParser.js +6 -6
  556. package/dist/xml/XMLParser.js.map +1 -1
  557. package/dist/zip/ZipHandler.d.ts +1 -1
  558. package/dist/zip/ZipHandler.d.ts.map +1 -1
  559. package/dist/zip/ZipHandler.js +8 -8
  560. package/dist/zip/ZipHandler.js.map +1 -1
  561. package/dist/zip/ZipReader.d.ts +1 -1
  562. package/dist/zip/ZipReader.d.ts.map +1 -1
  563. package/dist/zip/ZipReader.js +14 -14
  564. package/dist/zip/ZipReader.js.map +1 -1
  565. package/dist/zip/ZipWriter.d.ts +1 -1
  566. package/dist/zip/ZipWriter.d.ts.map +1 -1
  567. package/dist/zip/ZipWriter.js +10 -10
  568. package/dist/zip/ZipWriter.js.map +1 -1
  569. package/package.json +20 -4
  570. package/src/constants/legacyCompatFlags.ts +1 -1
  571. package/src/core/Document.ts +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 +2180 -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 +3 -3
  600. package/src/elements/MathElement.ts +1 -1
  601. package/src/elements/Paragraph.ts +221 -88
  602. package/src/elements/PreservedElement.ts +1 -1
  603. package/src/elements/PropertyChangeTypes.ts +2 -2
  604. package/src/elements/RangeMarker.ts +153 -12
  605. package/src/elements/RegisteredBodyElement.ts +52 -0
  606. package/src/elements/Revision.ts +14 -14
  607. package/src/elements/RevisionContent.ts +3 -3
  608. package/src/elements/RevisionManager.ts +3 -3
  609. package/src/elements/Run.ts +221 -94
  610. package/src/elements/Section.ts +136 -69
  611. package/src/elements/Shape.ts +4 -4
  612. package/src/elements/StructuredDocumentTag.ts +3 -3
  613. package/src/elements/Table.ts +91 -27
  614. package/src/elements/TableCell.ts +62 -34
  615. package/src/elements/TableGridChange.ts +1 -1
  616. package/src/elements/TableOfContents.ts +1 -1
  617. package/src/elements/TableOfContentsElement.ts +2 -2
  618. package/src/elements/TableRow.ts +192 -48
  619. package/src/elements/TextBox.ts +5 -5
  620. package/src/formatting/AbstractNumbering.ts +3 -3
  621. package/src/formatting/NumberingInstance.ts +2 -2
  622. package/src/formatting/NumberingLevel.ts +201 -10
  623. package/src/formatting/NumberingManager.ts +5 -5
  624. package/src/formatting/Style.ts +382 -86
  625. package/src/formatting/StylesManager.ts +4 -4
  626. package/src/helpers/CleanupHelper.ts +6 -6
  627. package/src/index.ts +118 -127
  628. package/src/internal.ts +79 -0
  629. package/src/managers/DrawingManager.ts +3 -3
  630. package/src/{utils → processors}/ChangelogGenerator.ts +3 -3
  631. package/src/{utils → processors}/CompatibilityUpgrader.ts +2 -2
  632. package/src/{utils → processors}/InMemoryRevisionAcceptor.ts +100 -12
  633. package/src/{utils → processors}/MoveOperationHelper.ts +5 -5
  634. package/src/{utils → processors}/RevisionAwareProcessor.ts +3 -3
  635. package/src/{utils → processors}/RevisionWalker.ts +42 -1
  636. package/src/{utils → processors}/SelectiveRevisionAcceptor.ts +98 -39
  637. package/src/{utils → processors}/ShadingResolver.ts +5 -5
  638. package/src/{utils → processors}/acceptRevisions.ts +77 -9
  639. package/src/{utils → processors}/cnfStyleDecoder.ts +1 -1
  640. package/src/{utils → processors}/stripTrackedChanges.ts +35 -10
  641. package/src/tracking/DocumentTrackingContext.ts +12 -14
  642. package/src/tracking/TrackingContext.ts +8 -8
  643. package/src/types/document-types.ts +53 -0
  644. package/src/types/formatting.ts +4 -4
  645. package/src/types/settings-types.ts +32 -0
  646. package/src/utils/KeyedRegistry.ts +41 -0
  647. package/src/utils/corruptionDetection.ts +2 -2
  648. package/src/utils/deepEqual.ts +58 -0
  649. package/src/utils/list-detection.ts +2 -2
  650. package/src/utils/parsingHelpers.ts +11 -3
  651. package/src/utils/validation.ts +3 -3
  652. package/src/utils/xmlSanitization.ts +1 -1
  653. package/src/validation/RevisionAutoFixer.ts +5 -5
  654. package/src/validation/RevisionValidator.ts +39 -28
  655. package/src/validation/ValidationRuleRegistry.ts +86 -0
  656. package/src/validation/index.ts +3 -3
  657. package/src/xml/XMLBuilder.ts +13 -3
  658. package/src/xml/XMLParser.ts +2 -2
  659. package/src/zip/ZipHandler.ts +4 -4
  660. package/src/zip/ZipReader.ts +3 -3
  661. package/src/zip/ZipWriter.ts +3 -3
  662. package/dist/utils/ChangelogGenerator.d.ts.map +0 -1
  663. package/dist/utils/ChangelogGenerator.js.map +0 -1
  664. package/dist/utils/CompatibilityUpgrader.d.ts.map +0 -1
  665. package/dist/utils/CompatibilityUpgrader.js.map +0 -1
  666. package/dist/utils/InMemoryRevisionAcceptor.d.ts.map +0 -1
  667. package/dist/utils/InMemoryRevisionAcceptor.js.map +0 -1
  668. package/dist/utils/MoveOperationHelper.d.ts.map +0 -1
  669. package/dist/utils/MoveOperationHelper.js.map +0 -1
  670. package/dist/utils/RevisionAwareProcessor.d.ts.map +0 -1
  671. package/dist/utils/RevisionAwareProcessor.js.map +0 -1
  672. package/dist/utils/RevisionWalker.d.ts.map +0 -1
  673. package/dist/utils/RevisionWalker.js.map +0 -1
  674. package/dist/utils/SelectiveRevisionAcceptor.d.ts.map +0 -1
  675. package/dist/utils/SelectiveRevisionAcceptor.js.map +0 -1
  676. package/dist/utils/ShadingResolver.d.ts +0 -6
  677. package/dist/utils/ShadingResolver.d.ts.map +0 -1
  678. package/dist/utils/ShadingResolver.js.map +0 -1
  679. package/dist/utils/acceptRevisions.d.ts.map +0 -1
  680. package/dist/utils/acceptRevisions.js.map +0 -1
  681. package/dist/utils/cnfStyleDecoder.d.ts.map +0 -1
  682. package/dist/utils/cnfStyleDecoder.js.map +0 -1
  683. package/dist/utils/stripTrackedChanges.d.ts +0 -3
  684. package/dist/utils/stripTrackedChanges.d.ts.map +0 -1
  685. package/dist/utils/stripTrackedChanges.js.map +0 -1
  686. package/src/__tests__/helper-methods.test.ts +0 -512
  687. package/src/constants/CLAUDE.md +0 -28
  688. package/src/core/CLAUDE.md +0 -113
  689. package/src/elements/CLAUDE.md +0 -142
  690. package/src/formatting/CLAUDE.md +0 -78
  691. package/src/managers/CLAUDE.md +0 -47
  692. package/src/tracking/CLAUDE.md +0 -30
  693. package/src/types/CLAUDE.md +0 -39
  694. package/src/utils/CLAUDE.md +0 -168
  695. package/src/validation/CLAUDE.md +0 -40
  696. package/src/xml/CLAUDE.md +0 -65
  697. package/src/zip/CLAUDE.md +0 -55
  698. /package/dist/{utils → processors}/CompatibilityUpgrader.d.ts +0 -0
  699. /package/dist/{utils → processors}/cnfStyleDecoder.js +0 -0
@@ -3,24 +3,26 @@
3
3
  * Extracts content from ZIP archives and converts XML to structured data
4
4
  */
5
5
 
6
- import { AlternateContent } from '../elements/AlternateContent';
7
- import { Bookmark } from '../elements/Bookmark';
8
- import { Endnote, EndnoteType } from '../elements/Endnote';
9
- import { Footnote, FootnoteType } from '../elements/Footnote';
10
- import { BookmarkManager } from '../elements/BookmarkManager';
11
- import { Comment } from '../elements/Comment';
12
- import { CustomXmlBlock } from '../elements/CustomXml';
13
- import { PreservedElement } from '../elements/PreservedElement';
14
- import { MathParagraph } from '../elements/MathElement';
15
- import { ComplexField, Field } from '../elements/Field';
16
- import { isHyperlinkInstruction, parseHyperlinkInstruction } from '../elements/FieldHelpers';
17
- import { Footer } from '../elements/Footer';
18
- import { Header } from '../elements/Header';
19
- import { Hyperlink } from '../elements/Hyperlink';
20
- import { ImageManager } from '../elements/ImageManager';
21
- import { ImageRun } from '../elements/ImageRun';
22
- import { Paragraph, ParagraphFormatting, ParagraphContent } from '../elements/Paragraph';
23
- import { Revision } from '../elements/Revision';
6
+ import { AlternateContent } from '../elements/AlternateContent.js';
7
+ import { Bookmark } from '../elements/Bookmark.js';
8
+ import { Endnote, EndnoteType } from '../elements/Endnote.js';
9
+ import { Footnote, FootnoteType } from '../elements/Footnote.js';
10
+ import { BookmarkManager } from '../elements/BookmarkManager.js';
11
+ import { Comment } from '../elements/Comment.js';
12
+ import { CustomXmlBlock } from '../elements/CustomXml.js';
13
+ import { PreservedElement } from '../elements/PreservedElement.js';
14
+ import { RegisteredBodyElement } from '../elements/RegisteredBodyElement.js';
15
+ import { ElementRegistry } from './ElementRegistry.js';
16
+ import { MathParagraph } from '../elements/MathElement.js';
17
+ import { ComplexField, Field } from '../elements/Field.js';
18
+ import { isHyperlinkInstruction, parseHyperlinkInstruction } from '../elements/FieldHelpers.js';
19
+ import { Footer } from '../elements/Footer.js';
20
+ import { Header } from '../elements/Header.js';
21
+ import { Hyperlink } from '../elements/Hyperlink.js';
22
+ import { ImageManager } from '../elements/ImageManager.js';
23
+ import { ImageRun } from '../elements/ImageRun.js';
24
+ import { Paragraph, ParagraphFormatting, ParagraphContent } from '../elements/Paragraph.js';
25
+ import { Revision } from '../elements/Revision.js';
24
26
  import {
25
27
  BreakType,
26
28
  FormFieldCheckBox,
@@ -30,35 +32,40 @@ import {
30
32
  Run,
31
33
  RunContent,
32
34
  RunFormatting,
33
- } from '../elements/Run';
34
- import { Section, SectionProperties, SectionType } from '../elements/Section';
35
- import { StructuredDocumentTag } from '../elements/StructuredDocumentTag';
36
- import { Table, TableBorder } from '../elements/Table';
37
- import { TableCell } from '../elements/TableCell';
38
- import { TableOfContents } from '../elements/TableOfContents';
39
- import { TableOfContentsElement } from '../elements/TableOfContentsElement';
40
- import { TableGridChange } from '../elements/TableGridChange';
41
- import { TableRow } from '../elements/TableRow';
42
- import { AbstractNumbering } from '../formatting/AbstractNumbering';
43
- import { NumberingInstance } from '../formatting/NumberingInstance';
44
- import { Style, StyleProperties, StyleType } from '../formatting/Style';
45
- import { logParagraphContent, logParsing, logTextDirection } from '../utils/diagnostics';
46
- import { getGlobalLogger, createScopedLogger, ILogger, defaultLogger } from '../utils/logger';
47
- import { safeParseInt, isExplicitlySet, parseOoxmlBoolean } from '../utils/parsingHelpers';
48
- import { halfPointsToPoints } from '../utils/units';
49
- import type { ShadingConfig } from '../elements/CommonTypes';
35
+ } from '../elements/Run.js';
36
+ import { Section, SectionProperties, SectionType } from '../elements/Section.js';
37
+ import { StructuredDocumentTag } from '../elements/StructuredDocumentTag.js';
38
+ import { Table, TableBorder } from '../elements/Table.js';
39
+ import { TableCell } from '../elements/TableCell.js';
40
+ import { TableOfContents } from '../elements/TableOfContents.js';
41
+ import { TableOfContentsElement } from '../elements/TableOfContentsElement.js';
42
+ import { TableGridChange } from '../elements/TableGridChange.js';
43
+ import { TableRow } from '../elements/TableRow.js';
44
+ import { AbstractNumbering } from '../formatting/AbstractNumbering.js';
45
+ import { NumberingInstance } from '../formatting/NumberingInstance.js';
46
+ import { Style, StyleProperties, StyleType } from '../formatting/Style.js';
47
+ import { logParagraphContent, logParsing, logTextDirection } from '../utils/diagnostics.js';
48
+ import { getGlobalLogger, createScopedLogger, ILogger, defaultLogger } from '../utils/logger.js';
49
+ import {
50
+ safeParseInt,
51
+ isExplicitlySet,
52
+ parseOoxmlBoolean,
53
+ parseOnOffAttribute,
54
+ } from '../utils/parsingHelpers.js';
55
+ import { halfPointsToPoints } from '../utils/units.js';
56
+ import type { ShadingConfig } from '../elements/CommonTypes.js';
50
57
 
51
58
  // Create scoped logger for DocumentParser operations
52
59
  function getLogger(): ILogger {
53
60
  return createScopedLogger(getGlobalLogger(), 'DocumentParser');
54
61
  }
55
- import { XMLBuilder, XMLElement } from '../xml/XMLBuilder';
56
- import { XMLParser } from '../xml/XMLParser';
57
- import { ZipHandler } from '../zip/ZipHandler';
58
- import { DOCX_PATHS } from '../zip/types';
59
- import { DocumentProperties } from './Document';
60
- import { BodyElement } from './DocumentContent';
61
- import { RelationshipManager } from './RelationshipManager';
62
+ import { XMLBuilder, XMLElement } from '../xml/XMLBuilder.js';
63
+ import { XMLParser } from '../xml/XMLParser.js';
64
+ import { ZipHandler } from '../zip/ZipHandler.js';
65
+ import { DOCX_PATHS } from '../zip/types.js';
66
+ import type { DocumentProperties } from '../types/document-types.js';
67
+ import { BodyElement } from './DocumentContent.js';
68
+ import { RelationshipManager } from './RelationshipManager.js';
62
69
 
63
70
  /**
64
71
  * Parse error tracking
@@ -242,7 +249,7 @@ export class DocumentParser {
242
249
  const nextCXml = this.findNextTopLevelTag(bodyContent, 'w:customXml', pos);
243
250
  const nextAltChunk = this.findNextTopLevelTag(bodyContent, 'w:altChunk', pos);
244
251
 
245
- const candidates = [];
252
+ const candidates: { type: string; pos: number; registeredTag?: string }[] = [];
246
253
  if (nextP !== -1) candidates.push({ type: 'p', pos: nextP });
247
254
  if (nextTbl !== -1) candidates.push({ type: 'tbl', pos: nextTbl });
248
255
  if (nextSdt !== -1) candidates.push({ type: 'sdt', pos: nextSdt });
@@ -251,6 +258,15 @@ export class DocumentParser {
251
258
  if (nextCXml !== -1) candidates.push({ type: 'customXml', pos: nextCXml });
252
259
  if (nextAltChunk !== -1) candidates.push({ type: 'altChunk', pos: nextAltChunk });
253
260
 
261
+ // ElementRegistry plugin tags — consumer-registered handlers for
262
+ // qualified-name body elements outside the framework's native set.
263
+ // We scan each registered tag at every step so the position-based
264
+ // dispatch picks up registered elements interleaved with native ones.
265
+ for (const tag of ElementRegistry.registeredTags()) {
266
+ const p = this.findNextTopLevelTag(bodyContent, tag, pos);
267
+ if (p !== -1) candidates.push({ type: 'registered', pos: p, registeredTag: tag });
268
+ }
269
+
254
270
  if (candidates.length === 0) break;
255
271
 
256
272
  candidates.sort((a, b) => a.pos - b.pos);
@@ -399,6 +415,29 @@ export class DocumentParser {
399
415
  } else {
400
416
  pos = next.pos + 1;
401
417
  }
418
+ } else if (next.type === 'registered' && next.registeredTag) {
419
+ // Consumer-registered element via ElementRegistry. Hand the raw XML
420
+ // to the handler's parse(); on save the model is round-tripped via
421
+ // handler.serialize(). A throwing parse degrades gracefully to a
422
+ // PreservedElement so a buggy custom parser cannot fail the load.
423
+ const tag = next.registeredTag;
424
+ const elementXml = this.extractSingleElement(bodyContent, tag, next.pos);
425
+ if (elementXml) {
426
+ const handler = ElementRegistry.get(tag);
427
+ if (handler) {
428
+ try {
429
+ const model = handler.parse(elementXml);
430
+ bodyElements.push(new RegisteredBodyElement(tag, model, handler, elementXml));
431
+ } catch {
432
+ bodyElements.push(new PreservedElement(elementXml, tag, 'block'));
433
+ }
434
+ } else {
435
+ bodyElements.push(new PreservedElement(elementXml, tag, 'block'));
436
+ }
437
+ pos = next.pos + elementXml.length;
438
+ } else {
439
+ pos = next.pos + 1;
440
+ }
402
441
  }
403
442
 
404
443
  // Attach any pending body-level bookmarkStarts to the just-parsed element
@@ -507,10 +546,18 @@ export class DocumentParser {
507
546
  if (idAttr) {
508
547
  const id = parseInt(idAttr, 10);
509
548
  if (!isNaN(id)) {
549
+ // CT_MarkupRange §17.13.5 — preserve w:displacedByCustomXml.
550
+ const displacedAttr = XMLParser.extractAttribute(
551
+ bookmarkEndXml,
552
+ 'w:displacedByCustomXml'
553
+ );
554
+ const displacedByCustomXml =
555
+ displacedAttr === 'next' || displacedAttr === 'prev' ? displacedAttr : undefined;
510
556
  const bookmark = new Bookmark({
511
557
  name: `_end_${id}`,
512
558
  id: id,
513
559
  skipNormalization: true,
560
+ displacedByCustomXml,
514
561
  });
515
562
  bookmarks.push(bookmark);
516
563
  }
@@ -905,14 +952,27 @@ export class DocumentParser {
905
952
  }
906
953
  }
907
954
 
908
- // Parse w14:paraId and w14:textId if present
909
- const paraId = pElement['w14:paraId'];
955
+ // Parse w14:paraId and w14:textId (Word 2010+ paragraph identifiers
956
+ // per MC-DOCX §2.6.19, ST_LongHexNumber — 8-char hex string). These
957
+ // are XML *attributes* on w:p, so XMLParser stores them under the
958
+ // @_-prefixed keys. The previous lookup (`pElement['w14:paraId']`)
959
+ // accessed an element-shaped key that never exists, silently
960
+ // dropping both IDs on every load → save cycle. XMLParser's
961
+ // numeric coercion of purely-digit hex strings (e.g. "00000001" →
962
+ // 1) means we normalise back to the zero-padded 8-char form so
963
+ // validators accept the output.
964
+ const normaliseHexId = (raw: unknown): string | undefined => {
965
+ if (raw === undefined || raw === null) return undefined;
966
+ const asStr = typeof raw === 'number' ? raw.toString(16) : String(raw);
967
+ return asStr.toUpperCase().padStart(8, '0');
968
+ };
969
+ const paraId = normaliseHexId(pElement['@_w14:paraId']);
910
970
  if (paraId) {
911
- paragraph.formatting.paraId = paraId as string;
971
+ paragraph.formatting.paraId = paraId;
912
972
  }
913
- const textId = pElement['w14:textId'];
973
+ const textId = normaliseHexId(pElement['@_w14:textId']);
914
974
  if (textId) {
915
- paragraph.formatting.textId = textId as string;
975
+ paragraph.formatting.textId = textId;
916
976
  }
917
977
 
918
978
  // CRITICAL FIX: Preserve document order of paragraph children (runs, hyperlinks, fields)
@@ -1533,7 +1593,7 @@ export class DocumentParser {
1533
1593
  } = { revision: null, bookmarkStarts: [], bookmarkEnds: [] };
1534
1594
  try {
1535
1595
  // Map XML tag to RevisionType
1536
- let revisionType: import('../elements/Revision').RevisionType;
1596
+ let revisionType: import('../elements/Revision.js').RevisionType;
1537
1597
  switch (tagName) {
1538
1598
  case 'w:ins':
1539
1599
  revisionType = 'insert';
@@ -1581,7 +1641,7 @@ export class DocumentParser {
1581
1641
  const runXmls = XMLParser.extractElements(xmlWithoutHyperlinks, 'w:r');
1582
1642
 
1583
1643
  // Use RevisionContent to hold both Run and Hyperlink objects
1584
- const content: import('../elements/RevisionContent').RevisionContent[] = [];
1644
+ const content: import('../elements/RevisionContent.js').RevisionContent[] = [];
1585
1645
 
1586
1646
  // Parse standalone runs (not inside hyperlinks)
1587
1647
  for (const runXml of runXmls) {
@@ -1727,8 +1787,8 @@ export class DocumentParser {
1727
1787
  const id = parseInt(idAttr, 10);
1728
1788
  const date = dateAttr ? new Date(dateAttr) : new Date();
1729
1789
  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';
1790
+ // Per ECMA-376 §17.17.4, w:done is ST_OnOff accept 1/0/true/false/on/off
1791
+ const done = parseOnOffAttribute(doneAttr);
1732
1792
 
1733
1793
  // Parse content (runs from paragraphs within the comment)
1734
1794
  const runs: Run[] = [];
@@ -1921,12 +1981,20 @@ export class DocumentParser {
1921
1981
  // Parse optional column range for table bookmarks (ECMA-376 §17.16.5)
1922
1982
  const colFirstAttr = XMLParser.extractAttribute(bookmarkXml, 'w:colFirst');
1923
1983
  const colLastAttr = XMLParser.extractAttribute(bookmarkXml, 'w:colLast');
1984
+ // Parse optional w:displacedByCustomXml per CT_MarkupRange (§17.13.5).
1985
+ // Without this the attribute was dropped on load, so any Word document
1986
+ // with custom-XML-displaced bookmarks lost the disambiguator even
1987
+ // though the model now supports round-tripping it.
1988
+ const displacedAttr = XMLParser.extractAttribute(bookmarkXml, 'w:displacedByCustomXml');
1989
+ const displacedByCustomXml =
1990
+ displacedAttr === 'next' || displacedAttr === 'prev' ? displacedAttr : undefined;
1924
1991
  const bookmark = new Bookmark({
1925
1992
  name: nameAttr,
1926
1993
  id: id,
1927
1994
  skipNormalization: true,
1928
1995
  colFirst: colFirstAttr ? parseInt(colFirstAttr, 10) : undefined,
1929
1996
  colLast: colLastAttr ? parseInt(colLastAttr, 10) : undefined,
1997
+ displacedByCustomXml,
1930
1998
  });
1931
1999
 
1932
2000
  // Register with BookmarkManager to enable hasBookmark() checks
@@ -1969,12 +2037,22 @@ export class DocumentParser {
1969
2037
 
1970
2038
  const id = parseInt(idAttr, 10);
1971
2039
 
2040
+ // CT_MarkupRange (§17.13.5) also permits w:displacedByCustomXml on
2041
+ // the end marker. Previously dropped on load, so a Word document
2042
+ // whose bookmark-end was displaced across a custom-XML node lost
2043
+ // the disambiguator even though the Bookmark model already emits
2044
+ // it from toEndXML().
2045
+ const displacedAttr = XMLParser.extractAttribute(bookmarkXml, 'w:displacedByCustomXml');
2046
+ const displacedByCustomXml =
2047
+ displacedAttr === 'next' || displacedAttr === 'prev' ? displacedAttr : undefined;
2048
+
1972
2049
  // Create a placeholder bookmark for the end marker
1973
2050
  // The name doesn't matter for bookmarkEnd as it only uses the ID
1974
2051
  const bookmark = new Bookmark({
1975
2052
  name: `_end_${id}`,
1976
2053
  id: id,
1977
2054
  skipNormalization: true,
2055
+ displacedByCustomXml,
1978
2056
  });
1979
2057
 
1980
2058
  return bookmark;
@@ -1996,12 +2074,23 @@ export class DocumentParser {
1996
2074
  try {
1997
2075
  const paragraph = new Paragraph();
1998
2076
 
1999
- // Parse w14:paraId and w14:textId attributes from paragraph element (Word 2010+)
2000
- const paraId = paraObj['w14:paraId'];
2077
+ // Parse w14:paraId and w14:textId attributes from paragraph element
2078
+ // (Word 2010+, ST_LongHexNumber 8-char hex). XMLParser keys
2079
+ // attributes under the @_ prefix and may numeric-coerce purely-
2080
+ // digit hex strings like "00000001" to the number 1 — normalise
2081
+ // back to 8-char uppercase hex so the output passes strict
2082
+ // validation. The prior code used the un-prefixed element-shaped
2083
+ // keys and always saw `undefined`.
2084
+ const normaliseHexId = (raw: unknown): string | undefined => {
2085
+ if (raw === undefined || raw === null) return undefined;
2086
+ const asStr = typeof raw === 'number' ? raw.toString(16) : String(raw);
2087
+ return asStr.toUpperCase().padStart(8, '0');
2088
+ };
2089
+ const paraId = normaliseHexId(paraObj['@_w14:paraId']);
2001
2090
  if (paraId) {
2002
2091
  paragraph.formatting.paraId = paraId;
2003
2092
  }
2004
- const textId = paraObj['w14:textId'];
2093
+ const textId = normaliseHexId(paraObj['@_w14:textId']);
2005
2094
  if (textId) {
2006
2095
  paragraph.formatting.textId = textId;
2007
2096
  }
@@ -2171,6 +2260,22 @@ export class DocumentParser {
2171
2260
  // Extract the formatting and set it as paragraph mark properties
2172
2261
  paragraph.setParagraphMarkFormatting(tempRun.getFormatting());
2173
2262
 
2263
+ // Transfer w:rPrChange (CT_ParaRPrChange, §17.3.1.30) from the
2264
+ // temp run onto the paragraph's formatting. Without this the
2265
+ // paragraph-mark rPrChange is silently dropped because
2266
+ // `tempRun.getFormatting()` exposes RunFormatting fields only —
2267
+ // `propertyChangeRevision` is a separate field on Run that was
2268
+ // previously discarded along with the temp run.
2269
+ const rPrChangeRev = tempRun.getPropertyChangeRevision();
2270
+ if (rPrChangeRev) {
2271
+ paragraph.formatting.paragraphMarkRunPropertiesChange = {
2272
+ id: rPrChangeRev.id,
2273
+ author: rPrChangeRev.author,
2274
+ date: rPrChangeRev.date,
2275
+ previousProperties: rPrChangeRev.previousProperties,
2276
+ };
2277
+ }
2278
+
2174
2279
  // Parse paragraph mark deletion tracking (w:del in w:pPr/w:rPr)
2175
2280
  // Per ECMA-376 Part 1 §17.13.5.14 - indicates the paragraph mark was deleted
2176
2281
  if (rPrObj['w:del']) {
@@ -2210,9 +2315,14 @@ export class DocumentParser {
2210
2315
  paragraph.setAlignment(pPrObj['w:jc']['@_w:val']);
2211
2316
  }
2212
2317
 
2213
- // Style
2214
- if (pPrObj['w:pStyle']?.['@_w:val']) {
2215
- paragraph.setStyle(pPrObj['w:pStyle']['@_w:val']);
2318
+ // Style (w:pStyle per ECMA-376 §17.3.1.27 — `w:val` is ST_String
2319
+ // referencing a style ID). Cast via String(...) so purely-numeric
2320
+ // style IDs that XMLParser's `parseAttributeValue: true` coerces to
2321
+ // JS numbers (e.g., a custom styleId of "1") survive as strings,
2322
+ // matching the `style?: string` field contract on
2323
+ // ParagraphFormatting.
2324
+ if (pPrObj['w:pStyle']?.['@_w:val'] !== undefined) {
2325
+ paragraph.setStyle(String(pPrObj['w:pStyle']['@_w:val']));
2216
2326
  }
2217
2327
 
2218
2328
  // Indentation
@@ -2231,6 +2341,24 @@ export class DocumentParser {
2231
2341
  // Parse hanging indent per ECMA-376 Part 1 §17.3.1.17
2232
2342
  if (isExplicitlySet(ind['@_w:hanging']))
2233
2343
  paragraph.setHangingIndent(safeParseInt(ind['@_w:hanging']));
2344
+
2345
+ // CJK character-unit indentation attributes per ECMA-376 §17.3.1.12.
2346
+ // start/endChars are bidi-aware alternatives to left/rightChars; collapse
2347
+ // them onto the leftChars/rightChars fields the same way the twips parser
2348
+ // collapses w:start → left. Values are ST_DecimalNumber (hundredths of a
2349
+ // character unit), and 0 is a legitimate value — use isExplicitlySet so
2350
+ // number-0 from XMLParser.parseAttributeValue is preserved.
2351
+ if (!paragraph.formatting.indentation) paragraph.formatting.indentation = {};
2352
+ const leftCharsVal = ind['@_w:startChars'] ?? ind['@_w:leftChars'];
2353
+ const rightCharsVal = ind['@_w:endChars'] ?? ind['@_w:rightChars'];
2354
+ if (isExplicitlySet(leftCharsVal))
2355
+ paragraph.formatting.indentation.leftChars = safeParseInt(leftCharsVal);
2356
+ if (isExplicitlySet(rightCharsVal))
2357
+ paragraph.formatting.indentation.rightChars = safeParseInt(rightCharsVal);
2358
+ if (isExplicitlySet(ind['@_w:firstLineChars']))
2359
+ paragraph.formatting.indentation.firstLineChars = safeParseInt(ind['@_w:firstLineChars']);
2360
+ if (isExplicitlySet(ind['@_w:hangingChars']))
2361
+ paragraph.formatting.indentation.hangingChars = safeParseInt(ind['@_w:hangingChars']);
2234
2362
  }
2235
2363
 
2236
2364
  // Spacing (ECMA-376 §17.3.1.33 — 8 attributes)
@@ -2251,14 +2379,13 @@ export class DocumentParser {
2251
2379
  paragraph.formatting.spacing.beforeLines = safeParseInt(spacing['@_w:beforeLines']);
2252
2380
  if (isExplicitlySet(spacing['@_w:afterLines']))
2253
2381
  paragraph.formatting.spacing.afterLines = safeParseInt(spacing['@_w:afterLines']);
2382
+ // ST_OnOff per ECMA-376 §17.17.4 — accept 1/0/true/false/on/off
2254
2383
  const beforeAuto = spacing['@_w:beforeAutospacing'];
2255
2384
  if (beforeAuto !== undefined)
2256
- paragraph.formatting.spacing.beforeAutospacing =
2257
- String(beforeAuto) === '1' || String(beforeAuto) === 'true';
2385
+ paragraph.formatting.spacing.beforeAutospacing = parseOnOffAttribute(beforeAuto);
2258
2386
  const afterAuto = spacing['@_w:afterAutospacing'];
2259
2387
  if (afterAuto !== undefined)
2260
- paragraph.formatting.spacing.afterAutospacing =
2261
- String(afterAuto) === '1' || String(afterAuto) === 'true';
2388
+ paragraph.formatting.spacing.afterAutospacing = parseOnOffAttribute(afterAuto);
2262
2389
  }
2263
2390
 
2264
2391
  // Keep properties — preserve explicit val="0" to override style inheritance
@@ -2306,7 +2433,12 @@ export class DocumentParser {
2306
2433
  const pBdr = pPrObj['w:pBdr'];
2307
2434
  const borders: any = {};
2308
2435
 
2309
- // Helper function to parse border definition
2436
+ // Helper function to parse border definition.
2437
+ // Covers the full CT_Border attribute set per ECMA-376 §17.18.2:
2438
+ // w:val, w:sz, w:color, w:space, w:themeColor, w:themeTint,
2439
+ // w:themeShade, w:shadow, w:frame. The last two are ST_OnOff —
2440
+ // route through parseOnOffAttribute so "off"/"false"/"0"/"on"
2441
+ // all resolve correctly even after XMLParser numeric coercion.
2310
2442
  const parseBorder = (borderObj: any): any => {
2311
2443
  if (!borderObj) return undefined;
2312
2444
  const border: any = {};
@@ -2315,6 +2447,15 @@ export class DocumentParser {
2315
2447
  if (borderObj['@_w:color']) border.color = borderObj['@_w:color'];
2316
2448
  if (borderObj['@_w:space'] !== undefined)
2317
2449
  border.space = safeParseInt(borderObj['@_w:space']);
2450
+ if (borderObj['@_w:themeColor']) border.themeColor = String(borderObj['@_w:themeColor']);
2451
+ if (borderObj['@_w:themeTint']) border.themeTint = String(borderObj['@_w:themeTint']);
2452
+ if (borderObj['@_w:themeShade']) border.themeShade = String(borderObj['@_w:themeShade']);
2453
+ if (borderObj['@_w:shadow'] !== undefined) {
2454
+ border.shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
2455
+ }
2456
+ if (borderObj['@_w:frame'] !== undefined) {
2457
+ border.frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
2458
+ }
2318
2459
  return Object.keys(border).length > 0 ? border : undefined;
2319
2460
  };
2320
2461
 
@@ -2353,7 +2494,15 @@ export class DocumentParser {
2353
2494
 
2354
2495
  for (const tabObj of tabElements) {
2355
2496
  const tab: any = {};
2356
- if (tabObj['@_w:pos']) tab.position = parseInt(tabObj['@_w:pos'], 10);
2497
+ // w:pos is REQUIRED per §17.3.1.38 and is ST_SignedTwipsMeasure — 0 and
2498
+ // negative values are both valid. Use `!== undefined` so that XMLParser's
2499
+ // parseAttributeValue coercion of "0" to number 0 doesn't silently drop
2500
+ // tabs at the left margin (the previous `if (tabObj['@_w:pos'])` truthy
2501
+ // check turned pos=0 into an invisible tab-loss bug).
2502
+ if (tabObj['@_w:pos'] !== undefined) {
2503
+ const parsed = parseInt(String(tabObj['@_w:pos']), 10);
2504
+ if (!isNaN(parsed)) tab.position = parsed;
2505
+ }
2357
2506
  if (tabObj['@_w:val']) tab.val = tabObj['@_w:val'];
2358
2507
  if (tabObj['@_w:leader']) tab.leader = tabObj['@_w:leader'];
2359
2508
 
@@ -2369,19 +2518,10 @@ export class DocumentParser {
2369
2518
 
2370
2519
  // Widow control per ECMA-376 Part 1 §17.3.1.40
2371
2520
  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
- }
2521
+ // Delegate to parseOoxmlBoolean so every ST_OnOff literal — including
2522
+ // "off" / "on" resolves correctly. The previous bespoke check missed
2523
+ // "off", silently flipping explicit-off to explicit-on.
2524
+ paragraph.setWidowControl(parseOoxmlBoolean(pPrObj['w:widowControl']));
2385
2525
  }
2386
2526
 
2387
2527
  // Outline level per ECMA-376 Part 1 §17.3.1.19
@@ -2397,15 +2537,11 @@ export class DocumentParser {
2397
2537
  paragraph.setSuppressLineNumbers(parseOoxmlBoolean(pPrObj['w:suppressLineNumbers']));
2398
2538
  }
2399
2539
 
2400
- // Bidirectional layout per ECMA-376 Part 1 §17.3.1.6
2540
+ // Bidirectional layout per ECMA-376 Part 1 §17.3.1.6 — delegate to
2541
+ // parseOoxmlBoolean so "off"/"on" literals resolve correctly (the
2542
+ // previous bespoke check missed them).
2401
2543
  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
- }
2544
+ paragraph.setBidi(parseOoxmlBoolean(pPrObj['w:bidi']));
2409
2545
  }
2410
2546
 
2411
2547
  // Text direction per ECMA-376 Part 1 §17.3.1.36
@@ -2423,20 +2559,10 @@ export class DocumentParser {
2423
2559
  paragraph.setMirrorIndents(parseOoxmlBoolean(pPrObj['w:mirrorIndents']));
2424
2560
  }
2425
2561
 
2426
- // Auto-adjust right indent per ECMA-376 Part 1 §17.3.1.1
2562
+ // Auto-adjust right indent per ECMA-376 Part 1 §17.3.1.1 — delegate to
2563
+ // parseOoxmlBoolean so "off"/"on" literals resolve correctly.
2427
2564
  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
- }
2565
+ paragraph.setAdjustRightInd(parseOoxmlBoolean(pPrObj['w:adjustRightInd']));
2440
2566
  }
2441
2567
 
2442
2568
  // Text frame properties per ECMA-376 Part 1 §17.3.1.11
@@ -2510,11 +2636,16 @@ export class DocumentParser {
2510
2636
  }
2511
2637
  }
2512
2638
 
2513
- // HTML div ID per ECMA-376 Part 1 §17.3.1.9
2639
+ // HTML div ID per ECMA-376 Part 1 §17.3.1.10 (CT_DivId). `w:val` is
2640
+ // ST_DecimalNumber — 0 is a valid ID referencing the first div in
2641
+ // web settings. XMLParser coerces `"0"` to the number 0, and the
2642
+ // previous `if (divIdVal)` truthy check silently dropped it, breaking
2643
+ // the paragraph's link to div index 0 on every round-trip.
2514
2644
  if (pPrObj['w:divId']) {
2515
2645
  const divIdVal = pPrObj['w:divId']?.['@_w:val'];
2516
- if (divIdVal) {
2517
- paragraph.setDivId(parseInt(divIdVal, 10));
2646
+ if (isExplicitlySet(divIdVal)) {
2647
+ const parsed = safeParseInt(divIdVal);
2648
+ if (!isNaN(parsed)) paragraph.setDivId(parsed);
2518
2649
  }
2519
2650
  }
2520
2651
 
@@ -2529,13 +2660,27 @@ export class DocumentParser {
2529
2660
  }
2530
2661
  }
2531
2662
 
2532
- // Paragraph property change tracking per ECMA-376 Part 1 §17.3.1.27
2663
+ // Paragraph property change tracking per ECMA-376 Part 1 §17.3.1.27.
2664
+ // CT_TrackChange attributes — `w:id` (ST_DecimalNumber, required),
2665
+ // `w:author` (ST_String, required), `w:date` (ST_DateTime, optional).
2666
+ // XMLParser coerces `w:id="0"` to the number 0; the previous
2667
+ // `if (changeObj['@_w:id'])` truthy gate silently dropped id=0,
2668
+ // producing `<w:pPrChange w:author="…" w:date="…"/>` on emission —
2669
+ // missing the required `w:id` and failing strict validation. The
2670
+ // sibling `trPrChange` / `tblPrChange` / `tcPrChange` / `sectPrChange`
2671
+ // parsers already use `|| '0'` or `!== undefined` for the same reason.
2533
2672
  if (pPrObj['w:pPrChange']) {
2534
2673
  const changeObj = pPrObj['w:pPrChange'];
2535
2674
  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']);
2675
+ if (changeObj['@_w:author'] !== undefined) {
2676
+ change.author = String(changeObj['@_w:author']);
2677
+ }
2678
+ if (changeObj['@_w:date'] !== undefined) {
2679
+ change.date = String(changeObj['@_w:date']);
2680
+ }
2681
+ if (changeObj['@_w:id'] !== undefined) {
2682
+ change.id = String(changeObj['@_w:id']);
2683
+ }
2539
2684
 
2540
2685
  // Parse child w:pPr for previousProperties to preserve tracked change history
2541
2686
  if (changeObj['w:pPr']) {
@@ -2566,7 +2711,11 @@ export class DocumentParser {
2566
2711
  }
2567
2712
 
2568
2713
  // Parse previous indentation
2569
- // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to w:left/w:right
2714
+ // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to w:left/w:right.
2715
+ // Also parse the six CJK character-unit variants (ST_DecimalNumber) per §17.3.1.12;
2716
+ // these round-trip alongside the twips so Word's rendering of the tracked "previous"
2717
+ // state stays locale-accurate for CJK-authored documents. Matches the iteration-21
2718
+ // fix on the main-path parser.
2570
2719
  if (prevPPr['w:ind']) {
2571
2720
  const ind = prevPPr['w:ind'];
2572
2721
  previousProperties.indentation = {};
@@ -2578,6 +2727,18 @@ export class DocumentParser {
2578
2727
  previousProperties.indentation.firstLine = parseInt(ind['@_w:firstLine'], 10);
2579
2728
  if (ind['@_w:hanging'] !== undefined)
2580
2729
  previousProperties.indentation.hanging = parseInt(ind['@_w:hanging'], 10);
2730
+ // CJK character-unit variants. startChars/endChars collapse onto
2731
+ // leftChars/rightChars (same pattern as the twips variants).
2732
+ const leftCharsVal = ind['@_w:startChars'] ?? ind['@_w:leftChars'];
2733
+ const rightCharsVal = ind['@_w:endChars'] ?? ind['@_w:rightChars'];
2734
+ if (leftCharsVal !== undefined)
2735
+ previousProperties.indentation.leftChars = parseInt(leftCharsVal, 10);
2736
+ if (rightCharsVal !== undefined)
2737
+ previousProperties.indentation.rightChars = parseInt(rightCharsVal, 10);
2738
+ if (ind['@_w:firstLineChars'] !== undefined)
2739
+ previousProperties.indentation.firstLineChars = parseInt(ind['@_w:firstLineChars'], 10);
2740
+ if (ind['@_w:hangingChars'] !== undefined)
2741
+ previousProperties.indentation.hangingChars = parseInt(ind['@_w:hangingChars'], 10);
2581
2742
  }
2582
2743
 
2583
2744
  // Parse previous alignment
@@ -2601,48 +2762,51 @@ export class DocumentParser {
2601
2762
  previousProperties.spacing.beforeLines = parseInt(spacing['@_w:beforeLines'], 10);
2602
2763
  if (spacing['@_w:afterLines'] !== undefined)
2603
2764
  previousProperties.spacing.afterLines = parseInt(spacing['@_w:afterLines'], 10);
2765
+ // ST_OnOff per ECMA-376 §17.17.4 — accept 1/0/true/false/on/off
2604
2766
  const beforeAuto = spacing['@_w:beforeAutospacing'];
2605
2767
  if (beforeAuto !== undefined)
2606
- previousProperties.spacing.beforeAutospacing =
2607
- String(beforeAuto) === '1' || String(beforeAuto) === 'true';
2768
+ previousProperties.spacing.beforeAutospacing = parseOnOffAttribute(beforeAuto);
2608
2769
  const afterAuto = spacing['@_w:afterAutospacing'];
2609
2770
  if (afterAuto !== undefined)
2610
- previousProperties.spacing.afterAutospacing =
2611
- String(afterAuto) === '1' || String(afterAuto) === 'true';
2771
+ previousProperties.spacing.afterAutospacing = parseOnOffAttribute(afterAuto);
2612
2772
  }
2613
2773
 
2614
- // Parse previous keepNext/keepLines/pageBreakBefore
2774
+ // CT_OnOff properties per ECMA-376 §17.17.4 — accept "1"/"0"/"true"/"false"/"on"/"off"
2775
+ // plus the number forms produced by fast-xml-parser's parseAttributeValue. Using
2776
+ // parseOoxmlBoolean() keeps pPrChange round-trips consistent with the main pPr parser;
2777
+ // the previous `!== '0'` pattern silently flipped "false", "off", and the numeric 0.
2615
2778
  if (prevPPr['w:keepNext']) {
2616
- previousProperties.keepNext = prevPPr['w:keepNext']['@_w:val'] !== '0';
2779
+ previousProperties.keepNext = parseOoxmlBoolean(prevPPr['w:keepNext']);
2617
2780
  }
2618
2781
  if (prevPPr['w:keepLines']) {
2619
- previousProperties.keepLines = prevPPr['w:keepLines']['@_w:val'] !== '0';
2782
+ previousProperties.keepLines = parseOoxmlBoolean(prevPPr['w:keepLines']);
2620
2783
  }
2621
2784
  if (prevPPr['w:pageBreakBefore']) {
2622
- previousProperties.pageBreakBefore = prevPPr['w:pageBreakBefore']['@_w:val'] !== '0';
2785
+ previousProperties.pageBreakBefore = parseOoxmlBoolean(prevPPr['w:pageBreakBefore']);
2623
2786
  }
2624
2787
 
2625
2788
  // === Extended paragraph property parsing per ECMA-376 Part 1 §17.3.1 ===
2626
2789
 
2627
2790
  // Parse widowControl (w:widowControl) - orphan/widow control
2628
2791
  if (prevPPr['w:widowControl']) {
2629
- previousProperties.widowControl = prevPPr['w:widowControl']['@_w:val'] !== '0';
2792
+ previousProperties.widowControl = parseOoxmlBoolean(prevPPr['w:widowControl']);
2630
2793
  }
2631
2794
 
2632
2795
  // Parse suppressAutoHyphens (w:suppressAutoHyphens)
2633
2796
  if (prevPPr['w:suppressAutoHyphens']) {
2634
- previousProperties.suppressAutoHyphens =
2635
- prevPPr['w:suppressAutoHyphens']['@_w:val'] !== '0';
2797
+ previousProperties.suppressAutoHyphens = parseOoxmlBoolean(
2798
+ prevPPr['w:suppressAutoHyphens']
2799
+ );
2636
2800
  }
2637
2801
 
2638
2802
  // Parse contextualSpacing (w:contextualSpacing)
2639
2803
  if (prevPPr['w:contextualSpacing']) {
2640
- previousProperties.contextualSpacing = prevPPr['w:contextualSpacing']['@_w:val'] !== '0';
2804
+ previousProperties.contextualSpacing = parseOoxmlBoolean(prevPPr['w:contextualSpacing']);
2641
2805
  }
2642
2806
 
2643
2807
  // Parse mirrorIndents (w:mirrorIndents)
2644
2808
  if (prevPPr['w:mirrorIndents']) {
2645
- previousProperties.mirrorIndents = prevPPr['w:mirrorIndents']['@_w:val'] !== '0';
2809
+ previousProperties.mirrorIndents = parseOoxmlBoolean(prevPPr['w:mirrorIndents']);
2646
2810
  }
2647
2811
 
2648
2812
  // Parse outlineLevel (w:outlineLvl @w:val)
@@ -2650,40 +2814,106 @@ export class DocumentParser {
2650
2814
  previousProperties.outlineLevel = parseInt(prevPPr['w:outlineLvl']['@_w:val'], 10);
2651
2815
  }
2652
2816
 
2817
+ // Parse previous text frame properties (w:framePr) per ECMA-376
2818
+ // Part 1 §17.3.1.11 CT_FramePr. The pPrChange emitter already
2819
+ // rebuilds every framePr attribute (see Paragraph.ts §3634), but
2820
+ // the parser never read them — so a tracked change to any
2821
+ // frame property (drop-cap, text-box positioning, wrap mode,
2822
+ // anchor lock…) silently lost the previous state on round-trip.
2823
+ if (prevPPr['w:framePr']) {
2824
+ const framePr = prevPPr['w:framePr'];
2825
+ const frameProps: any = {};
2826
+ if (isExplicitlySet(framePr['@_w:w'])) frameProps.w = safeParseInt(framePr['@_w:w']);
2827
+ if (isExplicitlySet(framePr['@_w:h'])) frameProps.h = safeParseInt(framePr['@_w:h']);
2828
+ if (framePr['@_w:hRule']) frameProps.hRule = String(framePr['@_w:hRule']);
2829
+ if (isExplicitlySet(framePr['@_w:x'])) frameProps.x = safeParseInt(framePr['@_w:x']);
2830
+ if (isExplicitlySet(framePr['@_w:y'])) frameProps.y = safeParseInt(framePr['@_w:y']);
2831
+ if (framePr['@_w:xAlign']) frameProps.xAlign = String(framePr['@_w:xAlign']);
2832
+ if (framePr['@_w:yAlign']) frameProps.yAlign = String(framePr['@_w:yAlign']);
2833
+ if (framePr['@_w:hAnchor']) frameProps.hAnchor = String(framePr['@_w:hAnchor']);
2834
+ if (framePr['@_w:vAnchor']) frameProps.vAnchor = String(framePr['@_w:vAnchor']);
2835
+ if (isExplicitlySet(framePr['@_w:hSpace'])) {
2836
+ frameProps.hSpace = safeParseInt(framePr['@_w:hSpace']);
2837
+ }
2838
+ if (isExplicitlySet(framePr['@_w:vSpace'])) {
2839
+ frameProps.vSpace = safeParseInt(framePr['@_w:vSpace']);
2840
+ }
2841
+ if (framePr['@_w:wrap']) frameProps.wrap = String(framePr['@_w:wrap']);
2842
+ if (framePr['@_w:dropCap']) frameProps.dropCap = String(framePr['@_w:dropCap']);
2843
+ if (isExplicitlySet(framePr['@_w:lines'])) {
2844
+ frameProps.lines = safeParseInt(framePr['@_w:lines']);
2845
+ }
2846
+ if (isExplicitlySet(framePr['@_w:anchorLock'])) {
2847
+ frameProps.anchorLock = parseOnOffAttribute(String(framePr['@_w:anchorLock']), true);
2848
+ }
2849
+ if (Object.keys(frameProps).length > 0) {
2850
+ previousProperties.framePr = frameProps;
2851
+ }
2852
+ }
2853
+
2653
2854
  // Parse bidi (w:bidi) - right-to-left paragraph
2654
2855
  if (prevPPr['w:bidi']) {
2655
- previousProperties.bidi = prevPPr['w:bidi']['@_w:val'] !== '0';
2856
+ previousProperties.bidi = parseOoxmlBoolean(prevPPr['w:bidi']);
2656
2857
  }
2657
2858
 
2658
2859
  // Parse suppressLineNumbers (w:suppressLineNumbers)
2659
2860
  if (prevPPr['w:suppressLineNumbers']) {
2660
- previousProperties.suppressLineNumbers =
2661
- prevPPr['w:suppressLineNumbers']['@_w:val'] !== '0';
2861
+ previousProperties.suppressLineNumbers = parseOoxmlBoolean(
2862
+ prevPPr['w:suppressLineNumbers']
2863
+ );
2662
2864
  }
2663
2865
 
2664
2866
  // Parse adjustRightInd (w:adjustRightInd)
2665
2867
  if (prevPPr['w:adjustRightInd']) {
2666
- previousProperties.adjustRightInd = prevPPr['w:adjustRightInd']['@_w:val'] !== '0';
2868
+ previousProperties.adjustRightInd = parseOoxmlBoolean(prevPPr['w:adjustRightInd']);
2667
2869
  }
2668
2870
 
2669
2871
  // Parse snapToGrid (w:snapToGrid)
2670
2872
  if (prevPPr['w:snapToGrid']) {
2671
- previousProperties.snapToGrid = prevPPr['w:snapToGrid']['@_w:val'] !== '0';
2873
+ previousProperties.snapToGrid = parseOoxmlBoolean(prevPPr['w:snapToGrid']);
2672
2874
  }
2673
2875
 
2674
2876
  // Parse wordWrap (w:wordWrap)
2675
2877
  if (prevPPr['w:wordWrap']) {
2676
- previousProperties.wordWrap = prevPPr['w:wordWrap']['@_w:val'] !== '0';
2878
+ previousProperties.wordWrap = parseOoxmlBoolean(prevPPr['w:wordWrap']);
2677
2879
  }
2678
2880
 
2679
2881
  // Parse autoSpaceDE (w:autoSpaceDE) - East Asian/numeric spacing
2680
2882
  if (prevPPr['w:autoSpaceDE']) {
2681
- previousProperties.autoSpaceDE = prevPPr['w:autoSpaceDE']['@_w:val'] !== '0';
2883
+ previousProperties.autoSpaceDE = parseOoxmlBoolean(prevPPr['w:autoSpaceDE']);
2682
2884
  }
2683
2885
 
2684
2886
  // Parse autoSpaceDN (w:autoSpaceDN) - East Asian/Western spacing
2685
2887
  if (prevPPr['w:autoSpaceDN']) {
2686
- previousProperties.autoSpaceDN = prevPPr['w:autoSpaceDN']['@_w:val'] !== '0';
2888
+ previousProperties.autoSpaceDN = parseOoxmlBoolean(prevPPr['w:autoSpaceDN']);
2889
+ }
2890
+
2891
+ // Parse kinsoku / overflowPunct / topLinePunct / suppressOverlap —
2892
+ // CJK typography CT_OnOff flags. The Paragraph pPrChange generator
2893
+ // already emits these in the previous-properties block, but the
2894
+ // parser was missing the read side, so tracked paragraph-property
2895
+ // revisions that recorded any of these four flags were silently
2896
+ // dropped on load → save. Uses `parseOoxmlBoolean` to honour every
2897
+ // ST_OnOff literal (bare, 1/0, true/false, on/off).
2898
+ if (prevPPr['w:kinsoku']) {
2899
+ (previousProperties as { kinsoku?: boolean }).kinsoku = parseOoxmlBoolean(
2900
+ prevPPr['w:kinsoku']
2901
+ );
2902
+ }
2903
+ if (prevPPr['w:overflowPunct']) {
2904
+ (previousProperties as { overflowPunct?: boolean }).overflowPunct = parseOoxmlBoolean(
2905
+ prevPPr['w:overflowPunct']
2906
+ );
2907
+ }
2908
+ if (prevPPr['w:topLinePunct']) {
2909
+ (previousProperties as { topLinePunct?: boolean }).topLinePunct = parseOoxmlBoolean(
2910
+ prevPPr['w:topLinePunct']
2911
+ );
2912
+ }
2913
+ if (prevPPr['w:suppressOverlap']) {
2914
+ (previousProperties as { suppressOverlap?: boolean }).suppressOverlap = parseOoxmlBoolean(
2915
+ prevPPr['w:suppressOverlap']
2916
+ );
2687
2917
  }
2688
2918
 
2689
2919
  // Parse textDirection (w:textDirection @w:val)
@@ -2696,23 +2926,68 @@ export class DocumentParser {
2696
2926
  previousProperties.textAlignment = String(prevPPr['w:textAlignment']['@_w:val']);
2697
2927
  }
2698
2928
 
2929
+ // Parse previous divId (w:divId) per ECMA-376 §17.3.1.10 —
2930
+ // ST_DecimalNumber referencing a web-settings div. Zero is a
2931
+ // legal ID (first div). XMLParser coerces `"0"` to number 0, so
2932
+ // gate via `isExplicitlySet` to preserve divId=0 on tracked
2933
+ // previous state. The pPrChange emitter (Paragraph.ts §3915)
2934
+ // re-emits prev.divId via `!== undefined`.
2935
+ if (prevPPr['w:divId']?.['@_w:val'] !== undefined) {
2936
+ const rawDivId = prevPPr['w:divId']['@_w:val'];
2937
+ const parsedDivId = safeParseInt(rawDivId);
2938
+ if (!isNaN(parsedDivId)) {
2939
+ previousProperties.divId = parsedDivId;
2940
+ }
2941
+ }
2942
+
2943
+ // Parse previous cnfStyle (w:cnfStyle) per ECMA-376 §17.3.1.8 —
2944
+ // 12-character bitmask identifying which conditional-formatting
2945
+ // flags from the parent table style apply. XMLParser coerces
2946
+ // purely-numeric hex strings, but the custom parseValue keeps
2947
+ // 7+-digit strings as-is (so 12-char bitmasks survive); use
2948
+ // String + padStart to defensively normalise any shorter form.
2949
+ if (prevPPr['w:cnfStyle']?.['@_w:val'] !== undefined) {
2950
+ previousProperties.cnfStyle = String(prevPPr['w:cnfStyle']['@_w:val']).padStart(12, '0');
2951
+ }
2952
+
2699
2953
  // Parse paragraph borders (w:pBdr) per ECMA-376 Part 1 §17.3.1.24
2954
+ // Previous versions stored the attribute values under the wrong
2955
+ // field names (`val`/`sz` instead of `style`/`size`) — the
2956
+ // paragraph emitter reads `style`/`size`, so every tracked
2957
+ // previous border collapsed to `<w:top w:val="nil"/>` on
2958
+ // round-trip. The CT_Border attribute coverage here now matches
2959
+ // the main parser (§17.18.2): all nine attrs, with shadow/frame
2960
+ // routed through parseOnOffAttribute so ST_OnOff literals
2961
+ // ("on"/"off"/"true"/"false") resolve correctly.
2700
2962
  if (prevPPr['w:pBdr']) {
2701
2963
  const pBdr = prevPPr['w:pBdr'];
2702
2964
  previousProperties.borders = {};
2703
2965
 
2704
2966
  const parseBorder = (borderObj: any) => {
2705
2967
  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
- };
2968
+ const border: any = {};
2969
+ if (borderObj['@_w:val']) border.style = borderObj['@_w:val'];
2970
+ if (borderObj['@_w:sz'] !== undefined) border.size = safeParseInt(borderObj['@_w:sz']);
2971
+ if (borderObj['@_w:space'] !== undefined) {
2972
+ border.space = safeParseInt(borderObj['@_w:space']);
2973
+ }
2974
+ if (borderObj['@_w:color']) border.color = borderObj['@_w:color'];
2975
+ if (borderObj['@_w:themeColor']) {
2976
+ border.themeColor = String(borderObj['@_w:themeColor']);
2977
+ }
2978
+ if (borderObj['@_w:themeTint']) {
2979
+ border.themeTint = String(borderObj['@_w:themeTint']);
2980
+ }
2981
+ if (borderObj['@_w:themeShade']) {
2982
+ border.themeShade = String(borderObj['@_w:themeShade']);
2983
+ }
2984
+ if (borderObj['@_w:shadow'] !== undefined) {
2985
+ border.shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
2986
+ }
2987
+ if (borderObj['@_w:frame'] !== undefined) {
2988
+ border.frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
2989
+ }
2990
+ return Object.keys(border).length > 0 ? border : undefined;
2716
2991
  };
2717
2992
 
2718
2993
  if (pBdr['w:top']) previousProperties.borders.top = parseBorder(pBdr['w:top']);
@@ -3873,11 +4148,15 @@ export class DocumentParser {
3873
4148
  return XMLBuilder.unescapeXml(String(node));
3874
4149
  };
3875
4150
 
3876
- const parseBooleanAttr = (value: any): boolean | undefined => {
4151
+ // Field-character attributes (w:dirty, w:fldLock, w:lock on w:fldChar) are
4152
+ // ST_OnOff per ECMA-376 §17.16.18. Delegate to parseOnOffAttribute so every
4153
+ // literal is honoured — the previous inline check missed "on" (silently
4154
+ // coerced to false) and was tighter than the spec requires.
4155
+ const parseBooleanAttr = (value: unknown): boolean | undefined => {
3877
4156
  if (value === undefined || value === null) {
3878
4157
  return undefined;
3879
4158
  }
3880
- return value === '1' || value === 1 || value === true || value === 'true';
4159
+ return parseOnOffAttribute(value);
3881
4160
  };
3882
4161
 
3883
4162
  // Parse w:ffData from a fldChar object (form field data per ECMA-376 §17.16.17)
@@ -3892,15 +4171,13 @@ export class DocumentParser {
3892
4171
  if (ffDataObj['w:name']?.['@_w:val'] !== undefined) {
3893
4172
  ffd.name = String(ffDataObj['w:name']['@_w:val']);
3894
4173
  }
3895
- // w:enabled (presence = true, w:val="0" = false)
4174
+ // w:enabled — CT_OnOff per ECMA-376 §17.16.11; presence = true, w:val honours ST_OnOff
3896
4175
  if (ffDataObj['w:enabled'] !== undefined) {
3897
- const enabledVal = ffDataObj['w:enabled']?.['@_w:val'];
3898
- ffd.enabled = enabledVal === '0' || enabledVal === 0 ? false : true;
4176
+ ffd.enabled = parseOoxmlBoolean(ffDataObj['w:enabled']);
3899
4177
  }
3900
- // w:calcOnExit
4178
+ // w:calcOnExit — CT_OnOff per ECMA-376 §17.16.4; presence = true, w:val honours ST_OnOff
3901
4179
  if (ffDataObj['w:calcOnExit'] !== undefined) {
3902
- const calcVal = ffDataObj['w:calcOnExit']?.['@_w:val'];
3903
- ffd.calcOnExit = calcVal === '1' || calcVal === 1 || calcVal === true;
4180
+ ffd.calcOnExit = parseOoxmlBoolean(ffDataObj['w:calcOnExit']);
3904
4181
  }
3905
4182
  // w:helpText
3906
4183
  if (ffDataObj['w:helpText']?.['@_w:val'] !== undefined) {
@@ -3936,13 +4213,14 @@ export class DocumentParser {
3936
4213
  if (ffDataObj['w:checkBox'] !== undefined) {
3937
4214
  const cb: XmlNode = ffDataObj['w:checkBox'];
3938
4215
  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;
4216
+ // w:default / w:checked are CT_OnOff per ECMA-376 §17.16.18 —
4217
+ // honour every ST_OnOff literal ("true"/"false"/"1"/"0"/"on"/"off")
4218
+ // and treat a bare self-closing element as true.
4219
+ if (cb['w:default'] !== undefined) {
4220
+ checkBox.defaultChecked = parseOoxmlBoolean(cb['w:default']);
3942
4221
  }
3943
- if (cb['w:checked']?.['@_w:val'] !== undefined) {
3944
- checkBox.checked =
3945
- cb['w:checked']['@_w:val'] === '1' || cb['w:checked']['@_w:val'] === 1;
4222
+ if (cb['w:checked'] !== undefined) {
4223
+ checkBox.checked = parseOoxmlBoolean(cb['w:checked']);
3946
4224
  }
3947
4225
  if (cb['w:size']?.['@_w:val'] !== undefined) {
3948
4226
  checkBox.size = Number(cb['w:size']['@_w:val']);
@@ -4146,30 +4424,47 @@ export class DocumentParser {
4146
4424
  content.push({ type: 'annotationRef' });
4147
4425
  break;
4148
4426
 
4149
- // Footnote reference (w:footnoteReference) per ECMA-376 Part 1 §17.11.13
4427
+ // Footnote reference (w:footnoteReference) per ECMA-376 Part 1 §17.11.13.
4428
+ // w:customMarkFollows is ST_OnOff — honour every literal via parseOnOffAttribute.
4150
4429
  case 'w:footnoteReference': {
4151
4430
  const fnRefElements = toArray(runObj['w:footnoteReference']);
4152
4431
  const fnRef = fnRefElements[elementIndex] || fnRefElements[0];
4153
4432
  const fnId = fnRef?.['@_w:id'];
4433
+ const fnCustomMark = fnRef?.['@_w:customMarkFollows'];
4154
4434
  content.push({
4155
4435
  type: 'footnoteReference',
4156
4436
  footnoteId: fnId !== undefined ? parseInt(fnId, 10) : undefined,
4437
+ customMarkFollows:
4438
+ fnCustomMark !== undefined ? parseOnOffAttribute(fnCustomMark) : undefined,
4157
4439
  });
4158
4440
  break;
4159
4441
  }
4160
4442
 
4161
- // Endnote reference (w:endnoteReference) per ECMA-376 Part 1 §17.11.2
4443
+ // Endnote reference (w:endnoteReference) per ECMA-376 Part 1 §17.11.2.
4444
+ // Same ST_OnOff treatment for w:customMarkFollows.
4162
4445
  case 'w:endnoteReference': {
4163
4446
  const enRefElements = toArray(runObj['w:endnoteReference']);
4164
4447
  const enRef = enRefElements[elementIndex] || enRefElements[0];
4165
4448
  const enId = enRef?.['@_w:id'];
4449
+ const enCustomMark = enRef?.['@_w:customMarkFollows'];
4166
4450
  content.push({
4167
4451
  type: 'endnoteReference',
4168
4452
  endnoteId: enId !== undefined ? parseInt(enId, 10) : undefined,
4453
+ customMarkFollows:
4454
+ enCustomMark !== undefined ? parseOnOffAttribute(enCustomMark) : undefined,
4169
4455
  });
4170
4456
  break;
4171
4457
  }
4172
4458
 
4459
+ // Auto-numbered marks INSIDE a footnote/endnote body per
4460
+ // ECMA-376 §17.11.14 / §17.11.3. Empty self-closing elements.
4461
+ case 'w:footnoteRef':
4462
+ content.push({ type: 'footnoteRef' });
4463
+ break;
4464
+ case 'w:endnoteRef':
4465
+ content.push({ type: 'endnoteRef' });
4466
+ break;
4467
+
4173
4468
  case 'w:dayShort':
4174
4469
  content.push({ type: 'dayShort' });
4175
4470
  break;
@@ -4355,14 +4650,18 @@ export class DocumentParser {
4355
4650
  if (runObj['w:annotationRef'] !== undefined) {
4356
4651
  content.push({ type: 'annotationRef' });
4357
4652
  }
4358
- // Footnote/endnote reference fallback
4653
+ // Footnote/endnote reference fallback. w:customMarkFollows is ST_OnOff
4654
+ // per ECMA-376 §17.11.13 / §17.11.2 — honour every literal.
4359
4655
  if (runObj['w:footnoteReference'] !== undefined) {
4360
4656
  const fnRefElements = toArray(runObj['w:footnoteReference']);
4361
4657
  for (const fnRef of fnRefElements) {
4362
4658
  const fnId = fnRef?.['@_w:id'];
4659
+ const fnCustomMark = fnRef?.['@_w:customMarkFollows'];
4363
4660
  content.push({
4364
4661
  type: 'footnoteReference',
4365
4662
  footnoteId: fnId !== undefined ? parseInt(fnId, 10) : undefined,
4663
+ customMarkFollows:
4664
+ fnCustomMark !== undefined ? parseOnOffAttribute(fnCustomMark) : undefined,
4366
4665
  });
4367
4666
  }
4368
4667
  }
@@ -4370,12 +4669,22 @@ export class DocumentParser {
4370
4669
  const enRefElements = toArray(runObj['w:endnoteReference']);
4371
4670
  for (const enRef of enRefElements) {
4372
4671
  const enId = enRef?.['@_w:id'];
4672
+ const enCustomMark = enRef?.['@_w:customMarkFollows'];
4373
4673
  content.push({
4374
4674
  type: 'endnoteReference',
4375
4675
  endnoteId: enId !== undefined ? parseInt(enId, 10) : undefined,
4676
+ customMarkFollows:
4677
+ enCustomMark !== undefined ? parseOnOffAttribute(enCustomMark) : undefined,
4376
4678
  });
4377
4679
  }
4378
4680
  }
4681
+ // Auto-numbered marks INSIDE a footnote/endnote body — empty elements.
4682
+ if (runObj['w:footnoteRef'] !== undefined) {
4683
+ content.push({ type: 'footnoteRef' });
4684
+ }
4685
+ if (runObj['w:endnoteRef'] !== undefined) {
4686
+ content.push({ type: 'endnoteRef' });
4687
+ }
4379
4688
  if (runObj['w:dayShort'] !== undefined) {
4380
4689
  content.push({ type: 'dayShort' });
4381
4690
  }
@@ -4474,12 +4783,40 @@ export class DocumentParser {
4474
4783
  : [hyperlinkObj['w:bookmarkStart']];
4475
4784
  for (const bs of bookmarkStarts) {
4476
4785
  const id = bs['@_w:id'];
4477
- const name = bs['@_w:name'];
4786
+ // w:name is ST_String per §17.16.5 CT_Bookmark. XMLParser
4787
+ // coerces purely-numeric bookmark names ("12345") to JS
4788
+ // numbers; cast so Bookmark.name holds the declared string
4789
+ // type contract (parent parsers already do the same —
4790
+ // iter 125 toOptString helper).
4791
+ const rawName = bs['@_w:name'];
4792
+ const name =
4793
+ rawName === undefined || rawName === null || rawName === ''
4794
+ ? undefined
4795
+ : String(rawName);
4478
4796
  if (id !== undefined && name) {
4797
+ // CT_Bookmark per ECMA-376 §17.16.5: the object-form parser
4798
+ // must carry the same four "markup" attributes that the
4799
+ // XML-string bookmarkStart parser handles — colFirst/colLast
4800
+ // (table-column-scoped bookmarks) and displacedByCustomXml
4801
+ // (custom-XML boundary disambiguator). Previously dropped
4802
+ // whenever a hyperlink wrapped a bookmark, so inline
4803
+ // hyperlinks anchored to table-column bookmarks lost their
4804
+ // column range on round-trip.
4805
+ const rawColFirst = bs['@_w:colFirst'];
4806
+ const rawColLast = bs['@_w:colLast'];
4807
+ const rawDisplaced = bs['@_w:displacedByCustomXml'];
4808
+ const colFirst =
4809
+ rawColFirst === undefined ? undefined : parseInt(String(rawColFirst), 10);
4810
+ const colLast = rawColLast === undefined ? undefined : parseInt(String(rawColLast), 10);
4811
+ const displacedByCustomXml =
4812
+ rawDisplaced === 'next' || rawDisplaced === 'prev' ? rawDisplaced : undefined;
4479
4813
  const bookmark = new Bookmark({
4480
4814
  name: name,
4481
4815
  id: typeof id === 'number' ? id : parseInt(id, 10),
4482
4816
  skipNormalization: true,
4817
+ colFirst: Number.isNaN(colFirst as number) ? undefined : colFirst,
4818
+ colLast: Number.isNaN(colLast as number) ? undefined : colLast,
4819
+ displacedByCustomXml,
4483
4820
  });
4484
4821
  result.bookmarkStarts.push(bookmark);
4485
4822
  // Also register with BookmarkManager
@@ -4501,23 +4838,47 @@ export class DocumentParser {
4501
4838
  for (const be of bookmarkEnds) {
4502
4839
  const id = be['@_w:id'];
4503
4840
  if (id !== undefined) {
4841
+ // CT_MarkupRange per ECMA-376 §17.13.5 — preserve
4842
+ // w:displacedByCustomXml on bookmarkEnd when a custom-XML
4843
+ // boundary forced the marker to be displaced.
4844
+ const rawDisplaced = be['@_w:displacedByCustomXml'];
4845
+ const displacedByCustomXml =
4846
+ rawDisplaced === 'next' || rawDisplaced === 'prev' ? rawDisplaced : undefined;
4504
4847
  const bookmark = new Bookmark({
4505
4848
  name: `_end_${id}`,
4506
4849
  id: typeof id === 'number' ? id : parseInt(id, 10),
4507
4850
  skipNormalization: true,
4851
+ displacedByCustomXml,
4508
4852
  });
4509
4853
  result.bookmarkEnds.push(bookmark);
4510
4854
  }
4511
4855
  }
4512
4856
  }
4513
4857
 
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'];
4858
+ // Extract hyperlink attributes. Per ECMA-376 §17.16.22 CT_Hyperlink,
4859
+ // w:anchor / w:tooltip / w:tgtFrame / w:docLocation / r:id are all
4860
+ // ST_String. XMLParser's `parseAttributeValue: true` coerces
4861
+ // purely-numeric strings (e.g., a bookmark name like "12345") to
4862
+ // JS numbers — cast via String(...) so downstream `Hyperlink`
4863
+ // storage and string-method callers see the declared `string`
4864
+ // type contract.
4865
+ const toOptString = (v: unknown): string | undefined =>
4866
+ v === undefined || v === null ? undefined : String(v);
4867
+ const relationshipId = toOptString(hyperlinkObj['@_r:id']);
4868
+ const anchor = toOptString(hyperlinkObj['@_w:anchor']);
4869
+ const tooltip = toOptString(hyperlinkObj['@_w:tooltip']);
4870
+ const tgtFrame = toOptString(hyperlinkObj['@_w:tgtFrame']);
4871
+ // w:history is CT_OnOff per ECMA-376 §17.16.22 — honour every
4872
+ // ST_OnOff literal ("1"/"0"/"true"/"false"/"on"/"off") and every
4873
+ // XMLParser-coerced form (number 0/1, boolean). The Hyperlink
4874
+ // serializer accepts a string, so normalise to the canonical
4875
+ // "1"/"0" form. Without this, `w:history="0"` or `w:history="false"`
4876
+ // coerced to falsy values and the emitter's truthy check dropped
4877
+ // the attribute on round-trip.
4878
+ const rawHistory = hyperlinkObj['@_w:history'];
4879
+ const history =
4880
+ rawHistory === undefined ? undefined : parseOnOffAttribute(rawHistory) ? '1' : '0';
4881
+ const docLocation = toOptString(hyperlinkObj['@_w:docLocation']);
4521
4882
 
4522
4883
  // Parse runs inside the hyperlink
4523
4884
  const runs = hyperlinkObj['w:r'];
@@ -4815,8 +5176,20 @@ export class DocumentParser {
4815
5176
  }
4816
5177
 
4817
5178
  // 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;
5179
+ const typeMatch = String(instruction)
5180
+ .trim()
5181
+ .match(/^(\w+)/);
5182
+ const type = (typeMatch?.[1] || 'PAGE') as import('../elements/Field.js').FieldType;
5183
+
5184
+ // CT_SimpleField (§17.16.16) carries two ST_OnOff attributes besides
5185
+ // the required w:instr — w:fldLock (update lock) and w:dirty
5186
+ // (cached-result staleness). Previously neither was parsed, so
5187
+ // Word's "update field" indicator and "lock field" flag were
5188
+ // silently cleared on every load → save round-trip.
5189
+ const fldLockRaw = fieldObj['@_w:fldLock'];
5190
+ const dirtyRaw = fieldObj['@_w:dirty'];
5191
+ const fldLock = fldLockRaw !== undefined ? parseOnOffAttribute(fldLockRaw) : undefined;
5192
+ const dirty = dirtyRaw !== undefined ? parseOnOffAttribute(dirtyRaw) : undefined;
4820
5193
 
4821
5194
  // Parse run formatting from w:rPr if present
4822
5195
  let formatting: RunFormatting | undefined;
@@ -4829,8 +5202,10 @@ export class DocumentParser {
4829
5202
  // Create field with instruction
4830
5203
  const field = Field.create({
4831
5204
  type,
4832
- instruction,
5205
+ instruction: String(instruction),
4833
5206
  formatting,
5207
+ fldLock,
5208
+ dirty,
4834
5209
  });
4835
5210
 
4836
5211
  return field;
@@ -4848,15 +5223,25 @@ export class DocumentParser {
4848
5223
  private parseRunPropertiesFromObject(rPrObj: any, run: Run): void {
4849
5224
  if (!rPrObj) return;
4850
5225
 
4851
- // Parse character style reference (w:rStyle) per ECMA-376 Part 1 §17.3.2.36
5226
+ // Parse character style reference (w:rStyle) per ECMA-376 Part 1
5227
+ // §17.3.2.36 — `w:val` is ST_String referencing a style ID. Cast
5228
+ // via String(...) so a purely-numeric style ID (e.g., "1") that
5229
+ // XMLParser coerces to the number 1 survives as the string "1",
5230
+ // matching the `characterStyle?: string` field contract on
5231
+ // RunFormatting.
4852
5232
  if (rPrObj['w:rStyle']) {
4853
5233
  const styleId = rPrObj['w:rStyle']['@_w:val'];
4854
- if (styleId) {
4855
- run.setCharacterStyle(styleId);
5234
+ if (styleId !== undefined && styleId !== null && styleId !== '') {
5235
+ run.setCharacterStyle(String(styleId));
4856
5236
  }
4857
5237
  }
4858
5238
 
4859
- // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.5
5239
+ // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.5 — CT_Border
5240
+ // §17.18.2 attribute set: val / sz / space / color / themeColor /
5241
+ // themeTint / themeShade / shadow / frame. Previously only the first
5242
+ // four were read, so themed character borders lost their theme linkage
5243
+ // on round-trip. The emitter (Run.generateRunPropertiesXML) handles
5244
+ // all nine since iteration 79.
4860
5245
  if (rPrObj['w:bdr']) {
4861
5246
  const bdr = rPrObj['w:bdr'];
4862
5247
  const border: any = {};
@@ -4864,6 +5249,20 @@ export class DocumentParser {
4864
5249
  if (bdr['@_w:sz']) border.size = parseInt(bdr['@_w:sz'], 10);
4865
5250
  if (bdr['@_w:color']) border.color = bdr['@_w:color'];
4866
5251
  if (bdr['@_w:space']) border.space = parseInt(bdr['@_w:space'], 10);
5252
+ // Per ECMA-376 §17.18.82 CT_Border: themeTint / themeShade are
5253
+ // ST_UcharHexNumber (2-char hex). XMLParser coerces purely-digit
5254
+ // hex strings like "80" / "50" to JS numbers; cast via String(...)
5255
+ // so the declared `string` contract on the model holds for any
5256
+ // downstream code that calls string methods (.toUpperCase(), etc.).
5257
+ if (bdr['@_w:themeColor']) border.themeColor = String(bdr['@_w:themeColor']);
5258
+ if (bdr['@_w:themeTint']) border.themeTint = String(bdr['@_w:themeTint']);
5259
+ if (bdr['@_w:themeShade']) border.themeShade = String(bdr['@_w:themeShade']);
5260
+ if (bdr['@_w:shadow'] !== undefined) {
5261
+ border.shadow = parseOnOffAttribute(String(bdr['@_w:shadow']), true);
5262
+ }
5263
+ if (bdr['@_w:frame'] !== undefined) {
5264
+ border.frame = parseOnOffAttribute(String(bdr['@_w:frame']), true);
5265
+ }
4867
5266
  if (Object.keys(border).length > 0) {
4868
5267
  run.setBorder(border);
4869
5268
  }
@@ -4883,26 +5282,30 @@ export class DocumentParser {
4883
5282
  if (val) run.setEmphasis(val);
4884
5283
  }
4885
5284
 
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);
5285
+ // CT_OnOff text effects — presence + w:val both matter. Use `!== undefined`
5286
+ // to detect presence, then parseOoxmlBoolean() for the value, so an explicit
5287
+ // `<w:outline w:val="0"/>` override of a style-inherited true is preserved
5288
+ // (not silently dropped into "inherit"). Applies to all OnOffType rPr flags
5289
+ // per ECMA-376 §17.3.2.
5290
+ if (rPrObj['w:outline'] !== undefined) run.setOutline(parseOoxmlBoolean(rPrObj['w:outline']));
5291
+ if (rPrObj['w:shadow'] !== undefined) run.setShadow(parseOoxmlBoolean(rPrObj['w:shadow']));
5292
+ if (rPrObj['w:emboss'] !== undefined) run.setEmboss(parseOoxmlBoolean(rPrObj['w:emboss']));
5293
+ if (rPrObj['w:imprint'] !== undefined) run.setImprint(parseOoxmlBoolean(rPrObj['w:imprint']));
5294
+ if (rPrObj['w:noProof'] !== undefined) run.setNoProof(parseOoxmlBoolean(rPrObj['w:noProof']));
4893
5295
  // snapToGrid: default when absent is true (§17.3.2.34), so explicit val="0" must be preserved
4894
5296
  if (rPrObj['w:snapToGrid'] !== undefined) {
4895
5297
  run.setSnapToGrid(parseOoxmlBoolean(rPrObj['w:snapToGrid']));
4896
5298
  }
4897
- if (parseOoxmlBoolean(rPrObj['w:vanish'])) run.setVanish(true);
4898
- if (parseOoxmlBoolean(rPrObj['w:specVanish'])) run.setSpecVanish(true);
5299
+ if (rPrObj['w:vanish'] !== undefined) run.setVanish(parseOoxmlBoolean(rPrObj['w:vanish']));
5300
+ if (rPrObj['w:specVanish'] !== undefined)
5301
+ run.setSpecVanish(parseOoxmlBoolean(rPrObj['w:specVanish']));
4899
5302
 
4900
5303
  // Boolean properties - use parseOoxmlBoolean helper
4901
5304
  // Per ECMA-376: <w:b/> or <w:b w:val="1"/> or <w:b w:val="true"/> means true
4902
5305
  // <w:b w:val="0"/> or <w:b w:val="false"/> means false (omit from document)
4903
5306
 
4904
5307
  // Parse RTL text (w:rtl) per ECMA-376 Part 1 §17.3.2.30
4905
- if (parseOoxmlBoolean(rPrObj['w:rtl'])) run.setRTL(true);
5308
+ if (rPrObj['w:rtl'] !== undefined) run.setRTL(parseOoxmlBoolean(rPrObj['w:rtl']));
4906
5309
 
4907
5310
  // b, bCs, i, iCs: preserve explicit val="0" to override style-inherited formatting
4908
5311
  if (rPrObj['w:b'] !== undefined) run.setBold(parseOoxmlBoolean(rPrObj['w:b']));
@@ -4919,11 +5322,12 @@ export class DocumentParser {
4919
5322
  run.setSmallCaps(parseOoxmlBoolean(rPrObj['w:smallCaps']));
4920
5323
  if (rPrObj['w:caps'] !== undefined) run.setAllCaps(parseOoxmlBoolean(rPrObj['w:caps']));
4921
5324
 
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);
5325
+ // Parse complex script flag (w:cs) per ECMA-376 Part 1 §17.3.2.7 — CT_OnOff
5326
+ if (rPrObj['w:cs'] !== undefined) run.setComplexScript(parseOoxmlBoolean(rPrObj['w:cs']));
4924
5327
 
4925
- // Parse web hidden (w:webHidden) per ECMA-376 Part 1 §17.3.2.44
4926
- if (parseOoxmlBoolean(rPrObj['w:webHidden'])) run.setWebHidden(true);
5328
+ // Parse web hidden (w:webHidden) per ECMA-376 Part 1 §17.3.2.44 — CT_OnOff
5329
+ if (rPrObj['w:webHidden'] !== undefined)
5330
+ run.setWebHidden(parseOoxmlBoolean(rPrObj['w:webHidden']));
4927
5331
 
4928
5332
  if (rPrObj['w:u']) {
4929
5333
  // XMLParser adds @_ prefix to attributes
@@ -4943,28 +5347,37 @@ export class DocumentParser {
4943
5347
  );
4944
5348
  }
4945
5349
 
4946
- // Parse character spacing (w:spacing) per ECMA-376 Part 1 §17.3.2.33
5350
+ // Parse character spacing (w:spacing) per ECMA-376 Part 1 §17.3.2.35.
5351
+ // ST_SignedTwipsMeasure — 0 and negative values are valid (default /
5352
+ // tighter spacing). XMLParser.parseAttributeValue coerces "0" to number 0,
5353
+ // which is falsy — so the previous `if (val)` truthy check silently dropped
5354
+ // explicit zero / baseline-reset formatting on every run that used it.
5355
+ // Matches the rPrChange parser below which already uses `!== undefined`.
4947
5356
  if (rPrObj['w:spacing']) {
4948
5357
  const val = rPrObj['w:spacing']['@_w:val'];
4949
- if (val) run.setCharacterSpacing(parseInt(val, 10));
5358
+ if (val !== undefined) run.setCharacterSpacing(parseInt(String(val), 10));
4950
5359
  }
4951
5360
 
4952
- // Parse horizontal scaling (w:w) per ECMA-376 Part 1 §17.3.2.43
5361
+ // Parse horizontal scaling (w:w) per ECMA-376 Part 1 §17.3.2.43.
5362
+ // ST_TextScale — min 1 per schema, so value 0 is not spec-valid; keep
5363
+ // truthy check as a mild sanity guard against malformed sources.
4953
5364
  if (rPrObj['w:w']) {
4954
5365
  const val = rPrObj['w:w']['@_w:val'];
4955
- if (val) run.setScaling(parseInt(val, 10));
5366
+ if (val) run.setScaling(parseInt(String(val), 10));
4956
5367
  }
4957
5368
 
4958
- // Parse vertical position (w:position) per ECMA-376 Part 1 §17.3.2.31
5369
+ // Parse vertical position (w:position) per ECMA-376 Part 1 §17.3.2.31.
5370
+ // ST_SignedHpsMeasure — 0 = baseline (default / explicit reset).
4959
5371
  if (rPrObj['w:position']) {
4960
5372
  const val = rPrObj['w:position']['@_w:val'];
4961
- if (val) run.setPosition(parseInt(val, 10));
5373
+ if (val !== undefined) run.setPosition(parseInt(String(val), 10));
4962
5374
  }
4963
5375
 
4964
- // Parse kerning (w:kern) per ECMA-376 Part 1 §17.3.2.20
5376
+ // Parse kerning (w:kern) per ECMA-376 Part 1 §17.3.2.20.
5377
+ // ST_HpsMeasure — 0 means "kern at every size" (no minimum threshold).
4965
5378
  if (rPrObj['w:kern']) {
4966
5379
  const val = rPrObj['w:kern']['@_w:val'];
4967
- if (val) run.setKerning(parseInt(val, 10));
5380
+ if (val !== undefined) run.setKerning(parseInt(String(val), 10));
4968
5381
  }
4969
5382
 
4970
5383
  // Parse language (w:lang) per ECMA-376 Part 1 §17.3.2.20 (CT_Language)
@@ -4984,14 +5397,27 @@ export class DocumentParser {
4984
5397
  }
4985
5398
  }
4986
5399
 
4987
- // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1 §17.3.2.10
5400
+ // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1
5401
+ // §17.3.2.10 CT_EastAsianLayout. `w:vert` / `w:vertCompress` /
5402
+ // `w:combine` are ST_OnOff attributes — route through
5403
+ // parseOnOffAttribute so every literal ("1"/"0"/"true"/"false"/
5404
+ // "on"/"off") resolves correctly. The previous truthy gate both
5405
+ // dropped explicit false (`w:vert="0"` → coerced 0 → undefined) AND
5406
+ // wrongly marked `w:vert="off"` as true (non-empty string is truthy
5407
+ // without parsing).
4988
5408
  if (rPrObj['w:eastAsianLayout']) {
4989
5409
  const layoutObj = rPrObj['w:eastAsianLayout'];
4990
5410
  const layout: any = {};
4991
5411
  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;
5412
+ if (layoutObj['@_w:vert'] !== undefined) {
5413
+ layout.vert = parseOnOffAttribute(String(layoutObj['@_w:vert']), true);
5414
+ }
5415
+ if (layoutObj['@_w:vertCompress'] !== undefined) {
5416
+ layout.vertCompress = parseOnOffAttribute(String(layoutObj['@_w:vertCompress']), true);
5417
+ }
5418
+ if (layoutObj['@_w:combine'] !== undefined) {
5419
+ layout.combine = parseOnOffAttribute(String(layoutObj['@_w:combine']), true);
5420
+ }
4995
5421
  if (layoutObj['@_w:combineBrackets'])
4996
5422
  layout.combineBrackets = layoutObj['@_w:combineBrackets'];
4997
5423
 
@@ -5021,17 +5447,23 @@ export class DocumentParser {
5021
5447
 
5022
5448
  if (rPrObj['w:rFonts']) {
5023
5449
  const rFonts = rPrObj['w:rFonts'];
5024
- if (rFonts['@_w:ascii']) run.setFont(rFonts['@_w:ascii']);
5450
+ // Per ECMA-376 §17.3.2.26 CT_Fonts, all four literal-font
5451
+ // attributes (ascii/hAnsi/eastAsia/cs) are ST_String. XMLParser
5452
+ // coerces purely-numeric font names ("2010", etc.) to JS
5453
+ // numbers; cast through String() so RunFormatting's
5454
+ // declared-string font fields keep their type contract.
5455
+ if (rFonts['@_w:ascii'] !== undefined) run.setFont(String(rFonts['@_w:ascii']));
5025
5456
  // 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']);
5457
+ if (rFonts['@_w:hAnsi'] !== undefined) run.setFontHAnsi(String(rFonts['@_w:hAnsi']));
5458
+ if (rFonts['@_w:eastAsia'] !== undefined) run.setFontEastAsia(String(rFonts['@_w:eastAsia']));
5459
+ if (rFonts['@_w:cs'] !== undefined) run.setFontCs(String(rFonts['@_w:cs']));
5460
+ if (rFonts['@_w:hint']) run.setFontHint(String(rFonts['@_w:hint']));
5030
5461
  // 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']);
5462
+ if (rFonts['@_w:asciiTheme']) run.setFontAsciiTheme(String(rFonts['@_w:asciiTheme']));
5463
+ if (rFonts['@_w:hAnsiTheme']) run.setFontHAnsiTheme(String(rFonts['@_w:hAnsiTheme']));
5464
+ if (rFonts['@_w:eastAsiaTheme'])
5465
+ run.setFontEastAsiaTheme(String(rFonts['@_w:eastAsiaTheme']));
5466
+ if (rFonts['@_w:cstheme']) run.setFontCsTheme(String(rFonts['@_w:cstheme']));
5035
5467
  }
5036
5468
 
5037
5469
  if (rPrObj['w:sz']) {
@@ -5099,7 +5531,7 @@ export class DocumentParser {
5099
5531
  // This records what the run formatting was BEFORE a change was made
5100
5532
  if (rPrObj['w:rPrChange']) {
5101
5533
  const changeObj = rPrObj['w:rPrChange'];
5102
- const propChange: import('../elements/PropertyChangeTypes').RunPropertyChange = {
5534
+ const propChange: import('../elements/PropertyChangeTypes.js').RunPropertyChange = {
5103
5535
  id: changeObj['@_w:id'] !== undefined ? parseInt(String(changeObj['@_w:id']), 10) : 0,
5104
5536
  author: changeObj['@_w:author'] ? String(changeObj['@_w:author']) : '',
5105
5537
  date: changeObj['@_w:date'] ? new Date(String(changeObj['@_w:date'])) : new Date(),
@@ -5109,7 +5541,7 @@ export class DocumentParser {
5109
5541
  // Parse previous run properties from child w:rPr element
5110
5542
  if (changeObj['w:rPr']) {
5111
5543
  const prevRPr = changeObj['w:rPr'];
5112
- const prevProps: Partial<import('../elements/Run').RunFormatting> = {};
5544
+ const prevProps: Partial<import('../elements/Run.js').RunFormatting> = {};
5113
5545
 
5114
5546
  // Parse previous bold
5115
5547
  if (prevRPr['w:b']) {
@@ -5121,10 +5553,22 @@ export class DocumentParser {
5121
5553
  prevProps.italic = parseOoxmlBoolean(prevRPr['w:i']);
5122
5554
  }
5123
5555
 
5124
- // Parse previous underline
5556
+ // Parse previous underline — CT_Underline per §17.3.2.40 has `val`
5557
+ // plus color / themeColor / themeTint / themeShade. Main rPr parser
5558
+ // reads all of them; rPrChange previously only read `val`, so
5559
+ // underline color metadata on tracked "previous" state was dropped.
5125
5560
  if (prevRPr['w:u']) {
5126
- const uVal = prevRPr['w:u']['@_w:val'];
5561
+ const uObj = prevRPr['w:u'];
5562
+ const uVal = uObj['@_w:val'];
5127
5563
  prevProps.underline = uVal || true;
5564
+ if (uObj['@_w:color']) prevProps.underlineColor = uObj['@_w:color'];
5565
+ if (uObj['@_w:themeColor']) prevProps.underlineThemeColor = uObj['@_w:themeColor'];
5566
+ if (uObj['@_w:themeTint'] !== undefined) {
5567
+ prevProps.underlineThemeTint = parseInt(String(uObj['@_w:themeTint']), 16);
5568
+ }
5569
+ if (uObj['@_w:themeShade'] !== undefined) {
5570
+ prevProps.underlineThemeShade = parseInt(String(uObj['@_w:themeShade']), 16);
5571
+ }
5128
5572
  }
5129
5573
 
5130
5574
  // Parse previous strikethrough
@@ -5133,13 +5577,28 @@ export class DocumentParser {
5133
5577
  }
5134
5578
 
5135
5579
  // Parse previous font (all w:rFonts attributes per ECMA-376 Part 1 §17.3.2.26)
5580
+ // including theme font references (asciiTheme/hAnsiTheme/eastAsiaTheme/
5581
+ // cstheme). Previously only the literal-font attributes were read, so
5582
+ // rPrChange tracked history of theme-font changes lost the theme linkage
5583
+ // on round-trip — a paragraph whose "previous" font was a theme
5584
+ // reference (e.g. w:asciiTheme="minorHAnsi") silently dropped it.
5136
5585
  if (prevRPr['w:rFonts']) {
5137
5586
  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'];
5587
+ // Mirror the main-path String() casts on rPrChange
5588
+ // previous-font reads — ECMA-376 §17.3.2.26 CT_Fonts declares
5589
+ // ascii/hAnsi/eastAsia/cs as ST_String, so purely-numeric
5590
+ // font names must survive round-trip as strings here too.
5591
+ if (rFonts['@_w:ascii'] !== undefined) prevProps.font = String(rFonts['@_w:ascii']);
5592
+ if (rFonts['@_w:hAnsi'] !== undefined) prevProps.fontHAnsi = String(rFonts['@_w:hAnsi']);
5593
+ if (rFonts['@_w:eastAsia'] !== undefined)
5594
+ prevProps.fontEastAsia = String(rFonts['@_w:eastAsia']);
5595
+ if (rFonts['@_w:cs'] !== undefined) prevProps.fontCs = String(rFonts['@_w:cs']);
5596
+ if (rFonts['@_w:hint']) prevProps.fontHint = String(rFonts['@_w:hint']);
5597
+ if (rFonts['@_w:asciiTheme']) prevProps.fontAsciiTheme = String(rFonts['@_w:asciiTheme']);
5598
+ if (rFonts['@_w:hAnsiTheme']) prevProps.fontHAnsiTheme = String(rFonts['@_w:hAnsiTheme']);
5599
+ if (rFonts['@_w:eastAsiaTheme'])
5600
+ prevProps.fontEastAsiaTheme = String(rFonts['@_w:eastAsiaTheme']);
5601
+ if (rFonts['@_w:cstheme']) prevProps.fontCsTheme = String(rFonts['@_w:cstheme']);
5143
5602
  }
5144
5603
 
5145
5604
  // Parse previous size (half-points to points)
@@ -5337,17 +5796,33 @@ export class DocumentParser {
5337
5796
  }
5338
5797
  }
5339
5798
 
5340
- // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.4
5341
- // Maps to TextBorder interface: style, size, color, space
5799
+ // Parse text border (w:bdr) per ECMA-376 Part 1 §17.3.2.5 — full
5800
+ // CT_Border attribute set for rPrChange previous-properties fidelity.
5342
5801
  if (prevRPr['w:bdr']) {
5343
5802
  const bdrObj = prevRPr['w:bdr'];
5344
- prevProps.border = {
5345
- style: bdrObj['@_w:val'] as import('../elements/Run').TextBorderStyle,
5803
+ const tb: import('../elements/Run.js').TextBorder = {
5804
+ style: bdrObj['@_w:val'] as import('../elements/Run.js').TextBorderStyle,
5346
5805
  size: bdrObj['@_w:sz'] !== undefined ? safeParseInt(bdrObj['@_w:sz']) : undefined,
5347
5806
  space:
5348
5807
  bdrObj['@_w:space'] !== undefined ? safeParseInt(bdrObj['@_w:space']) : undefined,
5349
5808
  color: bdrObj['@_w:color'],
5350
5809
  };
5810
+ // String(...) cast: XMLParser coerces "80"/"50" hex to numbers
5811
+ // — preserve the declared string contract on the model.
5812
+ if (bdrObj['@_w:themeColor']) {
5813
+ tb.themeColor = String(
5814
+ bdrObj['@_w:themeColor']
5815
+ ) as import('../elements/Run.js').ThemeColorValue;
5816
+ }
5817
+ if (bdrObj['@_w:themeTint']) tb.themeTint = String(bdrObj['@_w:themeTint']);
5818
+ if (bdrObj['@_w:themeShade']) tb.themeShade = String(bdrObj['@_w:themeShade']);
5819
+ if (bdrObj['@_w:shadow'] !== undefined) {
5820
+ tb.shadow = parseOnOffAttribute(String(bdrObj['@_w:shadow']), true);
5821
+ }
5822
+ if (bdrObj['@_w:frame'] !== undefined) {
5823
+ tb.frame = parseOnOffAttribute(String(bdrObj['@_w:frame']), true);
5824
+ }
5825
+ prevProps.border = tb;
5351
5826
  }
5352
5827
 
5353
5828
  // Parse character shading (w:shd) per ECMA-376 Part 1 §17.3.2.32
@@ -5358,24 +5833,54 @@ export class DocumentParser {
5358
5833
  }
5359
5834
  }
5360
5835
 
5361
- // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1 §17.3.2.10
5836
+ // Parse East Asian layout (w:eastAsianLayout) per ECMA-376 Part 1
5837
+ // §17.3.2.10 CT_EastAsianLayout. Parity-fix with the main rPr
5838
+ // parser: route the three ST_OnOff attributes through
5839
+ // parseOnOffAttribute so every literal — including "0"
5840
+ // (explicit-false override) and "off" — resolves correctly. The
5841
+ // previous truthy gate both dropped explicit-false (XMLParser
5842
+ // coerces "0" to number 0 → falsy → undefined) and wrongly
5843
+ // coerced "off" to true.
5362
5844
  if (prevRPr['w:eastAsianLayout']) {
5363
5845
  const eaObj = prevRPr['w:eastAsianLayout'];
5364
5846
  prevProps.eastAsianLayout = {
5365
5847
  id: eaObj['@_w:id'] !== undefined ? safeParseInt(eaObj['@_w:id']) : undefined,
5366
- combine: eaObj['@_w:combine']
5367
- ? parseOoxmlBoolean({ '@_w:val': eaObj['@_w:combine'] })
5368
- : undefined,
5848
+ combine:
5849
+ eaObj['@_w:combine'] !== undefined
5850
+ ? parseOnOffAttribute(String(eaObj['@_w:combine']), true)
5851
+ : undefined,
5369
5852
  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,
5853
+ vert:
5854
+ eaObj['@_w:vert'] !== undefined
5855
+ ? parseOnOffAttribute(String(eaObj['@_w:vert']), true)
5856
+ : undefined,
5857
+ vertCompress:
5858
+ eaObj['@_w:vertCompress'] !== undefined
5859
+ ? parseOnOffAttribute(String(eaObj['@_w:vertCompress']), true)
5860
+ : undefined,
5376
5861
  };
5377
5862
  }
5378
5863
 
5864
+ // Collect w14: namespace elements from the previous rPr for
5865
+ // passthrough (Word 2010+ text effects: w14:textOutline,
5866
+ // w14:shadow, w14:reflection, w14:glow, w14:ligatures,
5867
+ // w14:numForm, w14:numSpacing, w14:cntxtAlts, w14:stylisticSets).
5868
+ // The main rPr parser already collects these and the rPrChange
5869
+ // emitter (via generateRunPropertiesXML line 3130) re-emits
5870
+ // prevProps.rawW14Properties, but the rPrChange parser never
5871
+ // captured them — so tracked changes to any w14 text effect
5872
+ // silently lost the previous state on load → save.
5873
+ const prevRawW14: string[] = [];
5874
+ for (const key of Object.keys(prevRPr)) {
5875
+ if (key.startsWith('w14:')) {
5876
+ const rawXml = this.objectToXml({ [key]: prevRPr[key] });
5877
+ if (rawXml) prevRawW14.push(rawXml);
5878
+ }
5879
+ }
5880
+ if (prevRawW14.length > 0) {
5881
+ (prevProps as { rawW14Properties?: string[] }).rawW14Properties = prevRawW14;
5882
+ }
5883
+
5379
5884
  propChange.previousProperties = prevProps;
5380
5885
  }
5381
5886
 
@@ -5446,8 +5951,15 @@ export class DocumentParser {
5446
5951
  let docPrId = 1;
5447
5952
  let hidden = false;
5448
5953
  if (docPrObj) {
5449
- name = docPrObj['@_name'] || 'image';
5450
- description = docPrObj['@_descr'] || 'Image';
5954
+ // wp:docPr @name and @descr are xsd:string per ECMA-376
5955
+ // §20.4.2.5 CT_NonVisualDrawingProps. XMLParser coerces
5956
+ // purely-numeric values ("2010") to JS numbers; cast through
5957
+ // String() so Image.name / Image.description keep the declared
5958
+ // string contract (matches the @_title handling below).
5959
+ const rawName = docPrObj['@_name'];
5960
+ name = rawName !== undefined && rawName !== null ? String(rawName) : 'image';
5961
+ const rawDescr = docPrObj['@_descr'];
5962
+ description = rawDescr !== undefined && rawDescr !== null ? String(rawDescr) : 'Image';
5451
5963
  if (docPrObj['@_title']) {
5452
5964
  title = String(docPrObj['@_title']);
5453
5965
  }
@@ -5776,7 +6288,7 @@ export class DocumentParser {
5776
6288
  );
5777
6289
 
5778
6290
  // Create image from buffer with all properties
5779
- const { Image: ImageClass } = await import('../elements/Image');
6291
+ const { Image: ImageClass } = await import('../elements/Image.js');
5780
6292
  const image = await ImageClass.create({
5781
6293
  source: imageData,
5782
6294
  width,
@@ -6137,12 +6649,36 @@ export class DocumentParser {
6137
6649
  */
6138
6650
  private parseBorderElement(borderObj: any): TableBorder | undefined {
6139
6651
  if (!borderObj) return undefined;
6652
+ // Extract the full CT_Border attribute set per ECMA-376 §17.18.2:
6653
+ // val (required) / sz / space / color / themeColor / themeTint /
6654
+ // themeShade / shadow / frame. Previously the last five were silently
6655
+ // dropped on load, so themed borders and shadow/frame flags were lost
6656
+ // on every round-trip.
6140
6657
  const border: TableBorder = {
6141
6658
  style: (borderObj['@_w:val'] || 'single') as TableBorder['style'],
6142
6659
  };
6143
6660
  if (borderObj['@_w:sz'] !== undefined) border.size = safeParseInt(borderObj['@_w:sz']);
6144
6661
  if (borderObj['@_w:space'] !== undefined) border.space = safeParseInt(borderObj['@_w:space']);
6145
- if (borderObj['@_w:color']) border.color = borderObj['@_w:color'];
6662
+ if (borderObj['@_w:color']) border.color = String(borderObj['@_w:color']);
6663
+ // String(...) cast: themeTint / themeShade are ST_UcharHexNumber
6664
+ // (2-char hex) declared as `string` on the model. XMLParser coerces
6665
+ // purely-digit hex like "80"/"50" to numbers — cast to preserve
6666
+ // the type contract.
6667
+ if (borderObj['@_w:themeColor']) {
6668
+ (border as any).themeColor = String(borderObj['@_w:themeColor']);
6669
+ }
6670
+ if (borderObj['@_w:themeTint']) {
6671
+ (border as any).themeTint = String(borderObj['@_w:themeTint']);
6672
+ }
6673
+ if (borderObj['@_w:themeShade']) {
6674
+ (border as any).themeShade = String(borderObj['@_w:themeShade']);
6675
+ }
6676
+ if (borderObj['@_w:shadow'] !== undefined) {
6677
+ (border as any).shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
6678
+ }
6679
+ if (borderObj['@_w:frame'] !== undefined) {
6680
+ (border as any).frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
6681
+ }
6146
6682
  return border;
6147
6683
  }
6148
6684
 
@@ -6188,8 +6724,10 @@ export class DocumentParser {
6188
6724
  const gridChange = TableGridChange.create(
6189
6725
  safeParseInt(changeObj['@_w:id'], 0),
6190
6726
  prevWidths,
6191
- changeObj['@_w:author'] || undefined,
6192
- changeObj['@_w:date'] ? new Date(changeObj['@_w:date']) : undefined
6727
+ changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : undefined,
6728
+ changeObj['@_w:date'] !== undefined
6729
+ ? new Date(String(changeObj['@_w:date']))
6730
+ : undefined
6193
6731
  );
6194
6732
  table.setTblGridChange(gridChange);
6195
6733
  }
@@ -6284,53 +6822,90 @@ export class DocumentParser {
6284
6822
  private parseTablePropertiesFromObject(tblPrObj: any, table: Table): void {
6285
6823
  if (!tblPrObj) return;
6286
6824
 
6287
- // Parse table style reference (w:tblStyle)
6825
+ // Parse table style reference (w:tblStyle) per ECMA-376 §17.7.4.62.
6826
+ // w:val is ST_String — cast through String() so purely-numeric
6827
+ // custom style IDs ("2025", "1", …) don't leak as JS numbers
6828
+ // through XMLParser's parseAttributeValue coercion into the
6829
+ // string-typed `formatting.style` field.
6288
6830
  if (tblPrObj['w:tblStyle']) {
6289
6831
  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
6832
+ if (styleId !== undefined && styleId !== null && styleId !== '') {
6833
+ table.setStyle(String(styleId));
6834
+ }
6835
+ }
6836
+
6837
+ // Parse table look flags (w:tblLook) per ECMA-376 §17.4.57 supports both
6838
+ // hex-string format (w:val="04A0") AND individual ST_OnOff attributes
6839
+ // (firstRow/lastRow/firstColumn/lastColumn/noHBand/noVBand).
6840
+ //
6841
+ // XMLParser.parseToObject runs with `parseAttributeValue: true` by default,
6842
+ // so `"1"` coerces to the number `1` and `"true"` to the boolean `true`.
6843
+ // The previous `=== '1'` strict-string comparison missed both coerced
6844
+ // forms, silently flipping every individually-set flag to OFF and
6845
+ // producing `tblLook="0000"` for every Word-authored document whose
6846
+ // tblLook used the expanded attribute syntax. Route each attribute
6847
+ // through `parseOoxmlBoolean` (attribute form) so string/number/boolean
6848
+ // representations all resolve correctly.
6297
6849
  if (tblPrObj['w:tblLook']) {
6298
6850
  const look = tblPrObj['w:tblLook'];
6299
6851
  if (look['@_w:val']) {
6300
6852
  // Hex string format
6301
6853
  table.setTblLook(look['@_w:val']);
6302
6854
  } 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
6855
+ // Individual attribute format construct hex value.
6856
+ // Bits per §17.4.57: firstRow=0x0020, lastRow=0x0040, firstCol=0x0080,
6857
+ // lastCol=0x0100, noHBand=0x0200, noVBand=0x0400.
6858
+ const attrIsOn = (name: string): boolean => {
6859
+ const v = look[name];
6860
+ if (v === undefined) return false;
6861
+ // parseOoxmlBoolean accepts the value wrapped as `{'@_w:val': v}` —
6862
+ // handles string "1"/"0"/"true"/"false"/"on"/"off", number 1/0,
6863
+ // and boolean true/false uniformly.
6864
+ return parseOoxmlBoolean({ '@_w:val': v });
6865
+ };
6305
6866
  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;
6867
+ if (attrIsOn('@_w:firstRow')) value |= 0x0020;
6868
+ if (attrIsOn('@_w:lastRow')) value |= 0x0040;
6869
+ if (attrIsOn('@_w:firstColumn')) value |= 0x0080;
6870
+ if (attrIsOn('@_w:lastColumn')) value |= 0x0100;
6871
+ if (attrIsOn('@_w:noHBand')) value |= 0x0200;
6872
+ if (attrIsOn('@_w:noVBand')) value |= 0x0400;
6312
6873
  table.setTblLook(value.toString(16).toUpperCase().padStart(4, '0'));
6313
6874
  }
6314
6875
  }
6315
6876
 
6316
- // Parse table positioning (tblpPr) - for floating tables
6877
+ // Parse table positioning (tblpPr) - for floating tables.
6878
+ // Per ECMA-376 §17.4.52 CT_TblPPr, the six numeric attributes
6879
+ // (tblpX/tblpY/leftFromText/rightFromText/topFromText/bottomFromText)
6880
+ // are ST_SignedTwipsMeasure / ST_TwipsMeasure where 0 is a valid
6881
+ // value (e.g. float table anchored exactly at the anchor point).
6882
+ // XMLParser coerces "0" to the number 0 (falsy), so the previous
6883
+ // truthy gate silently dropped zero-offset positions. Table's
6884
+ // emitter uses `!== undefined`, so the asymmetry lost zeroes on
6885
+ // round-trip. Route each numeric read through isExplicitlySet +
6886
+ // safeParseInt.
6317
6887
  if (tblPrObj['w:tblpPr']) {
6318
6888
  const tblpPr = tblPrObj['w:tblpPr'];
6319
6889
  const position: any = {};
6320
6890
 
6321
- if (tblpPr['@_w:tblpX']) position.x = parseInt(tblpPr['@_w:tblpX'], 10);
6322
- if (tblpPr['@_w:tblpY']) position.y = parseInt(tblpPr['@_w:tblpY'], 10);
6891
+ if (isExplicitlySet(tblpPr['@_w:tblpX'])) position.x = safeParseInt(tblpPr['@_w:tblpX']);
6892
+ if (isExplicitlySet(tblpPr['@_w:tblpY'])) position.y = safeParseInt(tblpPr['@_w:tblpY']);
6323
6893
  if (tblpPr['@_w:horzAnchor']) position.horizontalAnchor = tblpPr['@_w:horzAnchor'];
6324
6894
  if (tblpPr['@_w:vertAnchor']) position.verticalAnchor = tblpPr['@_w:vertAnchor'];
6325
6895
  if (tblpPr['@_w:tblpXSpec']) position.horizontalAlignment = tblpPr['@_w:tblpXSpec'];
6326
6896
  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);
6897
+ if (isExplicitlySet(tblpPr['@_w:leftFromText'])) {
6898
+ position.leftFromText = safeParseInt(tblpPr['@_w:leftFromText']);
6899
+ }
6900
+ if (isExplicitlySet(tblpPr['@_w:rightFromText'])) {
6901
+ position.rightFromText = safeParseInt(tblpPr['@_w:rightFromText']);
6902
+ }
6903
+ if (isExplicitlySet(tblpPr['@_w:topFromText'])) {
6904
+ position.topFromText = safeParseInt(tblpPr['@_w:topFromText']);
6905
+ }
6906
+ if (isExplicitlySet(tblpPr['@_w:bottomFromText'])) {
6907
+ position.bottomFromText = safeParseInt(tblpPr['@_w:bottomFromText']);
6908
+ }
6334
6909
 
6335
6910
  if (Object.keys(position).length > 0) {
6336
6911
  table.setPosition(position);
@@ -6343,9 +6918,9 @@ export class DocumentParser {
6343
6918
  table.setOverlap(val === 'overlap');
6344
6919
  }
6345
6920
 
6346
- // Parse bidirectional visual layout
6921
+ // Parse bidirectional visual layout — CT_OnOff, honour w:val per ECMA-376 §17.17.4
6347
6922
  if (tblPrObj['w:bidiVisual']) {
6348
- table.setBidiVisual(true);
6923
+ table.setBidiVisual(parseOoxmlBoolean(tblPrObj['w:bidiVisual']));
6349
6924
  }
6350
6925
 
6351
6926
  // Parse table width — always set when w:tblW is present, including w:w="0" w:type="auto"
@@ -6358,24 +6933,37 @@ export class DocumentParser {
6358
6933
  table.setWidthType(widthType);
6359
6934
  }
6360
6935
 
6361
- // Parse table caption
6936
+ // Parse table caption — ST_String per §17.4.62. Cast through
6937
+ // String() so a purely-numeric caption ("42") is preserved as a
6938
+ // string in `formatting.caption` rather than a JS number.
6362
6939
  if (tblPrObj['w:tblCaption']) {
6363
6940
  const caption = tblPrObj['w:tblCaption']['@_w:val'];
6364
- if (caption) table.setCaption(caption);
6941
+ if (caption !== undefined && caption !== null && caption !== '') {
6942
+ table.setCaption(String(caption));
6943
+ }
6365
6944
  }
6366
6945
 
6367
- // Parse table description
6946
+ // Parse table description — ST_String per §17.4.63.
6368
6947
  if (tblPrObj['w:tblDescription']) {
6369
6948
  const description = tblPrObj['w:tblDescription']['@_w:val'];
6370
- if (description) table.setDescription(description);
6949
+ if (description !== undefined && description !== null && description !== '') {
6950
+ table.setDescription(String(description));
6951
+ }
6371
6952
  }
6372
6953
 
6373
- // Parse cell spacing
6954
+ // Parse table-level cell spacing (w:tblCellSpacing) per ECMA-376
6955
+ // §17.4.44 CT_TblCellSpacing. w:w is ST_MeasurementOrPercent; 0 is
6956
+ // a legal "explicit zero spacing" value (overrides any style-level
6957
+ // inherited tblCellSpacing). The emitter uses `!== undefined`, so
6958
+ // the previous `spacing > 0` gate created a parser/emitter
6959
+ // asymmetry: a tracked table-property change recording a *previous*
6960
+ // state of `<w:tblCellSpacing w:w="0" …/>` lost the override on
6961
+ // every round-trip.
6374
6962
  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);
6963
+ const rawW = tblPrObj['w:tblCellSpacing']['@_w:w'];
6964
+ if (isExplicitlySet(rawW)) {
6965
+ table.setCellSpacing(safeParseInt(rawW));
6966
+ const spacingType = tblPrObj['w:tblCellSpacing']['@_w:type'] || 'dxa';
6379
6967
  table.setCellSpacingType(spacingType);
6380
6968
  }
6381
6969
  }
@@ -6394,7 +6982,7 @@ export class DocumentParser {
6394
6982
  table.setIndent(indentVal);
6395
6983
  const indentType = tblPrObj['w:tblInd']['@_w:type'];
6396
6984
  if (indentType) {
6397
- table.setIndentType(indentType as import('../elements/Table').TableWidthType);
6985
+ table.setIndentType(indentType as import('../elements/Table.js').TableWidthType);
6398
6986
  }
6399
6987
  }
6400
6988
 
@@ -6460,15 +7048,26 @@ export class DocumentParser {
6460
7048
  }
6461
7049
  }
6462
7050
 
6463
- // Parse table borders (w:tblBorders) per ECMA-376 Part 1 §17.4.40
7051
+ // Parse table borders (w:tblBorders) per ECMA-376 Part 1 §17.4.40.
7052
+ // left / right have bidi-aware aliases `w:start` / `w:end` (the
7053
+ // preferred spelling in modern Word-authored documents). Prefer
7054
+ // them when present, falling back to the legacy names — the
7055
+ // internal model stores under `left` / `right`, matching the
7056
+ // emitter. Without this fallback, any table whose side borders
7057
+ // were authored with the bidi-aware form silently lost those
7058
+ // borders on every round-trip (the emitter would replace them
7059
+ // with absent w:left/w:right, and the parser would never revive
7060
+ // the w:start/w:end it dropped).
6464
7061
  if (tblPrObj['w:tblBorders']) {
6465
7062
  const bordersObj = tblPrObj['w:tblBorders'];
6466
- const borders: import('../elements/Table').TableBorders = {};
7063
+ const borders: import('../elements/Table.js').TableBorders = {};
6467
7064
 
6468
7065
  if (bordersObj['w:top']) borders.top = this.parseBorderElement(bordersObj['w:top']);
6469
7066
  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']);
7067
+ const leftBorder = bordersObj['w:start'] ?? bordersObj['w:left'];
7068
+ if (leftBorder) borders.left = this.parseBorderElement(leftBorder);
7069
+ const rightBorder = bordersObj['w:end'] ?? bordersObj['w:right'];
7070
+ if (rightBorder) borders.right = this.parseBorderElement(rightBorder);
6472
7071
  if (bordersObj['w:insideH'])
6473
7072
  borders.insideH = this.parseBorderElement(bordersObj['w:insideH']);
6474
7073
  if (bordersObj['w:insideV'])
@@ -6484,8 +7083,8 @@ export class DocumentParser {
6484
7083
  const changeObj = tblPrObj['w:tblPrChange'];
6485
7084
  table.setTblPrChange({
6486
7085
  id: String(changeObj['@_w:id'] || '0'),
6487
- author: changeObj['@_w:author'] || '',
6488
- date: changeObj['@_w:date'] || '',
7086
+ author: changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : '',
7087
+ date: changeObj['@_w:date'] !== undefined ? String(changeObj['@_w:date']) : '',
6489
7088
  previousProperties: this.parseGenericPreviousProperties(changeObj['w:tblPr']),
6490
7089
  });
6491
7090
  }
@@ -6562,14 +7161,20 @@ export class DocumentParser {
6562
7161
  private parseTableRowPropertiesFromObject(trPrObj: any, row: TableRow): void {
6563
7162
  if (!trPrObj) return;
6564
7163
 
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"
7164
+ // Parse row height (w:trHeight) per ECMA-376 Part 1 §17.4.81.
7165
+ // w:val is ST_TwipsMeasure; zero is a valid value and, combined
7166
+ // with w:hRule="exact", represents a hidden / collapsed row.
7167
+ // XMLParser coerces "0" to the number 0 (falsy), and the previous
7168
+ // `heightVal > 0` gate silently dropped explicit zero-height rows
7169
+ // even though the emitter (TableRow.ts §914) preserves them via
7170
+ // `!== undefined`. Route through isExplicitlySet so zero survives.
7171
+ // Per §17.18.33 (ST_HeightRule), when w:hRule is absent the
7172
+ // default is "auto".
6567
7173
  if (trPrObj['w:trHeight']) {
6568
- const heightVal = parseInt(trPrObj['w:trHeight']['@_w:val'] || '0', 10);
7174
+ const rawVal = trPrObj['w:trHeight']['@_w:val'];
6569
7175
  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
7176
+ if (isExplicitlySet(rawVal)) {
7177
+ const heightVal = safeParseInt(rawVal);
6573
7178
  row.setHeight(heightVal);
6574
7179
  if (heightRule) {
6575
7180
  row.setHeightRule(heightRule);
@@ -6581,14 +7186,14 @@ export class DocumentParser {
6581
7186
  }
6582
7187
  }
6583
7188
 
6584
- // Parse table header row (w:tblHeader) per ECMA-376 Part 1 §17.4.49
7189
+ // Parse table header row (w:tblHeader) per ECMA-376 Part 1 §17.4.49 — CT_OnOff
6585
7190
  if (trPrObj['w:tblHeader']) {
6586
- row.setHeader(true);
7191
+ row.setHeader(parseOoxmlBoolean(trPrObj['w:tblHeader']));
6587
7192
  }
6588
7193
 
6589
- // Parse can't split (w:cantSplit) per ECMA-376 Part 1 §17.4.5
7194
+ // Parse can't split (w:cantSplit) per ECMA-376 Part 1 §17.4.5 — CT_OnOff
6590
7195
  if (trPrObj['w:cantSplit']) {
6591
- row.setCantSplit(true);
7196
+ row.setCantSplit(parseOoxmlBoolean(trPrObj['w:cantSplit']));
6592
7197
  }
6593
7198
 
6594
7199
  // Parse row justification (w:jc) per ECMA-376 Part 1 §17.4.79
@@ -6599,9 +7204,9 @@ export class DocumentParser {
6599
7204
  }
6600
7205
  }
6601
7206
 
6602
- // Parse hidden (w:hidden) per ECMA-376 Part 1 §17.4.23
7207
+ // Parse hidden (w:hidden) per ECMA-376 Part 1 §17.4.23 — CT_OnOff
6603
7208
  if (trPrObj['w:hidden']) {
6604
- row.setHidden(true);
7209
+ row.setHidden(parseOoxmlBoolean(trPrObj['w:hidden']));
6605
7210
  }
6606
7211
 
6607
7212
  // Parse grid before (w:gridBefore) per ECMA-376 Part 1 §17.4.15
@@ -6620,30 +7225,36 @@ export class DocumentParser {
6620
7225
  }
6621
7226
  }
6622
7227
 
6623
- // Parse width before (w:wBefore) per ECMA-376 Part 1 §17.4.83
7228
+ // Parse width before (w:wBefore) per ECMA-376 Part 1 §17.4.83.
7229
+ // w:w is ST_TblWidth; 0 paired with w:type="auto" is the idiomatic
7230
+ // "no width" form, and explicit 0 in dxa twips can override an
7231
+ // inherited wBefore. Previous `w > 0` gate silently dropped both.
6624
7232
  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);
7233
+ const rawW = trPrObj['w:wBefore']['@_w:w'];
7234
+ if (isExplicitlySet(rawW)) {
7235
+ const type = (trPrObj['w:wBefore']['@_w:type'] as string | undefined) || 'dxa';
7236
+ row.setWBefore(safeParseInt(rawW), type);
6629
7237
  }
6630
7238
  }
6631
7239
 
6632
- // Parse width after (w:wAfter) per ECMA-376 Part 1 §17.4.82
7240
+ // Parse width after (w:wAfter) per ECMA-376 Part 1 §17.4.82 — same
7241
+ // ST_TblWidth semantics as wBefore.
6633
7242
  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);
7243
+ const rawW = trPrObj['w:wAfter']['@_w:w'];
7244
+ if (isExplicitlySet(rawW)) {
7245
+ const type = (trPrObj['w:wAfter']['@_w:type'] as string | undefined) || 'dxa';
7246
+ row.setWAfter(safeParseInt(rawW), type);
6638
7247
  }
6639
7248
  }
6640
7249
 
6641
- // Parse row-level cell spacing (w:tblCellSpacing)
7250
+ // Parse row-level cell spacing (w:tblCellSpacing). Zero is a valid
7251
+ // override — "explicitly no extra spacing" on a row overriding a
7252
+ // non-zero table-level tblCellSpacing.
6642
7253
  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);
7254
+ const rawW = trPrObj['w:tblCellSpacing']['@_w:w'];
7255
+ if (isExplicitlySet(rawW)) {
7256
+ const type = (trPrObj['w:tblCellSpacing']['@_w:type'] as string | undefined) || 'dxa';
7257
+ row.setRowCellSpacing(safeParseInt(rawW), type);
6647
7258
  }
6648
7259
  }
6649
7260
 
@@ -6655,11 +7266,39 @@ export class DocumentParser {
6655
7266
  }
6656
7267
  }
6657
7268
 
6658
- // Parse divId (w:divId) per ECMA-376 Part 1 §17.4.9
7269
+ // Parse divId (w:divId) per ECMA-376 Part 1 §17.4.9. `w:val` is
7270
+ // ST_DecimalNumber; 0 is a valid reference to the first div in web
7271
+ // settings. The previous `val > 0` gate silently dropped it on load.
6659
7272
  if (trPrObj['w:divId']) {
6660
- const val = parseInt(trPrObj['w:divId']['@_w:val'] || '0', 10);
6661
- if (val > 0) {
6662
- row.setDivId(val);
7273
+ const rawVal = trPrObj['w:divId']['@_w:val'];
7274
+ if (isExplicitlySet(rawVal)) {
7275
+ const parsed = safeParseInt(rawVal);
7276
+ if (!isNaN(parsed)) row.setDivId(parsed);
7277
+ }
7278
+ }
7279
+
7280
+ // Parse tracked row insertion / deletion (CT_TrackChange inside CT_TrPr)
7281
+ // per ECMA-376 Part 1 §17.13.5.19 (ins) / §17.13.5.14 (del). These mark
7282
+ // the entire row as a tracked revision; a previous version silently
7283
+ // dropped both markers on load → save because the parser skipped them.
7284
+ if (trPrObj['w:ins']) {
7285
+ const insObj = Array.isArray(trPrObj['w:ins']) ? trPrObj['w:ins'][0] : trPrObj['w:ins'];
7286
+ if (insObj && typeof insObj === 'object') {
7287
+ row.setRowInsertion({
7288
+ id: String(insObj['@_w:id'] ?? '0'),
7289
+ author: String(insObj['@_w:author'] ?? ''),
7290
+ date: String(insObj['@_w:date'] ?? ''),
7291
+ });
7292
+ }
7293
+ }
7294
+ if (trPrObj['w:del']) {
7295
+ const delObj = Array.isArray(trPrObj['w:del']) ? trPrObj['w:del'][0] : trPrObj['w:del'];
7296
+ if (delObj && typeof delObj === 'object') {
7297
+ row.setRowDeletion({
7298
+ id: String(delObj['@_w:id'] ?? '0'),
7299
+ author: String(delObj['@_w:author'] ?? ''),
7300
+ date: String(delObj['@_w:date'] ?? ''),
7301
+ });
6663
7302
  }
6664
7303
  }
6665
7304
 
@@ -6668,8 +7307,8 @@ export class DocumentParser {
6668
7307
  const changeObj = trPrObj['w:trPrChange'];
6669
7308
  row.setTrPrChange({
6670
7309
  id: String(changeObj['@_w:id'] || '0'),
6671
- author: changeObj['@_w:author'] || '',
6672
- date: changeObj['@_w:date'] || '',
7310
+ author: changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : '',
7311
+ date: changeObj['@_w:date'] !== undefined ? String(changeObj['@_w:date']) : '',
6673
7312
  previousProperties: this.parseGenericPreviousProperties(changeObj['w:trPr']),
6674
7313
  });
6675
7314
  }
@@ -6685,11 +7324,15 @@ export class DocumentParser {
6685
7324
 
6686
7325
  const exceptions: any = {};
6687
7326
 
6688
- // Parse table width exception (w:tblW)
7327
+ // Parse table width exception (w:tblW). The `val > 0` gate previously
7328
+ // dropped both w:w="0" (explicit zero-width override, valid when
7329
+ // paired with w:type="nil"/"auto") and negative overrides. Route
7330
+ // through isExplicitlySet + safeParseInt so zero and negative widths
7331
+ // round-trip.
6689
7332
  if (tblPrExObj['w:tblW']) {
6690
- const widthVal = parseInt(tblPrExObj['w:tblW']['@_w:w'] || '0', 10);
6691
- if (widthVal > 0) {
6692
- exceptions.width = widthVal;
7333
+ const rawW = tblPrExObj['w:tblW']['@_w:w'];
7334
+ if (isExplicitlySet(rawW)) {
7335
+ exceptions.width = safeParseInt(rawW);
6693
7336
  }
6694
7337
  }
6695
7338
 
@@ -6701,19 +7344,26 @@ export class DocumentParser {
6701
7344
  }
6702
7345
  }
6703
7346
 
6704
- // Parse cell spacing exception (w:tblCellSpacing)
7347
+ // Parse cell spacing exception (w:tblCellSpacing). Zero-value
7348
+ // override is valid (= "explicit no cell spacing" on a row that
7349
+ // would otherwise inherit non-zero spacing from the table-level
7350
+ // tblCellSpacing).
6705
7351
  if (tblPrExObj['w:tblCellSpacing']) {
6706
- const val = parseInt(tblPrExObj['w:tblCellSpacing']['@_w:w'] || '0', 10);
6707
- if (val > 0) {
6708
- exceptions.cellSpacing = val;
7352
+ const rawW = tblPrExObj['w:tblCellSpacing']['@_w:w'];
7353
+ if (isExplicitlySet(rawW)) {
7354
+ exceptions.cellSpacing = safeParseInt(rawW);
6709
7355
  }
6710
7356
  }
6711
7357
 
6712
- // Parse table indentation exception (w:tblInd)
7358
+ // Parse table indentation exception (w:tblInd). Per ECMA-376
7359
+ // §17.4.62 CT_TblWidth, w:w is ST_MeasurementOrPercent — 0 is a
7360
+ // legal "reset" value and negative values indicate an outdent (table
7361
+ // hanging into the page margin). The previous `val > 0` check
7362
+ // silently dropped both.
6713
7363
  if (tblPrExObj['w:tblInd']) {
6714
- const val = parseInt(tblPrExObj['w:tblInd']['@_w:w'] || '0', 10);
6715
- if (val > 0) {
6716
- exceptions.indentation = val;
7364
+ const rawW = tblPrExObj['w:tblInd']['@_w:w'];
7365
+ if (isExplicitlySet(rawW)) {
7366
+ exceptions.indentation = safeParseInt(rawW);
6717
7367
  }
6718
7368
  }
6719
7369
 
@@ -6744,15 +7394,40 @@ export class DocumentParser {
6744
7394
  const borderNames = ['top', 'bottom', 'left', 'right', 'insideH', 'insideV'];
6745
7395
 
6746
7396
  for (const name of borderNames) {
6747
- const borderKey = `w:${name}`;
6748
- if (bordersObj[borderKey]) {
6749
- const borderObj = bordersObj[borderKey];
7397
+ // Prefer bidi-aware `w:start`/`w:end` aliases over legacy `w:left`/
7398
+ // `w:right` per ECMA-376 §17.4.40 CT_TblBorders. Modern Word-
7399
+ // authored documents emit the bidi-aware form by default; the
7400
+ // internal model stores under the legacy keys to match the emitter.
7401
+ const aliasKey = name === 'left' ? 'w:start' : name === 'right' ? 'w:end' : undefined;
7402
+ const borderObj = (aliasKey && bordersObj[aliasKey]) || bordersObj[`w:${name}`];
7403
+ if (borderObj) {
6750
7404
  borders[name] = {};
6751
7405
 
7406
+ // Full CT_Border attribute set (§17.18.2) — previously only the four
7407
+ // basic attrs were read, so tblPrEx borders lost themed-color linkage
7408
+ // on every round-trip.
6752
7409
  if (borderObj['@_w:val']) borders[name].style = borderObj['@_w:val'];
6753
7410
  if (borderObj['@_w:sz']) borders[name].size = parseInt(borderObj['@_w:sz'], 10);
6754
7411
  if (borderObj['@_w:space']) borders[name].space = parseInt(borderObj['@_w:space'], 10);
6755
- if (borderObj['@_w:color']) borders[name].color = borderObj['@_w:color'];
7412
+ if (borderObj['@_w:color']) borders[name].color = String(borderObj['@_w:color']);
7413
+ // String(...) cast: themeTint / themeShade are ST_UcharHexNumber
7414
+ // (2-char hex). XMLParser coerces purely-digit hex to numbers —
7415
+ // cast so the string contract on the model is preserved.
7416
+ if (borderObj['@_w:themeColor']) {
7417
+ borders[name].themeColor = String(borderObj['@_w:themeColor']);
7418
+ }
7419
+ if (borderObj['@_w:themeTint']) {
7420
+ borders[name].themeTint = String(borderObj['@_w:themeTint']);
7421
+ }
7422
+ if (borderObj['@_w:themeShade']) {
7423
+ borders[name].themeShade = String(borderObj['@_w:themeShade']);
7424
+ }
7425
+ if (borderObj['@_w:shadow'] !== undefined) {
7426
+ borders[name].shadow = parseOnOffAttribute(String(borderObj['@_w:shadow']), true);
7427
+ }
7428
+ if (borderObj['@_w:frame'] !== undefined) {
7429
+ borders[name].frame = parseOnOffAttribute(String(borderObj['@_w:frame']), true);
7430
+ }
6756
7431
  }
6757
7432
  }
6758
7433
 
@@ -6773,12 +7448,26 @@ export class DocumentParser {
6773
7448
  // Parse cell properties (w:tcPr) per ECMA-376 Part 1 §17.4.42
6774
7449
  const tcPr = cellObj['w:tcPr'];
6775
7450
  if (tcPr) {
6776
- // Parse cell width (w:tcW) with type per ECMA-376 Part 1 §17.4.81
7451
+ // Parse cell width (w:tcW) with type per ECMA-376 Part 1 §17.4.72
7452
+ // CT_TblWidth — w:w is ST_MeasurementOrPercent, w:type is
7453
+ // ST_TblWidth. Zero is a legal explicit override:
7454
+ // - `w:w="0" w:type="auto"` is the idiomatic "size to content"
7455
+ // form (also the default when w:tcW is absent).
7456
+ // - `w:w="0" w:type="dxa"` / `"pct"` / `"nil"` explicitly
7457
+ // override an inherited non-zero width back to zero.
7458
+ // The emitter at TableCell.ts:1353 uses `!== undefined`, so the
7459
+ // previous `widthVal > 0 || widthType === 'auto'` gate created a
7460
+ // parser/emitter asymmetry — any cell with an explicit zero
7461
+ // override in a non-auto width type silently reinherited the
7462
+ // style-level width on every round-trip.
6777
7463
  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);
7464
+ const rawW = tcPr['w:tcW']['@_w:w'];
7465
+ if (isExplicitlySet(rawW)) {
7466
+ const widthType = (tcPr['w:tcW']['@_w:type'] as string | undefined) || 'dxa';
7467
+ cell.setWidthType(
7468
+ safeParseInt(rawW),
7469
+ widthType as import('../elements/TableCell.js').CellWidthType
7470
+ );
6782
7471
  }
6783
7472
  }
6784
7473
 
@@ -6790,7 +7479,11 @@ export class DocumentParser {
6790
7479
  }
6791
7480
  }
6792
7481
 
6793
- // Parse cell borders (w:tcBorders)
7482
+ // Parse cell borders (w:tcBorders) per ECMA-376 Part 1 §17.4.66.
7483
+ // Supports both legacy LTR names (w:left / w:right) and bidi-
7484
+ // aware aliases (w:start / w:end). Prefer w:start / w:end when
7485
+ // present. Includes diagonal borders (w:tl2br / w:tr2bl) which
7486
+ // are cell-specific.
6794
7487
  if (tcPr['w:tcBorders']) {
6795
7488
  const bordersObj = tcPr['w:tcBorders'];
6796
7489
  const borders: any = {};
@@ -6798,8 +7491,10 @@ export class DocumentParser {
6798
7491
  if (bordersObj['w:top']) borders.top = this.parseBorderElement(bordersObj['w:top']);
6799
7492
  if (bordersObj['w:bottom'])
6800
7493
  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']);
7494
+ const leftBorder = bordersObj['w:start'] ?? bordersObj['w:left'];
7495
+ if (leftBorder) borders.left = this.parseBorderElement(leftBorder);
7496
+ const rightBorder = bordersObj['w:end'] ?? bordersObj['w:right'];
7497
+ if (rightBorder) borders.right = this.parseBorderElement(rightBorder);
6803
7498
  if (bordersObj['w:tl2br']) borders.tl2br = this.parseBorderElement(bordersObj['w:tl2br']);
6804
7499
  if (bordersObj['w:tr2bl']) borders.tr2bl = this.parseBorderElement(bordersObj['w:tr2bl']);
6805
7500
 
@@ -6842,10 +7537,15 @@ export class DocumentParser {
6842
7537
  }
6843
7538
  }
6844
7539
 
6845
- // Parse vertical alignment (w:vAlign)
7540
+ // Parse vertical alignment (w:vAlign) per ECMA-376 §17.4.83.
7541
+ // ST_VerticalJc has four values (§17.18.101): top, center, both,
7542
+ // bottom. The previous whitelist dropped "both" silently — the
7543
+ // style-level parser accepts it, so the asymmetry truncated cell
7544
+ // vertical alignment on cells using the "both" (justified)
7545
+ // vertical alignment on load.
6846
7546
  if (tcPr['w:vAlign']) {
6847
7547
  const valign = tcPr['w:vAlign']['@_w:val'];
6848
- if (valign && (valign === 'top' || valign === 'center' || valign === 'bottom')) {
7548
+ if (valign === 'top' || valign === 'center' || valign === 'both' || valign === 'bottom') {
6849
7549
  cell.setVerticalAlignment(valign);
6850
7550
  }
6851
7551
  }
@@ -6866,14 +7566,14 @@ export class DocumentParser {
6866
7566
  }
6867
7567
  }
6868
7568
 
6869
- // Parse no wrap (w:noWrap) per ECMA-376 Part 1 §17.4.34
7569
+ // Parse no wrap (w:noWrap) per ECMA-376 Part 1 §17.4.34 — CT_OnOff
6870
7570
  if (tcPr['w:noWrap']) {
6871
- cell.setNoWrap(true);
7571
+ cell.setNoWrap(parseOoxmlBoolean(tcPr['w:noWrap']));
6872
7572
  }
6873
7573
 
6874
- // Parse hide mark (w:hideMark) per ECMA-376 Part 1 §17.4.24
7574
+ // Parse hide mark (w:hideMark) per ECMA-376 Part 1 §17.4.24 — CT_OnOff
6875
7575
  if (tcPr['w:hideMark']) {
6876
- cell.setHideMark(true);
7576
+ cell.setHideMark(parseOoxmlBoolean(tcPr['w:hideMark']));
6877
7577
  }
6878
7578
 
6879
7579
  // Parse headers (w:headers) per ECMA-376 Part 1 §17.4.26
@@ -6884,9 +7584,9 @@ export class DocumentParser {
6884
7584
  }
6885
7585
  }
6886
7586
 
6887
- // Parse fit text (w:tcFitText) per ECMA-376 Part 1 §17.4.68
7587
+ // Parse fit text (w:tcFitText) per ECMA-376 Part 1 §17.4.68 — CT_OnOff
6888
7588
  if (tcPr['w:tcFitText']) {
6889
- cell.setFitText(true);
7589
+ cell.setFitText(parseOoxmlBoolean(tcPr['w:tcFitText']));
6890
7590
  }
6891
7591
 
6892
7592
  // Parse vertical merge (w:vMerge) per ECMA-376 Part 1 §17.4.85
@@ -6913,10 +7613,11 @@ export class DocumentParser {
6913
7613
  // Parse table cell insertion marker (w:cellIns) per ECMA-376 Part 1 §17.13.5.5
6914
7614
  if (tcPr['w:cellIns']) {
6915
7615
  const cellIns = tcPr['w:cellIns'];
6916
- const id = parseInt(cellIns['@_w:id'] || '0', 10);
6917
- const author = cellIns['@_w:author'] || 'Unknown';
7616
+ const id = parseInt(String(cellIns['@_w:id'] ?? '0'), 10);
7617
+ const author =
7618
+ cellIns['@_w:author'] !== undefined ? String(cellIns['@_w:author']) : 'Unknown';
6918
7619
  const dateAttr = cellIns['@_w:date'];
6919
- const date = dateAttr ? new Date(dateAttr) : new Date();
7620
+ const date = dateAttr !== undefined ? new Date(String(dateAttr)) : new Date();
6920
7621
 
6921
7622
  const revision = new Revision({
6922
7623
  id,
@@ -6931,10 +7632,11 @@ export class DocumentParser {
6931
7632
  // Parse table cell deletion marker (w:cellDel) per ECMA-376 Part 1 §17.13.5.6
6932
7633
  if (tcPr['w:cellDel']) {
6933
7634
  const cellDel = tcPr['w:cellDel'];
6934
- const id = parseInt(cellDel['@_w:id'] || '0', 10);
6935
- const author = cellDel['@_w:author'] || 'Unknown';
7635
+ const id = parseInt(String(cellDel['@_w:id'] ?? '0'), 10);
7636
+ const author =
7637
+ cellDel['@_w:author'] !== undefined ? String(cellDel['@_w:author']) : 'Unknown';
6936
7638
  const dateAttr = cellDel['@_w:date'];
6937
- const date = dateAttr ? new Date(dateAttr) : new Date();
7639
+ const date = dateAttr !== undefined ? new Date(String(dateAttr)) : new Date();
6938
7640
 
6939
7641
  const revision = new Revision({
6940
7642
  id,
@@ -6949,10 +7651,11 @@ export class DocumentParser {
6949
7651
  // Parse table cell merge marker (w:cellMerge) per ECMA-376 Part 1 §17.13.5.4
6950
7652
  if (tcPr['w:cellMerge']) {
6951
7653
  const cellMerge = tcPr['w:cellMerge'];
6952
- const id = parseInt(cellMerge['@_w:id'] || '0', 10);
6953
- const author = cellMerge['@_w:author'] || 'Unknown';
7654
+ const id = parseInt(String(cellMerge['@_w:id'] ?? '0'), 10);
7655
+ const author =
7656
+ cellMerge['@_w:author'] !== undefined ? String(cellMerge['@_w:author']) : 'Unknown';
6954
7657
  const dateAttr = cellMerge['@_w:date'];
6955
- const date = dateAttr ? new Date(dateAttr) : new Date();
7658
+ const date = dateAttr !== undefined ? new Date(String(dateAttr)) : new Date();
6956
7659
  const vMergeAttr = cellMerge['@_w:vMerge'];
6957
7660
  const vMergeOrigAttr = cellMerge['@_w:vMergeOrig'];
6958
7661
  // ST_AnnotationVMerge uses "rest"/"cont" but API uses "restart"/"continue"
@@ -6977,8 +7680,8 @@ export class DocumentParser {
6977
7680
  const changeObj = tcPr['w:tcPrChange'];
6978
7681
  cell.setTcPrChange({
6979
7682
  id: String(changeObj['@_w:id'] || '0'),
6980
- author: changeObj['@_w:author'] || '',
6981
- date: changeObj['@_w:date'] || '',
7683
+ author: changeObj['@_w:author'] !== undefined ? String(changeObj['@_w:author']) : '',
7684
+ date: changeObj['@_w:date'] !== undefined ? String(changeObj['@_w:date']) : '',
6982
7685
  previousProperties: this.parseGenericPreviousProperties(changeObj['w:tcPr']),
6983
7686
  });
6984
7687
  }
@@ -7226,28 +7929,40 @@ export class DocumentParser {
7226
7929
  // Parse SDT properties (sdtPr)
7227
7930
  const sdtPr = sdtObj['w:sdtPr'];
7228
7931
  if (sdtPr) {
7229
- // Parse ID
7932
+ // Parse `<w:id w:val="…"/>` per ECMA-376 §17.5.2.18. `w:val` is
7933
+ // ST_DecimalNumber (xsd:integer) — 0 is legal. XMLParser coerces
7934
+ // `"0"` to the number `0`, so the previous truthy gate silently
7935
+ // dropped w:id=0 on every load → save cycle. The emitter uses
7936
+ // `!== undefined`, creating a parser/emitter asymmetry.
7230
7937
  const idElement = sdtPr['w:id'];
7231
- if (idElement?.['@_w:val']) {
7232
- properties.id = parseInt(idElement['@_w:val'], 10);
7938
+ if (isExplicitlySet(idElement?.['@_w:val'])) {
7939
+ const parsed = safeParseInt(idElement['@_w:val']);
7940
+ if (!isNaN(parsed)) properties.id = parsed;
7233
7941
  }
7234
7942
 
7235
- // Parse tag
7943
+ // Parse `<w:tag w:val="…"/>` per ECMA-376 §17.5.2.34. `w:val`
7944
+ // is ST_String — any string is legal, including numeric-looking
7945
+ // strings like "123" that XMLParser coerces to the number 123.
7946
+ // Cast via `String(…)` so the tag round-trips as text rather
7947
+ // than leaking a JS number into a `tag?: string` field.
7236
7948
  const tagElement = sdtPr['w:tag'];
7237
- if (tagElement?.['@_w:val']) {
7238
- properties.tag = tagElement['@_w:val'];
7949
+ if (tagElement?.['@_w:val'] !== undefined) {
7950
+ properties.tag = String(tagElement['@_w:val']);
7239
7951
  }
7240
7952
 
7241
- // Parse lock
7953
+ // Parse lock — ST_Lock enum: "sdtLocked" / "contentLocked" /
7954
+ // "sdtContentLocked" / "unlocked". Always a non-numeric string,
7955
+ // so no XMLParser coercion concern; truthy check fine.
7242
7956
  const lockElement = sdtPr['w:lock'];
7243
7957
  if (lockElement?.['@_w:val']) {
7244
7958
  properties.lock = lockElement['@_w:val'];
7245
7959
  }
7246
7960
 
7247
- // Parse alias
7961
+ // Parse alias — ST_String. Same numeric-coercion concern as
7962
+ // `w:tag`; cast via `String(…)`.
7248
7963
  const aliasElement = sdtPr['w:alias'];
7249
- if (aliasElement?.['@_w:val']) {
7250
- properties.alias = aliasElement['@_w:val'];
7964
+ if (aliasElement?.['@_w:val'] !== undefined) {
7965
+ properties.alias = String(aliasElement['@_w:val']);
7251
7966
  }
7252
7967
 
7253
7968
  // Parse control type from various elements
@@ -7256,9 +7971,18 @@ export class DocumentParser {
7256
7971
  } else if (sdtPr['w:text']) {
7257
7972
  properties.controlType = 'plainText';
7258
7973
  const textElement = sdtPr['w:text'];
7974
+ // w:multiLine is an OPTIONAL ST_OnOff attribute per ECMA-376
7975
+ // §17.5.2.33 CT_SdtText. Only record a value when the source
7976
+ // actually set it — otherwise leave the field undefined so
7977
+ // the emitter (which uses `!== undefined`) preserves the
7978
+ // "attribute absent" state on round-trip. Previously the
7979
+ // parser unconditionally stored `false` for any absent
7980
+ // attribute, then the emitter wrote `w:multiLine="0"` —
7981
+ // adding spec-noise that wasn't in the source.
7982
+ const rawMultiLine = textElement?.['@_w:multiLine'];
7259
7983
  properties.plainText = {
7260
7984
  multiLine:
7261
- textElement?.['@_w:multiLine'] === '1' || textElement?.['@_w:multiLine'] === 'true',
7985
+ rawMultiLine === undefined ? undefined : parseOnOffAttribute(String(rawMultiLine)),
7262
7986
  };
7263
7987
  } else if (sdtPr['w:comboBox']) {
7264
7988
  properties.controlType = 'comboBox';
@@ -7286,14 +8010,11 @@ export class DocumentParser {
7286
8010
  } else if (sdtPr['w14:checkbox']) {
7287
8011
  properties.controlType = 'checkbox';
7288
8012
  const checkboxElement = sdtPr['w14:checkbox'];
7289
- // Handle both string and numeric values from XML parser
7290
- const checkedVal = checkboxElement?.['w14:checked']?.['@_w14:val'];
8013
+ // <w14:checked> is CT_OnOff in the Word 2010+ extension namespace.
8014
+ // Honour every ST_OnOff literal ("1"/"0"/"true"/"false"/"on"/"off")
8015
+ // and treat a bare self-closing `<w14:checked/>` as true.
7291
8016
  properties.checkbox = {
7292
- checked:
7293
- checkedVal === 1 ||
7294
- checkedVal === '1' ||
7295
- checkedVal === true ||
7296
- checkedVal === 'true',
8017
+ checked: parseOoxmlBoolean(checkboxElement?.['w14:checked'], '@_w14:val'),
7297
8018
  checkedState: String(checkboxElement?.['w14:checkedState']?.['@_w14:val'] ?? ''),
7298
8019
  uncheckedState: String(checkboxElement?.['w14:uncheckedState']?.['@_w14:val'] ?? ''),
7299
8020
  };
@@ -7343,11 +8064,10 @@ export class DocumentParser {
7343
8064
  };
7344
8065
  }
7345
8066
 
7346
- // Parse showing placeholder flag (w:showingPlcHdr)
8067
+ // Parse showing placeholder flag (w:showingPlcHdr) — CT_OnOff per ECMA-376 §17.5.2.40
7347
8068
  const showingPlcHdr = sdtPr['w:showingPlcHdr'];
7348
8069
  if (showingPlcHdr) {
7349
- const val = showingPlcHdr['@_w:val'];
7350
- properties.showingPlcHdr = val === '1' || val === 'true' || val === true;
8070
+ properties.showingPlcHdr = parseOoxmlBoolean(showingPlcHdr);
7351
8071
  }
7352
8072
  }
7353
8073
 
@@ -7887,7 +8607,25 @@ export class DocumentParser {
7887
8607
  }
7888
8608
 
7889
8609
  /**
7890
- * Helper to parse list items for combo box / dropdown
8610
+ * Helper to parse list items for combo box / dropdown per ECMA-376
8611
+ * Part 1 §17.5.2.13 CT_SdtListItem. `w:value` is required; both
8612
+ * `w:displayText` and `w:value` are ST_String so any string
8613
+ * (including the empty string) is legal.
8614
+ *
8615
+ * The previous truthy gate dropped legitimate list items whenever:
8616
+ * - `w:value="0"` / `w:value="123"` — XMLParser coerces numeric
8617
+ * strings to numbers; `0` fails the truthy check entirely, and
8618
+ * storing a raw number instead of a string breaks the `ListItem`
8619
+ * `value: string` contract downstream.
8620
+ * - `w:displayText=""` — empty displayText is legal (e.g. a
8621
+ * separator / blank choice); the gate dropped it.
8622
+ * The fix:
8623
+ * - Gate on presence (`!== undefined`), not truthiness.
8624
+ * - Coerce both attributes to `String(…)` so numeric-coerced
8625
+ * attribute values serialise back to their original textual form.
8626
+ * - Default missing `w:displayText` to the stringified `w:value`
8627
+ * (the idiomatic Word fallback when authors author list items
8628
+ * with only a value attribute).
7891
8629
  */
7892
8630
  private parseListItems(element: any): any {
7893
8631
  const items: any[] = [];
@@ -7895,17 +8633,18 @@ export class DocumentParser {
7895
8633
  const itemArray = Array.isArray(listItems) ? listItems : listItems ? [listItems] : [];
7896
8634
 
7897
8635
  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
- }
8636
+ const rawValue = item['@_w:value'];
8637
+ if (rawValue === undefined) continue; // w:value is required by the schema
8638
+ const value = String(rawValue);
8639
+ const rawDisplay = item['@_w:displayText'];
8640
+ const displayText = rawDisplay === undefined ? value : String(rawDisplay);
8641
+ items.push({ displayText, value });
7904
8642
  }
7905
8643
 
8644
+ const rawLast = element?.['@_w:lastValue'];
7906
8645
  return {
7907
8646
  items,
7908
- lastValue: element?.['@_w:lastValue'],
8647
+ lastValue: rawLast === undefined ? undefined : String(rawLast),
7909
8648
  };
7910
8649
  }
7911
8650
 
@@ -8381,12 +9120,21 @@ export class DocumentParser {
8381
9120
  if (color) border.color = color;
8382
9121
  const space = XMLParser.extractAttribute(sideXml, 'w:space');
8383
9122
  if (space) border.space = parseInt(space.toString(), 10);
9123
+ // w:shadow and w:frame are ST_OnOff per ECMA-376 §17.17.4.
9124
+ // Use `!== undefined` gating so explicit-false survives round-trip
9125
+ // (previous code only stored `true`, silently dropping `w:shadow="0"`).
8384
9126
  const shadow = XMLParser.extractAttribute(sideXml, 'w:shadow');
8385
- if (shadow === '1' || shadow === 'true') border.shadow = true;
9127
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
8386
9128
  const frame = XMLParser.extractAttribute(sideXml, 'w:frame');
8387
- if (frame === '1' || frame === 'true') border.frame = true;
9129
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
8388
9130
  const themeColor = XMLParser.extractAttribute(sideXml, 'w:themeColor');
8389
9131
  if (themeColor) border.themeColor = themeColor;
9132
+ // Theme tint / shade per §17.18.82 — CT_TopBorder/CT_BottomBorder extend
9133
+ // CT_Border so inherit the full themed-color attribute set.
9134
+ const themeTint = XMLParser.extractAttribute(sideXml, 'w:themeTint');
9135
+ if (themeTint) border.themeTint = themeTint;
9136
+ const themeShade = XMLParser.extractAttribute(sideXml, 'w:themeShade');
9137
+ if (themeShade) border.themeShade = themeShade;
8390
9138
  const artId = XMLParser.extractAttribute(sideXml, 'w:id');
8391
9139
  if (artId) border.artId = parseInt(artId.toString(), 10);
8392
9140
  return Object.keys(border).length > 0 ? border : undefined;
@@ -8407,7 +9155,14 @@ export class DocumentParser {
8407
9155
  }
8408
9156
  }
8409
9157
 
8410
- // Parse columns (enhanced with separator and custom widths)
9158
+ // Parse columns per ECMA-376 §17.6.4 CT_Columns. Every attribute
9159
+ // (num / sep / space / equalWidth) is optional with spec-defined
9160
+ // defaults — num defaults to 1, equalWidth to true, sep to false,
9161
+ // space to 720 twips. The previous `if (num)` gate silently dropped
9162
+ // every `<w:cols>` that relied on the default num=1 (e.g. a bare
9163
+ // `<w:cols w:sep="1" w:space="720"/>` specifying a single column
9164
+ // with a separator), which is the exact form Word emits when the
9165
+ // user toggles the column separator without changing column count.
8411
9166
  const colsElements = XMLParser.extractElements(sectPr, 'w:cols');
8412
9167
  if (colsElements.length > 0) {
8413
9168
  const cols = colsElements[0];
@@ -8436,19 +9191,23 @@ export class DocumentParser {
8436
9191
  }
8437
9192
  }
8438
9193
 
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
- }
9194
+ // Spec default for num is 1; fall back to column-count from
9195
+ // child `<w:col>` children when available (the expanded per-column
9196
+ // form), otherwise to the literal default.
9197
+ const count = num
9198
+ ? parseInt(num.toString(), 10)
9199
+ : columnWidths.length > 0
9200
+ ? columnWidths.length
9201
+ : 1;
9202
+
9203
+ sectionProps.columns = {
9204
+ count,
9205
+ space: space ? parseInt(space.toString(), 10) : undefined,
9206
+ equalWidth: equalWidth ? parseOnOffAttribute(equalWidth) : undefined,
9207
+ separator: sep ? parseOnOffAttribute(sep) : undefined,
9208
+ columnWidths: columnWidths.length > 0 ? columnWidths : undefined,
9209
+ columnSpaces: hasColumnSpaces ? columnSpaces : undefined,
9210
+ };
8452
9211
  }
8453
9212
  }
8454
9213
 
@@ -8489,9 +9248,13 @@ export class DocumentParser {
8489
9248
  }
8490
9249
  }
8491
9250
 
8492
- // Parse title page flag
8493
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:titlePg')) {
8494
- sectionProps.titlePage = true;
9251
+ // Parse title page flag (w:titlePg) — CT_OnOff per ECMA-376 §17.6.23;
9252
+ // honour w:val so an explicit `w:val="0"` override of an inherited
9253
+ // true is not silently flipped to true.
9254
+ const titlePgEls = XMLParser.extractElements(sectPr, 'w:titlePg');
9255
+ if (titlePgEls.length > 0 && titlePgEls[0]) {
9256
+ const v = XMLParser.extractAttribute(titlePgEls[0], 'w:val');
9257
+ sectionProps.titlePage = parseOnOffAttribute(v, true);
8495
9258
  }
8496
9259
 
8497
9260
  // Parse header references
@@ -8574,14 +9337,18 @@ export class DocumentParser {
8574
9337
  }
8575
9338
  }
8576
9339
 
8577
- // Parse bidi (right-to-left section layout)
8578
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:bidi')) {
8579
- sectionProps.bidi = true;
9340
+ // Parse bidi (w:bidi) — CT_OnOff per ECMA-376 §17.6.1 (RTL section)
9341
+ const bidiEls = XMLParser.extractElements(sectPr, 'w:bidi');
9342
+ if (bidiEls.length > 0 && bidiEls[0]) {
9343
+ const v = XMLParser.extractAttribute(bidiEls[0], 'w:val');
9344
+ sectionProps.bidi = parseOnOffAttribute(v, true);
8580
9345
  }
8581
9346
 
8582
- // Parse RTL gutter
8583
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:rtlGutter')) {
8584
- sectionProps.rtlGutter = true;
9347
+ // Parse RTL gutter (w:rtlGutter) — CT_OnOff per ECMA-376 §17.6.16
9348
+ const rtlGutterEls = XMLParser.extractElements(sectPr, 'w:rtlGutter');
9349
+ if (rtlGutterEls.length > 0 && rtlGutterEls[0]) {
9350
+ const v = XMLParser.extractAttribute(rtlGutterEls[0], 'w:val');
9351
+ sectionProps.rtlGutter = parseOnOffAttribute(v, true);
8585
9352
  }
8586
9353
 
8587
9354
  // Parse document grid (w:docGrid)
@@ -8659,14 +9426,18 @@ export class DocumentParser {
8659
9426
  if (Object.keys(props).length > 0) sectionProps.endnotePr = props;
8660
9427
  }
8661
9428
 
8662
- // Parse noEndnote
8663
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:noEndnote')) {
8664
- sectionProps.noEndnote = true;
9429
+ // Parse noEndnote (w:noEndnote) — CT_OnOff per ECMA-376 §17.11.14
9430
+ const noEndEls = XMLParser.extractElements(sectPr, 'w:noEndnote');
9431
+ if (noEndEls.length > 0 && noEndEls[0]) {
9432
+ const v = XMLParser.extractAttribute(noEndEls[0], 'w:val');
9433
+ sectionProps.noEndnote = parseOnOffAttribute(v, true);
8665
9434
  }
8666
9435
 
8667
- // Parse form protection
8668
- if (XMLParser.hasSelfClosingTag(sectPr, 'w:formProt')) {
8669
- sectionProps.formProt = true;
9436
+ // Parse form protection (w:formProt) — CT_OnOff per ECMA-376 §17.6.8
9437
+ const formProtEls = XMLParser.extractElements(sectPr, 'w:formProt');
9438
+ if (formProtEls.length > 0 && formProtEls[0]) {
9439
+ const v = XMLParser.extractAttribute(formProtEls[0], 'w:val');
9440
+ sectionProps.formProt = parseOnOffAttribute(v, true);
8670
9441
  }
8671
9442
 
8672
9443
  // Parse printer settings (w:printerSettings r:id)
@@ -8789,34 +9560,45 @@ export class DocumentParser {
8789
9560
  runFormatting = this.parseRunFormattingFromXml(rPrXml);
8790
9561
  }
8791
9562
 
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 ');
9563
+ // Parse metadata CT_OnOff flags per ECMA-376 §17.7.4 (OnOffType bindings).
9564
+ // Each flag honours `w:val` so an explicit `<w:qFormat w:val="0"/>` override
9565
+ // of a based-on style's qFormat=true round-trips as `false`. The old code
9566
+ // detected presence via `styleXml.includes('<w:qFormat/>')` which ignored
9567
+ // w:val entirely and flipped any explicit-false to true.
9568
+ const parseStyleOnOffFlag = (tagName: string): boolean | undefined => {
9569
+ const els = XMLParser.extractElements(styleXml, tagName);
9570
+ if (els.length === 0 || !els[0]) return undefined;
9571
+ const v = XMLParser.extractAttribute(els[0], 'w:val');
9572
+ return parseOnOffAttribute(v, true);
9573
+ };
8816
9574
 
8817
- // autoRedefine - Update style from formatting
8818
- const autoRedefine =
8819
- styleXml.includes('<w:autoRedefine/>') || styleXml.includes('<w:autoRedefine ');
9575
+ const qFormat = parseStyleOnOffFlag('w:qFormat');
9576
+ const semiHidden = parseStyleOnOffFlag('w:semiHidden');
9577
+ const unhideWhenUsed = parseStyleOnOffFlag('w:unhideWhenUsed');
9578
+ const locked = parseStyleOnOffFlag('w:locked');
9579
+ const personal = parseStyleOnOffFlag('w:personal');
9580
+ const personalCompose = parseStyleOnOffFlag('w:personalCompose');
9581
+ const personalReply = parseStyleOnOffFlag('w:personalReply');
9582
+ const autoRedefine = parseStyleOnOffFlag('w:autoRedefine');
9583
+ // `<w:hidden>` (CT_Style §17.7.4, OnOffOnlyType) — completely hide the
9584
+ // style. Previously not modeled; now round-trips as `properties.hidden`.
9585
+ const hidden = parseStyleOnOffFlag('w:hidden');
9586
+
9587
+ // `<w:rsid w:val="HEX"/>` (CT_Style §17.7.4, CT_LongHexNumber §17.18.50) —
9588
+ // revision-save ID stamp identifying the session in which this style
9589
+ // definition was last edited. Schema position: between `personalReply`
9590
+ // and `pPr`. Previously dropped entirely on parse, now preserved on
9591
+ // StyleProperties so round-trips stay faithful.
9592
+ let styleRsid: string | undefined;
9593
+ if (styleXml.includes('<w:rsid')) {
9594
+ const rsidTag = XMLParser.extractSelfClosingTag(styleXml, 'w:rsid');
9595
+ if (rsidTag) {
9596
+ const v = XMLParser.extractAttribute(`<w:rsid${rsidTag}`, 'w:val');
9597
+ if (v && v.length > 0) {
9598
+ styleRsid = v;
9599
+ }
9600
+ }
9601
+ }
8820
9602
 
8821
9603
  // uiPriority - Sort order
8822
9604
  let uiPriority: number | undefined;
@@ -8855,7 +9637,7 @@ export class DocumentParser {
8855
9637
  }
8856
9638
 
8857
9639
  // Parse table style properties (Phase 5.1)
8858
- let tableStyle: import('../formatting/Style').TableStyleProperties | undefined;
9640
+ let tableStyle: import('../formatting/Style.js').TableStyleProperties | undefined;
8859
9641
  if (typeAttr === 'table') {
8860
9642
  tableStyle = this.parseTableStyleProperties(styleXml);
8861
9643
  }
@@ -8867,21 +9649,27 @@ export class DocumentParser {
8867
9649
  type: typeAttr,
8868
9650
  basedOn,
8869
9651
  next,
8870
- isDefault: defaultAttr === '1' || defaultAttr === 'true',
8871
- customStyle: customStyleAttr === '1' || customStyleAttr === 'true',
9652
+ // w:default and w:customStyle are ST_OnOff per ECMA-376 §17.17.4
9653
+ isDefault: parseOnOffAttribute(defaultAttr),
9654
+ customStyle: parseOnOffAttribute(customStyleAttr),
8872
9655
  paragraphFormatting,
8873
9656
  numPr: styleNumPr,
8874
9657
  runFormatting,
8875
9658
  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,
9659
+ // Metadata CT_OnOff flags (ECMA-376 §17.7.4). parseStyleOnOffFlag returns
9660
+ // undefined when the element is absent, or the actual boolean (true/false)
9661
+ // when present — preserve both "explicit false" (override) and "absent"
9662
+ // (inherit) faithfully through the Style properties record.
9663
+ qFormat,
9664
+ semiHidden,
9665
+ hidden,
9666
+ unhideWhenUsed,
9667
+ locked,
9668
+ personal,
9669
+ personalCompose,
9670
+ personalReply,
9671
+ rsid: styleRsid,
9672
+ autoRedefine,
8885
9673
  uiPriority,
8886
9674
  link,
8887
9675
  aliases,
@@ -8898,6 +9686,86 @@ export class DocumentParser {
8898
9686
  private parseParagraphFormattingFromXml(pPrXml: string): ParagraphFormatting {
8899
9687
  const formatting: ParagraphFormatting = {};
8900
9688
 
9689
+ // Parse framePr (text frame properties) per ECMA-376 Part 1 §17.3.1.11 —
9690
+ // CT_FramePr is a CT_PPrBase child (#5, between pageBreakBefore and
9691
+ // widowControl). Each attribute is independently optional; numeric
9692
+ // attributes (w/h/x/y/hSpace/vSpace/lines) may legitimately be zero
9693
+ // so use explicit string presence rather than truthy checks.
9694
+ const framePrTag = XMLParser.extractSelfClosingTag(pPrXml, 'w:framePr');
9695
+ if (framePrTag) {
9696
+ const fpStr = `<w:framePr${framePrTag}`;
9697
+ const frameProps: NonNullable<ParagraphFormatting['framePr']> = {};
9698
+ const wAttr = XMLParser.extractAttribute(fpStr, 'w:w');
9699
+ if (wAttr !== undefined) frameProps.w = parseInt(wAttr, 10);
9700
+ const hAttr = XMLParser.extractAttribute(fpStr, 'w:h');
9701
+ if (hAttr !== undefined) frameProps.h = parseInt(hAttr, 10);
9702
+ const hRule = XMLParser.extractAttribute(fpStr, 'w:hRule');
9703
+ if (hRule === 'auto' || hRule === 'atLeast' || hRule === 'exact') {
9704
+ frameProps.hRule = hRule;
9705
+ }
9706
+ const xAttr = XMLParser.extractAttribute(fpStr, 'w:x');
9707
+ if (xAttr !== undefined) frameProps.x = parseInt(xAttr, 10);
9708
+ const yAttr = XMLParser.extractAttribute(fpStr, 'w:y');
9709
+ if (yAttr !== undefined) frameProps.y = parseInt(yAttr, 10);
9710
+ const xAlign = XMLParser.extractAttribute(fpStr, 'w:xAlign');
9711
+ if (
9712
+ xAlign === 'left' ||
9713
+ xAlign === 'center' ||
9714
+ xAlign === 'right' ||
9715
+ xAlign === 'inside' ||
9716
+ xAlign === 'outside'
9717
+ ) {
9718
+ frameProps.xAlign = xAlign;
9719
+ }
9720
+ const yAlign = XMLParser.extractAttribute(fpStr, 'w:yAlign');
9721
+ if (
9722
+ yAlign === 'top' ||
9723
+ yAlign === 'center' ||
9724
+ yAlign === 'bottom' ||
9725
+ yAlign === 'inline' ||
9726
+ yAlign === 'inside' ||
9727
+ yAlign === 'outside'
9728
+ ) {
9729
+ frameProps.yAlign = yAlign;
9730
+ }
9731
+ const hAnchor = XMLParser.extractAttribute(fpStr, 'w:hAnchor');
9732
+ if (hAnchor === 'page' || hAnchor === 'margin' || hAnchor === 'text') {
9733
+ frameProps.hAnchor = hAnchor;
9734
+ }
9735
+ const vAnchor = XMLParser.extractAttribute(fpStr, 'w:vAnchor');
9736
+ if (vAnchor === 'page' || vAnchor === 'margin' || vAnchor === 'text') {
9737
+ frameProps.vAnchor = vAnchor;
9738
+ }
9739
+ const hSpace = XMLParser.extractAttribute(fpStr, 'w:hSpace');
9740
+ if (hSpace !== undefined) frameProps.hSpace = parseInt(hSpace, 10);
9741
+ const vSpace = XMLParser.extractAttribute(fpStr, 'w:vSpace');
9742
+ if (vSpace !== undefined) frameProps.vSpace = parseInt(vSpace, 10);
9743
+ const wrap = XMLParser.extractAttribute(fpStr, 'w:wrap');
9744
+ if (
9745
+ wrap === 'around' ||
9746
+ wrap === 'auto' ||
9747
+ wrap === 'none' ||
9748
+ wrap === 'notBeside' ||
9749
+ wrap === 'through' ||
9750
+ wrap === 'tight'
9751
+ ) {
9752
+ frameProps.wrap = wrap;
9753
+ }
9754
+ const dropCap = XMLParser.extractAttribute(fpStr, 'w:dropCap');
9755
+ if (dropCap === 'none' || dropCap === 'drop' || dropCap === 'margin') {
9756
+ frameProps.dropCap = dropCap;
9757
+ }
9758
+ const lines = XMLParser.extractAttribute(fpStr, 'w:lines');
9759
+ if (lines !== undefined) frameProps.lines = parseInt(lines, 10);
9760
+ const anchorLock = XMLParser.extractAttribute(fpStr, 'w:anchorLock');
9761
+ if (anchorLock !== undefined) {
9762
+ frameProps.anchorLock = parseOnOffAttribute(anchorLock, true);
9763
+ }
9764
+ if (Object.keys(frameProps).length > 0) {
9765
+ formatting.framePr = frameProps;
9766
+ }
9767
+ }
9768
+
8901
9769
  // Parse alignment (w:jc)
8902
9770
  const jcElement = XMLParser.extractSelfClosingTag(pPrXml, 'w:jc');
8903
9771
  if (jcElement) {
@@ -8937,15 +9805,17 @@ export class DocumentParser {
8937
9805
  lineRule: validatedLineRule,
8938
9806
  beforeLines: beforeLines ? parseInt(beforeLines, 10) : undefined,
8939
9807
  afterLines: afterLines ? parseInt(afterLines, 10) : undefined,
8940
- beforeAutospacing: beforeAutosp
8941
- ? beforeAutosp === '1' || beforeAutosp === 'true'
8942
- : undefined,
8943
- afterAutospacing: afterAutosp ? afterAutosp === '1' || afterAutosp === 'true' : undefined,
9808
+ // ST_OnOff per ECMA-376 §17.17.4 — accept 1/0/true/false/on/off
9809
+ beforeAutospacing: beforeAutosp ? parseOnOffAttribute(beforeAutosp) : undefined,
9810
+ afterAutospacing: afterAutosp ? parseOnOffAttribute(afterAutosp) : undefined,
8944
9811
  };
8945
9812
  }
8946
9813
 
8947
9814
  // 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
9815
+ // Per ECMA-376 §17.3.1.15: w:start/w:end are bidi-aware alternatives to
9816
+ // w:left/w:right. §17.3.1.12 also defines six CJK character-unit variants
9817
+ // (ST_DecimalNumber) — parse those alongside so styles authored in CJK
9818
+ // locales preserve their character-unit indent spec through round-trip.
8949
9819
  const indElement = XMLParser.extractSelfClosingTag(pPrXml, 'w:ind');
8950
9820
  if (indElement) {
8951
9821
  const indTag = `<w:ind${indElement}`;
@@ -8955,33 +9825,137 @@ export class DocumentParser {
8955
9825
  const right = XMLParser.extractAttribute(indTag, 'w:right');
8956
9826
  const firstLine = XMLParser.extractAttribute(indTag, 'w:firstLine');
8957
9827
  const hanging = XMLParser.extractAttribute(indTag, 'w:hanging');
9828
+ // CJK character-unit variants. startChars/endChars collapse to
9829
+ // leftChars/rightChars (same bidi-aware rule as the twips pair).
9830
+ const startChars = XMLParser.extractAttribute(indTag, 'w:startChars');
9831
+ const leftChars = XMLParser.extractAttribute(indTag, 'w:leftChars');
9832
+ const endChars = XMLParser.extractAttribute(indTag, 'w:endChars');
9833
+ const rightChars = XMLParser.extractAttribute(indTag, 'w:rightChars');
9834
+ const firstLineChars = XMLParser.extractAttribute(indTag, 'w:firstLineChars');
9835
+ const hangingChars = XMLParser.extractAttribute(indTag, 'w:hangingChars');
8958
9836
 
8959
9837
  const leftVal = start || left;
8960
9838
  const rightVal = end || right;
9839
+ const leftCharsVal = startChars || leftChars;
9840
+ const rightCharsVal = endChars || rightChars;
8961
9841
 
8962
9842
  formatting.indentation = {
8963
9843
  left: leftVal ? parseInt(leftVal, 10) : undefined,
8964
9844
  right: rightVal ? parseInt(rightVal, 10) : undefined,
8965
9845
  firstLine: firstLine ? parseInt(firstLine, 10) : undefined,
8966
9846
  hanging: hanging ? parseInt(hanging, 10) : undefined,
9847
+ leftChars: leftCharsVal ? parseInt(leftCharsVal, 10) : undefined,
9848
+ rightChars: rightCharsVal ? parseInt(rightCharsVal, 10) : undefined,
9849
+ firstLineChars: firstLineChars ? parseInt(firstLineChars, 10) : undefined,
9850
+ hangingChars: hangingChars ? parseInt(hangingChars, 10) : undefined,
8967
9851
  };
8968
9852
  }
8969
9853
 
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;
9854
+ // Parse CT_OnOff boolean flags per ECMA-376 §17.17.4 / §17.3.1. The previous
9855
+ // substring-only detection (`pPrXml.includes('<w:keepNext/>') ||
9856
+ // pPrXml.includes('<w:keepNext ')`) hard-coded the flag to true whenever
9857
+ // the element appeared at all — silently flipping `<w:keepNext w:val="0"/>`
9858
+ // (explicit override) into an enabled flag. Read w:val when present and
9859
+ // honour every ST_OnOff literal (1/0/true/false/on/off).
9860
+ const parseStylePPrCtOnOff = (tagName: string): boolean | undefined => {
9861
+ // extractSelfClosingTag returns the ATTRIBUTE STRING (possibly empty)
9862
+ // when found, or `undefined` when absent. Earlier this helper checked
9863
+ // `=== null` by mistake — that let the "absent" case fall through and
9864
+ // construct a garbage tag that produced `true`, silently enabling the
9865
+ // flag on every style that didn't set it.
9866
+ const el = XMLParser.extractSelfClosingTag(pPrXml, tagName);
9867
+ if (el === undefined) return undefined;
9868
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
9869
+ if (val === undefined) return true;
9870
+ return parseOnOffAttribute(val, true);
9871
+ };
9872
+
9873
+ const keepNextVal = parseStylePPrCtOnOff('w:keepNext');
9874
+ if (keepNextVal !== undefined) formatting.keepNext = keepNextVal;
9875
+
9876
+ const keepLinesVal = parseStylePPrCtOnOff('w:keepLines');
9877
+ if (keepLinesVal !== undefined) formatting.keepLines = keepLinesVal;
9878
+
9879
+ const pageBreakBeforeVal = parseStylePPrCtOnOff('w:pageBreakBefore');
9880
+ if (pageBreakBeforeVal !== undefined) formatting.pageBreakBefore = pageBreakBeforeVal;
9881
+
9882
+ // Contextual spacing per ECMA-376 Part 1 §17.3.1.9
9883
+ // "Don't add space between paragraphs of the same style"
9884
+ const contextualSpacingVal = parseStylePPrCtOnOff('w:contextualSpacing');
9885
+ if (contextualSpacingVal !== undefined) formatting.contextualSpacing = contextualSpacingVal;
9886
+
9887
+ // Remaining CT_PPrBase CT_OnOff flags per ECMA-376 Part 1 §17.3.1.
9888
+ // The main paragraph parser handles all of these; the style-level parser
9889
+ // previously dropped them (substring matches existed only for the four
9890
+ // flags above). Any style using the explicit-false form to override a
9891
+ // based-on style's enabled flag was silently losing the override.
9892
+ const widowControlVal = parseStylePPrCtOnOff('w:widowControl');
9893
+ if (widowControlVal !== undefined) formatting.widowControl = widowControlVal;
9894
+
9895
+ const suppressLineNumbersVal = parseStylePPrCtOnOff('w:suppressLineNumbers');
9896
+ if (suppressLineNumbersVal !== undefined)
9897
+ formatting.suppressLineNumbers = suppressLineNumbersVal;
9898
+
9899
+ const bidiVal = parseStylePPrCtOnOff('w:bidi');
9900
+ if (bidiVal !== undefined) formatting.bidi = bidiVal;
9901
+
9902
+ const mirrorIndentsVal = parseStylePPrCtOnOff('w:mirrorIndents');
9903
+ if (mirrorIndentsVal !== undefined) formatting.mirrorIndents = mirrorIndentsVal;
9904
+
9905
+ const adjustRightIndVal = parseStylePPrCtOnOff('w:adjustRightInd');
9906
+ if (adjustRightIndVal !== undefined) formatting.adjustRightInd = adjustRightIndVal;
9907
+
9908
+ const suppressAutoHyphensVal = parseStylePPrCtOnOff('w:suppressAutoHyphens');
9909
+ if (suppressAutoHyphensVal !== undefined)
9910
+ formatting.suppressAutoHyphens = suppressAutoHyphensVal;
9911
+
9912
+ const kinsokuVal = parseStylePPrCtOnOff('w:kinsoku');
9913
+ if (kinsokuVal !== undefined) formatting.kinsoku = kinsokuVal;
9914
+
9915
+ const wordWrapVal = parseStylePPrCtOnOff('w:wordWrap');
9916
+ if (wordWrapVal !== undefined) formatting.wordWrap = wordWrapVal;
9917
+
9918
+ const overflowPunctVal = parseStylePPrCtOnOff('w:overflowPunct');
9919
+ if (overflowPunctVal !== undefined) formatting.overflowPunct = overflowPunctVal;
9920
+
9921
+ const topLinePunctVal = parseStylePPrCtOnOff('w:topLinePunct');
9922
+ if (topLinePunctVal !== undefined) formatting.topLinePunct = topLinePunctVal;
9923
+
9924
+ const autoSpaceDEVal = parseStylePPrCtOnOff('w:autoSpaceDE');
9925
+ if (autoSpaceDEVal !== undefined) formatting.autoSpaceDE = autoSpaceDEVal;
9926
+
9927
+ const autoSpaceDNVal = parseStylePPrCtOnOff('w:autoSpaceDN');
9928
+ if (autoSpaceDNVal !== undefined) formatting.autoSpaceDN = autoSpaceDNVal;
9929
+
9930
+ const suppressOverlapVal = parseStylePPrCtOnOff('w:suppressOverlap');
9931
+ if (suppressOverlapVal !== undefined) formatting.suppressOverlap = suppressOverlapVal;
9932
+
9933
+ // Parse `w:val`-attribute string-enum children per CT_PPrBase.
9934
+ // Position #28 textDirection (ST_TextDirection), #29 textAlignment
9935
+ // (ST_TextAlignment), #30 textboxTightWrap (ST_TextboxTightWrapType).
9936
+ // The main paragraph parser handles these; the style-level parser
9937
+ // previously dropped them because the substring scan was never
9938
+ // extended past the iteration-25 CT_OnOff helper.
9939
+ const parseStylePPrValAttr = (tagName: string): string | undefined => {
9940
+ const el = XMLParser.extractSelfClosingTag(pPrXml, tagName);
9941
+ if (el === undefined) return undefined;
9942
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
9943
+ return val === undefined ? undefined : String(val);
9944
+ };
9945
+
9946
+ const textDirectionVal = parseStylePPrValAttr('w:textDirection');
9947
+ if (textDirectionVal !== undefined) {
9948
+ formatting.textDirection = textDirectionVal as ParagraphFormatting['textDirection'];
8976
9949
  }
8977
- if (pPrXml.includes('<w:pageBreakBefore/>') || pPrXml.includes('<w:pageBreakBefore ')) {
8978
- formatting.pageBreakBefore = true;
9950
+
9951
+ const textAlignmentVal = parseStylePPrValAttr('w:textAlignment');
9952
+ if (textAlignmentVal !== undefined) {
9953
+ formatting.textAlignment = textAlignmentVal as ParagraphFormatting['textAlignment'];
8979
9954
  }
8980
9955
 
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;
9956
+ const textboxTightWrapVal = parseStylePPrValAttr('w:textboxTightWrap');
9957
+ if (textboxTightWrapVal !== undefined) {
9958
+ formatting.textboxTightWrap = textboxTightWrapVal as ParagraphFormatting['textboxTightWrap'];
8985
9959
  }
8986
9960
 
8987
9961
  // Parse outline level (w:outlineLvl) - used for TOC generation
@@ -8997,7 +9971,29 @@ export class DocumentParser {
8997
9971
  }
8998
9972
  }
8999
9973
 
9000
- // Parse paragraph borders (w:pBdr) per ECMA-376 Part 1 §17.3.1.24
9974
+ // Parse divId (CT_PPrBase #32, §17.3.1.10) — numeric HTML div
9975
+ // association. Previously dropped on the style parser; the main
9976
+ // paragraph parser reads it at the pPrObj level but the style pPr
9977
+ // parser used string-based extraction and skipped both divId and
9978
+ // cnfStyle below.
9979
+ const divIdVal = parseStylePPrValAttr('w:divId');
9980
+ if (divIdVal !== undefined) {
9981
+ const parsedDivId = parseInt(divIdVal, 10);
9982
+ if (!isNaN(parsedDivId)) formatting.divId = parsedDivId;
9983
+ }
9984
+
9985
+ // Parse cnfStyle (CT_PPrBase #33, §17.3.1.8) — conditional formatting
9986
+ // bitmask string (12-char 0/1 sequence, e.g. "100000000100").
9987
+ const cnfStyleVal = parseStylePPrValAttr('w:cnfStyle');
9988
+ if (cnfStyleVal !== undefined) formatting.cnfStyle = cnfStyleVal;
9989
+
9990
+ // Parse paragraph borders (w:pBdr) per ECMA-376 Part 1 §17.3.1.24.
9991
+ // Covers the full CT_Border attribute set (§17.18.2): val, sz, space,
9992
+ // color, themeColor, themeTint, themeShade, shadow, frame. The style
9993
+ // *emitter* already round-trips all nine, so any style-pBdr authored
9994
+ // by Word with themed or shadow/frame attributes was silently flattened
9995
+ // here before this fix. Shadow/frame route through parseOnOffAttribute
9996
+ // so ST_OnOff literals ("on"/"off"/"1"/"0"/"true"/"false") resolve.
9001
9997
  const pBdrXml = XMLParser.extractBetweenTags(pPrXml, '<w:pBdr>', '</w:pBdr>');
9002
9998
  if (pBdrXml) {
9003
9999
  const borders: any = {};
@@ -9011,11 +10007,21 @@ export class DocumentParser {
9011
10007
  const size = XMLParser.extractAttribute(bTag, 'w:sz');
9012
10008
  const space = XMLParser.extractAttribute(bTag, 'w:space');
9013
10009
  const color = XMLParser.extractAttribute(bTag, 'w:color');
10010
+ const themeColor = XMLParser.extractAttribute(bTag, 'w:themeColor');
10011
+ const themeTint = XMLParser.extractAttribute(bTag, 'w:themeTint');
10012
+ const themeShade = XMLParser.extractAttribute(bTag, 'w:themeShade');
10013
+ const shadow = XMLParser.extractAttribute(bTag, 'w:shadow');
10014
+ const frame = XMLParser.extractAttribute(bTag, 'w:frame');
9014
10015
  const border: any = {};
9015
10016
  if (style) border.style = style;
9016
10017
  if (size) border.size = parseInt(size, 10);
9017
10018
  if (space) border.space = parseInt(space, 10);
9018
10019
  if (color) border.color = color;
10020
+ if (themeColor) border.themeColor = themeColor;
10021
+ if (themeTint) border.themeTint = themeTint;
10022
+ if (themeShade) border.themeShade = themeShade;
10023
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
10024
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
9019
10025
  if (Object.keys(border).length > 0) borders[type] = border;
9020
10026
  }
9021
10027
  }
@@ -9062,37 +10068,113 @@ export class DocumentParser {
9062
10068
  private parseRunFormattingFromXml(rPrXml: string): RunFormatting {
9063
10069
  const formatting: RunFormatting = {};
9064
10070
 
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
- }
10071
+ // CT_OnOff rPr children per ECMA-376 §17.3.2. Previously detected via
10072
+ // substring-include which hard-coded the flag to `true` whenever the
10073
+ // element appeared — silently flipping `<w:b w:val="0"/>` (explicit
10074
+ // override of a based-on style's bold) into an enabled flag, and
10075
+ // never setting the field to `false` for legitimate overrides.
10076
+ // Mirrors the pPr `parseStylePPrCtOnOff` helper introduced in
10077
+ // iteration 25 / 26.
10078
+ const parseStyleRPrCtOnOff = (tagName: string): boolean | undefined => {
10079
+ const el = XMLParser.extractSelfClosingTag(rPrXml, tagName);
10080
+ if (el === undefined) return undefined;
10081
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
10082
+ if (val === undefined) return true;
10083
+ return parseOnOffAttribute(val, true);
10084
+ };
10085
+
10086
+ const boldVal = parseStyleRPrCtOnOff('w:b');
10087
+ if (boldVal !== undefined) formatting.bold = boldVal;
9081
10088
 
9082
- // Parse underline — all attributes per ECMA-376 §17.3.2.40
10089
+ const italicVal = parseStyleRPrCtOnOff('w:i');
10090
+ if (italicVal !== undefined) formatting.italic = italicVal;
10091
+
10092
+ const strikeVal = parseStyleRPrCtOnOff('w:strike');
10093
+ if (strikeVal !== undefined) formatting.strike = strikeVal;
10094
+
10095
+ const smallCapsVal = parseStyleRPrCtOnOff('w:smallCaps');
10096
+ if (smallCapsVal !== undefined) formatting.smallCaps = smallCapsVal;
10097
+
10098
+ const allCapsVal = parseStyleRPrCtOnOff('w:caps');
10099
+ if (allCapsVal !== undefined) formatting.allCaps = allCapsVal;
10100
+
10101
+ // Extended CT_OnOff run children per ECMA-376 §17.3.2. The style-level
10102
+ // rPr parser previously dropped all of these silently, so character
10103
+ // styles setting dstrike, outline, shadow, emboss, imprint, rtl,
10104
+ // vanish, noProof, snapToGrid, specVanish, webHidden, or complex-script
10105
+ // variants (bCs / iCs / cs) lost their overrides on programmatic save.
10106
+ const boldCsVal = parseStyleRPrCtOnOff('w:bCs');
10107
+ if (boldCsVal !== undefined) formatting.complexScriptBold = boldCsVal;
10108
+
10109
+ const italicCsVal = parseStyleRPrCtOnOff('w:iCs');
10110
+ if (italicCsVal !== undefined) formatting.complexScriptItalic = italicCsVal;
10111
+
10112
+ const csVal = parseStyleRPrCtOnOff('w:cs');
10113
+ if (csVal !== undefined) formatting.complexScript = csVal;
10114
+
10115
+ const dstrikeVal = parseStyleRPrCtOnOff('w:dstrike');
10116
+ if (dstrikeVal !== undefined) formatting.dstrike = dstrikeVal;
10117
+
10118
+ const outlineVal = parseStyleRPrCtOnOff('w:outline');
10119
+ if (outlineVal !== undefined) formatting.outline = outlineVal;
10120
+
10121
+ const shadowVal = parseStyleRPrCtOnOff('w:shadow');
10122
+ if (shadowVal !== undefined) formatting.shadow = shadowVal;
10123
+
10124
+ const embossVal = parseStyleRPrCtOnOff('w:emboss');
10125
+ if (embossVal !== undefined) formatting.emboss = embossVal;
10126
+
10127
+ const imprintVal = parseStyleRPrCtOnOff('w:imprint');
10128
+ if (imprintVal !== undefined) formatting.imprint = imprintVal;
10129
+
10130
+ const rtlVal = parseStyleRPrCtOnOff('w:rtl');
10131
+ if (rtlVal !== undefined) formatting.rtl = rtlVal;
10132
+
10133
+ const vanishVal = parseStyleRPrCtOnOff('w:vanish');
10134
+ if (vanishVal !== undefined) formatting.vanish = vanishVal;
10135
+
10136
+ const noProofVal = parseStyleRPrCtOnOff('w:noProof');
10137
+ if (noProofVal !== undefined) formatting.noProof = noProofVal;
10138
+
10139
+ const snapToGridVal = parseStyleRPrCtOnOff('w:snapToGrid');
10140
+ if (snapToGridVal !== undefined) formatting.snapToGrid = snapToGridVal;
10141
+
10142
+ const specVanishVal = parseStyleRPrCtOnOff('w:specVanish');
10143
+ if (specVanishVal !== undefined) formatting.specVanish = specVanishVal;
10144
+
10145
+ const webHiddenVal = parseStyleRPrCtOnOff('w:webHidden');
10146
+ if (webHiddenVal !== undefined) formatting.webHidden = webHiddenVal;
10147
+
10148
+ // Parse underline — all attributes per ECMA-376 §17.3.2.40.
10149
+ // Whitelist covers the full ST_Underline enumeration (18 values);
10150
+ // unknown / out-of-spec values fall through to `underline = true`
10151
+ // (underline enabled with default style) to match the main parser.
9083
10152
  const uElement = XMLParser.extractSelfClosingTag(rPrXml, 'w:u');
9084
10153
  if (uElement) {
9085
10154
  const uTag = `<w:u${uElement}`;
9086
10155
  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;
10156
+ const ST_UNDERLINE = new Set<string>([
10157
+ 'single',
10158
+ 'words',
10159
+ 'double',
10160
+ 'thick',
10161
+ 'dotted',
10162
+ 'dottedHeavy',
10163
+ 'dash',
10164
+ 'dashedHeavy',
10165
+ 'dashLong',
10166
+ 'dashLongHeavy',
10167
+ 'dotDash',
10168
+ 'dashDotHeavy',
10169
+ 'dotDotDash',
10170
+ 'dashDotDotHeavy',
10171
+ 'wave',
10172
+ 'wavyHeavy',
10173
+ 'wavyDouble',
10174
+ 'none',
10175
+ ]);
10176
+ if (uVal !== undefined && ST_UNDERLINE.has(String(uVal))) {
10177
+ formatting.underline = String(uVal) as RunFormatting['underline'];
9096
10178
  } else {
9097
10179
  formatting.underline = true;
9098
10180
  }
@@ -9100,7 +10182,8 @@ export class DocumentParser {
9100
10182
  if (uColor) formatting.underlineColor = uColor;
9101
10183
  const uThemeColor = XMLParser.extractAttribute(uTag, 'w:themeColor');
9102
10184
  if (uThemeColor) {
9103
- formatting.underlineThemeColor = uThemeColor as import('../elements/Run').ThemeColorValue;
10185
+ formatting.underlineThemeColor =
10186
+ uThemeColor as import('../elements/Run.js').ThemeColorValue;
9104
10187
  }
9105
10188
  const uThemeTint = XMLParser.extractAttribute(uTag, 'w:themeTint');
9106
10189
  if (uThemeTint) formatting.underlineThemeTint = parseInt(uThemeTint, 16);
@@ -9167,17 +10250,25 @@ export class DocumentParser {
9167
10250
  }
9168
10251
  }
9169
10252
 
9170
- // Parse color (w:color) — all attributes per ECMA-376 §17.3.2.6
10253
+ // Parse color (w:color) — all attributes per ECMA-376 §17.3.2.6 / ST_HexColor
10254
+ // per §17.18.38. `w:val="auto"` is a valid ST_HexColorAuto sentinel that
10255
+ // tells Word to use the automatic/window text color; the previous parser
10256
+ // dropped it (only storing non-auto hex values), so a style-level rPr with
10257
+ // `<w:color w:val="auto"/>` silently lost that marker on round-trip and
10258
+ // the emitter defaulted to `"000000"` — changing the rendering of any
10259
+ // style that relied on the auto fallback. Preserve the literal "auto" so
10260
+ // it survives through emission. (Matches the object-format parser path
10261
+ // for direct-run rPr at parseRunFromObject line ~5210.)
9171
10262
  const colorElement = XMLParser.extractSelfClosingTag(rPrXml, 'w:color');
9172
10263
  if (colorElement) {
9173
10264
  const colorTag = `<w:color${colorElement}`;
9174
10265
  const val = XMLParser.extractAttribute(colorTag, 'w:val');
9175
- if (val && val !== 'auto') {
10266
+ if (val) {
9176
10267
  formatting.color = val;
9177
10268
  }
9178
10269
  const themeColor = XMLParser.extractAttribute(colorTag, 'w:themeColor');
9179
10270
  if (themeColor) {
9180
- formatting.themeColor = themeColor as import('../elements/Run').ThemeColorValue;
10271
+ formatting.themeColor = themeColor as import('../elements/Run.js').ThemeColorValue;
9181
10272
  }
9182
10273
  const themeTint = XMLParser.extractAttribute(colorTag, 'w:themeTint');
9183
10274
  if (themeTint) {
@@ -9242,6 +10333,186 @@ export class DocumentParser {
9242
10333
  formatting.shading = shading;
9243
10334
  }
9244
10335
 
10336
+ // Character spacing (w:spacing §17.3.2.35, ST_SignedTwipsMeasure) —
10337
+ // previously dropped on the style parser; 0 and negative values are
10338
+ // valid per spec.
10339
+ const spacingEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:spacing');
10340
+ if (spacingEl !== undefined) {
10341
+ const val = XMLParser.extractAttribute(`<w:spacing${spacingEl}`, 'w:val');
10342
+ if (val !== undefined) {
10343
+ const n = parseInt(String(val), 10);
10344
+ if (!isNaN(n)) formatting.characterSpacing = n;
10345
+ }
10346
+ }
10347
+
10348
+ // Vertical position (w:position §17.3.2.31, ST_SignedHpsMeasure).
10349
+ // 0 = baseline (explicit reset); negative = lowered; positive = raised.
10350
+ const positionEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:position');
10351
+ if (positionEl !== undefined) {
10352
+ const val = XMLParser.extractAttribute(`<w:position${positionEl}`, 'w:val');
10353
+ if (val !== undefined) {
10354
+ const n = parseInt(String(val), 10);
10355
+ if (!isNaN(n)) formatting.position = n;
10356
+ }
10357
+ }
10358
+
10359
+ // Kerning threshold (w:kern §17.3.2.20, ST_HpsMeasure). 0 = kern at
10360
+ // every size (no minimum font-size threshold).
10361
+ const kernEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:kern');
10362
+ if (kernEl !== undefined) {
10363
+ const val = XMLParser.extractAttribute(`<w:kern${kernEl}`, 'w:val');
10364
+ if (val !== undefined) {
10365
+ const n = parseInt(String(val), 10);
10366
+ if (!isNaN(n)) formatting.kerning = n;
10367
+ }
10368
+ }
10369
+
10370
+ // Language (w:lang §17.3.2.20, CT_Language). Single val → plain string;
10371
+ // multi-script (eastAsia and/or bidi present) → LanguageConfig object.
10372
+ const langEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:lang');
10373
+ if (langEl !== undefined) {
10374
+ const langTag = `<w:lang${langEl}`;
10375
+ const val = XMLParser.extractAttribute(langTag, 'w:val');
10376
+ const eastAsia = XMLParser.extractAttribute(langTag, 'w:eastAsia');
10377
+ const bidi = XMLParser.extractAttribute(langTag, 'w:bidi');
10378
+ if (eastAsia || bidi) {
10379
+ formatting.language = {
10380
+ val: val ? String(val) : undefined,
10381
+ eastAsia: eastAsia ? String(eastAsia) : undefined,
10382
+ bidi: bidi ? String(bidi) : undefined,
10383
+ };
10384
+ } else if (val) {
10385
+ formatting.language = String(val);
10386
+ }
10387
+ }
10388
+
10389
+ // Horizontal scaling (w:w §17.3.2.43, ST_TextScale — percentage,
10390
+ // min 1 per spec, so 0 is not valid and we keep a truthy check).
10391
+ const scaleEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:w');
10392
+ if (scaleEl !== undefined) {
10393
+ const val = XMLParser.extractAttribute(`<w:w${scaleEl}`, 'w:val');
10394
+ if (val) {
10395
+ const n = parseInt(String(val), 10);
10396
+ if (!isNaN(n)) formatting.scaling = n;
10397
+ }
10398
+ }
10399
+
10400
+ // Emphasis mark (w:em §17.3.2.13, ST_Em — "dot"/"comma"/"circle"/
10401
+ // "underDot"/"none"). Commonly paired with East Asian typography.
10402
+ const emEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:em');
10403
+ if (emEl !== undefined) {
10404
+ const val = XMLParser.extractAttribute(`<w:em${emEl}`, 'w:val');
10405
+ if (val) {
10406
+ formatting.emphasis = String(val) as RunFormatting['emphasis'];
10407
+ }
10408
+ }
10409
+
10410
+ // Animated text effect (w:effect §17.3.2.12, ST_TextEffect —
10411
+ // "blinkBackground"/"lights"/"antsBlack"/"antsRed"/"shimmer"/"sparkle"/
10412
+ // "none"). Legacy feature but still valid per schema.
10413
+ const effectEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:effect');
10414
+ if (effectEl !== undefined) {
10415
+ const val = XMLParser.extractAttribute(`<w:effect${effectEl}`, 'w:val');
10416
+ if (val) {
10417
+ formatting.effect = String(val) as RunFormatting['effect'];
10418
+ }
10419
+ }
10420
+
10421
+ // Text border (w:bdr §17.3.2.5) — character/run border. Full CT_Border
10422
+ // attribute set (§17.18.2): val / sz / space / color / themeColor /
10423
+ // themeTint / themeShade / shadow / frame.
10424
+ const bdrEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:bdr');
10425
+ if (bdrEl !== undefined) {
10426
+ const bdrTag = `<w:bdr${bdrEl}`;
10427
+ const border: {
10428
+ style?: string;
10429
+ size?: number;
10430
+ color?: string;
10431
+ space?: number;
10432
+ themeColor?: string;
10433
+ themeTint?: string;
10434
+ themeShade?: string;
10435
+ shadow?: boolean;
10436
+ frame?: boolean;
10437
+ } = {};
10438
+ const val = XMLParser.extractAttribute(bdrTag, 'w:val');
10439
+ if (val) border.style = String(val);
10440
+ const sz = XMLParser.extractAttribute(bdrTag, 'w:sz');
10441
+ if (sz !== undefined) {
10442
+ const n = parseInt(String(sz), 10);
10443
+ if (!isNaN(n)) border.size = n;
10444
+ }
10445
+ const color = XMLParser.extractAttribute(bdrTag, 'w:color');
10446
+ if (color) border.color = String(color);
10447
+ const space = XMLParser.extractAttribute(bdrTag, 'w:space');
10448
+ if (space !== undefined) {
10449
+ const n = parseInt(String(space), 10);
10450
+ if (!isNaN(n)) border.space = n;
10451
+ }
10452
+ const themeColor = XMLParser.extractAttribute(bdrTag, 'w:themeColor');
10453
+ if (themeColor) border.themeColor = String(themeColor);
10454
+ const themeTint = XMLParser.extractAttribute(bdrTag, 'w:themeTint');
10455
+ if (themeTint) border.themeTint = String(themeTint);
10456
+ const themeShade = XMLParser.extractAttribute(bdrTag, 'w:themeShade');
10457
+ if (themeShade) border.themeShade = String(themeShade);
10458
+ const shadow = XMLParser.extractAttribute(bdrTag, 'w:shadow');
10459
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
10460
+ const frame = XMLParser.extractAttribute(bdrTag, 'w:frame');
10461
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
10462
+ if (Object.keys(border).length > 0) {
10463
+ formatting.border = border as RunFormatting['border'];
10464
+ }
10465
+ }
10466
+
10467
+ // Manual run width (w:fitText §17.3.2.15). Value is twips; 0 is
10468
+ // technically representable as "explicit zero" — use `!== undefined`.
10469
+ const fitTextEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:fitText');
10470
+ if (fitTextEl !== undefined) {
10471
+ const val = XMLParser.extractAttribute(`<w:fitText${fitTextEl}`, 'w:val');
10472
+ if (val !== undefined) {
10473
+ const n = parseInt(String(val), 10);
10474
+ if (!isNaN(n)) formatting.fitText = n;
10475
+ }
10476
+ }
10477
+
10478
+ // East Asian layout (w:eastAsianLayout §17.3.2.10) — combined
10479
+ // characters / vertical text / compression attributes.
10480
+ const ealEl = XMLParser.extractSelfClosingTag(rPrXml, 'w:eastAsianLayout');
10481
+ if (ealEl !== undefined) {
10482
+ const ealTag = `<w:eastAsianLayout${ealEl}`;
10483
+ const layout: Partial<{
10484
+ id: number;
10485
+ vert: boolean;
10486
+ vertCompress: boolean;
10487
+ combine: boolean;
10488
+ combineBrackets: 'none' | 'round' | 'square' | 'angle' | 'curly';
10489
+ }> = {};
10490
+ const id = XMLParser.extractAttribute(ealTag, 'w:id');
10491
+ if (id !== undefined) {
10492
+ const n = Number(id);
10493
+ if (!isNaN(n)) layout.id = n;
10494
+ }
10495
+ const vert = XMLParser.extractAttribute(ealTag, 'w:vert');
10496
+ if (vert !== undefined && parseOnOffAttribute(vert, true)) layout.vert = true;
10497
+ const vertCompress = XMLParser.extractAttribute(ealTag, 'w:vertCompress');
10498
+ if (vertCompress !== undefined && parseOnOffAttribute(vertCompress, true))
10499
+ layout.vertCompress = true;
10500
+ const combine = XMLParser.extractAttribute(ealTag, 'w:combine');
10501
+ if (combine !== undefined && parseOnOffAttribute(combine, true)) layout.combine = true;
10502
+ const combineBrackets = XMLParser.extractAttribute(ealTag, 'w:combineBrackets');
10503
+ if (combineBrackets) {
10504
+ layout.combineBrackets = String(combineBrackets) as
10505
+ | 'none'
10506
+ | 'round'
10507
+ | 'square'
10508
+ | 'angle'
10509
+ | 'curly';
10510
+ }
10511
+ if (Object.keys(layout).length > 0) {
10512
+ formatting.eastAsianLayout = layout as RunFormatting['eastAsianLayout'];
10513
+ }
10514
+ }
10515
+
9245
10516
  return formatting;
9246
10517
  }
9247
10518
 
@@ -9252,8 +10523,8 @@ export class DocumentParser {
9252
10523
  */
9253
10524
  private parseTableStyleProperties(
9254
10525
  styleXml: string
9255
- ): import('../formatting/Style').TableStyleProperties {
9256
- const tableStyle: import('../formatting/Style').TableStyleProperties = {};
10526
+ ): import('../formatting/Style.js').TableStyleProperties {
10527
+ const tableStyle: import('../formatting/Style.js').TableStyleProperties = {};
9257
10528
 
9258
10529
  // Parse tblPr (table properties)
9259
10530
  const tblPrXml = XMLParser.extractBetweenTags(styleXml, '<w:tblPr>', '</w:tblPr>');
@@ -9306,8 +10577,8 @@ export class DocumentParser {
9306
10577
  */
9307
10578
  private parseTableFormattingFromXml(
9308
10579
  tblPrXml: string
9309
- ): import('../formatting/Style').TableStyleFormatting {
9310
- const formatting: import('../formatting/Style').TableStyleFormatting = {};
10580
+ ): import('../formatting/Style.js').TableStyleFormatting {
10581
+ const formatting: import('../formatting/Style.js').TableStyleFormatting = {};
9311
10582
 
9312
10583
  // Parse indent (w:tblInd) — preserve w:type per ECMA-376 ST_TblWidth
9313
10584
  if (tblPrXml.includes('<w:tblInd')) {
@@ -9320,18 +10591,27 @@ export class DocumentParser {
9320
10591
  }
9321
10592
  const type = XMLParser.extractAttribute(tblIndTag, 'w:type');
9322
10593
  if (type) {
9323
- formatting.indentType = type as import('../elements/Table').TableWidthType;
10594
+ formatting.indentType = type as import('../elements/Table.js').TableWidthType;
9324
10595
  }
9325
10596
  }
9326
10597
  }
9327
10598
 
9328
- // Parse alignment
10599
+ // Parse alignment — ST_JcTable has 5 values (start, end, center, left,
10600
+ // right) per ECMA-376 §17.18.45. The whitelist previously only accepted
10601
+ // the three legacy LTR-centric values, silently dropping `start` / `end`
10602
+ // (the bidi-aware defaults a modern authoring tool emits).
9329
10603
  if (tblPrXml.includes('<w:jc')) {
9330
10604
  const tag = XMLParser.extractSelfClosingTag(tblPrXml, 'w:jc');
9331
10605
  if (tag) {
9332
10606
  const val = XMLParser.extractAttribute(`<w:jc${tag}`, 'w:val');
9333
- if (val === 'left' || val === 'center' || val === 'right') {
9334
- formatting.alignment = val;
10607
+ if (
10608
+ val === 'left' ||
10609
+ val === 'center' ||
10610
+ val === 'right' ||
10611
+ val === 'start' ||
10612
+ val === 'end'
10613
+ ) {
10614
+ formatting.alignment = val as import('../formatting/Style.js').TableAlignment;
9335
10615
  }
9336
10616
  }
9337
10617
  }
@@ -9372,8 +10652,8 @@ export class DocumentParser {
9372
10652
  */
9373
10653
  private parseTableCellFormattingFromXml(
9374
10654
  tcPrXml: string
9375
- ): import('../formatting/Style').TableCellStyleFormatting {
9376
- const formatting: import('../formatting/Style').TableCellStyleFormatting = {};
10655
+ ): import('../formatting/Style.js').TableCellStyleFormatting {
10656
+ const formatting: import('../formatting/Style.js').TableCellStyleFormatting = {};
9377
10657
 
9378
10658
  // Parse borders
9379
10659
  const bordersXml = XMLParser.extractBetweenTags(tcPrXml, '<w:tcBorders>', '</w:tcBorders>');
@@ -9381,7 +10661,7 @@ export class DocumentParser {
9381
10661
  formatting.borders = this.parseBordersFromXml(
9382
10662
  bordersXml,
9383
10663
  true
9384
- ) as import('../formatting/Style').CellBorders;
10664
+ ) as import('../formatting/Style.js').CellBorders;
9385
10665
  }
9386
10666
 
9387
10667
  // Parse shading
@@ -9395,12 +10675,15 @@ export class DocumentParser {
9395
10675
  formatting.margins = this.parseCellMarginsFromXml(marginXml);
9396
10676
  }
9397
10677
 
9398
- // Parse vertical alignment
10678
+ // Parse vertical alignment — ST_VerticalJc has four values
10679
+ // (top / center / both / bottom) per ECMA-376 §17.18.101. Previously
10680
+ // the whitelist only accepted the first three, silently dropping
10681
+ // `<w:vAlign w:val="both"/>` on cell styles.
9399
10682
  if (tcPrXml.includes('<w:vAlign')) {
9400
10683
  const tag = XMLParser.extractSelfClosingTag(tcPrXml, 'w:vAlign');
9401
10684
  if (tag) {
9402
10685
  const val = XMLParser.extractAttribute(`<w:vAlign${tag}`, 'w:val');
9403
- if (val === 'top' || val === 'center' || val === 'bottom') {
10686
+ if (val === 'top' || val === 'center' || val === 'both' || val === 'bottom') {
9404
10687
  formatting.verticalAlignment = val;
9405
10688
  }
9406
10689
  }
@@ -9414,8 +10697,8 @@ export class DocumentParser {
9414
10697
  */
9415
10698
  private parseTableRowFormattingFromXml(
9416
10699
  trPrXml: string
9417
- ): import('../formatting/Style').TableRowStyleFormatting {
9418
- const formatting: import('../formatting/Style').TableRowStyleFormatting = {};
10700
+ ): import('../formatting/Style.js').TableRowStyleFormatting {
10701
+ const formatting: import('../formatting/Style.js').TableRowStyleFormatting = {};
9419
10702
 
9420
10703
  // Parse height
9421
10704
  if (trPrXml.includes('<w:trHeight')) {
@@ -9432,15 +10715,25 @@ export class DocumentParser {
9432
10715
  }
9433
10716
  }
9434
10717
 
9435
- // Parse cantSplit
9436
- if (trPrXml.includes('<w:cantSplit/>') || trPrXml.includes('<w:cantSplit ')) {
9437
- formatting.cantSplit = true;
9438
- }
10718
+ // Parse cantSplit / tblHeader — both OnOffOnlyType (§17.4.6, §17.4.50).
10719
+ // Previous substring-include detection hard-coded the flag to `true`
10720
+ // whenever the element appeared, silently flipping an explicit-off
10721
+ // override (e.g., a tblStylePr conditional un-splitting a header row)
10722
+ // into an enabled flag. Reuse parseOnOffAttribute so bare, "on", and
10723
+ // "off" all map correctly, and so absent stays undefined.
10724
+ const parseTrPrOnOffOnly = (tagName: string): boolean | undefined => {
10725
+ const el = XMLParser.extractSelfClosingTag(trPrXml, tagName);
10726
+ if (el === undefined) return undefined;
10727
+ const val = XMLParser.extractAttribute(`<${tagName}${el}`, 'w:val');
10728
+ if (val === undefined) return true;
10729
+ return parseOnOffAttribute(val, true);
10730
+ };
9439
10731
 
9440
- // Parse tblHeader (isHeader)
9441
- if (trPrXml.includes('<w:tblHeader/>') || trPrXml.includes('<w:tblHeader ')) {
9442
- formatting.isHeader = true;
9443
- }
10732
+ const cantSplitVal = parseTrPrOnOffOnly('w:cantSplit');
10733
+ if (cantSplitVal !== undefined) formatting.cantSplit = cantSplitVal;
10734
+
10735
+ const tblHeaderVal = parseTrPrOnOffOnly('w:tblHeader');
10736
+ if (tblHeaderVal !== undefined) formatting.isHeader = tblHeaderVal;
9444
10737
 
9445
10738
  return formatting;
9446
10739
  }
@@ -9450,8 +10743,8 @@ export class DocumentParser {
9450
10743
  */
9451
10744
  private parseConditionalFormattingFromXml(
9452
10745
  styleXml: string
9453
- ): import('../formatting/Style').ConditionalTableFormatting[] | undefined {
9454
- const conditionalFormatting: import('../formatting/Style').ConditionalTableFormatting[] = [];
10746
+ ): import('../formatting/Style.js').ConditionalTableFormatting[] | undefined {
10747
+ const conditionalFormatting: import('../formatting/Style.js').ConditionalTableFormatting[] = [];
9455
10748
 
9456
10749
  // Find all tblStylePr elements
9457
10750
  let searchFrom = 0;
@@ -9467,8 +10760,8 @@ export class DocumentParser {
9467
10760
  // Extract type attribute
9468
10761
  const typeAttr = XMLParser.extractAttribute(tblStylePrXml, 'w:type');
9469
10762
  if (typeAttr) {
9470
- const conditional: import('../formatting/Style').ConditionalTableFormatting = {
9471
- type: typeAttr as import('../formatting/Style').ConditionalFormattingType,
10763
+ const conditional: import('../formatting/Style.js').ConditionalTableFormatting = {
10764
+ type: typeAttr as import('../formatting/Style.js').ConditionalFormattingType,
9472
10765
  };
9473
10766
 
9474
10767
  // Parse pPr
@@ -9518,29 +10811,58 @@ export class DocumentParser {
9518
10811
  private parseBordersFromXml(
9519
10812
  bordersXml: string,
9520
10813
  includeDiagonals: boolean
9521
- ): import('../formatting/Style').TableBorders | import('../formatting/Style').CellBorders {
10814
+ ): import('../formatting/Style.js').TableBorders | import('../formatting/Style.js').CellBorders {
9522
10815
  const borders: any = {};
9523
10816
 
10817
+ // Local helper so both the main-side loop and the diagonal loop share
10818
+ // the full CT_Border attribute set (§17.18.2): val / sz / space / color
10819
+ // / themeColor / themeTint / themeShade / shadow / frame. Previously
10820
+ // this parser only extracted the four "basic" attrs, so themed borders
10821
+ // and shadow/frame flags on page/table/cell borders were silently
10822
+ // dropped on every load → save round-trip.
10823
+ const parseBorderAttrs = (
10824
+ type: string
10825
+ ): import('../formatting/Style.js').BorderProperties | null => {
10826
+ const tag = XMLParser.extractSelfClosingTag(bordersXml, `w:${type}`);
10827
+ if (!tag) return null;
10828
+ const ref = `<w:${type}${tag}`;
10829
+ const border: import('../formatting/Style.js').BorderProperties = {};
10830
+ const style = XMLParser.extractAttribute(ref, 'w:val');
10831
+ const size = XMLParser.extractAttribute(ref, 'w:sz');
10832
+ const space = XMLParser.extractAttribute(ref, 'w:space');
10833
+ const color = XMLParser.extractAttribute(ref, 'w:color');
10834
+ const themeColor = XMLParser.extractAttribute(ref, 'w:themeColor');
10835
+ const themeTint = XMLParser.extractAttribute(ref, 'w:themeTint');
10836
+ const themeShade = XMLParser.extractAttribute(ref, 'w:themeShade');
10837
+ const shadow = XMLParser.extractAttribute(ref, 'w:shadow');
10838
+ const frame = XMLParser.extractAttribute(ref, 'w:frame');
10839
+ if (style) border.style = style as any;
10840
+ if (size) border.size = parseInt(size, 10);
10841
+ if (space) border.space = parseInt(space, 10);
10842
+ if (color) border.color = color;
10843
+ if (themeColor) (border as any).themeColor = themeColor;
10844
+ if (themeTint) (border as any).themeTint = themeTint;
10845
+ if (themeShade) (border as any).themeShade = themeShade;
10846
+ if (shadow !== undefined) (border as any).shadow = parseOnOffAttribute(shadow, true);
10847
+ if (frame !== undefined) (border as any).frame = parseOnOffAttribute(frame, true);
10848
+ return Object.keys(border).length > 0 ? border : null;
10849
+ };
10850
+
10851
+ // Per ECMA-376 §17.4.40 CT_TblBorders and §17.4.66 CT_TcBorders the
10852
+ // left / right borders have bidi-aware aliases `w:start` / `w:end`.
10853
+ // Modern authoring tools (Word 2013+, Google Docs) emit the bidi-
10854
+ // aware form by default — prefer those over the legacy `w:left` /
10855
+ // `w:right` so bidi-authored tables round-trip their side borders
10856
+ // (the internal model stores under the left/right keys, matching
10857
+ // the emitter).
9524
10858
  const borderTypes = ['top', 'bottom', 'left', 'right', 'insideH', 'insideV'];
9525
10859
  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
- }
10860
+ // For left/right: prefer bidi-aware start/end alias if present.
10861
+ const alias = type === 'left' ? 'start' : type === 'right' ? 'end' : type;
10862
+ const tagNameToRead = bordersXml.includes(`<w:${alias}`) ? alias : type;
10863
+ if (bordersXml.includes(`<w:${tagNameToRead}`)) {
10864
+ const border = parseBorderAttrs(tagNameToRead);
10865
+ if (border) borders[type] = border;
9544
10866
  }
9545
10867
  }
9546
10868
 
@@ -9549,23 +10871,8 @@ export class DocumentParser {
9549
10871
  const diagonalTypes = ['tl2br', 'tr2bl'];
9550
10872
  for (const type of diagonalTypes) {
9551
10873
  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
- }
10874
+ const border = parseBorderAttrs(type);
10875
+ if (border) borders[type] = border;
9569
10876
  }
9570
10877
  }
9571
10878
  }
@@ -9578,16 +10885,25 @@ export class DocumentParser {
9578
10885
  * Extracts all 9 ECMA-376 shading attributes including theme colors.
9579
10886
  */
9580
10887
  private parseShadingFromObj(shd: any): ShadingConfig | undefined {
10888
+ // Per ECMA-376 §17.3.1.32 CT_Shd, every string-typed attribute
10889
+ // (ST_UcharHexNumber tint/shade, ST_ThemeColor theme refs,
10890
+ // ST_HexColor fill/color, ST_Shd pattern) can be purely numeric in
10891
+ // hex form — "80", "00", "FF", "80000000", etc. XMLParser's
10892
+ // `parseAttributeValue: true` coerces purely-digit hex strings like
10893
+ // "80" to the JS number 80, violating the `string` type on
10894
+ // ShadingConfig. Previously stored values leaked downstream as
10895
+ // numbers (e.g. `.toUpperCase()` would throw); cast every attribute
10896
+ // through `String(...)` so the declared-type contract holds.
9581
10897
  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'];
10898
+ if (shd['@_w:val']) shading.pattern = String(shd['@_w:val']) as ShadingConfig['pattern'];
10899
+ if (shd['@_w:fill']) shading.fill = String(shd['@_w:fill']);
10900
+ if (shd['@_w:color']) shading.color = String(shd['@_w:color']);
10901
+ if (shd['@_w:themeFill']) shading.themeFill = String(shd['@_w:themeFill']);
10902
+ if (shd['@_w:themeColor']) shading.themeColor = String(shd['@_w:themeColor']);
10903
+ if (shd['@_w:themeFillTint']) shading.themeFillTint = String(shd['@_w:themeFillTint']);
10904
+ if (shd['@_w:themeFillShade']) shading.themeFillShade = String(shd['@_w:themeFillShade']);
10905
+ if (shd['@_w:themeTint']) shading.themeTint = String(shd['@_w:themeTint']);
10906
+ if (shd['@_w:themeShade']) shading.themeShade = String(shd['@_w:themeShade']);
9591
10907
  return Object.keys(shading).length > 0 ? shading : undefined;
9592
10908
  }
9593
10909
 
@@ -9628,8 +10944,8 @@ export class DocumentParser {
9628
10944
  */
9629
10945
  private parseCellMarginsFromXml(
9630
10946
  marginXml: string
9631
- ): import('../formatting/Style').CellMargins | undefined {
9632
- const margins: import('../formatting/Style').CellMargins = {};
10947
+ ): import('../formatting/Style.js').CellMargins | undefined {
10948
+ const margins: import('../formatting/Style.js').CellMargins = {};
9633
10949
 
9634
10950
  // Parse top and bottom directly
9635
10951
  for (const type of ['top', 'bottom'] as const) {
@@ -9894,23 +11210,23 @@ export class DocumentParser {
9894
11210
  imageManager: ImageManager
9895
11211
  ): Promise<{
9896
11212
  headers: {
9897
- header: import('../elements/Header').Header;
11213
+ header: import('../elements/Header.js').Header;
9898
11214
  relationshipId: string;
9899
11215
  filename: string;
9900
11216
  }[];
9901
11217
  footers: {
9902
- footer: import('../elements/Footer').Footer;
11218
+ footer: import('../elements/Footer.js').Footer;
9903
11219
  relationshipId: string;
9904
11220
  filename: string;
9905
11221
  }[];
9906
11222
  }> {
9907
11223
  const headers: {
9908
- header: import('../elements/Header').Header;
11224
+ header: import('../elements/Header.js').Header;
9909
11225
  relationshipId: string;
9910
11226
  filename: string;
9911
11227
  }[] = [];
9912
11228
  const footers: {
9913
- footer: import('../elements/Footer').Footer;
11229
+ footer: import('../elements/Footer.js').Footer;
9914
11230
  relationshipId: string;
9915
11231
  filename: string;
9916
11232
  }[] = [];
@@ -9924,7 +11240,7 @@ export class DocumentParser {
9924
11240
  // Parse headers
9925
11241
  // Track already-parsed headers by rId to avoid creating duplicates
9926
11242
  // when multiple section property types (default, first, even) reference the same header file
9927
- const parsedHeadersByRId = new Map<string, import('../elements/Header').Header>();
11243
+ const parsedHeadersByRId = new Map<string, import('../elements/Header.js').Header>();
9928
11244
 
9929
11245
  if (sectionProps.headers) {
9930
11246
  for (const [type, rId] of Object.entries(sectionProps.headers)) {
@@ -9987,7 +11303,7 @@ export class DocumentParser {
9987
11303
  // Parse footers
9988
11304
  // Track already-parsed footers by rId to avoid creating duplicates
9989
11305
  // when multiple section property types (default, first, even) reference the same footer file
9990
- const parsedFootersByRId = new Map<string, import('../elements/Footer').Footer>();
11306
+ const parsedFootersByRId = new Map<string, import('../elements/Footer.js').Footer>();
9991
11307
 
9992
11308
  if (sectionProps.footers) {
9993
11309
  for (const [type, rId] of Object.entries(sectionProps.footers)) {
@@ -10176,29 +11492,42 @@ export class DocumentParser {
10176
11492
 
10177
11493
  // Table-level properties (w:tblPr context)
10178
11494
  if (propsObj['w:tblStyle']) {
10179
- result.style = propsObj['w:tblStyle']['@_w:val'] || '';
10180
- }
10181
- // tblpPr (floating table position)
11495
+ // w:tblStyle w:val is ST_String (§17.7.4.62). XMLParser coerces
11496
+ // purely-numeric style IDs (e.g. "2025") to numbers; cast so the
11497
+ // declared `string` contract holds on tracked-change history.
11498
+ const v = propsObj['w:tblStyle']['@_w:val'];
11499
+ result.style = v !== undefined && v !== null ? String(v) : '';
11500
+ }
11501
+ // tblpPr (floating table position) — mirror main-path zero-value
11502
+ // preservation. The tblPrChange emitter re-emits position via
11503
+ // `!== undefined`, so dropping zero-valued tracked "previous"
11504
+ // positions here lost them silently on round-trip.
10182
11505
  if (propsObj['w:tblpPr']) {
10183
11506
  const tblpPr = propsObj['w:tblpPr'];
10184
11507
  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);
11508
+ if (isExplicitlySet(tblpPr['@_w:tblpX'])) pos.x = safeParseInt(tblpPr['@_w:tblpX']);
11509
+ if (isExplicitlySet(tblpPr['@_w:tblpY'])) pos.y = safeParseInt(tblpPr['@_w:tblpY']);
10187
11510
  if (tblpPr['@_w:horzAnchor']) pos.horizontalAnchor = tblpPr['@_w:horzAnchor'];
10188
11511
  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);
11512
+ if (isExplicitlySet(tblpPr['@_w:leftFromText'])) {
11513
+ pos.leftFromText = safeParseInt(tblpPr['@_w:leftFromText']);
11514
+ }
11515
+ if (isExplicitlySet(tblpPr['@_w:rightFromText'])) {
11516
+ pos.rightFromText = safeParseInt(tblpPr['@_w:rightFromText']);
11517
+ }
11518
+ if (isExplicitlySet(tblpPr['@_w:topFromText'])) {
11519
+ pos.topFromText = safeParseInt(tblpPr['@_w:topFromText']);
11520
+ }
11521
+ if (isExplicitlySet(tblpPr['@_w:bottomFromText'])) {
11522
+ pos.bottomFromText = safeParseInt(tblpPr['@_w:bottomFromText']);
11523
+ }
10195
11524
  if (Object.keys(pos).length > 0) result.position = pos;
10196
11525
  }
10197
11526
  if (propsObj['w:tblOverlap']) {
10198
11527
  result.overlap = propsObj['w:tblOverlap']['@_w:val'];
10199
11528
  }
10200
11529
  if (propsObj['w:bidiVisual']) {
10201
- result.bidiVisual = true;
11530
+ result.bidiVisual = parseOoxmlBoolean(propsObj['w:bidiVisual']);
10202
11531
  }
10203
11532
  if (propsObj['w:tblStyleRowBandSize']) {
10204
11533
  result.tblStyleRowBandSize = parseInt(
@@ -10244,21 +11573,55 @@ export class DocumentParser {
10244
11573
  const borders: any = {};
10245
11574
  const bordersObj = propsObj['w:tblBorders'];
10246
11575
  for (const side of ['top', 'bottom', 'left', 'right', 'insideH', 'insideV']) {
10247
- if (bordersObj[`w:${side}`]) {
10248
- borders[side] = this.parseBorderElement(bordersObj[`w:${side}`]);
11576
+ // Prefer bidi-aware w:start/w:end aliases over legacy w:left/
11577
+ // w:right (ECMA-376 §17.4.40 CT_TblBorders). Same pattern as
11578
+ // the main table borders parser — the bidi-aware form is the
11579
+ // preferred modern spelling.
11580
+ const aliasKey = side === 'left' ? 'w:start' : side === 'right' ? 'w:end' : undefined;
11581
+ const borderObj = (aliasKey && bordersObj[aliasKey]) || bordersObj[`w:${side}`];
11582
+ if (borderObj) {
11583
+ borders[side] = this.parseBorderElement(borderObj);
10249
11584
  }
10250
11585
  }
10251
11586
  if (Object.keys(borders).length > 0) result.borders = borders;
10252
11587
  }
11588
+ // tblLook per ECMA-376 §17.4.57 — supports both hex-string format
11589
+ // (w:val="04A0") AND the expanded individual-attribute form
11590
+ // (firstRow/lastRow/firstColumn/lastColumn/noHBand/noVBand).
11591
+ // Word often emits the expanded form (no w:val) inside *PrChange
11592
+ // previous-properties; the hex-only read silently collapsed every
11593
+ // flag to "0000" on round-trip.
10253
11594
  if (propsObj['w:tblLook']) {
10254
11595
  const look = propsObj['w:tblLook'];
10255
- result.tblLook = look['@_w:val'] || '0000';
11596
+ if (look['@_w:val']) {
11597
+ result.tblLook = String(look['@_w:val']);
11598
+ } else {
11599
+ const attrIsOn = (name: string): boolean => {
11600
+ const v = look[name];
11601
+ if (v === undefined) return false;
11602
+ return parseOoxmlBoolean({ '@_w:val': v });
11603
+ };
11604
+ let value = 0;
11605
+ if (attrIsOn('@_w:firstRow')) value |= 0x0020;
11606
+ if (attrIsOn('@_w:lastRow')) value |= 0x0040;
11607
+ if (attrIsOn('@_w:firstColumn')) value |= 0x0080;
11608
+ if (attrIsOn('@_w:lastColumn')) value |= 0x0100;
11609
+ if (attrIsOn('@_w:noHBand')) value |= 0x0200;
11610
+ if (attrIsOn('@_w:noVBand')) value |= 0x0400;
11611
+ result.tblLook = value.toString(16).toUpperCase().padStart(4, '0');
11612
+ }
10256
11613
  }
10257
11614
  if (propsObj['w:tblCaption']) {
10258
- result.caption = propsObj['w:tblCaption']['@_w:val'];
11615
+ // w:tblCaption w:val is ST_String (§17.4.62). Cast through
11616
+ // String() so purely-numeric caption text round-trips as a
11617
+ // string inside the tracked-change previousProperties.
11618
+ const v = propsObj['w:tblCaption']['@_w:val'];
11619
+ result.caption = v !== undefined && v !== null ? String(v) : undefined;
10259
11620
  }
10260
11621
  if (propsObj['w:tblDescription']) {
10261
- result.description = propsObj['w:tblDescription']['@_w:val'];
11622
+ // w:tblDescription w:val is ST_String (§17.4.63).
11623
+ const v = propsObj['w:tblDescription']['@_w:val'];
11624
+ result.description = v !== undefined && v !== null ? String(v) : undefined;
10262
11625
  }
10263
11626
 
10264
11627
  // Row-level properties (w:trPr context) — all CT_TrPr elements
@@ -10289,14 +11652,15 @@ export class DocumentParser {
10289
11652
  const rule = propsObj['w:trHeight']['@_w:hRule'];
10290
11653
  if (rule) result.heightRule = rule;
10291
11654
  }
11655
+ // Row CT_OnOff — honour w:val per ECMA-376 §17.17.4 (ST_OnOff)
10292
11656
  if (propsObj['w:tblHeader']) {
10293
- result.isHeader = true;
11657
+ result.isHeader = parseOoxmlBoolean(propsObj['w:tblHeader']);
10294
11658
  }
10295
11659
  if (propsObj['w:cantSplit']) {
10296
- result.cantSplit = true;
11660
+ result.cantSplit = parseOoxmlBoolean(propsObj['w:cantSplit']);
10297
11661
  }
10298
11662
  if (propsObj['w:hidden']) {
10299
- result.hidden = true;
11663
+ result.hidden = parseOoxmlBoolean(propsObj['w:hidden']);
10300
11664
  }
10301
11665
 
10302
11666
  // Cell-level properties (w:tcPr context) — all CT_TcPr elements
@@ -10317,14 +11681,19 @@ export class DocumentParser {
10317
11681
  const borders: any = {};
10318
11682
  const bordersObj = propsObj['w:tcBorders'];
10319
11683
  for (const side of ['top', 'bottom', 'left', 'right', 'tl2br', 'tr2bl']) {
10320
- if (bordersObj[`w:${side}`]) {
10321
- borders[side] = this.parseBorderElement(bordersObj[`w:${side}`]);
11684
+ // Prefer bidi-aware w:start/w:end aliases for left/right
11685
+ // (ECMA-376 §17.4.66 CT_TcBorders). Diagonals (tl2br/tr2bl)
11686
+ // have no bidi aliases.
11687
+ const aliasKey = side === 'left' ? 'w:start' : side === 'right' ? 'w:end' : undefined;
11688
+ const borderObj = (aliasKey && bordersObj[aliasKey]) || bordersObj[`w:${side}`];
11689
+ if (borderObj) {
11690
+ borders[side] = this.parseBorderElement(borderObj);
10322
11691
  }
10323
11692
  }
10324
11693
  if (Object.keys(borders).length > 0) result.borders = borders;
10325
11694
  }
10326
11695
  if (propsObj['w:noWrap']) {
10327
- result.noWrap = true;
11696
+ result.noWrap = parseOoxmlBoolean(propsObj['w:noWrap']);
10328
11697
  }
10329
11698
  if (propsObj['w:tcMar']) {
10330
11699
  const tcMar = propsObj['w:tcMar'];
@@ -10341,13 +11710,13 @@ export class DocumentParser {
10341
11710
  result.textDirection = propsObj['w:textDirection']['@_w:val'];
10342
11711
  }
10343
11712
  if (propsObj['w:tcFitText']) {
10344
- result.fitText = true;
11713
+ result.fitText = parseOoxmlBoolean(propsObj['w:tcFitText']);
10345
11714
  }
10346
11715
  if (propsObj['w:vAlign']) {
10347
11716
  result.verticalAlignment = propsObj['w:vAlign']['@_w:val'];
10348
11717
  }
10349
11718
  if (propsObj['w:hideMark']) {
10350
- result.hideMark = true;
11719
+ result.hideMark = parseOoxmlBoolean(propsObj['w:hideMark']);
10351
11720
  }
10352
11721
  if (propsObj['w:cnfStyle']) {
10353
11722
  result.cnfStyle = propsObj['w:cnfStyle']['@_w:val'];
@@ -10377,6 +11746,79 @@ export class DocumentParser {
10377
11746
  if (!sectPrXml) return {};
10378
11747
  const result: Record<string, any> = {};
10379
11748
 
11749
+ // Footnote properties (w:footnotePr) per §17.11.9. The main sectPr parser
11750
+ // reads these, and the emitter supports prev.footnotePr, but the
11751
+ // sectPrChange parser previously dropped them entirely — tracked history
11752
+ // of changes to footnote numbering format / position / start / restart
11753
+ // was lost on every round-trip.
11754
+ const footnotePrElements = XMLParser.extractElements(sectPrXml, 'w:footnotePr');
11755
+ if (footnotePrElements.length > 0 && footnotePrElements[0]) {
11756
+ const fnPr = footnotePrElements[0];
11757
+ const fnObj: any = {};
11758
+ const posElements = XMLParser.extractElements(fnPr, 'w:pos');
11759
+ if (posElements[0]) {
11760
+ const pos = XMLParser.extractAttribute(posElements[0], 'w:val');
11761
+ if (pos) fnObj.position = pos;
11762
+ }
11763
+ const numFmtElements = XMLParser.extractElements(fnPr, 'w:numFmt');
11764
+ if (numFmtElements[0]) {
11765
+ const fmt = XMLParser.extractAttribute(numFmtElements[0], 'w:val');
11766
+ if (fmt) fnObj.numberFormat = fmt;
11767
+ }
11768
+ const numStartElements = XMLParser.extractElements(fnPr, 'w:numStart');
11769
+ if (numStartElements[0]) {
11770
+ const start = XMLParser.extractAttribute(numStartElements[0], 'w:val');
11771
+ if (start !== undefined) fnObj.startNumber = parseInt(String(start), 10);
11772
+ }
11773
+ const numRestartElements = XMLParser.extractElements(fnPr, 'w:numRestart');
11774
+ if (numRestartElements[0]) {
11775
+ const restart = XMLParser.extractAttribute(numRestartElements[0], 'w:val');
11776
+ if (restart) fnObj.restart = restart;
11777
+ }
11778
+ if (Object.keys(fnObj).length > 0) result.footnotePr = fnObj;
11779
+ }
11780
+
11781
+ // Endnote properties (w:endnotePr) per §17.11.5 — mirror of footnotePr.
11782
+ const endnotePrElements = XMLParser.extractElements(sectPrXml, 'w:endnotePr');
11783
+ if (endnotePrElements.length > 0 && endnotePrElements[0]) {
11784
+ const enPr = endnotePrElements[0];
11785
+ const enObj: any = {};
11786
+ const posElements = XMLParser.extractElements(enPr, 'w:pos');
11787
+ if (posElements[0]) {
11788
+ const pos = XMLParser.extractAttribute(posElements[0], 'w:val');
11789
+ if (pos) enObj.position = pos;
11790
+ }
11791
+ const numFmtElements = XMLParser.extractElements(enPr, 'w:numFmt');
11792
+ if (numFmtElements[0]) {
11793
+ const fmt = XMLParser.extractAttribute(numFmtElements[0], 'w:val');
11794
+ if (fmt) enObj.numberFormat = fmt;
11795
+ }
11796
+ const numStartElements = XMLParser.extractElements(enPr, 'w:numStart');
11797
+ if (numStartElements[0]) {
11798
+ const start = XMLParser.extractAttribute(numStartElements[0], 'w:val');
11799
+ if (start !== undefined) enObj.startNumber = parseInt(String(start), 10);
11800
+ }
11801
+ const numRestartElements = XMLParser.extractElements(enPr, 'w:numRestart');
11802
+ if (numRestartElements[0]) {
11803
+ const restart = XMLParser.extractAttribute(numRestartElements[0], 'w:val');
11804
+ if (restart) enObj.restart = restart;
11805
+ }
11806
+ if (Object.keys(enObj).length > 0) result.endnotePr = enObj;
11807
+ }
11808
+
11809
+ // Paper source (w:paperSrc) per §17.6.12 CT_PaperSource — first-page / other
11810
+ // paper tray selection. Both attributes optional per schema.
11811
+ const paperSrcElements = XMLParser.extractElements(sectPrXml, 'w:paperSrc');
11812
+ if (paperSrcElements.length > 0 && paperSrcElements[0]) {
11813
+ const ps = paperSrcElements[0];
11814
+ const psObj: any = {};
11815
+ const first = XMLParser.extractAttribute(ps, 'w:first');
11816
+ if (first !== undefined) psObj.first = parseInt(String(first), 10);
11817
+ const other = XMLParser.extractAttribute(ps, 'w:other');
11818
+ if (other !== undefined) psObj.other = parseInt(String(other), 10);
11819
+ if (Object.keys(psObj).length > 0) result.paperSource = psObj;
11820
+ }
11821
+
10380
11822
  // Page size
10381
11823
  const pgSzElements = XMLParser.extractElements(sectPrXml, 'w:pgSz');
10382
11824
  if (pgSzElements.length > 0 && pgSzElements[0]) {
@@ -10395,7 +11837,10 @@ export class DocumentParser {
10395
11837
  }
10396
11838
  }
10397
11839
 
10398
- // Margins
11840
+ // Margins — full CT_PageMar attribute set (§17.6.11) including w:gutter
11841
+ // (the book-binding margin). Previously gutter was dropped on sectPrChange
11842
+ // history, so any tracked change to a binding-gutter value lost the
11843
+ // previous value on round-trip.
10399
11844
  const pgMarElements = XMLParser.extractElements(sectPrXml, 'w:pgMar');
10400
11845
  if (pgMarElements.length > 0 && pgMarElements[0]) {
10401
11846
  const pgMar = pgMarElements[0];
@@ -10412,6 +11857,8 @@ export class DocumentParser {
10412
11857
  if (header) margins.header = parseInt(header, 10);
10413
11858
  const footer = XMLParser.extractAttribute(pgMar, 'w:footer');
10414
11859
  if (footer) margins.footer = parseInt(footer, 10);
11860
+ const gutter = XMLParser.extractAttribute(pgMar, 'w:gutter');
11861
+ if (gutter) margins.gutter = parseInt(gutter, 10);
10415
11862
  if (Object.keys(margins).length > 0) result.margins = margins;
10416
11863
  }
10417
11864
 
@@ -10438,7 +11885,13 @@ export class DocumentParser {
10438
11885
  if (Object.keys(lnObj).length > 0) result.lineNumbering = lnObj;
10439
11886
  }
10440
11887
 
10441
- // Page numbering
11888
+ // Page numbering — full CT_PageNumber attribute set (§17.6.12):
11889
+ // fmt / start / chapStyle / chapSep. Previously only fmt+start were read,
11890
+ // so tracked-change history of chapter-numbering edits (e.g. switching
11891
+ // from "Heading 1" to "Heading 2" as the chapter marker, or changing the
11892
+ // chapter separator from hyphen to emDash) lost the previous values.
11893
+ // The Section.ts emitter stores chapStyle / chapSep as top-level
11894
+ // properties rather than on pageNumbering, so expose them the same way.
10442
11895
  const pgNumElements = XMLParser.extractElements(sectPrXml, 'w:pgNumType');
10443
11896
  if (pgNumElements.length > 0 && pgNumElements[0]) {
10444
11897
  const pn = pgNumElements[0];
@@ -10448,6 +11901,12 @@ export class DocumentParser {
10448
11901
  const fmt = XMLParser.extractAttribute(pn, 'w:fmt');
10449
11902
  if (fmt) pnObj.format = fmt;
10450
11903
  if (Object.keys(pnObj).length > 0) result.pageNumbering = pnObj;
11904
+ // Mirror the main-sectPr parser: chapStyle / chapSep live at the root
11905
+ // of the section properties, not inside pageNumbering.
11906
+ const chapStyle = XMLParser.extractAttribute(pn, 'w:chapStyle');
11907
+ if (chapStyle !== undefined) result.chapStyle = parseInt(String(chapStyle), 10);
11908
+ const chapSep = XMLParser.extractAttribute(pn, 'w:chapSep');
11909
+ if (chapSep) result.chapSep = chapSep;
10451
11910
  }
10452
11911
 
10453
11912
  // Columns
@@ -10456,16 +11915,58 @@ export class DocumentParser {
10456
11915
  const cols = colsElements[0];
10457
11916
  const num = XMLParser.extractAttribute(cols, 'w:num');
10458
11917
  const space = XMLParser.extractAttribute(cols, 'w:space');
11918
+ // Full CT_Columns attribute set (§17.6.4): num / space / equalWidth / sep
11919
+ // plus the child <w:col w:w="..." w:space="..."/> entries for per-column
11920
+ // widths. Previously only num+space were read, so sectPrChange history of
11921
+ // a columns-layout change dropped equalWidth, the separator line, and
11922
+ // the entire custom column-width / per-column-space configuration.
11923
+ const equalWidth = XMLParser.extractAttribute(cols, 'w:equalWidth');
11924
+ const sep = XMLParser.extractAttribute(cols, 'w:sep');
11925
+
11926
+ // Extract individual <w:col> children for non-equal-width layouts.
11927
+ const colChildElements = XMLParser.extractElements(cols, 'w:col');
11928
+ const columnWidths: number[] = [];
11929
+ const columnSpaces: number[] = [];
11930
+ let hasColumnSpaces = false;
11931
+ for (const col of colChildElements) {
11932
+ const width = XMLParser.extractAttribute(col, 'w:w');
11933
+ if (width) columnWidths.push(parseInt(width, 10));
11934
+ const colSpace = XMLParser.extractAttribute(col, 'w:space');
11935
+ if (colSpace) {
11936
+ columnSpaces.push(parseInt(colSpace, 10));
11937
+ hasColumnSpaces = true;
11938
+ } else {
11939
+ columnSpaces.push(0);
11940
+ }
11941
+ }
11942
+
10459
11943
  if (num) {
10460
11944
  result.columns = {
10461
11945
  count: parseInt(num, 10),
10462
11946
  space: space ? parseInt(space, 10) : undefined,
11947
+ equalWidth: equalWidth ? parseOnOffAttribute(equalWidth) : undefined,
11948
+ separator: sep ? parseOnOffAttribute(sep) : undefined,
11949
+ columnWidths: columnWidths.length > 0 ? columnWidths : undefined,
11950
+ columnSpaces: hasColumnSpaces ? columnSpaces : undefined,
10463
11951
  };
10464
11952
  }
10465
11953
  }
10466
11954
 
10467
- // Form protection
10468
- if (sectPrXml.includes('<w:formProt')) result.formProt = true;
11955
+ // CT_OnOff sectPr flags — honour w:val per ECMA-376 §17.17.4 (ST_OnOff).
11956
+ // Previously these used substring `.includes()`, which both ignored w:val
11957
+ // (flipping explicit false to true) and could false-positive on prefix
11958
+ // matches (e.g. "<w:bidi" inside "<w:bidiVisual"). Use extractElements +
11959
+ // extractAttribute + parseOnOffAttribute instead.
11960
+ const parseSectCtOnOff = (tagName: string): boolean | undefined => {
11961
+ const els = XMLParser.extractElements(sectPrXml, tagName);
11962
+ if (els.length === 0 || !els[0]) return undefined;
11963
+ const v = XMLParser.extractAttribute(els[0], 'w:val');
11964
+ return parseOnOffAttribute(v, true);
11965
+ };
11966
+
11967
+ // Form protection (w:formProt) — CT_OnOff
11968
+ const formProtVal = parseSectCtOnOff('w:formProt');
11969
+ if (formProtVal !== undefined) result.formProt = formProtVal;
10469
11970
 
10470
11971
  // Vertical alignment
10471
11972
  const vAlignElements = XMLParser.extractElements(sectPrXml, 'w:vAlign');
@@ -10474,11 +11975,13 @@ export class DocumentParser {
10474
11975
  if (val) result.verticalAlignment = val;
10475
11976
  }
10476
11977
 
10477
- // Suppress endnotes
10478
- if (sectPrXml.includes('<w:noEndnote')) result.noEndnote = true;
11978
+ // Suppress endnotes (w:noEndnote) — CT_OnOff
11979
+ const noEndnoteVal = parseSectCtOnOff('w:noEndnote');
11980
+ if (noEndnoteVal !== undefined) result.noEndnote = noEndnoteVal;
10479
11981
 
10480
- // Title page
10481
- if (sectPrXml.includes('<w:titlePg')) result.titlePage = true;
11982
+ // Title page (w:titlePg) — CT_OnOff
11983
+ const titlePgVal = parseSectCtOnOff('w:titlePg');
11984
+ if (titlePgVal !== undefined) result.titlePage = titlePgVal;
10482
11985
 
10483
11986
  // Text direction
10484
11987
  const textDirElements = XMLParser.extractElements(sectPrXml, 'w:textDirection');
@@ -10487,11 +11990,13 @@ export class DocumentParser {
10487
11990
  if (val) result.textDirection = val;
10488
11991
  }
10489
11992
 
10490
- // Bidi section
10491
- if (sectPrXml.includes('<w:bidi')) result.bidi = true;
11993
+ // Bidi section (w:bidi) — CT_OnOff
11994
+ const bidiVal = parseSectCtOnOff('w:bidi');
11995
+ if (bidiVal !== undefined) result.bidi = bidiVal;
10492
11996
 
10493
- // RTL gutter
10494
- if (sectPrXml.includes('<w:rtlGutter')) result.rtlGutter = true;
11997
+ // RTL gutter (w:rtlGutter) — CT_OnOff
11998
+ const rtlGutterVal = parseSectCtOnOff('w:rtlGutter');
11999
+ if (rtlGutterVal !== undefined) result.rtlGutter = rtlGutterVal;
10495
12000
 
10496
12001
  // Document grid
10497
12002
  const docGridElements = XMLParser.extractElements(sectPrXml, 'w:docGrid');
@@ -10507,6 +12012,64 @@ export class DocumentParser {
10507
12012
  if (Object.keys(dgObj).length > 0) result.docGrid = dgObj;
10508
12013
  }
10509
12014
 
12015
+ // Page borders (w:pgBorders) per ECMA-376 §17.6.10. The main sectPr parser
12016
+ // reads these, but the sectPrChange previous-sectPr parser previously
12017
+ // didn't — so a tracked-change history of page-border edits lost the
12018
+ // entire "previous" border configuration (style, color, themeColor,
12019
+ // themeTint, themeShade, shadow, frame) every round-trip. The emitter
12020
+ // supports prev.pageBorders already; this is the missing parser half.
12021
+ const pgBordersElements = XMLParser.extractElements(sectPrXml, 'w:pgBorders');
12022
+ if (pgBordersElements.length > 0 && pgBordersElements[0]) {
12023
+ const pgBordersXml = pgBordersElements[0];
12024
+ const pageBorders: any = {};
12025
+ const offsetFrom = XMLParser.extractAttribute(pgBordersXml, 'w:offsetFrom');
12026
+ if (offsetFrom) pageBorders.offsetFrom = offsetFrom;
12027
+ const display = XMLParser.extractAttribute(pgBordersXml, 'w:display');
12028
+ if (display) pageBorders.display = display;
12029
+ const zOrder = XMLParser.extractAttribute(pgBordersXml, 'w:zOrder');
12030
+ if (zOrder) pageBorders.zOrder = zOrder;
12031
+
12032
+ // Per-side border parser mirrors the main-sectPr logic — full CT_Border
12033
+ // attribute set including themed colors and shadow/frame flags.
12034
+ const parsePrevBorder = (sideXml: string): any | undefined => {
12035
+ if (!sideXml) return undefined;
12036
+ const border: any = {};
12037
+ const val = XMLParser.extractAttribute(sideXml, 'w:val');
12038
+ if (val) border.style = val;
12039
+ const sz = XMLParser.extractAttribute(sideXml, 'w:sz');
12040
+ if (sz) border.size = parseInt(sz, 10);
12041
+ const color = XMLParser.extractAttribute(sideXml, 'w:color');
12042
+ if (color) border.color = color;
12043
+ const space = XMLParser.extractAttribute(sideXml, 'w:space');
12044
+ if (space) border.space = parseInt(space, 10);
12045
+ const shadow = XMLParser.extractAttribute(sideXml, 'w:shadow');
12046
+ if (shadow !== undefined) border.shadow = parseOnOffAttribute(shadow, true);
12047
+ const frame = XMLParser.extractAttribute(sideXml, 'w:frame');
12048
+ if (frame !== undefined) border.frame = parseOnOffAttribute(frame, true);
12049
+ const themeColor = XMLParser.extractAttribute(sideXml, 'w:themeColor');
12050
+ if (themeColor) border.themeColor = themeColor;
12051
+ const themeTint = XMLParser.extractAttribute(sideXml, 'w:themeTint');
12052
+ if (themeTint) border.themeTint = themeTint;
12053
+ const themeShade = XMLParser.extractAttribute(sideXml, 'w:themeShade');
12054
+ if (themeShade) border.themeShade = themeShade;
12055
+ const artId = XMLParser.extractAttribute(sideXml, 'w:id');
12056
+ if (artId) border.artId = parseInt(artId, 10);
12057
+ return Object.keys(border).length > 0 ? border : undefined;
12058
+ };
12059
+
12060
+ for (const side of ['top', 'left', 'bottom', 'right']) {
12061
+ const sideElements = XMLParser.extractElements(pgBordersXml, `w:${side}`);
12062
+ if (sideElements.length > 0 && sideElements[0]) {
12063
+ const border = parsePrevBorder(sideElements[0]);
12064
+ if (border) pageBorders[side] = border;
12065
+ }
12066
+ }
12067
+
12068
+ if (Object.keys(pageBorders).length > 0) {
12069
+ result.pageBorders = pageBorders;
12070
+ }
12071
+ }
12072
+
10510
12073
  return result;
10511
12074
  }
10512
12075
  }