@usejunior/docx-core 0.9.1 → 0.11.0

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 (335) hide show
  1. package/LICENSE +202 -21
  2. package/NOTICE +2 -0
  3. package/README.md +2 -2
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/atomizer.d.ts +28 -8
  6. package/dist/atomizer.d.ts.map +1 -1
  7. package/dist/atomizer.js +96 -25
  8. package/dist/atomizer.js.map +1 -1
  9. package/dist/baselines/atomizer/auxiliaryIdCollision.d.ts +99 -0
  10. package/dist/baselines/atomizer/auxiliaryIdCollision.d.ts.map +1 -0
  11. package/dist/baselines/atomizer/auxiliaryIdCollision.js +415 -0
  12. package/dist/baselines/atomizer/auxiliaryIdCollision.js.map +1 -0
  13. package/dist/baselines/atomizer/documentReconstructor.d.ts.map +1 -1
  14. package/dist/baselines/atomizer/documentReconstructor.js +333 -112
  15. package/dist/baselines/atomizer/documentReconstructor.js.map +1 -1
  16. package/dist/baselines/atomizer/formattingFidelity.d.ts +99 -0
  17. package/dist/baselines/atomizer/formattingFidelity.d.ts.map +1 -0
  18. package/dist/baselines/atomizer/formattingFidelity.js +449 -0
  19. package/dist/baselines/atomizer/formattingFidelity.js.map +1 -0
  20. package/dist/baselines/atomizer/inPlaceModifier-bookmarks.d.ts +37 -0
  21. package/dist/baselines/atomizer/inPlaceModifier-bookmarks.d.ts.map +1 -0
  22. package/dist/baselines/atomizer/inPlaceModifier-bookmarks.js +189 -0
  23. package/dist/baselines/atomizer/inPlaceModifier-bookmarks.js.map +1 -0
  24. package/dist/baselines/atomizer/inPlaceModifier-containers.d.ts +74 -0
  25. package/dist/baselines/atomizer/inPlaceModifier-containers.d.ts.map +1 -0
  26. package/dist/baselines/atomizer/inPlaceModifier-containers.js +171 -0
  27. package/dist/baselines/atomizer/inPlaceModifier-containers.js.map +1 -0
  28. package/dist/baselines/atomizer/inPlaceModifier-deletion.d.ts +88 -0
  29. package/dist/baselines/atomizer/inPlaceModifier-deletion.d.ts.map +1 -0
  30. package/dist/baselines/atomizer/inPlaceModifier-deletion.js +326 -0
  31. package/dist/baselines/atomizer/inPlaceModifier-deletion.js.map +1 -0
  32. package/dist/baselines/atomizer/inPlaceModifier-postprocess.d.ts +85 -0
  33. package/dist/baselines/atomizer/inPlaceModifier-postprocess.d.ts.map +1 -0
  34. package/dist/baselines/atomizer/inPlaceModifier-postprocess.js +402 -0
  35. package/dist/baselines/atomizer/inPlaceModifier-postprocess.js.map +1 -0
  36. package/dist/baselines/atomizer/inPlaceModifier-presplit.d.ts +39 -0
  37. package/dist/baselines/atomizer/inPlaceModifier-presplit.d.ts.map +1 -0
  38. package/dist/baselines/atomizer/inPlaceModifier-presplit.js +265 -0
  39. package/dist/baselines/atomizer/inPlaceModifier-presplit.js.map +1 -0
  40. package/dist/baselines/atomizer/inPlaceModifier-shared.d.ts +62 -0
  41. package/dist/baselines/atomizer/inPlaceModifier-shared.d.ts.map +1 -0
  42. package/dist/baselines/atomizer/inPlaceModifier-shared.js +139 -0
  43. package/dist/baselines/atomizer/inPlaceModifier-shared.js.map +1 -0
  44. package/dist/baselines/atomizer/inPlaceModifier-wrappers.d.ts +198 -0
  45. package/dist/baselines/atomizer/inPlaceModifier-wrappers.d.ts.map +1 -0
  46. package/dist/baselines/atomizer/inPlaceModifier-wrappers.js +475 -0
  47. package/dist/baselines/atomizer/inPlaceModifier-wrappers.js.map +1 -0
  48. package/dist/baselines/atomizer/inPlaceModifier.d.ts +6 -290
  49. package/dist/baselines/atomizer/inPlaceModifier.d.ts.map +1 -1
  50. package/dist/baselines/atomizer/inPlaceModifier.js +23 -1828
  51. package/dist/baselines/atomizer/inPlaceModifier.js.map +1 -1
  52. package/dist/baselines/atomizer/pipeline.d.ts +36 -2
  53. package/dist/baselines/atomizer/pipeline.d.ts.map +1 -1
  54. package/dist/baselines/atomizer/pipeline.js +216 -144
  55. package/dist/baselines/atomizer/pipeline.js.map +1 -1
  56. package/dist/baselines/atomizer/trackChangesAcceptorAst.d.ts.map +1 -1
  57. package/dist/baselines/atomizer/trackChangesAcceptorAst.js +199 -173
  58. package/dist/baselines/atomizer/trackChangesAcceptorAst.js.map +1 -1
  59. package/dist/baselines/wmlcomparer/DotnetCli.d.ts.map +1 -1
  60. package/dist/baselines/wmlcomparer/DotnetCli.js +7 -0
  61. package/dist/baselines/wmlcomparer/DotnetCli.js.map +1 -1
  62. package/dist/cli/compare-two.d.ts.map +1 -1
  63. package/dist/cli/compare-two.js +3 -1
  64. package/dist/cli/compare-two.js.map +1 -1
  65. package/dist/cli/conformance-adapter.d.ts +3 -0
  66. package/dist/cli/conformance-adapter.d.ts.map +1 -0
  67. package/dist/cli/conformance-adapter.js +93 -0
  68. package/dist/cli/conformance-adapter.js.map +1 -0
  69. package/dist/cli/index.d.ts.map +1 -1
  70. package/dist/cli/index.js +5 -1
  71. package/dist/cli/index.js.map +1 -1
  72. package/dist/compare-types.d.ts +197 -0
  73. package/dist/compare-types.d.ts.map +1 -0
  74. package/dist/compare-types.js +2 -0
  75. package/dist/compare-types.js.map +1 -0
  76. package/dist/core-types.d.ts +5 -1
  77. package/dist/core-types.d.ts.map +1 -1
  78. package/dist/core-types.js +5 -1
  79. package/dist/core-types.js.map +1 -1
  80. package/dist/footnotes.d.ts +8 -3
  81. package/dist/footnotes.d.ts.map +1 -1
  82. package/dist/footnotes.js +8 -3
  83. package/dist/footnotes.js.map +1 -1
  84. package/dist/generation/compile.d.ts +21 -0
  85. package/dist/generation/compile.d.ts.map +1 -0
  86. package/dist/generation/compile.js +46 -0
  87. package/dist/generation/compile.js.map +1 -0
  88. package/dist/generation/context.d.ts +42 -0
  89. package/dist/generation/context.d.ts.map +1 -0
  90. package/dist/generation/context.js +65 -0
  91. package/dist/generation/context.js.map +1 -0
  92. package/dist/generation/emit/comments-part.d.ts +36 -0
  93. package/dist/generation/emit/comments-part.d.ts.map +1 -0
  94. package/dist/generation/emit/comments-part.js +116 -0
  95. package/dist/generation/emit/comments-part.js.map +1 -0
  96. package/dist/generation/emit/document-part.d.ts +24 -0
  97. package/dist/generation/emit/document-part.d.ts.map +1 -0
  98. package/dist/generation/emit/document-part.js +60 -0
  99. package/dist/generation/emit/document-part.js.map +1 -0
  100. package/dist/generation/emit/emit-context.d.ts +26 -0
  101. package/dist/generation/emit/emit-context.d.ts.map +1 -0
  102. package/dist/generation/emit/emit-context.js +19 -0
  103. package/dist/generation/emit/emit-context.js.map +1 -0
  104. package/dist/generation/emit/header-footer-part.d.ts +23 -0
  105. package/dist/generation/emit/header-footer-part.d.ts.map +1 -0
  106. package/dist/generation/emit/header-footer-part.js +57 -0
  107. package/dist/generation/emit/header-footer-part.js.map +1 -0
  108. package/dist/generation/emit/numbering-part.d.ts +29 -0
  109. package/dist/generation/emit/numbering-part.d.ts.map +1 -0
  110. package/dist/generation/emit/numbering-part.js +100 -0
  111. package/dist/generation/emit/numbering-part.js.map +1 -0
  112. package/dist/generation/emit/package-parts.d.ts +24 -0
  113. package/dist/generation/emit/package-parts.d.ts.map +1 -0
  114. package/dist/generation/emit/package-parts.js +121 -0
  115. package/dist/generation/emit/package-parts.js.map +1 -0
  116. package/dist/generation/emit/paragraph.d.ts +24 -0
  117. package/dist/generation/emit/paragraph.d.ts.map +1 -0
  118. package/dist/generation/emit/paragraph.js +63 -0
  119. package/dist/generation/emit/paragraph.js.map +1 -0
  120. package/dist/generation/emit/properties.d.ts +34 -0
  121. package/dist/generation/emit/properties.d.ts.map +1 -0
  122. package/dist/generation/emit/properties.js +138 -0
  123. package/dist/generation/emit/properties.js.map +1 -0
  124. package/dist/generation/emit/run.d.ts +15 -0
  125. package/dist/generation/emit/run.d.ts.map +1 -0
  126. package/dist/generation/emit/run.js +71 -0
  127. package/dist/generation/emit/run.js.map +1 -0
  128. package/dist/generation/emit/section.d.ts +29 -0
  129. package/dist/generation/emit/section.d.ts.map +1 -0
  130. package/dist/generation/emit/section.js +117 -0
  131. package/dist/generation/emit/section.js.map +1 -0
  132. package/dist/generation/emit/settings-part.d.ts +13 -0
  133. package/dist/generation/emit/settings-part.d.ts.map +1 -0
  134. package/dist/generation/emit/settings-part.js +24 -0
  135. package/dist/generation/emit/settings-part.js.map +1 -0
  136. package/dist/generation/emit/styles-part.d.ts +16 -0
  137. package/dist/generation/emit/styles-part.d.ts.map +1 -0
  138. package/dist/generation/emit/styles-part.js +80 -0
  139. package/dist/generation/emit/styles-part.js.map +1 -0
  140. package/dist/generation/emit/table.d.ts +26 -0
  141. package/dist/generation/emit/table.d.ts.map +1 -0
  142. package/dist/generation/emit/table.js +196 -0
  143. package/dist/generation/emit/table.js.map +1 -0
  144. package/dist/generation/errors.d.ts +22 -0
  145. package/dist/generation/errors.d.ts.map +1 -0
  146. package/dist/generation/errors.js +29 -0
  147. package/dist/generation/errors.js.map +1 -0
  148. package/dist/generation/index.d.ts +13 -0
  149. package/dist/generation/index.d.ts.map +1 -0
  150. package/dist/generation/index.js +12 -0
  151. package/dist/generation/index.js.map +1 -0
  152. package/dist/generation/ordering.d.ts +46 -0
  153. package/dist/generation/ordering.d.ts.map +1 -0
  154. package/dist/generation/ordering.js +119 -0
  155. package/dist/generation/ordering.js.map +1 -0
  156. package/dist/generation/recipes.d.ts +47 -0
  157. package/dist/generation/recipes.d.ts.map +1 -0
  158. package/dist/generation/recipes.js +84 -0
  159. package/dist/generation/recipes.js.map +1 -0
  160. package/dist/generation/structural-checks.d.ts +24 -0
  161. package/dist/generation/structural-checks.d.ts.map +1 -0
  162. package/dist/generation/structural-checks.js +318 -0
  163. package/dist/generation/structural-checks.js.map +1 -0
  164. package/dist/generation/types.d.ts +217 -0
  165. package/dist/generation/types.d.ts.map +1 -0
  166. package/dist/generation/types.js +16 -0
  167. package/dist/generation/types.js.map +1 -0
  168. package/dist/generation/validate-spec.d.ts +27 -0
  169. package/dist/generation/validate-spec.d.ts.map +1 -0
  170. package/dist/generation/validate-spec.js +307 -0
  171. package/dist/generation/validate-spec.js.map +1 -0
  172. package/dist/index.d.ts +9 -150
  173. package/dist/index.d.ts.map +1 -1
  174. package/dist/index.js +14 -0
  175. package/dist/index.js.map +1 -1
  176. package/dist/integration/generation-probes.d.ts +15 -0
  177. package/dist/integration/generation-probes.d.ts.map +1 -0
  178. package/dist/integration/generation-probes.js +84 -0
  179. package/dist/integration/generation-probes.js.map +1 -0
  180. package/dist/integration/libreoffice-oracle.d.ts +49 -0
  181. package/dist/integration/libreoffice-oracle.d.ts.map +1 -0
  182. package/dist/integration/libreoffice-oracle.js +290 -0
  183. package/dist/integration/libreoffice-oracle.js.map +1 -0
  184. package/dist/integration/synthetic-docx-fixture.d.ts +72 -0
  185. package/dist/integration/synthetic-docx-fixture.d.ts.map +1 -1
  186. package/dist/integration/synthetic-docx-fixture.js +131 -4
  187. package/dist/integration/synthetic-docx-fixture.js.map +1 -1
  188. package/dist/primitives/accept_changes.d.ts +4 -3
  189. package/dist/primitives/accept_changes.d.ts.map +1 -1
  190. package/dist/primitives/accept_changes.js +163 -77
  191. package/dist/primitives/accept_changes.js.map +1 -1
  192. package/dist/primitives/comments.d.ts +12 -3
  193. package/dist/primitives/comments.d.ts.map +1 -1
  194. package/dist/primitives/comments.js +374 -97
  195. package/dist/primitives/comments.js.map +1 -1
  196. package/dist/primitives/content_fingerprint.d.ts +29 -0
  197. package/dist/primitives/content_fingerprint.d.ts.map +1 -0
  198. package/dist/primitives/content_fingerprint.js +63 -0
  199. package/dist/primitives/content_fingerprint.js.map +1 -0
  200. package/dist/primitives/document.d.ts +94 -15
  201. package/dist/primitives/document.d.ts.map +1 -1
  202. package/dist/primitives/document.js +373 -36
  203. package/dist/primitives/document.js.map +1 -1
  204. package/dist/primitives/document_view-comments.d.ts +18 -0
  205. package/dist/primitives/document_view-comments.d.ts.map +1 -0
  206. package/dist/primitives/document_view-comments.js +160 -0
  207. package/dist/primitives/document_view-comments.js.map +1 -0
  208. package/dist/primitives/document_view-headings.d.ts +45 -0
  209. package/dist/primitives/document_view-headings.d.ts.map +1 -0
  210. package/dist/primitives/document_view-headings.js +247 -0
  211. package/dist/primitives/document_view-headings.js.map +1 -0
  212. package/dist/primitives/document_view-styles.d.ts +11 -0
  213. package/dist/primitives/document_view-styles.d.ts.map +1 -0
  214. package/dist/primitives/document_view-styles.js +104 -0
  215. package/dist/primitives/document_view-styles.js.map +1 -0
  216. package/dist/primitives/document_view-toon.d.ts +37 -0
  217. package/dist/primitives/document_view-toon.d.ts.map +1 -0
  218. package/dist/primitives/document_view-toon.js +199 -0
  219. package/dist/primitives/document_view-toon.js.map +1 -0
  220. package/dist/primitives/document_view-types.d.ts +152 -0
  221. package/dist/primitives/document_view-types.d.ts.map +1 -0
  222. package/dist/primitives/document_view-types.js +2 -0
  223. package/dist/primitives/document_view-types.js.map +1 -0
  224. package/dist/primitives/document_view.d.ts +8 -106
  225. package/dist/primitives/document_view.d.ts.map +1 -1
  226. package/dist/primitives/document_view.js +153 -312
  227. package/dist/primitives/document_view.js.map +1 -1
  228. package/dist/primitives/dom-helpers.d.ts +9 -0
  229. package/dist/primitives/dom-helpers.d.ts.map +1 -1
  230. package/dist/primitives/dom-helpers.js +10 -1
  231. package/dist/primitives/dom-helpers.js.map +1 -1
  232. package/dist/primitives/footnotes.d.ts +4 -3
  233. package/dist/primitives/footnotes.d.ts.map +1 -1
  234. package/dist/primitives/footnotes.js +232 -44
  235. package/dist/primitives/footnotes.js.map +1 -1
  236. package/dist/primitives/formatting_tags.d.ts +7 -0
  237. package/dist/primitives/formatting_tags.d.ts.map +1 -1
  238. package/dist/primitives/formatting_tags.js +22 -11
  239. package/dist/primitives/formatting_tags.js.map +1 -1
  240. package/dist/primitives/index.d.ts +10 -0
  241. package/dist/primitives/index.d.ts.map +1 -1
  242. package/dist/primitives/index.js +9 -0
  243. package/dist/primitives/index.js.map +1 -1
  244. package/dist/primitives/layout.d.ts +4 -3
  245. package/dist/primitives/layout.d.ts.map +1 -1
  246. package/dist/primitives/layout.js +45 -3
  247. package/dist/primitives/layout.js.map +1 -1
  248. package/dist/primitives/merge_runs.d.ts +21 -3
  249. package/dist/primitives/merge_runs.d.ts.map +1 -1
  250. package/dist/primitives/merge_runs.js +32 -10
  251. package/dist/primitives/merge_runs.js.map +1 -1
  252. package/dist/primitives/minimal_save.d.ts +38 -0
  253. package/dist/primitives/minimal_save.d.ts.map +1 -0
  254. package/dist/primitives/minimal_save.js +323 -0
  255. package/dist/primitives/minimal_save.js.map +1 -0
  256. package/dist/primitives/namespaces.d.ts +47 -0
  257. package/dist/primitives/namespaces.d.ts.map +1 -1
  258. package/dist/primitives/namespaces.js +52 -0
  259. package/dist/primitives/namespaces.js.map +1 -1
  260. package/dist/primitives/reject_changes.d.ts +6 -4
  261. package/dist/primitives/reject_changes.d.ts.map +1 -1
  262. package/dist/primitives/reject_changes.js +187 -91
  263. package/dist/primitives/reject_changes.js.map +1 -1
  264. package/dist/primitives/revision-parts.d.ts +7 -0
  265. package/dist/primitives/revision-parts.d.ts.map +1 -0
  266. package/dist/primitives/revision-parts.js +27 -0
  267. package/dist/primitives/revision-parts.js.map +1 -0
  268. package/dist/primitives/revision-vocabulary.d.ts +7 -0
  269. package/dist/primitives/revision-vocabulary.d.ts.map +1 -0
  270. package/dist/primitives/revision-vocabulary.js +39 -0
  271. package/dist/primitives/revision-vocabulary.js.map +1 -0
  272. package/dist/primitives/schema-corpus-capture.d.ts +19 -0
  273. package/dist/primitives/schema-corpus-capture.d.ts.map +1 -0
  274. package/dist/primitives/schema-corpus-capture.js +29 -0
  275. package/dist/primitives/schema-corpus-capture.js.map +1 -0
  276. package/dist/primitives/sectPrAudit.d.ts +19 -0
  277. package/dist/primitives/sectPrAudit.d.ts.map +1 -0
  278. package/dist/primitives/sectPrAudit.js +165 -0
  279. package/dist/primitives/sectPrAudit.js.map +1 -0
  280. package/dist/primitives/semantic_tags.d.ts +7 -0
  281. package/dist/primitives/semantic_tags.d.ts.map +1 -1
  282. package/dist/primitives/semantic_tags.js +23 -4
  283. package/dist/primitives/semantic_tags.js.map +1 -1
  284. package/dist/primitives/serialize_html.d.ts +37 -0
  285. package/dist/primitives/serialize_html.d.ts.map +1 -0
  286. package/dist/primitives/serialize_html.js +395 -0
  287. package/dist/primitives/serialize_html.js.map +1 -0
  288. package/dist/primitives/serialize_markdown.d.ts +16 -0
  289. package/dist/primitives/serialize_markdown.d.ts.map +1 -0
  290. package/dist/primitives/serialize_markdown.js +300 -0
  291. package/dist/primitives/serialize_markdown.js.map +1 -0
  292. package/dist/primitives/serialize_plaintext.d.ts +15 -0
  293. package/dist/primitives/serialize_plaintext.d.ts.map +1 -0
  294. package/dist/primitives/serialize_plaintext.js +154 -0
  295. package/dist/primitives/serialize_plaintext.js.map +1 -0
  296. package/dist/primitives/styles.d.ts +15 -0
  297. package/dist/primitives/styles.d.ts.map +1 -1
  298. package/dist/primitives/styles.js +33 -22
  299. package/dist/primitives/styles.js.map +1 -1
  300. package/dist/primitives/tables.d.ts.map +1 -1
  301. package/dist/primitives/tables.js +13 -3
  302. package/dist/primitives/tables.js.map +1 -1
  303. package/dist/primitives/text.d.ts +2 -1
  304. package/dist/primitives/text.d.ts.map +1 -1
  305. package/dist/primitives/text.js +116 -12
  306. package/dist/primitives/text.js.map +1 -1
  307. package/dist/primitives/track-changes-emitter.d.ts +148 -0
  308. package/dist/primitives/track-changes-emitter.d.ts.map +1 -0
  309. package/dist/primitives/track-changes-emitter.js +291 -0
  310. package/dist/primitives/track-changes-emitter.js.map +1 -0
  311. package/dist/primitives/validate_ai_revisions.d.ts +35 -0
  312. package/dist/primitives/validate_ai_revisions.d.ts.map +1 -0
  313. package/dist/primitives/validate_ai_revisions.js +323 -0
  314. package/dist/primitives/validate_ai_revisions.js.map +1 -0
  315. package/dist/primitives/xml-helpers.d.ts +29 -0
  316. package/dist/primitives/xml-helpers.d.ts.map +1 -0
  317. package/dist/primitives/xml-helpers.js +35 -0
  318. package/dist/primitives/xml-helpers.js.map +1 -0
  319. package/dist/primitives/xml.d.ts +5 -0
  320. package/dist/primitives/xml.d.ts.map +1 -1
  321. package/dist/primitives/xml.js +5 -0
  322. package/dist/primitives/xml.js.map +1 -1
  323. package/dist/primitives/zip.d.ts +1 -0
  324. package/dist/primitives/zip.d.ts.map +1 -1
  325. package/dist/primitives/zip.js +21 -3
  326. package/dist/primitives/zip.js.map +1 -1
  327. package/dist/shared/field-structure.d.ts +14 -0
  328. package/dist/shared/field-structure.d.ts.map +1 -0
  329. package/dist/shared/field-structure.js +166 -0
  330. package/dist/shared/field-structure.js.map +1 -0
  331. package/dist/shared/ooxml/namespaces.d.ts +4 -1
  332. package/dist/shared/ooxml/namespaces.d.ts.map +1 -1
  333. package/dist/shared/ooxml/namespaces.js +4 -1
  334. package/dist/shared/ooxml/namespaces.js.map +1 -1
  335. package/package.json +13 -9
