docxmlater 11.0.9 → 12.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (354) hide show
  1. package/README.md +51 -13
  2. package/dist/core/Document.d.ts +28 -2
  3. package/dist/core/Document.d.ts.map +1 -1
  4. package/dist/core/Document.js +725 -170
  5. package/dist/core/Document.js.map +1 -1
  6. package/dist/core/DocumentGenerator.d.ts +1 -0
  7. package/dist/core/DocumentGenerator.d.ts.map +1 -1
  8. package/dist/core/DocumentGenerator.js +77 -25
  9. package/dist/core/DocumentGenerator.js.map +1 -1
  10. package/dist/core/DocumentParser.d.ts +7 -3
  11. package/dist/core/DocumentParser.d.ts.map +1 -1
  12. package/dist/core/DocumentParser.js +431 -85
  13. package/dist/core/DocumentParser.js.map +1 -1
  14. package/dist/core/RelationshipManager.d.ts.map +1 -1
  15. package/dist/core/RelationshipManager.js +2 -2
  16. package/dist/core/RelationshipManager.js.map +1 -1
  17. package/dist/elements/Comment.d.ts +15 -1
  18. package/dist/elements/Comment.d.ts.map +1 -1
  19. package/dist/elements/Comment.js +67 -12
  20. package/dist/elements/Comment.js.map +1 -1
  21. package/dist/elements/CommentManager.d.ts +4 -0
  22. package/dist/elements/CommentManager.d.ts.map +1 -1
  23. package/dist/elements/CommentManager.js +74 -16
  24. package/dist/elements/CommentManager.js.map +1 -1
  25. package/dist/elements/EndnoteManager.d.ts.map +1 -1
  26. package/dist/elements/EndnoteManager.js.map +1 -1
  27. package/dist/elements/Field.d.ts +10 -1
  28. package/dist/elements/Field.d.ts.map +1 -1
  29. package/dist/elements/Field.js +76 -34
  30. package/dist/elements/Field.js.map +1 -1
  31. package/dist/elements/FieldHelpers.d.ts +1 -0
  32. package/dist/elements/FieldHelpers.d.ts.map +1 -1
  33. package/dist/elements/FieldHelpers.js +8 -4
  34. package/dist/elements/FieldHelpers.js.map +1 -1
  35. package/dist/elements/Footer.d.ts +3 -1
  36. package/dist/elements/Footer.d.ts.map +1 -1
  37. package/dist/elements/Footer.js +39 -53
  38. package/dist/elements/Footer.js.map +1 -1
  39. package/dist/elements/FootnoteManager.d.ts.map +1 -1
  40. package/dist/elements/FootnoteManager.js.map +1 -1
  41. package/dist/elements/Header.d.ts +3 -1
  42. package/dist/elements/Header.d.ts.map +1 -1
  43. package/dist/elements/Header.js +39 -53
  44. package/dist/elements/Header.js.map +1 -1
  45. package/dist/elements/HeaderFooterManager.d.ts +4 -2
  46. package/dist/elements/HeaderFooterManager.d.ts.map +1 -1
  47. package/dist/elements/HeaderFooterManager.js +36 -6
  48. package/dist/elements/HeaderFooterManager.js.map +1 -1
  49. package/dist/elements/Hyperlink.d.ts +6 -1
  50. package/dist/elements/Hyperlink.d.ts.map +1 -1
  51. package/dist/elements/Hyperlink.js +69 -44
  52. package/dist/elements/Hyperlink.js.map +1 -1
  53. package/dist/elements/Image.d.ts.map +1 -1
  54. package/dist/elements/Image.js +33 -6
  55. package/dist/elements/Image.js.map +1 -1
  56. package/dist/elements/ImageManager.d.ts +1 -0
  57. package/dist/elements/ImageManager.d.ts.map +1 -1
  58. package/dist/elements/ImageManager.js +11 -0
  59. package/dist/elements/ImageManager.js.map +1 -1
  60. package/dist/elements/Paragraph.d.ts +3 -0
  61. package/dist/elements/Paragraph.d.ts.map +1 -1
  62. package/dist/elements/Paragraph.js +91 -26
  63. package/dist/elements/Paragraph.js.map +1 -1
  64. package/dist/elements/Revision.d.ts +4 -0
  65. package/dist/elements/Revision.d.ts.map +1 -1
  66. package/dist/elements/Revision.js +369 -29
  67. package/dist/elements/Revision.js.map +1 -1
  68. package/dist/elements/Run.d.ts +1 -0
  69. package/dist/elements/Run.d.ts.map +1 -1
  70. package/dist/elements/Run.js +24 -4
  71. package/dist/elements/Run.js.map +1 -1
  72. package/dist/elements/Section.d.ts.map +1 -1
  73. package/dist/elements/Section.js +17 -7
  74. package/dist/elements/Section.js.map +1 -1
  75. package/dist/elements/Shape.d.ts.map +1 -1
  76. package/dist/elements/Shape.js +50 -42
  77. package/dist/elements/Shape.js.map +1 -1
  78. package/dist/elements/StructuredDocumentTag.d.ts +9 -1
  79. package/dist/elements/StructuredDocumentTag.d.ts.map +1 -1
  80. package/dist/elements/StructuredDocumentTag.js +46 -6
  81. package/dist/elements/StructuredDocumentTag.js.map +1 -1
  82. package/dist/elements/Table.d.ts +2 -0
  83. package/dist/elements/Table.d.ts.map +1 -1
  84. package/dist/elements/Table.js +90 -42
  85. package/dist/elements/Table.js.map +1 -1
  86. package/dist/elements/TableCell.d.ts +1 -0
  87. package/dist/elements/TableCell.d.ts.map +1 -1
  88. package/dist/elements/TableCell.js +32 -13
  89. package/dist/elements/TableCell.js.map +1 -1
  90. package/dist/elements/TableOfContents.d.ts.map +1 -1
  91. package/dist/elements/TableOfContents.js +5 -7
  92. package/dist/elements/TableOfContents.js.map +1 -1
  93. package/dist/elements/TableRow.d.ts +1 -0
  94. package/dist/elements/TableRow.d.ts.map +1 -1
  95. package/dist/elements/TableRow.js +16 -1
  96. package/dist/elements/TableRow.js.map +1 -1
  97. package/dist/elements/TextBox.d.ts.map +1 -1
  98. package/dist/elements/TextBox.js +55 -42
  99. package/dist/elements/TextBox.js.map +1 -1
  100. package/dist/esm/core/Document.js +726 -171
  101. package/dist/esm/core/Document.js.map +1 -1
  102. package/dist/esm/core/DocumentGenerator.js +77 -25
  103. package/dist/esm/core/DocumentGenerator.js.map +1 -1
  104. package/dist/esm/core/DocumentParser.js +431 -85
  105. package/dist/esm/core/DocumentParser.js.map +1 -1
  106. package/dist/esm/core/RelationshipManager.js +2 -2
  107. package/dist/esm/core/RelationshipManager.js.map +1 -1
  108. package/dist/esm/elements/Comment.js +67 -12
  109. package/dist/esm/elements/Comment.js.map +1 -1
  110. package/dist/esm/elements/CommentManager.js +74 -16
  111. package/dist/esm/elements/CommentManager.js.map +1 -1
  112. package/dist/esm/elements/EndnoteManager.js.map +1 -1
  113. package/dist/esm/elements/Field.js +77 -35
  114. package/dist/esm/elements/Field.js.map +1 -1
  115. package/dist/esm/elements/FieldHelpers.js +7 -4
  116. package/dist/esm/elements/FieldHelpers.js.map +1 -1
  117. package/dist/esm/elements/Footer.js +39 -53
  118. package/dist/esm/elements/Footer.js.map +1 -1
  119. package/dist/esm/elements/FootnoteManager.js.map +1 -1
  120. package/dist/esm/elements/Header.js +39 -53
  121. package/dist/esm/elements/Header.js.map +1 -1
  122. package/dist/esm/elements/HeaderFooterManager.js +36 -6
  123. package/dist/esm/elements/HeaderFooterManager.js.map +1 -1
  124. package/dist/esm/elements/Hyperlink.js +69 -44
  125. package/dist/esm/elements/Hyperlink.js.map +1 -1
  126. package/dist/esm/elements/Image.js +33 -6
  127. package/dist/esm/elements/Image.js.map +1 -1
  128. package/dist/esm/elements/ImageManager.js +11 -0
  129. package/dist/esm/elements/ImageManager.js.map +1 -1
  130. package/dist/esm/elements/Paragraph.js +91 -26
  131. package/dist/esm/elements/Paragraph.js.map +1 -1
  132. package/dist/esm/elements/Revision.js +369 -29
  133. package/dist/esm/elements/Revision.js.map +1 -1
  134. package/dist/esm/elements/Run.js +24 -4
  135. package/dist/esm/elements/Run.js.map +1 -1
  136. package/dist/esm/elements/Section.js +17 -7
  137. package/dist/esm/elements/Section.js.map +1 -1
  138. package/dist/esm/elements/Shape.js +50 -42
  139. package/dist/esm/elements/Shape.js.map +1 -1
  140. package/dist/esm/elements/StructuredDocumentTag.js +46 -6
  141. package/dist/esm/elements/StructuredDocumentTag.js.map +1 -1
  142. package/dist/esm/elements/Table.js +90 -42
  143. package/dist/esm/elements/Table.js.map +1 -1
  144. package/dist/esm/elements/TableCell.js +32 -13
  145. package/dist/esm/elements/TableCell.js.map +1 -1
  146. package/dist/esm/elements/TableOfContents.js +5 -7
  147. package/dist/esm/elements/TableOfContents.js.map +1 -1
  148. package/dist/esm/elements/TableRow.js +16 -1
  149. package/dist/esm/elements/TableRow.js.map +1 -1
  150. package/dist/esm/elements/TextBox.js +55 -42
  151. package/dist/esm/elements/TextBox.js.map +1 -1
  152. package/dist/esm/formatting/AbstractNumbering.js +1 -1
  153. package/dist/esm/formatting/AbstractNumbering.js.map +1 -1
  154. package/dist/esm/formatting/NumberingLevel.js +8 -6
  155. package/dist/esm/formatting/NumberingLevel.js.map +1 -1
  156. package/dist/esm/formatting/NumberingManager.js +16 -1
  157. package/dist/esm/formatting/NumberingManager.js.map +1 -1
  158. package/dist/esm/formatting/Style.js +14 -0
  159. package/dist/esm/formatting/Style.js.map +1 -1
  160. package/dist/esm/formatting/StylesManager.js +14 -2
  161. package/dist/esm/formatting/StylesManager.js.map +1 -1
  162. package/dist/esm/helpers/CleanupHelper.js +103 -26
  163. package/dist/esm/helpers/CleanupHelper.js.map +1 -1
  164. package/dist/esm/images/ImageOptimizer.js +52 -4
  165. package/dist/esm/images/ImageOptimizer.js.map +1 -1
  166. package/dist/esm/index.js +1 -0
  167. package/dist/esm/index.js.map +1 -1
  168. package/dist/esm/internal.js +1 -1
  169. package/dist/esm/internal.js.map +1 -1
  170. package/dist/esm/processors/CompatibilityUpgrader.js +7 -1
  171. package/dist/esm/processors/CompatibilityUpgrader.js.map +1 -1
  172. package/dist/esm/processors/InMemoryRevisionAcceptor.js +134 -13
  173. package/dist/esm/processors/InMemoryRevisionAcceptor.js.map +1 -1
  174. package/dist/esm/processors/RevisionWalker.js +58 -8
  175. package/dist/esm/processors/RevisionWalker.js.map +1 -1
  176. package/dist/esm/processors/SelectiveRevisionAcceptor.js +116 -17
  177. package/dist/esm/processors/SelectiveRevisionAcceptor.js.map +1 -1
  178. package/dist/esm/processors/cnfStyleDecoder.js +7 -7
  179. package/dist/esm/processors/cnfStyleDecoder.js.map +1 -1
  180. package/dist/esm/processors/stripTrackedChanges.js +16 -5
  181. package/dist/esm/processors/stripTrackedChanges.js.map +1 -1
  182. package/dist/esm/utils/deepClone.js +24 -11
  183. package/dist/esm/utils/deepClone.js.map +1 -1
  184. package/dist/esm/utils/units.js +1 -1
  185. package/dist/esm/utils/units.js.map +1 -1
  186. package/dist/esm/utils/validation.js +11 -23
  187. package/dist/esm/utils/validation.js.map +1 -1
  188. package/dist/esm/utils/xmlSanitization.js +131 -7
  189. package/dist/esm/utils/xmlSanitization.js.map +1 -1
  190. package/dist/esm/validation/RevisionAutoFixer.js +69 -55
  191. package/dist/esm/validation/RevisionAutoFixer.js.map +1 -1
  192. package/dist/esm/validation/RevisionValidator.js +24 -20
  193. package/dist/esm/validation/RevisionValidator.js.map +1 -1
  194. package/dist/esm/xml/XMLBuilder.js +52 -20
  195. package/dist/esm/xml/XMLBuilder.js.map +1 -1
  196. package/dist/esm/xml/XMLParser.js +55 -13
  197. package/dist/esm/xml/XMLParser.js.map +1 -1
  198. package/dist/esm/zip/ZipHandler.js +37 -13
  199. package/dist/esm/zip/ZipHandler.js.map +1 -1
  200. package/dist/esm/zip/ZipReader.js +53 -6
  201. package/dist/esm/zip/ZipReader.js.map +1 -1
  202. package/dist/esm/zip/errors.js +7 -0
  203. package/dist/esm/zip/errors.js.map +1 -1
  204. package/dist/esm/zip/types.js +4 -0
  205. package/dist/esm/zip/types.js.map +1 -1
  206. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  207. package/dist/formatting/AbstractNumbering.js +1 -1
  208. package/dist/formatting/AbstractNumbering.js.map +1 -1
  209. package/dist/formatting/NumberingLevel.d.ts +1 -1
  210. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  211. package/dist/formatting/NumberingLevel.js +8 -6
  212. package/dist/formatting/NumberingLevel.js.map +1 -1
  213. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  214. package/dist/formatting/NumberingManager.js +16 -1
  215. package/dist/formatting/NumberingManager.js.map +1 -1
  216. package/dist/formatting/Style.d.ts.map +1 -1
  217. package/dist/formatting/Style.js +14 -0
  218. package/dist/formatting/Style.js.map +1 -1
  219. package/dist/formatting/StylesManager.d.ts +2 -0
  220. package/dist/formatting/StylesManager.d.ts.map +1 -1
  221. package/dist/formatting/StylesManager.js +14 -2
  222. package/dist/formatting/StylesManager.js.map +1 -1
  223. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  224. package/dist/helpers/CleanupHelper.js +103 -26
  225. package/dist/helpers/CleanupHelper.js.map +1 -1
  226. package/dist/images/ImageOptimizer.d.ts.map +1 -1
  227. package/dist/images/ImageOptimizer.js +52 -4
  228. package/dist/images/ImageOptimizer.js.map +1 -1
  229. package/dist/index.d.ts +3 -1
  230. package/dist/index.d.ts.map +1 -1
  231. package/dist/index.js +4 -2
  232. package/dist/index.js.map +1 -1
  233. package/dist/internal.d.ts +1 -1
  234. package/dist/internal.d.ts.map +1 -1
  235. package/dist/internal.js +2 -1
  236. package/dist/internal.js.map +1 -1
  237. package/dist/processors/CompatibilityUpgrader.d.ts.map +1 -1
  238. package/dist/processors/CompatibilityUpgrader.js +7 -1
  239. package/dist/processors/CompatibilityUpgrader.js.map +1 -1
  240. package/dist/processors/InMemoryRevisionAcceptor.d.ts +1 -0
  241. package/dist/processors/InMemoryRevisionAcceptor.d.ts.map +1 -1
  242. package/dist/processors/InMemoryRevisionAcceptor.js +135 -13
  243. package/dist/processors/InMemoryRevisionAcceptor.js.map +1 -1
  244. package/dist/processors/RevisionWalker.d.ts +2 -0
  245. package/dist/processors/RevisionWalker.d.ts.map +1 -1
  246. package/dist/processors/RevisionWalker.js +58 -8
  247. package/dist/processors/RevisionWalker.js.map +1 -1
  248. package/dist/processors/SelectiveRevisionAcceptor.d.ts.map +1 -1
  249. package/dist/processors/SelectiveRevisionAcceptor.js +116 -17
  250. package/dist/processors/SelectiveRevisionAcceptor.js.map +1 -1
  251. package/dist/processors/cnfStyleDecoder.d.ts.map +1 -1
  252. package/dist/processors/cnfStyleDecoder.js +7 -7
  253. package/dist/processors/cnfStyleDecoder.js.map +1 -1
  254. package/dist/processors/stripTrackedChanges.d.ts.map +1 -1
  255. package/dist/processors/stripTrackedChanges.js +16 -5
  256. package/dist/processors/stripTrackedChanges.js.map +1 -1
  257. package/dist/utils/deepClone.d.ts.map +1 -1
  258. package/dist/utils/deepClone.js +24 -11
  259. package/dist/utils/deepClone.js.map +1 -1
  260. package/dist/utils/units.d.ts.map +1 -1
  261. package/dist/utils/units.js +1 -1
  262. package/dist/utils/units.js.map +1 -1
  263. package/dist/utils/validation.d.ts.map +1 -1
  264. package/dist/utils/validation.js +11 -23
  265. package/dist/utils/validation.js.map +1 -1
  266. package/dist/utils/xmlSanitization.d.ts +2 -0
  267. package/dist/utils/xmlSanitization.d.ts.map +1 -1
  268. package/dist/utils/xmlSanitization.js +133 -7
  269. package/dist/utils/xmlSanitization.js.map +1 -1
  270. package/dist/validation/RevisionAutoFixer.d.ts +6 -2
  271. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  272. package/dist/validation/RevisionAutoFixer.js +68 -54
  273. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  274. package/dist/validation/RevisionValidator.d.ts +2 -0
  275. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  276. package/dist/validation/RevisionValidator.js +26 -21
  277. package/dist/validation/RevisionValidator.js.map +1 -1
  278. package/dist/xml/XMLBuilder.d.ts +1 -0
  279. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  280. package/dist/xml/XMLBuilder.js +52 -20
  281. package/dist/xml/XMLBuilder.js.map +1 -1
  282. package/dist/xml/XMLParser.d.ts +1 -0
  283. package/dist/xml/XMLParser.d.ts.map +1 -1
  284. package/dist/xml/XMLParser.js +55 -13
  285. package/dist/xml/XMLParser.js.map +1 -1
  286. package/dist/zip/ZipHandler.d.ts.map +1 -1
  287. package/dist/zip/ZipHandler.js +37 -13
  288. package/dist/zip/ZipHandler.js.map +1 -1
  289. package/dist/zip/ZipReader.d.ts.map +1 -1
  290. package/dist/zip/ZipReader.js +52 -5
  291. package/dist/zip/ZipReader.js.map +1 -1
  292. package/dist/zip/errors.d.ts +3 -0
  293. package/dist/zip/errors.d.ts.map +1 -1
  294. package/dist/zip/errors.js +9 -1
  295. package/dist/zip/errors.js.map +1 -1
  296. package/dist/zip/types.d.ts +4 -0
  297. package/dist/zip/types.d.ts.map +1 -1
  298. package/dist/zip/types.js +4 -0
  299. package/dist/zip/types.js.map +1 -1
  300. package/package.json +2 -2
  301. package/src/core/Document.ts +1159 -240
  302. package/src/core/DocumentGenerator.ts +106 -26
  303. package/src/core/DocumentParser.ts +692 -120
  304. package/src/core/RelationshipManager.ts +446 -442
  305. package/src/elements/Comment.ts +123 -18
  306. package/src/elements/CommentManager.ts +121 -22
  307. package/src/elements/EndnoteManager.ts +4 -1
  308. package/src/elements/Field.ts +134 -43
  309. package/src/elements/FieldHelpers.ts +20 -4
  310. package/src/elements/Footer.ts +62 -67
  311. package/src/elements/FootnoteManager.ts +4 -1
  312. package/src/elements/Header.ts +62 -67
  313. package/src/elements/HeaderFooterManager.ts +56 -8
  314. package/src/elements/Hyperlink.ts +150 -55
  315. package/src/elements/Image.ts +51 -7
  316. package/src/elements/ImageManager.ts +24 -0
  317. package/src/elements/Paragraph.ts +161 -40
  318. package/src/elements/Revision.ts +446 -47
  319. package/src/elements/Run.ts +44 -6
  320. package/src/elements/Section.ts +25 -9
  321. package/src/elements/Shape.ts +52 -44
  322. package/src/elements/StructuredDocumentTag.ts +83 -8
  323. package/src/elements/Table.ts +151 -50
  324. package/src/elements/TableCell.ts +57 -17
  325. package/src/elements/TableOfContents.ts +10 -9
  326. package/src/elements/TableRow.ts +35 -3
  327. package/src/elements/TextBox.ts +58 -43
  328. package/src/formatting/AbstractNumbering.ts +4 -2
  329. package/src/formatting/NumberingLevel.ts +50 -12
  330. package/src/formatting/NumberingManager.ts +32 -3
  331. package/src/formatting/Style.ts +14 -0
  332. package/src/formatting/StylesManager.ts +29 -2
  333. package/src/helpers/CleanupHelper.ts +122 -30
  334. package/src/images/ImageOptimizer.ts +64 -5
  335. package/src/index.ts +7 -0
  336. package/src/internal.ts +1 -0
  337. package/src/processors/CompatibilityUpgrader.ts +15 -2
  338. package/src/processors/InMemoryRevisionAcceptor.ts +276 -17
  339. package/src/processors/RevisionWalker.ts +83 -7
  340. package/src/processors/SelectiveRevisionAcceptor.ts +164 -28
  341. package/src/processors/cnfStyleDecoder.ts +18 -12
  342. package/src/processors/stripTrackedChanges.ts +47 -10
  343. package/src/utils/deepClone.ts +48 -14
  344. package/src/utils/units.ts +3 -1
  345. package/src/utils/validation.ts +30 -32
  346. package/src/utils/xmlSanitization.ts +218 -22
  347. package/src/validation/RevisionAutoFixer.ts +109 -65
  348. package/src/validation/RevisionValidator.ts +48 -23
  349. package/src/xml/XMLBuilder.ts +82 -22
  350. package/src/xml/XMLParser.ts +87 -20
  351. package/src/zip/ZipHandler.ts +61 -21
  352. package/src/zip/ZipReader.ts +135 -13
  353. package/src/zip/errors.ts +15 -0
  354. package/src/zip/types.ts +44 -2