@@ -8,9 +8,11 @@ import { XMLSerializer } from '@xmldom/xmldom';
8
8
  import { parseXml } from '../../primitives/xml.js';
9
9
  import { CorrelationStatus } from '../../core-types.js';
10
10
  import { getLeafText, childElements, findChildByTagName } from '../../primitives/index.js';
11
+ import { allocateRevisionId, buildPPrChangeElement, convertSerializedDeletionContent, createRevisionContext, createRevisionIdState, escapeXmlAttr, formatDate, wrapSerializedContentWithDel, wrapSerializedContentWithIns, } from '../../primitives/track-changes-emitter.js';
11
12
  import { serializeToXml, cloneElement } from './xmlToWmlElement.js';
12
- import { EMPTY_PARAGRAPH_TAG, isParagraphLevelLeaf } from '../../atomizer.js';
13
+ import { EMPTY_PARAGRAPH_TAG, isParagraphLevelLeaf, nearestHyperlinkAncestor } from '../../atomizer.js';
13
14
  import { enforceConsumerCompatibility } from './consumerCompatibility.js';
15
+ import { placeParagraphMarkRevisionMarker } from './inPlaceModifier-wrappers.js';
14
16
  import { areRunPropertiesEqual } from '../../format-detection.js';
15
17
  import { debug } from './debug.js';
16
18
  const SYNTHETIC_DOC = parseXml('<root xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>');
@@ -22,21 +24,6 @@ function createEl(tag, attrs) {
22
24
  el.setAttribute(k, v);
23
25
  return el;
24
26
  }
25
- /**
26
- * Create initial revision ID state.
27
- */
28
- function createRevisionIdState() {
29
- return {
30
- nextId: 1,
31
- moveRangeIds: new Map(),
32
- };
33
- }
34
- /**
35
- * Allocate a new revision ID.
36
- */
37
- function allocateRevisionId(state) {
38
- return state.nextId++;
39
- }
40
27
  /**
41
28
  * Get or allocate move range IDs for a move name.
42
29
  */
@@ -51,12 +38,6 @@ function getMoveRangeIds(state, moveName) {
51
38
  }
52
39
  return ids;
53
40
  }
54
- /**
55
- * Format date for OOXML (ISO 8601).
56
- */
57
- function formatDate(date) {
58
- return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
59
- }
60
41
  /**
61
42
  * Reconstruct document.xml from merged atoms with track changes.
62
43
  *
@@ -343,8 +324,14 @@ function shouldStartNewRunGroup(currentGroup, atom) {
343
324
  if (currentGroup.moveName !== atom.moveName) {
344
325
  return true;
345
326
  }
346
- // Skip rPr splitting for MovedSource/MovedDestination to avoid
347
- // duplicate move range markers (moveFromRangeStart/End)
327
+ // Skip rPr splitting for MovedSource/MovedDestination: every moved run
328
+ // group is wrapped by wrapWithMoveFrom/wrapWithMoveTo, so splitting one
329
+ // move into several groups would emit moveFromRangeStart/End (resp.
330
+ // moveToRangeStart/End) once per slice with the same w:name and range ids.
331
+ // This stays required now that explicit move-range markers atomize: the
332
+ // synthetic-range suppression keyed off those markers is per paragraph, so
333
+ // a detected move in a marker-free paragraph still synthesizes one range
334
+ // pair per moved run group.
348
335
  if (currentGroup.status === CorrelationStatus.MovedSource ||
349
336
  currentGroup.status === CorrelationStatus.MovedDestination) {
350
337
  return false;
@@ -420,10 +407,28 @@ function isEmptyParagraphGroup(group) {
420
407
  }
421
408
  return group.runGroups.length > 0;
422
409
  }
410
+ const NO_EXPLICIT_MOVE_MARKERS = { moveFrom: false, moveTo: false };
411
+ function collectExplicitMoveMarkers(group) {
412
+ let moveFrom = false;
413
+ let moveTo = false;
414
+ for (const runGroup of group.runGroups) {
415
+ for (const atom of runGroup.atoms) {
416
+ const tag = atom.contentElement.tagName;
417
+ if (tag === 'w:moveFromRangeStart' || tag === 'w:moveFromRangeEnd') {
418
+ moveFrom = true;
419
+ }
420
+ else if (tag === 'w:moveToRangeStart' || tag === 'w:moveToRangeEnd') {
421
+ moveTo = true;
422
+ }
423
+ }
424
+ }
425
+ return { moveFrom, moveTo };
426
+ }
423
427
  /**
424
428
  * Build XML for a single paragraph with track changes.
425
429
  */