@@ -9,11 +9,13 @@
9
9
  * - 0x09 (tab), 0x0A (newline), 0x0D (carriage return)
10
10
  * - 0x20-0xD7FF, 0xE000-0xFFFD, 0x10000-0x10FFFF
11
11
  *
12
- * Invalid characters (control characters that must be removed):
12
+ * Invalid characters that must be removed:
13
13
  * - 0x00-0x08 (NULL through BACKSPACE)
14
14
  * - 0x0B-0x0C (VERTICAL TAB and FORM FEED)
15
15
  * - 0x0E-0x1F (SHIFT OUT through UNIT SEPARATOR)
16
16
  * - 0x7F (DELETE)
17
+ * - 0xFFFE-0xFFFF (Unicode noncharacters excluded by the Char production)
18
+ * - Unpaired surrogates (0xD800-0xDFFF outside a valid high/low pair)
17
19
  *
18
20
  * @module xmlSanitization
19
21
  */
@@ -21,26 +23,40 @@
21
23
  import { getGlobalLogger } from './logger.js';
22
24
 
23
25
  /**
24
- * Regular expression matching invalid XML 1.0 control characters.
25
- * Matches: 0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F
26
+ * Regular expression matching invalid XML 1.0 characters.
27
+ * Matches: 0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F, 0xFFFE, 0xFFFF
26
28
  * Does NOT match valid chars: 0x09 (tab), 0x0A (newline), 0x0D (CR)
27
29
  */