426
430
  function buildParagraphXml(group, author, dateStr, revState) {
431
+ const revisionCtx = createRevisionContext({ author, date: dateStr, idState: revState });
427
432
  // Track empty paragraph statuses for debugging
428
433
  if (isEmptyParagraphGroup(group)) {
429
434
  const status = group.runGroups[0]?.atoms[0]?.correlationStatus;
@@ -461,33 +466,25 @@ function buildParagraphXml(group, author, dateStr, revState) {
461
466
  // entirely (instead of leaving behind a stub <w:p> break).
462
467
  if (isEntireParagraphWithStatus(group, CorrelationStatus.Inserted)) {
463
468
  const paraId = allocateRevisionId(revState);
464
- const runId = allocateRevisionId(revState);
465
- const pPrChangeEl = buildPPrChangeElement(group.pPr, author, dateStr, revState);
469
+ const insertedRunXml = paragraphHasHyperlinkAtoms(group)
470
+ ? buildWholeParagraphRevisionContent(group, (runs) => wrapSerializedContentWithIns(runs, revisionCtx))
471
+ : wrapSerializedContentWithIns(group.runGroups.map((runGroup) => buildRunContentAsPlainRun(runGroup)).join(''), revisionCtx);
472
+ const pPrChangeEl = buildPPrChangeElement(group.pPr, revisionCtx);
466
473
  const parts = [];
467
474
  parts.push('<w:p>');
468
475
  parts.push(serializePPrWithParaRevisionMarker(group.pPr, 'w:ins', paraId, author, dateStr, pPrChangeEl));
469
- parts.push(`<w:ins w:id="${runId}" w:author="${escapeXmlAttr(author)}" w:date="${dateStr}">`);
470
- for (const runGroup of group.runGroups) {
471
- parts.push(buildRunContentAsPlainRun(runGroup));
472
- }
473
- parts.push('</w:ins>');
476
+ parts.push(insertedRunXml);
474
477
  parts.push('</w:p>');
475
478
  return parts.join('');
476
479
  }
477
480
  if (isEntireParagraphWithStatus(group, CorrelationStatus.Deleted)) {
478
481
  const paraId = allocateRevisionId(revState);
479
- const runId = allocateRevisionId(revState);
480
482
  const parts = [];
481
483
  parts.push('<w:p>');
482
484
  parts.push(serializePPrWithParaRevisionMarker(group.pPr, 'w:del', paraId, author, dateStr));
483
- parts.push(`<w:del w:id="${runId}" w:author="${escapeXmlAttr(author)}" w:date="${dateStr}">`);
484
- for (const runGroup of group.runGroups) {
485
- const plainRun = buildRunContentAsPlainRun(runGroup);
486
- parts.push(plainRun
487
- .replace(/<w:t([^>]*)>([^<]*)<\/w:t>/g, '<w:delText$1>$2</w:delText>')
488
- .replace(/<w:instrText([^>]*)>([^<]*)<\/w:instrText>/g, '<w:delInstrText$1>$2</w:delInstrText>'));
489
- }
490
- parts.push('</w:del>');
485
+ parts.push(paragraphHasHyperlinkAtoms(group)
486
+ ? buildWholeParagraphRevisionContent(group, (runs) => wrapSerializedContentWithDel(runs, revisionCtx))
487
+ : wrapSerializedContentWithDel(group.runGroups.map((runGroup) => buildRunContentAsPlainRun(runGroup)).join(''), revisionCtx));
491
488
  parts.push('</w:p>');
492
489
  return parts.join('');
493
490
  }
@@ -496,7 +493,7 @@ function buildParagraphXml(group, author, dateStr, revState) {
496
493
  // marker inside w:pPr > w:rPr.
497
494
  if (isEmptyParagraphWithStatus(group, CorrelationStatus.Inserted)) {
498
495
  const paraId = allocateRevisionId(revState);
499
- const pPrChangeEl = buildPPrChangeElement(group.pPr, author, dateStr, revState);
496
+ const pPrChangeEl = buildPPrChangeElement(group.pPr, revisionCtx);
500
497
  const pPrXml = serializePPrWithParaRevisionMarker(group.pPr, 'w:ins', paraId, author, dateStr, pPrChangeEl);
501
498
  return `<w:p>${pPrXml}</w:p>`;
502
499
  }
@@ -512,10 +509,18 @@ function buildParagraphXml(group, author, dateStr, revState) {
512
509
  if (group.pPr) {
513
510
  parts.push(serializeToXml(group.pPr));
514
511
  }
515
- // Add run groups with track changes
516
- for (const runGroup of group.runGroups) {
517
- const runXml = buildRunGroupXml(runGroup, author, dateStr, revState);
518
- parts.push(runXml);
512
+ // Add run groups with track changes, restoring w:hyperlink wrappers when
513
+ // the paragraph contains hyperlink atoms (issue #368). Hyperlink-free
514
+ // paragraphs keep the legacy per-group emission byte-identical.
515
+ const explicitMoveMarkers = collectExplicitMoveMarkers(group);
516
+ if (paragraphHasHyperlinkAtoms(group)) {
517
+ parts.push(buildRunGroupsWithHyperlinks(group.runGroups, author, dateStr, revState, explicitMoveMarkers));
518
+ }
519
+ else {
520
+ for (const runGroup of group.runGroups) {
521
+ const runXml = buildRunGroupXml(runGroup, author, dateStr, revState, explicitMoveMarkers);
522
+ parts.push(runXml);
523
+ }
519
524
  }
520
525
  parts.push('</w:p>');
521
526
  return parts.join('');
@@ -543,43 +548,25 @@ function serializePPrWithParaRevisionMarker(pPr, markerTag, id, author, dateStr,
543
548
  effectivePPr.appendChild(rPr);
544
549
  }
545
550
  }
546
- // Insert revision marker at start of rPr.
547
- const marker = createEl(markerTag, {
548
- 'w:id': String(id),
549
- 'w:author': author,
550
- 'w:date': dateStr,
551
- });
552
- rPr.insertBefore(marker, rPr.firstChild);
551
+ // Reuse a pre-existing paragraph-mark marker of the same kind cloned from the
552
+ // source pPr (issue #452): CT_ParaRPr allows at most one of each tracked-change
553
+ // child, and the source revision's metadata (author/date/id) outranks a
554
+ // synthetic duplicate. Either way the marker is placed in its schema-correct
555
+ // slot ahead of formatting children.
556
+ const existingMarker = findChildByTagName(rPr, markerTag);
557
+ const marker = existingMarker ??
558
+ createEl(markerTag, {
559
+ 'w:id': String(id),
560
+ 'w:author': author,
561
+ 'w:date': dateStr,
562
+ });
563
+ placeParagraphMarkRevisionMarker(rPr, marker, markerTag);
553
564
  // Append pPrChange at end if provided.
554
565
  if (pPrChangeEl) {
555
566
  effectivePPr.appendChild(pPrChangeEl);
556
567
  }
557
568
  return serializeToXml(effectivePPr);
558
569
  }
559
- /**
560
- * Build a `<w:pPrChange>` Element from a pPr DOM element.
561
- *
562
- * The child `<w:pPr>` conforms to CT_PPrBase — it excludes w:rPr, w:sectPr,
563
- * w:rPrChange, and w:pPrChange.
564
- */
565
- function buildPPrChangeElement(pPr, author, dateStr, revState) {
566
- const id = allocateRevisionId(revState);
567
- const EXCLUDED = new Set(['w:rPr', 'w:rPrChange', 'w:pPrChange', 'w:sectPr']);
568
- const pPrChange = createEl('w:pPrChange', {
569
- 'w:id': String(id),
570
- 'w:author': author,
571
- 'w:date': dateStr,
572
- });
573
- const oldPPr = createEl('w:pPr');
574
- if (pPr) {
575
- for (const child of childElements(pPr)) {
576
- if (!EXCLUDED.has(child.tagName))
577
- oldPPr.appendChild(child.cloneNode(true));
578
- }
579
- }
580
- pPrChange.appendChild(oldPPr);
581
- return pPrChange;
582
- }
583
570
  /**
584
571
  * Returns true if every atom in the paragraph is of the specified status
585
572
  * (ignoring EMPTY_PARAGRAPH_TAG markers).
@@ -638,8 +625,12 @@ function buildRunContentAsPlainRun(group) {
638
625
  }
639
626
  /**
640
627
  * Build XML for a run group with appropriate track changes wrapper.
628
+ *
629
+ * `explicitMoveMarkers` reports whether the surrounding paragraph's atom
630
+ * stream already carries explicit moveFromRange / moveToRange markers; moved
631
+ * groups then skip synthetic range emission (see ExplicitMoveMarkers).
641
632
  */
642
- function buildRunGroupXml(group, author, dateStr, revState) {
633
+ function buildRunGroupXml(group, author, dateStr, revState, explicitMoveMarkers = NO_EXPLICIT_MOVE_MARKERS) {
643
634
  const runContent = buildRunContent(group);
644
635
  // If run content is empty (e.g., only empty paragraph atoms), return empty string
645
636
  // This avoids generating empty track changes wrappers
@@ -655,9 +646,9 @@ function buildRunGroupXml(group, author, dateStr, revState) {
655
646
  case CorrelationStatus.Deleted:
656
647
  return wrapWithDel(runContent, author, dateStr, revState);
657
648
  case CorrelationStatus.MovedSource:
658
- return wrapWithMoveFrom(runContent, author, dateStr, group.moveName || 'move1', revState);
649
+ return wrapWithMoveFrom(runContent, author, dateStr, group.moveName || 'move1', revState, explicitMoveMarkers.moveFrom);
659
650
  case CorrelationStatus.MovedDestination:
660
- return wrapWithMoveTo(runContent, author, dateStr, group.moveName || 'move1', revState);
651
+ return wrapWithMoveTo(runContent, author, dateStr, group.moveName || 'move1', revState, explicitMoveMarkers.moveTo);
661
652
  case CorrelationStatus.FormatChanged:
662
653
  // For format changes, we include the run with rPrChange
663
654
  return buildFormatChangeRun(group, author, dateStr, revState);
@@ -713,9 +704,195 @@ function subGroupByRPr(atoms) {
713
704
  result.push({ rPr: currentRPr, atoms: currentAtoms });
714
705
  return result;
715
706
  }
707
+ /**
708
+ * Attribute fingerprint of a w:hyperlink element, used to recognize "the
709
+ * same" hyperlink across the original and revised trees (equal/deleted atoms
710
+ * reference the original tree's element, inserted atoms the revised tree's).
711
+ */
712
+ function hyperlinkKey(el) {
713
+ const parts = [];
714
+ for (let i = 0; i < el.attributes.length; i++) {
715
+ const attr = el.attributes.item(i);
716
+ if (attr.name.startsWith('xmlns'))
717
+ continue;
718
+ parts.push(`${attr.name}=${attr.value}`);
719
+ }
720
+ return parts.sort().join('\u0000');
721
+ }
722
+ /**
723
+ * Resolve the hyperlink wrapper an atom belongs to, preferring the
724
+ * original-tree element so the re-emitted r:id resolves against the
725
+ * original-based rebuild package: deleted atoms carry original ancestry
726
+ * directly; equal atoms (revised tree) reach it via comparisonUnitAtomBefore.
727
+ *
728
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.22
729
+ * @see https://github.com/UseJunior/safe-docx/issues/368
730
+ */
731
+ function resolveHyperlinkForAtom(atom) {
732
+ const own = nearestHyperlinkAncestor(atom);
733
+ if (!own)
734
+ return null;
735
+ if (atom.sourceDocument === 'original') {
736
+ return { element: own, key: hyperlinkKey(own), fromOriginal: true };
737
+ }
738
+ const before = atom.comparisonUnitAtomBefore;
739
+ const beforeHyperlink = before ? nearestHyperlinkAncestor(before) : null;
740
+ // Attribute to the original wrapper only when both trees agree on the
741
+ // hyperlink's attributes. When they differ (e.g. the revision retargeted
742
+ // the link to a new r:id), emitting the original wrapper would pin the
743
+ // still-equal link text to the STALE target in the accepted document —
744
+ // worse than dropping the wrapper. Such atoms fall through to the
745
+ // revised-only policy below instead.
746
+ // TODO(#376): the faithful tracked representation of a retargeted link
747
+ // is delete-old-link + insert-new-link (what Word emits), which needs the
748
+ // hyperlink fingerprint in atom identity so the LCS stops matching text
749
+ // across different link targets.
750
+ if (beforeHyperlink && hyperlinkKey(beforeHyperlink) === hyperlinkKey(own)) {
751
+ return { element: beforeHyperlink, key: hyperlinkKey(beforeHyperlink), fromOriginal: true };
752
+ }
753
+ // Revised-only attribution (purely inserted hyperlink). Emitting its r:id
754
+ // would dangle against the original-based package, so the caller only
755
+ // wraps when the hyperlink carries no relationship reference (anchor-only).
756
+ return { element: own, key: hyperlinkKey(own), fromOriginal: false };
757
+ }
758
+ /**
759
+ * Whether a resolved hyperlink is safe to re-emit. Original-attributed
760
+ * wrappers always are; revised-only wrappers are safe only without an r:id
761
+ * (internal anchor links), because the rebuild package ships the ORIGINAL
762
+ * document.xml.rels and a revised-only r:id would be a dangling reference
763
+ * (Word treats those as a corrupt package). Revised-only r:id hyperlinks
764
+ * keep today's behavior — content emitted unwrapped.
765
+ */
766
+ function isEmittableHyperlink(resolved) {
767
+ return resolved.fromOriginal || resolved.element.getAttribute('r:id') === null;
768
+ }
769
+ /**
770
+ * True when any atom in the paragraph sits inside a w:hyperlink. Gates the
771
+ * hyperlink-aware emission paths so hyperlink-free paragraphs keep the
772
+ * byte-identical legacy output.
773
+ */
774
+ function paragraphHasHyperlinkAtoms(group) {
775
+ return group.runGroups.some((rg) => rg.atoms.some((atom) => nearestHyperlinkAncestor(atom) !== null));
776
+ }
777
+ /**
778
+ * Split a RunGroup into contiguous hyperlink-pure sub-groups.
779
+ *
780
+ * Moved groups are returned whole: splitting them would emit
781
+ * moveFromRangeStart/End once per slice, corrupting the move ranges. A move
782
+ * spanning a hyperlink keeps today's unwrapped emission.
783
+ */
784
+ function splitRunGroupByHyperlink(group) {
785
+ if (group.status === CorrelationStatus.MovedSource ||
786
+ group.status === CorrelationStatus.MovedDestination) {
787
+ return [{ group, hyperlink: null }];
788
+ }
789
+ const segments = [];
790
+ let current = null;
791
+ for (const atom of group.atoms) {
792
+ // Emit-ability is decided per merged bucket, not per atom: an inserted
793
+ // atom inside an otherwise-original hyperlink folds into the adjacent
794
+ // original-attributed bucket via the shared key.
795
+ const resolved = resolveHyperlinkForAtom(atom);
796
+ const key = resolved?.key ?? null;
797
+ if (current && (current.hyperlink?.key ?? null) === key) {
798
+ current.group.atoms.push(atom);
799
+ // Prefer an original-attributed representative within the segment.
800
+ if (resolved?.fromOriginal && current.hyperlink && !current.hyperlink.fromOriginal) {
801
+ current.hyperlink = resolved;
802
+ }
803
+ }
804
+ else {
805
+ current = {
806
+ group: { ...group, atoms: [atom] },
807
+ hyperlink: resolved,
808
+ };
809
+ segments.push(current);
810
+ }
811
+ }
812
+ return segments;
813
+ }
814
+ /**
815
+ * Serialize the opening tag of a re-emitted w:hyperlink wrapper, copying the
816
+ * source element's attributes verbatim (r:id, w:anchor, w:history, ...).
817
+ */
818
+ function serializeHyperlinkOpenTag(el) {
819
+ const attrs = [];
820
+ for (let i = 0; i < el.attributes.length; i++) {
821
+ const attr = el.attributes.item(i);
822
+ if (attr.name.startsWith('xmlns'))
823
+ continue;
824
+ attrs.push(` ${attr.name}="${escapeXmlAttr(attr.value)}"`);
825
+ }
826
+ return `<w:hyperlink${attrs.join('')}>`;
827
+ }
828
+ /**
829
+ * Merge adjacent segments that resolve to the same hyperlink fingerprint, so
830
+ * an equal/deleted/inserted sequence inside one link shares one wrapper.
831
+ */
832
+ function mergeAdjacentHyperlinkSegments(segments) {
833
+ const buckets = [];
834
+ for (const segment of segments) {
835
+ const last = buckets[buckets.length - 1];
836
+ if (last && (last.hyperlink?.key ?? null) === (segment.hyperlink?.key ?? null)) {
837
+ last.groups.push(segment.group);
838
+ if (segment.hyperlink?.fromOriginal && last.hyperlink && !last.hyperlink.fromOriginal) {
839
+ last.hyperlink = segment.hyperlink;
840
+ }
841
+ }
842
+ else {
843
+ buckets.push({ hyperlink: segment.hyperlink, groups: [segment.group] });
844
+ }
845
+ }
846
+ return buckets;
847
+ }
848
+ /**
849
+ * Emit a paragraph's run groups with w:hyperlink wrappers restored around the
850
+ * runs whose atoms came from inside a hyperlink. Track-change wrappers nest
851
+ * INSIDE the hyperlink (`<w:hyperlink><w:ins>…`): CT_Hyperlink admits
852
+ * EG_RunLevelElts (w:ins / w:del / range markers), while CT_RunTrackChange
853
+ * does not admit w:hyperlink.
854
+ *
855
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.22
856
+ * @see https://github.com/UseJunior/safe-docx/issues/368
857
+ */
858
+ function buildRunGroupsWithHyperlinks(runGroups, author, dateStr, revState, explicitMoveMarkers = NO_EXPLICIT_MOVE_MARKERS) {
859
+ const buckets = mergeAdjacentHyperlinkSegments(runGroups.flatMap(splitRunGroupByHyperlink));
860
+ const parts = [];
861
+ for (const bucket of buckets) {
862
+ const content = bucket.groups
863
+ .map((g) => buildRunGroupXml(g, author, dateStr, revState, explicitMoveMarkers))
864
+ .join('');
865
+ if (!content)
866
+ continue;
867
+ parts.push(bucket.hyperlink && isEmittableHyperlink(bucket.hyperlink)
868
+ ? `${serializeHyperlinkOpenTag(bucket.hyperlink.element)}${content}</w:hyperlink>`
869
+ : content);
870
+ }
871
+ return parts.join('');
872
+ }
873
+ /**
874
+ * Whole-paragraph insert/delete emission with hyperlink wrappers restored.
875
+ * Each bucket gets its own revision wrapper so the hyperlink can stay
876
+ * OUTSIDE the w:ins / w:del (see buildRunGroupsWithHyperlinks).
877
+ */
878
+ function buildWholeParagraphRevisionContent(group, wrap) {
879
+ const buckets = mergeAdjacentHyperlinkSegments(group.runGroups.flatMap(splitRunGroupByHyperlink));
880
+ const parts = [];
881
+ for (const bucket of buckets) {
882
+ const runs = bucket.groups.map((g) => buildRunContentAsPlainRun(g)).join('');
883
+ if (!runs)
884
+ continue;
885
+ const wrapped = wrap(runs);
886
+ parts.push(bucket.hyperlink && isEmittableHyperlink(bucket.hyperlink)
887
+ ? `${serializeHyperlinkOpenTag(bucket.hyperlink.element)}${wrapped}</w:hyperlink>`
888
+ : wrapped);
889
+ }
890
+ return parts.join('');
891
+ }
716
892
  /**
717
893
  * Returns true when any atom in the group is a paragraph-level marker
718
- * (commentRange / bookmark) that must be emitted outside <w:r>.
894
+ * (commentRange / bookmark / moveFromRange / moveToRange / perm) that must
895
+ * be emitted outside <w:r>.
719
896
  */
720
897
  function groupHasParagraphLevelAtoms(group) {
721
898
  for (const atom of group.atoms) {
@@ -876,38 +1053,49 @@ function buildRunContent(group) {
876
1053
  * Wrap content with w:ins element.
877
1054
  */
878
1055
  function wrapWithIns(content, author, dateStr, revState) {
879
- const id = allocateRevisionId(revState);
880
- return `<w:ins w:id="${id}" w:author="${escapeXmlAttr(author)}" w:date="${dateStr}">${content}</w:ins>`;
1056
+ return wrapSerializedContentWithIns(content, createRevisionContext({ author, date: dateStr, idState: revState }));
881
1057
  }
882
1058
  /**
883
1059
  * Wrap content with w:del element.
884
1060
  */
885
1061
  function wrapWithDel(content, author, dateStr, revState) {
886
- const id = allocateRevisionId(revState);
887
- // For deletions, convert w:t to w:delText and w:instrText to w:delInstrText
888
- const delContent = content
889
- .replace(/<w:t([^>]*)>([^<]*)<\/w:t>/g, '<w:delText$1>$2</w:delText>')
890
- .replace(/<w:instrText([^>]*)>([^<]*)<\/w:instrText>/g, '<w:delInstrText$1>$2</w:delInstrText>');
891
- return `<w:del w:id="${id}" w:author="${escapeXmlAttr(author)}" w:date="${dateStr}">${delContent}</w:del>`;
1062
+ return wrapSerializedContentWithDel(content, createRevisionContext({ author, date: dateStr, idState: revState }));
892
1063
  }
893
1064
  /**
894
1065
  * Wrap content with w:moveFrom elements.
1066
+ *
1067
+ * When `suppressRangeMarkers` is true the paragraph's atom stream already
1068
+ * carries explicit w:moveFromRangeStart/End markers (re-emitted by
1069
+ * buildRunContentWithParagraphMarkers), so only the w:moveFrom wrapper is
1070
+ * synthesized — emitting a second range pair would corrupt the move ranges.
1071
+ *
1072
+ * @see https://github.com/UseJunior/safe-docx/issues/110
895
1073
  */
896
- function wrapWithMoveFrom(content, author, dateStr, moveName, revState) {
1074
+ function wrapWithMoveFrom(content, author, dateStr, moveName, revState, suppressRangeMarkers = false) {
1075
+ if (suppressRangeMarkers) {
1076
+ const moveId = allocateRevisionId(revState);
1077
+ const delContent = convertSerializedDeletionContent(content);
1078
+ return `<w:moveFrom w:id="${moveId}" w:author="${escapeXmlAttr(author)}" w:date="${dateStr}">${delContent}</w:moveFrom>`;
1079
+ }
897
1080
  const ids = getMoveRangeIds(revState, moveName);
898
1081
  const moveId = allocateRevisionId(revState);
899
- // Convert w:t to w:delText and w:instrText to w:delInstrText for moved-from content
900
- const delContent = content
901
- .replace(/<w:t([^>]*)>([^<]*)<\/w:t>/g, '<w:delText$1>$2</w:delText>')
902
- .replace(/<w:instrText([^>]*)>([^<]*)<\/w:instrText>/g, '<w:delInstrText$1>$2</w:delInstrText>');
1082
+ const delContent = convertSerializedDeletionContent(content);
903
1083
  return (`<w:moveFromRangeStart w:id="${ids.sourceRangeId}" w:name="${moveName}" w:author="${escapeXmlAttr(author)}" w:date="${dateStr}"/>` +
904
1084
  `<w:moveFrom w:id="${moveId}" w:author="${escapeXmlAttr(author)}" w:date="${dateStr}">${delContent}</w:moveFrom>` +
905
1085
  `<w:moveFromRangeEnd w:id="${ids.sourceRangeId}"/>`);
906
1086
  }
907
1087
  /**
908
1088
  * Wrap content with w:moveTo elements.
1089
+ *
1090
+ * When `suppressRangeMarkers` is true the paragraph's atom stream already
1091
+ * carries explicit w:moveToRangeStart/End markers, so only the w:moveTo
1092
+ * wrapper is synthesized (see wrapWithMoveFrom).
909
1093
  */
910
- function wrapWithMoveTo(content, author, dateStr, moveName, revState) {
1094
+ function wrapWithMoveTo(content, author, dateStr, moveName, revState, suppressRangeMarkers = false) {
1095
+ if (suppressRangeMarkers) {
1096
+ const moveId = allocateRevisionId(revState);
1097
+ return `<w:moveTo w:id="${moveId}" w:author="${escapeXmlAttr(author)}" w:date="${dateStr}">${content}</w:moveTo>`;
1098
+ }
911
1099
  const ids = getMoveRangeIds(revState, moveName);
912
1100
  const moveId = allocateRevisionId(revState);
913
1101
  return (`<w:moveToRangeStart w:id="${ids.destRangeId}" w:name="${moveName}" w:author="${escapeXmlAttr(author)}" w:date="${dateStr}"/>` +
@@ -932,7 +1120,13 @@ function buildFormatChangeRun(group, author, dateStr, revState) {
932
1120
  }
933
1121
  }
934
1122
  }
935
- // Add rPrChange with old properties (wrapped in w:rPr per OOXML spec)
1123
+ // Add rPrChange with old properties (wrapped in w:rPr per OOXML spec).
1124
+ // Kept as the original per-child serialization (NOT delegated to
1125
+ // buildRPrChangeElement) to preserve byte-identical output: xmldom emits
1126
+ // inline `xmlns:w="..."` declarations when serializing detached children,
1127
+ // and downstream consumers may pin on that exact serialized form. The
1128
+ // DOM-aware buildRPrChangeElement helper exists for new primitive code
1129
+ // paths (#136 onward).
936
1130
  const formatChange = group.atoms[0]?.formatChange;
937
1131
  if (formatChange?.oldRunProperties) {
938
1132
  const id = allocateRevisionId(revState);
@@ -1083,15 +1277,44 @@ function buildDocumentPreservingStructure(originalXml, paragraphXmls, paragraphG
1083
1277
  const slot = slots[i];
1084
1278
  slot.parent.removeChild(slot.element);
1085
1279
  }
1086
- // Strip inter-paragraph bookmark/comment range markers from the scaffold.
1087
- // These are bookmarkStart/End, commentRangeStart/End elements that were
1088
- // siblings of <w:p> in the original body. The paragraph rebuilder handles
1089
- // its own bookmark logic, so keeping these orphaned markers causes
1090
- // unmatched bookmark IDs.
1280
+ // Strip inter-paragraph bookmark/comment/move-range/permission markers from
1281
+ // the scaffold. These are bookmarkStart/End, commentRangeStart/End,
1282
+ // moveFromRange*/moveToRange*, and permStart/End elements that were siblings
1283
+ // of <w:p> in the original body. The paragraph rebuilder handles its own
1284
+ // bookmark logic, so keeping these orphaned markers causes unmatched
1285
+ // bookmark IDs. Body-level move-range markers are likewise scaffold
1286
+ // remnants: in-paragraph markers travel through the atom stream, and
1287
+ // detected moves synthesize fresh range pairs inside the reconstructed
1288
+ // paragraphs, so a leftover body-level pair would either dangle or double an
1289
+ // emitted range.
1290
+ //
1291
+ // Comment range markers are treated differently: a sibling-level
1292
+ // commentRangeStart/End is the legitimate shape for a comment range that
1293
+ // spans whole paragraphs, and such markers never enter the atom stream
1294
+ // (see isParagraphLevelLeaf in atomizer.ts), so nothing re-emits them.
1295
+ // Stripping them unconditionally destroys multi-paragraph comment ranges
1296
+ // (issue #103). Instead, strip a sibling-level comment range marker only
1297
+ // when its counterpart (same w:id) is absent from the rebuilt body —
1298
+ // i.e., it is a genuinely orphaned scaffold remnant.
1091
1299
  const SCAFFOLD_STRIP_TAGS = new Set([
1092
1300
  'w:bookmarkStart', 'w:bookmarkEnd',
1093
1301
  'w:commentRangeStart', 'w:commentRangeEnd',
1302
+ 'w:moveFromRangeStart', 'w:moveFromRangeEnd',
1303
+ 'w:moveToRangeStart', 'w:moveToRangeEnd',
1304
+ 'w:permStart', 'w:permEnd',
1094
1305
  ]);
1306
+ const COMMENT_RANGE_TAGS = new Set(['w:commentRangeStart', 'w:commentRangeEnd']);
1307
+ const commentRangeStartIds = new Set();
1308
+ const commentRangeEndIds = new Set();
1309
+ for (const el of Array.from(body.getElementsByTagName('*'))) {
1310
+ const id = el.getAttribute('w:id');
1311
+ if (id == null)
1312
+ continue;
1313
+ if (el.tagName === 'w:commentRangeStart')
1314
+ commentRangeStartIds.add(id);
1315
+ else if (el.tagName === 'w:commentRangeEnd')
1316
+ commentRangeEndIds.add(id);
1317
+ }
1095
1318
  const toRemove = [];
1096
1319
  for (const el of Array.from(body.getElementsByTagName('*'))) {
1097
1320
  if (SCAFFOLD_STRIP_TAGS.has(el.tagName) && el.parentNode) {
@@ -1105,9 +1328,17 @@ function buildDocumentPreservingStructure(originalXml, paragraphXmls, paragraphG
1105
1328
  }
1106
1329
  ancestor = ancestor.parentNode;
1107
1330
  }
1108
- if (!insideParagraph) {
1109
- toRemove.push(el);
1331
+ if (insideParagraph)
1332
+ continue;
1333
+ if (COMMENT_RANGE_TAGS.has(el.tagName)) {
1334
+ const id = el.getAttribute('w:id');
1335
+ const counterpartIds = el.tagName === 'w:commentRangeStart'
1336
+ ? commentRangeEndIds
1337
+ : commentRangeStartIds;
1338
+ if (id != null && counterpartIds.has(id))
1339
+ continue;
1110
1340
  }
1341
+ toRemove.push(el);
1111
1342
  }
1112
1343
  }
1113
1344
  for (const el of toRemove) {
@@ -1175,16 +1406,6 @@ function escapeXmlText(text) {
1175
1406
  .replace(/</g, '&lt;')
1176
1407
  .replace(/>/g, '&gt;');
1177
1408
  }
1178
- /**
1179
- * Escape XML attribute value.
1180
- */
1181
- function escapeXmlAttr(text) {
1182
- return text
1183
- .replace(/&/g, '&amp;')
1184
- .replace(/</g, '&lt;')
1185
- .replace(/>/g, '&gt;')
1186
- .replace(/"/g, '&quot;');
1187
- }
1188
1409
  /**
1189
1410
  * Count statistics from merged atoms.
1190
1411
  */