28
- const INVALID_XML_CHAR_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
30
+ const INVALID_XML_CHAR_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F￾￿]/g;
29
31
 
30
32
  /**
31
- * Removes invalid XML 1.0 control characters from text.
33
+ * Matches surrogate code units that do not form a valid high/low pair.
34
+ * XML 1.0 Char excludes 0xD800-0xDFFF entirely; only properly paired
35
+ * surrogates (which decode to a single 0x10000-0x10FFFF character) are
36
+ * legal, so lone halves must be stripped or they corrupt the part at
37
+ * UTF-8 encode time.
38
+ */
39
+ const UNPAIRED_SURROGATE_REGEX =
40
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
41
+
42
+ /**
43
+ * Removes invalid XML 1.0 characters from text.
32
44
  *
33
- * Per XML 1.0 spec, characters 0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F are invalid
34
- * and cannot appear in XML documents. This function removes them.
45
+ * Per XML 1.0 spec, characters 0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F,
46
+ * 0xFFFE, 0xFFFF, and unpaired surrogates are invalid and cannot appear
47
+ * in XML documents. This function removes them.
35
48
  *
36
49
  * Valid control characters are preserved:
37
50
  * - Tab (0x09)
38
51
  * - Line Feed / Newline (0x0A)
39
52
  * - Carriage Return (0x0D)
40
53
  *
54
+ * Properly paired surrogates (astral-plane characters such as emoji)
55
+ * are preserved.
56
+ *
41
57
  * @param text - Input text to sanitize
42
58
  * @param logWarning - If true, logs a warning when invalid chars are found (default: true)
43
- * @returns Sanitized text with invalid control characters removed
59
+ * @returns Sanitized text with invalid characters removed
44
60
  *
45
61
  * @example
46
62
  * ```typescript
@@ -54,31 +70,33 @@ const INVALID_XML_CHAR_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
54
70
  * ```
55
71
  */
56
72
  export function removeInvalidXmlChars(text: string, logWarning = true): string {
57
- // Reset regex lastIndex for global regex
73
+ // Reset regex lastIndex for global regexes
58
74
  INVALID_XML_CHAR_REGEX.lastIndex = 0;
75
+ UNPAIRED_SURROGATE_REGEX.lastIndex = 0;
59
76
 
60
- if (logWarning && INVALID_XML_CHAR_REGEX.test(text)) {
77
+ if (logWarning && (INVALID_XML_CHAR_REGEX.test(text) || UNPAIRED_SURROGATE_REGEX.test(text))) {
61
78
  // Reset regex lastIndex after test
62
79
  INVALID_XML_CHAR_REGEX.lastIndex = 0;
80
+ UNPAIRED_SURROGATE_REGEX.lastIndex = 0;
63
81
 
64
82
  const invalidChars = findInvalidXmlChars(text);
65
83
  const hexCodes = invalidChars
66
84
  .map((c) => `0x${c.toString(16).toUpperCase().padStart(2, '0')}`)
67
85
  .join(', ');
68
- getGlobalLogger().warn(
69
- `[XMLSanitization] Removing invalid XML control characters: ${hexCodes}`
70
- );
86
+ getGlobalLogger().warn(`[XMLSanitization] Removing invalid XML characters: ${hexCodes}`);
71
87
  }
72
88
 
73
89
  // Reset regex lastIndex before replace
74
90
  INVALID_XML_CHAR_REGEX.lastIndex = 0;
75
- return text.replace(INVALID_XML_CHAR_REGEX, '');
91
+ UNPAIRED_SURROGATE_REGEX.lastIndex = 0;
92
+ return text.replace(INVALID_XML_CHAR_REGEX, '').replace(UNPAIRED_SURROGATE_REGEX, '');
76
93
  }
77
94
 
78
95
  /**
79
- * Finds all invalid XML 1.0 control characters in text.
96
+ * Finds all invalid XML 1.0 characters in text.
80
97
  *
81
- * Returns an array of unique character codes that are invalid per XML 1.0 spec.
98
+ * Returns an array of unique character codes that are invalid per XML 1.0 spec,
99
+ * including 0xFFFE/0xFFFF noncharacters and unpaired surrogate code units.
82
100
  * This is useful for diagnostics and error reporting.
83
101
  *
84
102
  * @param text - Text to scan for invalid characters
@@ -100,12 +118,25 @@ export function findInvalidXmlChars(text: string): number[] {
100
118
  const code = text.charCodeAt(i);
101
119
 
102
120
  // Check if character is in invalid ranges
103
- if (
121
+ let isInvalid =
104
122
  (code >= 0x00 && code <= 0x08) || // NULL through BACKSPACE
105
123
  (code >= 0x0b && code <= 0x0c) || // VERTICAL TAB and FORM FEED
106
124
  (code >= 0x0e && code <= 0x1f) || // SHIFT OUT through UNIT SEPARATOR
107
- code === 0x7f // DELETE
108
- ) {
125
+ code === 0x7f || // DELETE
126
+ code === 0xfffe || // Noncharacter excluded by XML 1.0 Char
127
+ code === 0xffff; // Noncharacter excluded by XML 1.0 Char
128
+
129
+ if (!isInvalid && code >= 0xd800 && code <= 0xdbff) {
130
+ // High surrogate is only valid when followed by a low surrogate
131
+ const next = text.charCodeAt(i + 1); // NaN at end of string fails the range check
132
+ isInvalid = !(next >= 0xdc00 && next <= 0xdfff);
133
+ } else if (!isInvalid && code >= 0xdc00 && code <= 0xdfff) {
134
+ // Low surrogate is only valid when preceded by a high surrogate
135
+ const prev = text.charCodeAt(i - 1); // NaN at start of string fails the range check
136
+ isInvalid = !(prev >= 0xd800 && prev <= 0xdbff);
137
+ }
138
+
139
+ if (isInvalid) {
109
140
  // Only add unique codes
110
141
  if (!invalid.includes(code)) {
111
142
  invalid.push(code);
@@ -117,7 +148,7 @@ export function findInvalidXmlChars(text: string): number[] {
117
148
  }
118
149
 
119
150
  /**
120
- * Checks if text contains any invalid XML 1.0 control characters.
151
+ * Checks if text contains any invalid XML 1.0 characters.
121
152
  *
122
153
  * This is a fast check that returns true/false without identifying specific characters.
123
154
  * Use `findInvalidXmlChars()` if you need to know which characters are invalid.
@@ -133,9 +164,174 @@ export function findInvalidXmlChars(text: string): number[] {
133
164
  * ```
134
165
  */
135
166
  export function hasInvalidXmlChars(text: string): boolean {
136
- // Reset regex lastIndex for global regex
167
+ // Reset regex lastIndex for global regexes
137
168
  INVALID_XML_CHAR_REGEX.lastIndex = 0;
138
- return INVALID_XML_CHAR_REGEX.test(text);
169
+ UNPAIRED_SURROGATE_REGEX.lastIndex = 0;
170
+ return INVALID_XML_CHAR_REGEX.test(text) || UNPAIRED_SURROGATE_REGEX.test(text);
171
+ }
172
+
173
+ /**
174
+ * CT_RPr child element order per ECMA-376 Part 1 §17.3.2.28 (local names,
175
+ * `w:` namespace). Used to repair raw-preserved run-property blocks whose
176
+ * children are out of schema order — Word rejects, for example, a `w:b`
177
+ * that appears after `w:color`/`w:sz` and reports the document as corrupt.
178
+ */
179
+ const CT_RPR_CHILD_ORDER: readonly string[] = [
180
+ 'rStyle',
181
+ 'rFonts',
182
+ 'b',
183
+ 'bCs',
184
+ 'i',
185
+ 'iCs',
186
+ 'caps',
187
+ 'smallCaps',
188
+ 'strike',
189
+ 'dstrike',
190
+ 'outline',
191
+ 'shadow',
192
+ 'emboss',
193
+ 'imprint',
194
+ 'noProof',
195
+ 'snapToGrid',
196
+ 'vanish',
197
+ 'webHidden',
198
+ 'color',
199
+ 'spacing',
200
+ 'w',
201
+ 'kern',
202
+ 'position',
203
+ 'sz',
204
+ 'szCs',
205
+ 'highlight',
206
+ 'u',
207
+ 'effect',
208
+ 'bdr',
209
+ 'shd',
210
+ 'fitText',
211
+ 'vertAlign',
212
+ 'rtl',
213
+ 'cs',
214
+ 'em',
215
+ 'lang',
216
+ 'eastAsianLayout',
217
+ 'specVanish',
218
+ 'oMath',
219
+ ];
220
+
221
+ /**
222
+ * Splits the inner content of an element into its top-level child elements,
223
+ * respecting nesting so that a child's descendants are kept with it. Returns
224
+ * the raw XML fragment for each direct child in document order. Non-element
225
+ * text between children (whitespace) is dropped, which is safe for the
226
+ * element-only content models this helper targets (e.g. `w:rPr`).
227
+ */
228
+ function splitTopLevelElements(inner: string): string[] {
229
+ const children: string[] = [];
230
+ const tagRegex = /<([A-Za-z][\w:.-]*)([^>]*?)(\/?)>|<\/([A-Za-z][\w:.-]*)\s*>/g;
231
+ let depth = 0;
232
+ let start = -1;
233
+ let match: RegExpExecArray | null;
234
+ while ((match = tagRegex.exec(inner)) !== null) {
235
+ const isClosing = match[4] !== undefined;
236
+ const isSelfClosing = match[3] === '/';
237
+ if (isClosing) {
238
+ if (depth > 0) {
239
+ depth--;
240
+ if (depth === 0 && start !== -1) {
241
+ children.push(inner.slice(start, tagRegex.lastIndex));
242
+ start = -1;
243
+ }
244
+ }
245
+ continue;
246
+ }
247
+ if (isSelfClosing) {
248
+ if (depth === 0) {
249
+ children.push(inner.slice(match.index, tagRegex.lastIndex));
250
+ }
251
+ continue;
252
+ }
253
+ // Opening tag of a paired element
254
+ if (depth === 0) start = match.index;
255
+ depth++;
256
+ }
257
+ return children;
258
+ }
259
+
260
+ /**
261
+ * Reorders the direct children of every `<w:rPr>` block in a raw XML part to
262
+ * the ECMA-376 CT_RPr sequence. Unknown/unmodeled children retain their
263
+ * relative position at the end of the block. The transform is idempotent:
264
+ * already-ordered run properties are returned unchanged.
265
+ *
266
+ * Some producers (and older versions of this framework) appended boolean
267
+ * toggles such as `w:b`/`w:bCs` to the end of an existing `w:rPr`, leaving
268
+ * them after `w:color`/`w:sz`. Word treats the out-of-order child as an
269
+ * unexpected element and flags the whole document as corrupt. Numbering
270
+ * definitions are written back verbatim for fidelity, so this repair runs on
271
+ * the preserved part before it is saved.
272
+ *
273
+ * @param xml - Raw XML for a part containing `w:rPr` blocks (e.g. numbering.xml)
274
+ * @returns XML with every `w:rPr` child sequence normalized to schema order
275
+ */
276
+ export function reorderRunPropertyChildren(xml: string): string {
277
+ if (!xml || !xml.includes('<w:rPr')) return xml;
278
+
279
+ const orderIndex = (fragment: string): number => {
280
+ const nameMatch = /^<w:(\w+)/.exec(fragment);
281
+ const name = nameMatch?.[1];
282
+ if (!name) return CT_RPR_CHILD_ORDER.length;
283
+ const idx = CT_RPR_CHILD_ORDER.indexOf(name);
284
+ return idx === -1 ? CT_RPR_CHILD_ORDER.length : idx;
285
+ };
286
+
287
+ // Only match non-empty <w:rPr>...</w:rPr> blocks; self-closing <w:rPr/> has
288
+ // no children to reorder.
289
+ return xml.replace(/<w:rPr(\s[^>]*)?>([\s\S]*?)<\/w:rPr>/g, (full, attrs, inner) => {
290
+ if (!inner || !(inner as string).includes('<')) return full;
291
+ const children = splitTopLevelElements(inner);
292
+ if (children.length < 2) return full;
293
+
294
+ const ordered = children
295
+ .map((frag, i) => ({ frag, i, order: orderIndex(frag) }))
296
+ // Stable sort: ties (including unknown children) keep original order.
297
+ .sort((a, b) => a.order - b.order || a.i - b.i);
298
+
299
+ if (ordered.every((c, i) => c.i === i)) return full; // already in order
300
+
301
+ return `<w:rPr${attrs ?? ''}>${ordered.map((c) => c.frag).join('')}</w:rPr>`;
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Normalizes malformed floating-drawing relative-size elements in a raw anchor
307
+ * fragment. Per ECMA-376 (DrawingML wordprocessing 2010 extensions),
308
+ * `wp14:sizeRelH` must contain a `wp14:pctWidth` child and `wp14:sizeRelV` a
309
+ * `wp14:pctHeight` child. Some producers emit the percentage as bare text
310
+ * content (`<wp14:sizeRelH relativeFrom="margin">0</wp14:sizeRelH>`), which
311
+ * Word rejects as incomplete content. This wraps such text in the required
312
+ * child element. Already-valid nested forms are left untouched because the
313
+ * text-content pattern (`[^<]`) cannot match an element child.
314
+ *
315
+ * @param xml - Raw anchor-extras fragment captured during parsing
316
+ * @returns Fragment with sizeRelH/sizeRelV normalized to schema-valid form
317
+ */
318
+ export function normalizeAnchorSizeRel(xml: string): string {
319
+ if (!xml) return xml;
320
+ return xml
321
+ .replace(
322
+ /(<wp14:sizeRelH\b[^>]*>)([^<]+)(<\/wp14:sizeRelH>)/g,
323
+ (_m, open: string, text: string, close: string) =>
324
+ text.trim() === ''
325
+ ? `${open}${text}${close}`
326
+ : `${open}<wp14:pctWidth>${text.trim()}</wp14:pctWidth>${close}`
327
+ )
328
+ .replace(
329
+ /(<wp14:sizeRelV\b[^>]*>)([^<]+)(<\/wp14:sizeRelV>)/g,
330
+ (_m, open: string, text: string, close: string) =>
331
+ text.trim() === ''
332
+ ? `${open}${text}${close}`
333
+ : `${open}<wp14:pctHeight>${text.trim()}</wp14:pctHeight>${close}`
334
+ );
139
335
  }
140
336
 
141
337
  /**
@@ -17,7 +17,11 @@ import {
17
17
  FixAction,
18
18
  ValidationIssue,
19
19
  } from './ValidationRules.js';
20
- import { RevisionValidator } from './RevisionValidator.js';
20
+ import {
21
+ CONTENTLESS_REVISION_TYPES,
22
+ RevisionValidator,
23
+ revisionHasContent,
24
+ } from './RevisionValidator.js';
21
25
 
22
26
  /**
23
27
  * Automatically fixes revision validation issues.
@@ -81,9 +85,16 @@ export class RevisionAutoFixer {
81
85
  actions.push(...this.fixMissingAuthors(revisions, defaultAuthor, options?.dryRun));
82
86
  }
83
87
 
84
- // Fix orphaned move markers (REV003, REV004)
88
+ // Fix orphaned move markers (REV003, REV004) — one pairing pass
89
+ // produces both rules, so gate each side individually to honor
90
+ // onlyRules/skipRules without mutating revisions the caller excluded.
85
91
  if (shouldProcess('REV003') || shouldProcess('REV004')) {
86
- actions.push(...this.fixOrphanedMoveMarkers(revisionManager, revisions, options?.dryRun));
92
+ actions.push(
93
+ ...this.fixOrphanedMoveMarkers(revisionManager, revisions, options?.dryRun, doc, {
94
+ moveFrom: shouldProcess('REV003'),
95
+ moveTo: shouldProcess('REV004'),
96
+ })
97
+ );
87
98
  }
88
99
 
89
100
  // Fix missing dates (REV101)
@@ -98,7 +109,7 @@ export class RevisionAutoFixer {
98
109
 
99
110
  // Fix empty revisions (REV103)
100
111
  if (shouldProcess('REV103')) {
101
- actions.push(...this.fixEmptyRevisions(revisionManager, revisions, options?.dryRun));
112
+ actions.push(...this.fixEmptyRevisions(revisionManager, revisions, options?.dryRun, doc));
102
113
  }
103
114
 
104
115
  // Fix non-sequential IDs (REV104)
@@ -297,20 +308,51 @@ export class RevisionAutoFixer {
297
308
  return actions;
298
309
  }
299
310
 
311
+ /**
312
+ * Removes a revision from the paragraph content that owns it.
313
+ *
314
+ * RevisionManager.removeById() only deregisters the revision from the
315
+ * manager registry, but serialization reads Paragraph.content directly —
316
+ * without this step the removed markup would still be written on save.
317
+ *
318
+ * @param doc - Document whose paragraphs are searched
319
+ * @param revision - Revision instance to remove
320
+ * @returns True if the revision was found and removed from a paragraph
321
+ */
322
+ private static removeRevisionFromContent(doc: Document, revision: Revision): boolean {
323
+ for (const paragraph of doc.getAllParagraphs()) {
324
+ // replaceContent matches by identity and splices without tracking side effects
325
+ if (paragraph.replaceContent(revision, [])) {
326
+ return true;
327
+ }
328
+ }
329
+ return false;
330
+ }
331
+
300
332
  /**
301
333
  * Fix orphaned move markers by removing them.
302
334
  *
303
335
  * @param revisionManager - RevisionManager instance
304
336
  * @param revisions - Array of revisions
305
337
  * @param dryRun - If true, only report changes without applying
338
+ * @param doc - Document to remove the revision content from; without it
339
+ * only the manager registry is pruned and saved XML keeps the markup
340
+ * @param ruleFilter - Which sides of the pairing to fix; REV003 (moveFrom)
341
+ * and REV004 (moveTo) are separate rules, so callers honoring
342
+ * onlyRules/skipRules must be able to fix one without the other.
343
+ * Defaults to both.
306
344
  * @returns Array of fix actions
307
345
  */
308
346
  static fixOrphanedMoveMarkers(
309
347
  revisionManager: { removeById(id: number): boolean },
310
348
  revisions: Revision[],
311
- dryRun?: boolean
349
+ dryRun?: boolean,
350
+ doc?: Document,
351
+ ruleFilter?: { moveFrom?: boolean; moveTo?: boolean }
312
352
  ): FixAction[] {
313
353
  const actions: FixAction[] = [];
354
+ const fixMoveFrom = ruleFilter?.moveFrom ?? true;
355
+ const fixMoveTo = ruleFilter?.moveTo ?? true;
314
356
 
315
357
  const moveFromIds = new Map<string, Revision>();
316
358
  const moveToIds = new Map<string, Revision>();
@@ -326,52 +368,62 @@ export class RevisionAutoFixer {
326
368
  }
327
369
  }
328
370
 
329
- // Remove orphaned moveFrom
330
- for (const [moveId, rev] of moveFromIds) {
331
- if (!moveToIds.has(moveId)) {
332
- const issue: ValidationIssue = {
333
- code: REVISION_RULES.ORPHANED_MOVE_FROM.code,
334
- severity: 'error',
335
- message: `Orphaned moveFrom with moveId="${moveId}"`,
336
- location: { revisionId: rev.getId() },
337
- autoFixable: true,
338
- };
339
-
340
- actions.push({
341
- issue,
342
- action: `Removed orphaned moveFrom (ID: ${rev.getId()}, moveId: ${moveId})`,
343
- before: { type: 'moveFrom', moveId },
344
- after: null,
345
- success: true,
346
- });
347
-
348
- if (!dryRun) {
349
- revisionManager.removeById(rev.getId());
371
+ // Remove orphaned moveFrom (REV003)
372
+ if (fixMoveFrom) {
373
+ for (const [moveId, rev] of moveFromIds) {
374
+ if (!moveToIds.has(moveId)) {
375
+ const issue: ValidationIssue = {
376
+ code: REVISION_RULES.ORPHANED_MOVE_FROM.code,
377
+ severity: 'error',
378
+ message: `Orphaned moveFrom with moveId="${moveId}"`,
379
+ location: { revisionId: rev.getId() },
380
+ autoFixable: true,
381
+ };
382
+
383
+ actions.push({
384
+ issue,
385
+ action: `Removed orphaned moveFrom (ID: ${rev.getId()}, moveId: ${moveId})`,
386
+ before: { type: 'moveFrom', moveId },
387
+ after: null,
388
+ success: true,
389
+ });
390
+
391
+ if (!dryRun) {
392
+ revisionManager.removeById(rev.getId());
393
+ if (doc) {
394
+ this.removeRevisionFromContent(doc, rev);
395
+ }
396
+ }
350
397
  }
351
398
  }
352
399
  }
353
400
 
354
- // Remove orphaned moveTo
355
- for (const [moveId, rev] of moveToIds) {
356
- if (!moveFromIds.has(moveId)) {
357
- const issue: ValidationIssue = {
358
- code: REVISION_RULES.ORPHANED_MOVE_TO.code,
359
- severity: 'error',
360
- message: `Orphaned moveTo with moveId="${moveId}"`,
361
- location: { revisionId: rev.getId() },
362
- autoFixable: true,
363
- };
364
-
365
- actions.push({
366
- issue,
367
- action: `Removed orphaned moveTo (ID: ${rev.getId()}, moveId: ${moveId})`,
368
- before: { type: 'moveTo', moveId },
369
- after: null,
370
- success: true,
371
- });
372
-
373
- if (!dryRun) {
374
- revisionManager.removeById(rev.getId());
401
+ // Remove orphaned moveTo (REV004)
402
+ if (fixMoveTo) {
403
+ for (const [moveId, rev] of moveToIds) {
404
+ if (!moveFromIds.has(moveId)) {
405
+ const issue: ValidationIssue = {
406
+ code: REVISION_RULES.ORPHANED_MOVE_TO.code,
407
+ severity: 'error',
408
+ message: `Orphaned moveTo with moveId="${moveId}"`,
409
+ location: { revisionId: rev.getId() },
410
+ autoFixable: true,
411
+ };
412
+
413
+ actions.push({
414
+ issue,
415
+ action: `Removed orphaned moveTo (ID: ${rev.getId()}, moveId: ${moveId})`,
416
+ before: { type: 'moveTo', moveId },
417
+ after: null,
418
+ success: true,
419
+ });
420
+
421
+ if (!dryRun) {
422
+ revisionManager.removeById(rev.getId());
423
+ if (doc) {
424
+ this.removeRevisionFromContent(doc, rev);
425
+ }
426
+ }
375
427
  }
376
428
  }
377
429
  }
@@ -385,38 +437,27 @@ export class RevisionAutoFixer {
385
437
  * @param revisionManager - RevisionManager instance
386
438
  * @param revisions - Array of revisions
387
439
  * @param dryRun - If true, only report changes without applying
440
+ * @param doc - Document to remove the revision content from; without it
441
+ * only the manager registry is pruned and saved XML keeps the markup
388
442
  * @returns Array of fix actions
389
443
  */
390
444
  static fixEmptyRevisions(
391
445
  revisionManager: RevisionManager,
392
446
  revisions: Revision[],
393
- dryRun?: boolean
447
+ dryRun?: boolean,
448
+ doc?: Document
394
449
  ): FixAction[] {
395
450
  const actions: FixAction[] = [];
396
451
 
397
- const propertyChangeTypes = [
398
- 'runPropertiesChange',
399
- 'paragraphPropertiesChange',
400
- 'tablePropertiesChange',
401
- 'tableExceptionPropertiesChange',
402
- 'tableRowPropertiesChange',
403
- 'tableCellPropertiesChange',
404
- 'sectionPropertiesChange',
405
- 'numberingChange',
406
- ];
407
-
408
452
  for (const rev of revisions) {
409
453
  const type = rev.getType();
410
454
 
411
- // Skip property changes
412
- if (propertyChangeTypes.includes(type)) {
455
+ // Property changes and cell markers are contentless by design
456
+ if (CONTENTLESS_REVISION_TYPES.has(type)) {
413
457
  continue;
414
458
  }
415
459
 
416
- const runs = rev.getRuns();
417
- const hasContent = runs.length > 0 && runs.some((r) => r.getText().length > 0);
418
-
419
- if (!hasContent) {
460
+ if (!revisionHasContent(rev)) {
420
461
  const issue: ValidationIssue = {
421
462
  code: REVISION_RULES.EMPTY_REVISION.code,
422
463
  severity: 'warning',
@@ -435,6 +476,9 @@ export class RevisionAutoFixer {
435
476
 
436
477
  if (!dryRun) {
437
478
  revisionManager.removeById(rev.getId());
479
+ if (doc) {
480
+ this.removeRevisionFromContent(doc, rev);
481
+ }
438
482
  }
439
483
  }
440
484
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { Document } from '../core/Document.js';
11
11
  import type { Revision } from '../elements/Revision.js';
12
+ import { isHyperlinkContent, isImageRunContent } from '../elements/RevisionContent.js';
12
13
  import {
13
14
  REVISION_RULES,
14
15
  ValidationIssue,
@@ -18,6 +19,44 @@ import {
18
19
  } from './ValidationRules.js';
19
20
  import { ValidationRuleRegistry } from './ValidationRuleRegistry.js';
20
21
 
22
+ /**
23
+ * Revision types that are structurally contentless per ECMA-376: property
24
+ * change markers carry before/after property snapshots rather than runs, and
25
+ * table cell markers (w:cellIns/w:cellDel/w:cellMerge) annotate the cell
26
+ * itself. The REV103 empty-content rule must never flag these.
27
+ *
28
+ * Shared with RevisionAutoFixer so the validator and fixer cannot drift
29
+ * apart on which revision types are allowed to be empty.
30
+ */
31
+ export const CONTENTLESS_REVISION_TYPES: ReadonlySet<string> = new Set([
32
+ 'runPropertiesChange',
33
+ 'paragraphPropertiesChange',
34
+ 'tablePropertiesChange',
35
+ 'tableExceptionPropertiesChange',
36
+ 'tableRowPropertiesChange',
37
+ 'tableCellPropertiesChange',
38
+ 'sectionPropertiesChange',
39
+ 'numberingChange',
40
+ 'tableCellInsert',
41
+ 'tableCellDelete',
42
+ 'tableCellMerge',
43
+ ]);
44
+
45
+ /**
46
+ * True when a revision carries real content. Per ECMA-376, w:ins/w:del may
47
+ * contain w:hyperlink and image runs (w:r with w:drawing) in addition to
48
+ * text runs, so a runs-only text check would falsely flag hyperlink-only
49
+ * and image-only tracked changes as empty. Shared with RevisionAutoFixer.
50
+ */
51
+ export function revisionHasContent(rev: Revision): boolean {
52
+ // Covers text runs and hyperlink display text
53
+ if (rev.getText().length > 0) {
54
+ return true;
55
+ }
56
+ // Hyperlinks count even without display text; ImageRuns have no text at all
57
+ return rev.getContent().some((item) => isHyperlinkContent(item) || isImageRunContent(item));
58
+ }
59
+
21
60
  /**
22
61
  * Validates document revisions for ECMA-376 compliance.
23
62
  *
@@ -67,15 +106,15 @@ export class RevisionValidator {
67
106
  if (!skipRules.has('REV001')) {
68
107
  allIssues.push(...this.validateRevisionIds(revisions));
69
108
  }
70
- if (!skipRules.has('REV003') && !skipRules.has('REV004')) {
71
- allIssues.push(...this.validateMovePairs(revisions));
72
- }
109
+ // validateMovePairs emits both REV003 and REV004 from one pairing pass;
110
+ // filter per issue code so skipping one rule does not silently disable
111
+ // the other (REV004 is error-severity and corruption-relevant).
112
+ allIssues.push(...this.validateMovePairs(revisions).filter((i) => !skipRules.has(i.code)));
73
113
  if (!skipRules.has('REV002')) {
74
114
  allIssues.push(...this.validateAuthors(revisions));
75
115
  }
76
- if (!skipRules.has('REV101') && !skipRules.has('REV102')) {
77
- allIssues.push(...this.validateDates(revisions));
78
- }
116
+ // validateDates emits both REV101 and REV102 — same per-code filtering.
117
+ allIssues.push(...this.validateDates(revisions).filter((i) => !skipRules.has(i.code)));
79
118
  if (!skipRules.has('REV103')) {
80
119
  allIssues.push(...this.validateContent(revisions));
81
120
  }
@@ -302,29 +341,15 @@ export class RevisionValidator {
302
341
  static validateContent(revisions: Revision[]): ValidationIssue[] {
303
342
  const issues: ValidationIssue[] = [];
304
343
 
305
- const propertyChangeTypes = [
306
- 'runPropertiesChange',
307
- 'paragraphPropertiesChange',
308
- 'tablePropertiesChange',
309
- 'tableExceptionPropertiesChange',
310
- 'tableRowPropertiesChange',
311
- 'tableCellPropertiesChange',
312
- 'sectionPropertiesChange',
313
- 'numberingChange',
314
- ];
315
-
316
344
  for (const rev of revisions) {
317
345
  const type = rev.getType();
318
346
 
319
- // Property changes don't need text content
320
- if (propertyChangeTypes.includes(type)) {
347
+ // Property changes and cell markers are contentless by design
348
+ if (CONTENTLESS_REVISION_TYPES.has(type)) {
321
349
  continue;
322
350
  }
323
351
 
324
- const runs = rev.getRuns();
325
- const hasContent = runs.length > 0 && runs.some((r) => r.getText().length > 0);
326
-
327
- if (!hasContent) {
352
+ if (!revisionHasContent(rev)) {
328
353
  issues.push(
329
354
  createIssueFromRule(
330
355
  REVISION_RULES.EMPTY_REVISION,