docxmlater 10.1.3 → 10.1.5

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 (371) hide show
  1. package/README.md +759 -754
  2. package/dist/constants/legacyCompatFlags.js +1 -1
  3. package/dist/constants/legacyCompatFlags.js.map +1 -1
  4. package/dist/constants/limits.js.map +1 -1
  5. package/dist/core/Document.d.ts +50 -50
  6. package/dist/core/Document.d.ts.map +1 -1
  7. package/dist/core/Document.js +483 -471
  8. package/dist/core/Document.js.map +1 -1
  9. package/dist/core/DocumentContent.d.ts +9 -9
  10. package/dist/core/DocumentContent.d.ts.map +1 -1
  11. package/dist/core/DocumentContent.js +1 -1
  12. package/dist/core/DocumentContent.js.map +1 -1
  13. package/dist/core/DocumentGenerator.d.ts +11 -11
  14. package/dist/core/DocumentGenerator.d.ts.map +1 -1
  15. package/dist/core/DocumentGenerator.js +251 -251
  16. package/dist/core/DocumentGenerator.js.map +1 -1
  17. package/dist/core/DocumentIdManager.js.map +1 -1
  18. package/dist/core/DocumentParser.d.ts +15 -15
  19. package/dist/core/DocumentParser.d.ts.map +1 -1
  20. package/dist/core/DocumentParser.js +2123 -2155
  21. package/dist/core/DocumentParser.js.map +1 -1
  22. package/dist/core/DocumentValidator.d.ts.map +1 -1
  23. package/dist/core/DocumentValidator.js +2 -5
  24. package/dist/core/DocumentValidator.js.map +1 -1
  25. package/dist/core/Relationship.js.map +1 -1
  26. package/dist/core/RelationshipManager.d.ts.map +1 -1
  27. package/dist/core/RelationshipManager.js +3 -3
  28. package/dist/core/RelationshipManager.js.map +1 -1
  29. package/dist/elements/AlternateContent.js.map +1 -1
  30. package/dist/elements/Bookmark.d.ts.map +1 -1
  31. package/dist/elements/Bookmark.js +3 -1
  32. package/dist/elements/Bookmark.js.map +1 -1
  33. package/dist/elements/BookmarkManager.d.ts.map +1 -1
  34. package/dist/elements/BookmarkManager.js.map +1 -1
  35. package/dist/elements/Comment.d.ts.map +1 -1
  36. package/dist/elements/Comment.js +9 -6
  37. package/dist/elements/Comment.js.map +1 -1
  38. package/dist/elements/CommentManager.d.ts.map +1 -1
  39. package/dist/elements/CommentManager.js +18 -17
  40. package/dist/elements/CommentManager.js.map +1 -1
  41. package/dist/elements/CommonTypes.d.ts +21 -21
  42. package/dist/elements/CommonTypes.d.ts.map +1 -1
  43. package/dist/elements/CommonTypes.js +56 -56
  44. package/dist/elements/CommonTypes.js.map +1 -1
  45. package/dist/elements/CustomXml.js.map +1 -1
  46. package/dist/elements/Endnote.d.ts.map +1 -1
  47. package/dist/elements/Endnote.js +6 -6
  48. package/dist/elements/Endnote.js.map +1 -1
  49. package/dist/elements/EndnoteManager.d.ts.map +1 -1
  50. package/dist/elements/EndnoteManager.js +6 -7
  51. package/dist/elements/EndnoteManager.js.map +1 -1
  52. package/dist/elements/Field.d.ts.map +1 -1
  53. package/dist/elements/Field.js +82 -25
  54. package/dist/elements/Field.js.map +1 -1
  55. package/dist/elements/FieldHelpers.d.ts.map +1 -1
  56. package/dist/elements/FieldHelpers.js.map +1 -1
  57. package/dist/elements/FontManager.d.ts.map +1 -1
  58. package/dist/elements/FontManager.js +1 -1
  59. package/dist/elements/FontManager.js.map +1 -1
  60. package/dist/elements/Footer.js +2 -2
  61. package/dist/elements/Footer.js.map +1 -1
  62. package/dist/elements/Footnote.d.ts.map +1 -1
  63. package/dist/elements/Footnote.js +6 -6
  64. package/dist/elements/Footnote.js.map +1 -1
  65. package/dist/elements/FootnoteManager.d.ts.map +1 -1
  66. package/dist/elements/FootnoteManager.js +6 -7
  67. package/dist/elements/FootnoteManager.js.map +1 -1
  68. package/dist/elements/Header.js +2 -2
  69. package/dist/elements/Header.js.map +1 -1
  70. package/dist/elements/HeaderFooterManager.js.map +1 -1
  71. package/dist/elements/Hyperlink.d.ts +5 -3
  72. package/dist/elements/Hyperlink.d.ts.map +1 -1
  73. package/dist/elements/Hyperlink.js +134 -76
  74. package/dist/elements/Hyperlink.js.map +1 -1
  75. package/dist/elements/Image.d.ts.map +1 -1
  76. package/dist/elements/Image.js +238 -106
  77. package/dist/elements/Image.js.map +1 -1
  78. package/dist/elements/ImageManager.d.ts.map +1 -1
  79. package/dist/elements/ImageManager.js +1 -1
  80. package/dist/elements/ImageManager.js.map +1 -1
  81. package/dist/elements/ImageRun.js +1 -1
  82. package/dist/elements/ImageRun.js.map +1 -1
  83. package/dist/elements/MathElement.js.map +1 -1
  84. package/dist/elements/Paragraph.d.ts +24 -24
  85. package/dist/elements/Paragraph.d.ts.map +1 -1
  86. package/dist/elements/Paragraph.js +181 -188
  87. package/dist/elements/Paragraph.js.map +1 -1
  88. package/dist/elements/PreservedElement.js.map +1 -1
  89. package/dist/elements/PropertyChangeTypes.d.ts.map +1 -1
  90. package/dist/elements/PropertyChangeTypes.js +6 -6
  91. package/dist/elements/PropertyChangeTypes.js.map +1 -1
  92. package/dist/elements/RangeMarker.d.ts.map +1 -1
  93. package/dist/elements/RangeMarker.js.map +1 -1
  94. package/dist/elements/Revision.d.ts.map +1 -1
  95. package/dist/elements/Revision.js +4 -5
  96. package/dist/elements/Revision.js.map +1 -1
  97. package/dist/elements/RevisionContent.js.map +1 -1
  98. package/dist/elements/RevisionManager.d.ts.map +1 -1
  99. package/dist/elements/RevisionManager.js +40 -48
  100. package/dist/elements/RevisionManager.js.map +1 -1
  101. package/dist/elements/Run.d.ts +16 -16
  102. package/dist/elements/Run.d.ts.map +1 -1
  103. package/dist/elements/Run.js +256 -238
  104. package/dist/elements/Run.js.map +1 -1
  105. package/dist/elements/Section.d.ts.map +1 -1
  106. package/dist/elements/Section.js +36 -11
  107. package/dist/elements/Section.js.map +1 -1
  108. package/dist/elements/Shape.d.ts.map +1 -1
  109. package/dist/elements/Shape.js.map +1 -1
  110. package/dist/elements/StructuredDocumentTag.d.ts +6 -6
  111. package/dist/elements/StructuredDocumentTag.d.ts.map +1 -1
  112. package/dist/elements/StructuredDocumentTag.js +99 -104
  113. package/dist/elements/StructuredDocumentTag.js.map +1 -1
  114. package/dist/elements/Table.d.ts +11 -11
  115. package/dist/elements/Table.d.ts.map +1 -1
  116. package/dist/elements/Table.js +102 -107
  117. package/dist/elements/Table.js.map +1 -1
  118. package/dist/elements/TableCell.d.ts +10 -10
  119. package/dist/elements/TableCell.d.ts.map +1 -1
  120. package/dist/elements/TableCell.js +105 -106
  121. package/dist/elements/TableCell.js.map +1 -1
  122. package/dist/elements/TableGridChange.d.ts.map +1 -1
  123. package/dist/elements/TableGridChange.js.map +1 -1
  124. package/dist/elements/TableOfContents.d.ts.map +1 -1
  125. package/dist/elements/TableOfContents.js +4 -4
  126. package/dist/elements/TableOfContents.js.map +1 -1
  127. package/dist/elements/TableOfContentsElement.js.map +1 -1
  128. package/dist/elements/TableRow.d.ts.map +1 -1
  129. package/dist/elements/TableRow.js +13 -6
  130. package/dist/elements/TableRow.js.map +1 -1
  131. package/dist/elements/TextBox.d.ts.map +1 -1
  132. package/dist/elements/TextBox.js +3 -5
  133. package/dist/elements/TextBox.js.map +1 -1
  134. package/dist/formatting/AbstractNumbering.d.ts +4 -4
  135. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  136. package/dist/formatting/AbstractNumbering.js +54 -49
  137. package/dist/formatting/AbstractNumbering.js.map +1 -1
  138. package/dist/formatting/NumberingInstance.d.ts.map +1 -1
  139. package/dist/formatting/NumberingInstance.js +1 -3
  140. package/dist/formatting/NumberingInstance.js.map +1 -1
  141. package/dist/formatting/NumberingLevel.d.ts +5 -5
  142. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  143. package/dist/formatting/NumberingLevel.js +119 -125
  144. package/dist/formatting/NumberingLevel.js.map +1 -1
  145. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  146. package/dist/formatting/NumberingManager.js +9 -9
  147. package/dist/formatting/NumberingManager.js.map +1 -1
  148. package/dist/formatting/Style.d.ts +11 -11
  149. package/dist/formatting/Style.d.ts.map +1 -1
  150. package/dist/formatting/Style.js +219 -247
  151. package/dist/formatting/Style.js.map +1 -1
  152. package/dist/formatting/StylesManager.d.ts +2 -2
  153. package/dist/formatting/StylesManager.d.ts.map +1 -1
  154. package/dist/formatting/StylesManager.js +96 -102
  155. package/dist/formatting/StylesManager.js.map +1 -1
  156. package/dist/helpers/CleanupHelper.d.ts +1 -1
  157. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  158. package/dist/helpers/CleanupHelper.js +6 -6
  159. package/dist/helpers/CleanupHelper.js.map +1 -1
  160. package/dist/images/ImageOptimizer.js +7 -7
  161. package/dist/images/ImageOptimizer.js.map +1 -1
  162. package/dist/index.d.ts +9 -9
  163. package/dist/index.d.ts.map +1 -1
  164. package/dist/index.js.map +1 -1
  165. package/dist/managers/DrawingManager.js.map +1 -1
  166. package/dist/tracking/DocumentTrackingContext.d.ts.map +1 -1
  167. package/dist/tracking/DocumentTrackingContext.js +23 -7
  168. package/dist/tracking/DocumentTrackingContext.js.map +1 -1
  169. package/dist/tracking/TrackingContext.d.ts.map +1 -1
  170. package/dist/tracking/TrackingContext.js.map +1 -1
  171. package/dist/types/compatibility-types.js.map +1 -1
  172. package/dist/types/formatting.js.map +1 -1
  173. package/dist/types/list-types.d.ts +6 -6
  174. package/dist/types/list-types.js.map +1 -1
  175. package/dist/types/settings-types.js.map +1 -1
  176. package/dist/types/styleConfig.d.ts +2 -2
  177. package/dist/types/styleConfig.js.map +1 -1
  178. package/dist/utils/ChangelogGenerator.d.ts.map +1 -1
  179. package/dist/utils/ChangelogGenerator.js +97 -101
  180. package/dist/utils/ChangelogGenerator.js.map +1 -1
  181. package/dist/utils/CompatibilityUpgrader.d.ts.map +1 -1
  182. package/dist/utils/CompatibilityUpgrader.js +1 -1
  183. package/dist/utils/CompatibilityUpgrader.js.map +1 -1
  184. package/dist/utils/InMemoryRevisionAcceptor.d.ts.map +1 -1
  185. package/dist/utils/InMemoryRevisionAcceptor.js +1 -6
  186. package/dist/utils/InMemoryRevisionAcceptor.js.map +1 -1
  187. package/dist/utils/MoveOperationHelper.d.ts.map +1 -1
  188. package/dist/utils/MoveOperationHelper.js +1 -1
  189. package/dist/utils/MoveOperationHelper.js.map +1 -1
  190. package/dist/utils/RevisionAwareProcessor.d.ts.map +1 -1
  191. package/dist/utils/RevisionAwareProcessor.js +2 -4
  192. package/dist/utils/RevisionAwareProcessor.js.map +1 -1
  193. package/dist/utils/RevisionWalker.d.ts.map +1 -1
  194. package/dist/utils/RevisionWalker.js +4 -12
  195. package/dist/utils/RevisionWalker.js.map +1 -1
  196. package/dist/utils/SelectiveRevisionAcceptor.d.ts.map +1 -1
  197. package/dist/utils/SelectiveRevisionAcceptor.js +2 -6
  198. package/dist/utils/SelectiveRevisionAcceptor.js.map +1 -1
  199. package/dist/utils/ShadingResolver.d.ts.map +1 -1
  200. package/dist/utils/ShadingResolver.js +1 -1
  201. package/dist/utils/ShadingResolver.js.map +1 -1
  202. package/dist/utils/acceptRevisions.d.ts.map +1 -1
  203. package/dist/utils/acceptRevisions.js +23 -12
  204. package/dist/utils/acceptRevisions.js.map +1 -1
  205. package/dist/utils/cnfStyleDecoder.d.ts +1 -1
  206. package/dist/utils/cnfStyleDecoder.d.ts.map +1 -1
  207. package/dist/utils/cnfStyleDecoder.js +40 -40
  208. package/dist/utils/cnfStyleDecoder.js.map +1 -1
  209. package/dist/utils/corruptionDetection.d.ts.map +1 -1
  210. package/dist/utils/corruptionDetection.js.map +1 -1
  211. package/dist/utils/dateFormatting.js.map +1 -1
  212. package/dist/utils/deepClone.js +1 -1
  213. package/dist/utils/deepClone.js.map +1 -1
  214. package/dist/utils/diagnostics.d.ts.map +1 -1
  215. package/dist/utils/diagnostics.js +1 -1
  216. package/dist/utils/diagnostics.js.map +1 -1
  217. package/dist/utils/errorHandling.js.map +1 -1
  218. package/dist/utils/formatting.d.ts.map +1 -1
  219. package/dist/utils/formatting.js +10 -2
  220. package/dist/utils/formatting.js.map +1 -1
  221. package/dist/utils/list-detection.d.ts +2 -2
  222. package/dist/utils/list-detection.d.ts.map +1 -1
  223. package/dist/utils/list-detection.js +21 -23
  224. package/dist/utils/list-detection.js.map +1 -1
  225. package/dist/utils/logger.d.ts.map +1 -1
  226. package/dist/utils/logger.js +12 -7
  227. package/dist/utils/logger.js.map +1 -1
  228. package/dist/utils/parsingHelpers.js.map +1 -1
  229. package/dist/utils/stripTrackedChanges.d.ts.map +1 -1
  230. package/dist/utils/stripTrackedChanges.js +3 -3
  231. package/dist/utils/stripTrackedChanges.js.map +1 -1
  232. package/dist/utils/textDiff.d.ts +1 -1
  233. package/dist/utils/textDiff.js +8 -8
  234. package/dist/utils/textDiff.js.map +1 -1
  235. package/dist/utils/units.js.map +1 -1
  236. package/dist/utils/validation.d.ts.map +1 -1
  237. package/dist/utils/validation.js +24 -7
  238. package/dist/utils/validation.js.map +1 -1
  239. package/dist/utils/xmlSanitization.d.ts.map +1 -1
  240. package/dist/utils/xmlSanitization.js +3 -3
  241. package/dist/utils/xmlSanitization.js.map +1 -1
  242. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  243. package/dist/validation/RevisionAutoFixer.js +5 -5
  244. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  245. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  246. package/dist/validation/RevisionValidator.js +7 -9
  247. package/dist/validation/RevisionValidator.js.map +1 -1
  248. package/dist/validation/ValidationRules.js +3 -3
  249. package/dist/validation/ValidationRules.js.map +1 -1
  250. package/dist/validation/index.js.map +1 -1
  251. package/dist/xml/XMLBuilder.d.ts +1 -1
  252. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  253. package/dist/xml/XMLBuilder.js +98 -100
  254. package/dist/xml/XMLBuilder.js.map +1 -1
  255. package/dist/xml/XMLParser.d.ts.map +1 -1
  256. package/dist/xml/XMLParser.js +61 -66
  257. package/dist/xml/XMLParser.js.map +1 -1
  258. package/dist/zip/ZipHandler.d.ts.map +1 -1
  259. package/dist/zip/ZipHandler.js.map +1 -1
  260. package/dist/zip/ZipReader.d.ts.map +1 -1
  261. package/dist/zip/ZipReader.js +1 -3
  262. package/dist/zip/ZipReader.js.map +1 -1
  263. package/dist/zip/ZipWriter.d.ts +1 -1
  264. package/dist/zip/ZipWriter.d.ts.map +1 -1
  265. package/dist/zip/ZipWriter.js +28 -36
  266. package/dist/zip/ZipWriter.js.map +1 -1
  267. package/dist/zip/types.js +1 -1
  268. package/dist/zip/types.js.map +1 -1
  269. package/package.json +92 -92
  270. package/src/__tests__/helper-methods.test.ts +512 -512
  271. package/src/constants/legacyCompatFlags.ts +138 -138
  272. package/src/constants/limits.ts +50 -50
  273. package/src/core/Document.ts +985 -1145
  274. package/src/core/DocumentContent.ts +461 -467
  275. package/src/core/DocumentGenerator.ts +1133 -1104
  276. package/src/core/DocumentIdManager.ts +158 -158
  277. package/src/core/DocumentParser.ts +2347 -2716
  278. package/src/core/DocumentValidator.ts +363 -372
  279. package/src/core/Relationship.ts +367 -367
  280. package/src/core/RelationshipManager.ts +429 -428
  281. package/src/elements/AlternateContent.ts +42 -42
  282. package/src/elements/Bookmark.ts +212 -210
  283. package/src/elements/BookmarkManager.ts +247 -250
  284. package/src/elements/Comment.ts +356 -359
  285. package/src/elements/CommentManager.ts +499 -502
  286. package/src/elements/CommonTypes.ts +524 -549
  287. package/src/elements/CustomXml.ts +36 -36
  288. package/src/elements/Endnote.ts +221 -217
  289. package/src/elements/EndnoteManager.ts +246 -249
  290. package/src/elements/Field.ts +1292 -1233
  291. package/src/elements/FieldHelpers.ts +329 -333
  292. package/src/elements/FontManager.ts +336 -339
  293. package/src/elements/Footer.ts +269 -269
  294. package/src/elements/Footnote.ts +221 -217
  295. package/src/elements/FootnoteManager.ts +246 -249
  296. package/src/elements/Header.ts +269 -269
  297. package/src/elements/HeaderFooterManager.ts +219 -219
  298. package/src/elements/Hyperlink.ts +1288 -1193
  299. package/src/elements/Image.ts +1982 -1756
  300. package/src/elements/ImageManager.ts +437 -432
  301. package/src/elements/ImageRun.ts +59 -59
  302. package/src/elements/MathElement.ts +65 -65
  303. package/src/elements/Paragraph.ts +4347 -4287
  304. package/src/elements/PreservedElement.ts +53 -53
  305. package/src/elements/PropertyChangeTypes.ts +458 -442
  306. package/src/elements/RangeMarker.ts +382 -400
  307. package/src/elements/Revision.ts +1198 -1217
  308. package/src/elements/RevisionContent.ts +73 -73
  309. package/src/elements/RevisionManager.ts +1070 -1070
  310. package/src/elements/Run.ts +3103 -3073
  311. package/src/elements/Section.ts +1521 -1421
  312. package/src/elements/Shape.ts +884 -873
  313. package/src/elements/StructuredDocumentTag.ts +1176 -1207
  314. package/src/elements/Table.ts +2468 -2524
  315. package/src/elements/TableCell.ts +1617 -1621
  316. package/src/elements/TableGridChange.ts +149 -151
  317. package/src/elements/TableOfContents.ts +701 -691
  318. package/src/elements/TableOfContentsElement.ts +89 -89
  319. package/src/elements/TableRow.ts +960 -929
  320. package/src/elements/TextBox.ts +766 -768
  321. package/src/formatting/AbstractNumbering.ts +580 -579
  322. package/src/formatting/NumberingInstance.ts +295 -299
  323. package/src/formatting/NumberingLevel.ts +981 -1040
  324. package/src/formatting/NumberingManager.ts +833 -827
  325. package/src/formatting/Style.ts +1785 -1879
  326. package/src/formatting/StylesManager.ts +1090 -1130
  327. package/src/helpers/CleanupHelper.ts +524 -524
  328. package/src/images/ImageOptimizer.ts +274 -274
  329. package/src/index.ts +559 -554
  330. package/src/managers/DrawingManager.ts +319 -319
  331. package/src/tracking/DocumentTrackingContext.ts +687 -674
  332. package/src/tracking/TrackingContext.ts +175 -173
  333. package/src/types/compatibility-types.ts +49 -49
  334. package/src/types/formatting.ts +210 -210
  335. package/src/types/list-types.ts +14 -14
  336. package/src/types/settings-types.ts +59 -59
  337. package/src/types/styleConfig.ts +189 -189
  338. package/src/utils/ChangelogGenerator.ts +1583 -1581
  339. package/src/utils/CompatibilityUpgrader.ts +235 -237
  340. package/src/utils/InMemoryRevisionAcceptor.ts +691 -696
  341. package/src/utils/MoveOperationHelper.ts +233 -238
  342. package/src/utils/RevisionAwareProcessor.ts +518 -526
  343. package/src/utils/RevisionWalker.ts +427 -457
  344. package/src/utils/SelectiveRevisionAcceptor.ts +662 -683
  345. package/src/utils/ShadingResolver.ts +105 -107
  346. package/src/utils/acceptRevisions.ts +723 -714
  347. package/src/utils/cnfStyleDecoder.ts +212 -217
  348. package/src/utils/corruptionDetection.ts +346 -345
  349. package/src/utils/dateFormatting.ts +20 -20
  350. package/src/utils/deepClone.ts +77 -78
  351. package/src/utils/diagnostics.ts +125 -129
  352. package/src/utils/errorHandling.ts +80 -80
  353. package/src/utils/formatting.ts +220 -213
  354. package/src/utils/list-detection.ts +32 -42
  355. package/src/utils/logger.ts +412 -404
  356. package/src/utils/parsingHelpers.ts +190 -190
  357. package/src/utils/stripTrackedChanges.ts +356 -353
  358. package/src/utils/textDiff.ts +100 -100
  359. package/src/utils/units.ts +421 -421
  360. package/src/utils/validation.ts +553 -542
  361. package/src/utils/xmlSanitization.ts +179 -182
  362. package/src/validation/RevisionAutoFixer.ts +541 -542
  363. package/src/validation/RevisionValidator.ts +470 -460
  364. package/src/validation/ValidationRules.ts +338 -338
  365. package/src/validation/index.ts +30 -30
  366. package/src/xml/XMLBuilder.ts +857 -871
  367. package/src/xml/XMLParser.ts +877 -919
  368. package/src/zip/ZipHandler.ts +629 -637
  369. package/src/zip/ZipReader.ts +295 -299
  370. package/src/zip/ZipWriter.ts +374 -390
  371. package/src/zip/types.ts +116 -116
@@ -1,1193 +1,1288 @@
1
- /**
2
- * Hyperlink - Represents a hyperlink in a Word document
3
- *
4
- * Hyperlinks can be external (to websites, files) or internal (to bookmarks within the document).
5
- * They are represented using the `<w:hyperlink>` element.
6
- *
7
- * ## Important: Relationship ID Requirement
8
- *
9
- * **External hyperlinks REQUIRE a relationship ID to be set before XML generation.**
10
- * Per ECMA-376 Part 1 §17.16.22, `<w:hyperlink>` elements with external targets must have
11
- * an `r:id` attribute that references a relationship in `word/_rels/document.xml.rels`.
12
- *
13
- * ### Correct Usage Pattern:
14
- *
15
- * ```typescript
16
- * // RECOMMENDED: Use Document.save() - automatically handles relationships
17
- * const doc = Document.create();
18
- * const para = doc.createParagraph();
19
- * para.addHyperlink(Hyperlink.createExternal('https://example.com', 'Link'));
20
- * await doc.save('document.docx'); // ✅ Relationships auto-registered
21
- * ```
22
- *
23
- * ### Manual Relationship Registration (Advanced):
24
- *
25
- * ```typescript
26
- * const link = Hyperlink.createExternal('https://example.com', 'Link');
27
- * const relationship = relationshipManager.addHyperlink('https://example.com');
28
- * link.setRelationshipId(relationship.getId());
29
- * link.toXML(); // ✅ Now valid
30
- * ```
31
- *
32
- * ### What NOT to Do:
33
- *
34
- * ```typescript
35
- * const link = Hyperlink.createExternal('https://example.com', 'Link');
36
- * link.toXML(); // ❌ ERROR: Missing relationship ID
37
- * ```
38
- *
39
- * ## Internal Hyperlinks
40
- *
41
- * Internal hyperlinks (bookmarks) do NOT require relationships:
42
- *
43
- * ```typescript
44
- * const link = Hyperlink.createInternal('Section1', 'Go to Section 1');
45
- * link.toXML(); // ✅ Valid - uses w:anchor attribute
46
- * ```
47
- *
48
- * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-376/ | ECMA-376 Part 1 §17.16.22}
49
- */
50
-
51
- import { XMLElement } from "../xml/XMLBuilder";
52
- import { Run, RunFormatting } from "./Run";
53
- import { Revision } from "./Revision";
54
- import { validateRunText } from "../utils/validation";
55
- import { defaultLogger } from "../utils/logger";
56
-
57
- /**
58
- * Hyperlink properties
59
- */
60
- export interface HyperlinkProperties {
61
- /** Hyperlink URL (for external links) */
62
- url?: string;
63
- /** Bookmark anchor (for internal links) */
64
- anchor?: string;
65
- /** Display text (optional for empty/invisible hyperlinks) */
66
- text?: string;
67
- /** Text formatting */
68
- formatting?: RunFormatting;
69
- /** Tooltip text */
70
- tooltip?: string;
71
- /** Relationship ID (set by Document when saving) */
72
- relationshipId?: string;
73
- /** Whether this is an empty/invisible hyperlink with no display text */
74
- isEmpty?: boolean;
75
- /** Target frame attribute (e.g., "_blank" for new window) */
76
- tgtFrame?: string;
77
- /** History tracking attribute */
78
- history?: string;
79
- /** Document location for within-document navigation in external files (ECMA-376 §17.16.22) */
80
- docLocation?: string;
81
- }
82
-
83
- /**
84
- * Represents a hyperlink
85
- */
86
- export class Hyperlink {
87
- private url?: string;
88
- private anchor?: string;
89
- private text: string;
90
- private run: Run;
91
- private tooltip?: string;
92
- private relationshipId?: string;
93
- private formatting: RunFormatting;
94
- /** Whether this is an empty/invisible hyperlink with no display text */
95
- private _isEmpty = false;
96
- /** Target frame attribute (e.g., "_blank" for new window) */
97
- private tgtFrame?: string;
98
- /** History tracking attribute */
99
- private history?: string;
100
- /** Document location for within-document navigation in external files */
101
- private docLocation?: string;
102
- /** Tracking context for automatic change tracking */
103
- private trackingContext?: import('../tracking/TrackingContext').TrackingContext;
104
- /** Parent paragraph reference for automatic tracking */
105
- private _parentParagraph?: import('./Paragraph').Paragraph;
106
-
107
- /**
108
- * Creates a new hyperlink
109
- *
110
- * **Note:** A hyperlink must have either a URL (external) or anchor (internal), but not both.
111
- * If both are provided, the URL takes precedence and a warning is logged.
112
- *
113
- * @param properties Hyperlink properties
114
- */
115
- constructor(properties: HyperlinkProperties) {
116
- this.url = properties.url;
117
- this.anchor = properties.anchor;
118
- this.tooltip = properties.tooltip;
119
- this.relationshipId = properties.relationshipId;
120
- this.tgtFrame = properties.tgtFrame;
121
- this.history = properties.history;
122
- this.docLocation = properties.docLocation;
123
- this._isEmpty = properties.isEmpty ?? false;
124
-
125
- // VALIDATION: Warn about hybrid links (url + anchor)
126
- if (this.url && this.anchor) {
127
- defaultLogger.warn(
128
- `DocXML Warning: Hyperlink has both URL ("${this.url}") and anchor ("${this.anchor}"). ` +
129
- `This is ambiguous per ECMA-376 spec. URL will take precedence. ` +
130
- `Use Hyperlink.createExternal() or Hyperlink.createInternal() to avoid ambiguity.`
131
- );
132
- }
133
-
134
- // Handle empty/invisible hyperlinks (no display text)
135
- if (this._isEmpty) {
136
- this.text = "";
137
- this.formatting = {};
138
- this.run = new Run("", {});
139
- return;
140
- }
141
-
142
- // Text fallback: properties.text → url → 'Link'
143
- // NOTE: Do NOT use anchor (bookmark ID) as display text - it should only be used for navigation
144
- // Using bookmark IDs as visible text causes TOC corruption (Issue: TOC shows "HEADING=II.MNKE7E8NA385_" instead of proper headings)
145
- this.text = properties.text || this.url || "Link";
146
-
147
- // Validate text for XML patterns
148
- // Default to auto-cleaning XML patterns unless explicitly disabled (matches Run behavior)
149
- const validation = validateRunText(this.text, {
150
- context: "Hyperlink text",
151
- autoClean: properties.formatting?.cleanXmlFromText !== false,
152
- warnToConsole: true,
153
- });
154
-
155
- // Use cleaned text if available and cleaning was requested
156
- if (validation.cleanedText) {
157
- this.text = validation.cleanedText;
158
- }
159
-
160
- // Create run with default hyperlink styling (Verdana 12pt blue underlined)
161
- this.formatting = {
162
- font: "Verdana",
163
- size: 12,
164
- color: "0000FF", // Standard hyperlink blue
165
- underline: "single",
166
- ...properties.formatting,
167
- };
168
-
169
- this.run = new Run(this.text, this.formatting);
170
- }
171
-
172
- /**
173
- * Sets the tracking context for automatic change tracking.
174
- * Called by Document when track changes is enabled.
175
- * @internal
176
- */
177
- _setTrackingContext(context: import('../tracking/TrackingContext').TrackingContext): void {
178
- this.trackingContext = context;
179
- }
180
-
181
- /**
182
- * Sets the parent paragraph reference for automatic tracking.
183
- * Called by Paragraph when hyperlink is added.
184
- * @internal
185
- */
186
- _setParentParagraph(paragraph: import('./Paragraph').Paragraph): void {
187
- this._parentParagraph = paragraph;
188
- }
189
-
190
- /**
191
- * Gets the parent paragraph reference.
192
- * @internal
193
- */
194
- _getParentParagraph(): import('./Paragraph').Paragraph | undefined {
195
- return this._parentParagraph;
196
- }
197
-
198
- /**
199
- * Gets the hyperlink URL
200
- */
201
- getUrl(): string | undefined {
202
- return this.url;
203
- }
204
-
205
- /**
206
- * Gets the complete URL including any anchor fragment.
207
- *
208
- * For external links that also have an anchor (e.g., internal bookmark within external page),
209
- * this returns the URL with the anchor appended as a fragment.
210
- * For internal-only links (anchor without URL), returns undefined.
211
- *
212
- * Note: As of v7.2.0, DocumentParser automatically combines external URLs with anchors
213
- * during parsing, so getUrl() typically returns the full URL. This method is provided
214
- * for cases where URL and anchor are set separately via the API.
215
- *
216
- * @returns The complete URL with fragment, or undefined for internal-only links
217
- *
218
- * @example
219
- * ```typescript
220
- * // External link with anchor fragment
221
- * const link = new Hyperlink({ url: 'https://example.com/', anchor: '!/view?id=123', text: 'Link' });
222
- * link.getUrl(); // 'https://example.com/'
223
- * link.getAnchor(); // '!/view?id=123'
224
- * link.getFullUrl(); // 'https://example.com/#!/view?id=123'
225
- *
226
- * // External link without anchor
227
- * const link2 = Hyperlink.createExternal('https://example.com/page', 'Link');
228
- * link2.getFullUrl(); // 'https://example.com/page'
229
- *
230
- * // Internal link (bookmark reference)
231
- * const link3 = Hyperlink.createInternal('Section1', 'Go to Section 1');
232
- * link3.getFullUrl(); // undefined
233
- * ```
234
- */
235
- getFullUrl(): string | undefined {
236
- if (this.url && this.anchor) {
237
- return this.url + '#' + this.anchor;
238
- }
239
- return this.url;
240
- }
241
-
242
- /**
243
- * Gets the anchor (for internal links)
244
- */
245
- getAnchor(): string | undefined {
246
- return this.anchor;
247
- }
248
-
249
- /**
250
- * Returns whether this is an empty/invisible hyperlink (has no display text).
251
- * Empty hyperlinks are self-closing elements in the XML.
252
- */
253
- isEmpty(): boolean {
254
- return this._isEmpty;
255
- }
256
-
257
- /**
258
- * Gets the target frame attribute (e.g., "_blank" for new window)
259
- */
260
- getTgtFrame(): string | undefined {
261
- return this.tgtFrame;
262
- }
263
-
264
- /**
265
- * Gets the history tracking attribute
266
- */
267
- getHistory(): string | undefined {
268
- return this.history;
269
- }
270
-
271
- /**
272
- * Sets the target frame attribute
273
- * @param tgtFrame Target frame (e.g., "_blank" for new window)
274
- */
275
- setTgtFrame(tgtFrame: string | undefined): this {
276
- this.tgtFrame = tgtFrame;
277
- return this;
278
- }
279
-
280
- /**
281
- * Sets the history tracking attribute
282
- * @param history History value (e.g., "1" to add to history)
283
- */
284
- setHistory(history: string | undefined): this {
285
- this.history = history;
286
- return this;
287
- }
288
-
289
- /**
290
- * Gets the document location attribute (ECMA-376 §17.16.22)
291
- */
292
- getDocLocation(): string | undefined {
293
- return this.docLocation;
294
- }
295
-
296
- /**
297
- * Sets the document location for within-document navigation in external files
298
- * @param docLocation Location string
299
- */
300
- setDocLocation(docLocation: string | undefined): this {
301
- this.docLocation = docLocation;
302
- return this;
303
- }
304
-
305
- /**
306
- * Gets the display text
307
- *
308
- * This method delegates to the internal run to ensure the returned text
309
- * is always accurate and matches what will be in the generated XML,
310
- * per ECMA-376 Part 1 §17.16.22.
311
- *
312
- * @returns The display text including any special characters (tabs, breaks, etc.)
313
- */
314
- getText(): string {
315
- return this.run.getText();
316
- }
317
-
318
- /**
319
- * Sets the display text
320
- */
321
- setText(text: string): this {
322
- // Validate text for XML patterns
323
- // Default to auto-cleaning unless explicitly disabled (matches Run behavior)
324
- const validation = validateRunText(text, {
325
- context: "Hyperlink.setText",
326
- autoClean: this.formatting.cleanXmlFromText !== false,
327
- warnToConsole: true,
328
- });
329
-
330
- // Use cleaned text if available
331
- const cleanedText = validation.cleanedText || text;
332
-
333
- const previousValue = this.text;
334
- this.text = cleanedText;
335
- this.run.setText(cleanedText); // Run.setText also validates
336
- if (this.trackingContext?.isEnabled() && previousValue !== cleanedText) {
337
- this.trackingContext.trackHyperlinkChange(this, 'text', previousValue, cleanedText);
338
- }
339
- return this;
340
- }
341
-
342
- /**
343
- * Sets the internal run directly (for advanced use cases like TOC parsing)
344
- * Used by DocumentParser to preserve run content (tabs, breaks, etc.)
345
- * @param run - The run to use for this hyperlink
346
- */
347
- setRun(run: Run): this {
348
- this.run = run;
349
- this.text = run.getText();
350
- return this;
351
- }
352
-
353
- /**
354
- * Gets the tooltip
355
- */
356
- getTooltip(): string | undefined {
357
- return this.tooltip;
358
- }
359
-
360
- /**
361
- * Sets the tooltip
362
- */
363
- setTooltip(tooltip: string): this {
364
- const previousValue = this.tooltip;
365
- this.tooltip = tooltip;
366
- if (this.trackingContext?.isEnabled() && previousValue !== tooltip) {
367
- this.trackingContext.trackHyperlinkChange(this, 'tooltip', previousValue, tooltip);
368
- }
369
- return this;
370
- }
371
-
372
- /**
373
- * Gets the relationship ID
374
- */
375
- getRelationshipId(): string | undefined {
376
- return this.relationshipId;
377
- }
378
-
379
- /**
380
- * Sets the relationship ID (called by Document during save)
381
- */
382
- setRelationshipId(id: string): this {
383
- this.relationshipId = id;
384
- return this;
385
- }
386
-
387
- /**
388
- * Sets or updates the hyperlink URL
389
- *
390
- * When URL is updated, we mark that the relationship needs updating.
391
- * The actual relationship update happens during Document.save() to ensure
392
- * proper coordination with the RelationshipManager.
393
- *
394
- * **Important:** This method maintains the relationship ID but flags it for update.
395
- * The RelationshipManager will update the existing relationship's target URL
396
- * during save, preventing orphaned relationships per ECMA-376 §17.16.22.
397
- *
398
- * @param url - The new URL (or undefined to clear)
399
- * @returns This hyperlink for chaining
400
- * @throws {Error} If clearing URL would create empty hyperlink (no URL and no anchor)
401
- *
402
- * @example
403
- * ```typescript
404
- * const link = Hyperlink.createExternal('https://old.com', 'Link');
405
- * link.setUrl('https://new.com'); // Marks for relationship update
406
- * await doc.save('updated.docx'); // Updates relationship target
407
- * ```
408
- */
409
- setUrl(url: string | undefined): this {
410
- // Validate that clearing URL doesn't create empty hyperlink
411
- if (!url && !this.anchor) {
412
- throw new Error(
413
- `Cannot set URL to undefined: Hyperlink "${this.run.getText()}" has no anchor. ` +
414
- `Clearing the URL would create an invalid hyperlink per ECMA-376 §17.16.22. ` +
415
- `Either provide a new URL or delete the hyperlink entirely.`
416
- );
417
- }
418
-
419
- // Save old URL before updating (for text fallback logic)
420
- const oldUrl = this.url;
421
-
422
- // Skip if URL unchanged (optimization)
423
- if (oldUrl === url) {
424
- return this;
425
- }
426
-
427
- // If tracking enabled AND has parent paragraph, create revision pair
428
- // OOXML has no w:hyperlinkChange element - Word tracks hyperlink changes as delete/insert pairs
429
- if (this.trackingContext?.isEnabled() && this._parentParagraph) {
430
- const author = this.trackingContext.getAuthor();
431
-
432
- // Clone current state for deletion (before applying changes)
433
- const oldHyperlink = this.clone();
434
-
435
- // Apply the change to this hyperlink
436
- this.url = url;
437
- this.relationshipId = undefined;
438
- if (this.run.getText() === oldUrl) {
439
- this.text = url || this.anchor || "Link";
440
- this.run.setText(this.text);
441
- }
442
-
443
- // Create delete/insert revision pair
444
- const deletion = Revision.createDeletion(author, [oldHyperlink]);
445
- const insertion = Revision.createInsertion(author, [this]);
446
-
447
- // Replace this hyperlink with the revision pair in parent paragraph
448
- this._parentParagraph.replaceContent(this, [deletion, insertion]);
449
-
450
- // Clear parent reference since we're now inside a revision
451
- this._parentParagraph = undefined;
452
-
453
- return this;
454
- }
455
-
456
- // Non-tracking path (original behavior)
457
- this.url = url;
458
-
459
- // Clear the relationship ID so it will be re-registered during save
460
- // This ensures the relationship target is updated to point to the new URL
461
- this.relationshipId = undefined;
462
-
463
- // Update text ONLY if it was auto-generated from the old URL
464
- // This preserves user-provided text (even if it's "Link")
465
- // Use run.getText() to ensure we check the actual current text, not stale cache
466
- if (this.run.getText() === oldUrl) {
467
- this.text = url || this.anchor || "Link";
468
- this.run.setText(this.text);
469
- }
470
-
471
- return this;
472
- }
473
-
474
- /**
475
- * Sets the anchor (for internal links)
476
- * @param anchor Bookmark name to link to
477
- * @returns This hyperlink for chaining
478
- * @throws {Error} If clearing anchor would create empty hyperlink (no URL and no anchor)
479
- * @example
480
- * ```typescript
481
- * const link = Hyperlink.createInternal('OldBookmark', 'Go there');
482
- * link.setAnchor('NewBookmark'); // Update internal link target
483
- * ```
484
- */
485
- setAnchor(anchor: string | undefined): this {
486
- // Validate that clearing anchor doesn't create empty hyperlink
487
- if (!anchor && !this.url) {
488
- throw new Error(
489
- `Cannot set anchor to undefined: Hyperlink "${this.run.getText()}" has no URL. ` +
490
- `Clearing the anchor would create an invalid hyperlink per ECMA-376 §17.16.22. ` +
491
- `Either provide a new anchor or delete the hyperlink entirely.`
492
- );
493
- }
494
-
495
- // Save old anchor before updating
496
- const oldAnchor = this.anchor;
497
-
498
- // Skip if anchor unchanged (optimization)
499
- if (oldAnchor === anchor) {
500
- return this;
501
- }
502
-
503
- // If tracking enabled AND has parent paragraph, create revision pair
504
- // OOXML has no w:hyperlinkChange element - Word tracks hyperlink changes as delete/insert pairs
505
- if (this.trackingContext?.isEnabled() && this._parentParagraph) {
506
- const author = this.trackingContext.getAuthor();
507
-
508
- // Clone current state for deletion (before applying changes)
509
- const oldHyperlink = this.clone();
510
-
511
- // Apply the change to this hyperlink
512
- this.anchor = anchor;
513
- if (anchor && this.url) {
514
- defaultLogger.warn(
515
- `DocXML Warning: Setting anchor "${anchor}" on hyperlink that has URL "${this.url}". ` +
516
- `Clearing URL to make this an internal link. Use separate hyperlinks for external and internal links.`
517
- );
518
- this.url = undefined;
519
- this.relationshipId = undefined;
520
- }
521
- if (this.run.getText() === oldAnchor) {
522
- this.text = anchor || this.url || "Link";
523
- this.run.setText(this.text);
524
- }
525
-
526
- // Create delete/insert revision pair
527
- const deletion = Revision.createDeletion(author, [oldHyperlink]);
528
- const insertion = Revision.createInsertion(author, [this]);
529
-
530
- // Replace this hyperlink with the revision pair in parent paragraph
531
- this._parentParagraph.replaceContent(this, [deletion, insertion]);
532
-
533
- // Clear parent reference since we're now inside a revision
534
- this._parentParagraph = undefined;
535
-
536
- return this;
537
- }
538
-
539
- // Non-tracking path (original behavior)
540
- this.anchor = anchor;
541
-
542
- // If converting from external to internal, clear URL and relationship
543
- if (anchor && this.url) {
544
- defaultLogger.warn(
545
- `DocXML Warning: Setting anchor "${anchor}" on hyperlink that has URL "${this.url}". ` +
546
- `Clearing URL to make this an internal link. Use separate hyperlinks for external and internal links.`
547
- );
548
- this.url = undefined;
549
- this.relationshipId = undefined;
550
- }
551
-
552
- // Update text ONLY if it was auto-generated from the old anchor
553
- // Use run.getText() to ensure we check the actual current text, not stale cache
554
- if (this.run.getText() === oldAnchor) {
555
- this.text = anchor || this.url || "Link";
556
- this.run.setText(this.text);
557
- }
558
-
559
- return this;
560
- }
561
-
562
- /**
563
- * Gets the run
564
- */
565
- getRun(): Run {
566
- return this.run;
567
- }
568
-
569
- /**
570
- * Sets run formatting
571
- *
572
- * @param formatting - The formatting to apply
573
- * @param options - Optional settings
574
- * @param options.replace - If true, replaces ALL existing formatting instead of merging.
575
- * Use this when you want to clear inherited styles like characterStyle.
576
- *
577
- * @example
578
- * ```typescript
579
- * // Merge mode (default): adds/updates properties while preserving others
580
- * hyperlink.setFormatting({ bold: true });
581
- *
582
- * // Replace mode: clears all existing formatting and applies only the new properties
583
- * hyperlink.setFormatting({ font: "Verdana", size: 12 }, { replace: true });
584
- * ```
585
- */
586
- setFormatting(formatting: RunFormatting, options?: { replace?: boolean }): this {
587
- // Update stored formatting
588
- const previousValue = { ...this.formatting };
589
- if (options?.replace) {
590
- // Replace mode: new formatting replaces ALL existing properties
591
- this.formatting = { ...formatting };
592
- } else {
593
- // Merge mode (default, backwards-compatible): merge with existing
594
- this.formatting = { ...this.formatting, ...formatting };
595
- }
596
- // Create new run with updated formatting, preserving current text
597
- const currentText = this.run.getText();
598
- this.run = new Run(currentText, this.formatting);
599
- this.text = currentText; // Keep cache in sync
600
- if (this.trackingContext?.isEnabled()) {
601
- this.trackingContext.trackHyperlinkChange(this, 'formatting', previousValue, this.formatting);
602
- }
603
- return this;
604
- }
605
-
606
- /**
607
- * Gets run formatting (returns this hyperlink for fluent API)
608
- * @returns This hyperlink for method chaining
609
- *
610
- * @example
611
- * ```typescript
612
- * hyperlink.getFormatting().setColor('0563C1').setUnderline('single');
613
- * ```
614
- */
615
- getFormatting(): this {
616
- return this;
617
- }
618
-
619
- /**
620
- * Gets the raw formatting object (for direct access)
621
- * @returns RunFormatting object
622
- */
623
- getRawFormatting(): RunFormatting {
624
- return this.formatting;
625
- }
626
-
627
- // ============================================================================
628
- // Individual Formatting Getters
629
- // ============================================================================
630
-
631
- /**
632
- * Gets the text color
633
- * @returns Color hex string or undefined
634
- */
635
- getColor(): string | undefined {
636
- return this.formatting.color;
637
- }
638
-
639
- /**
640
- * Gets the underline style
641
- * @returns Underline style or undefined
642
- */
643
- getUnderline(): string | boolean | undefined {
644
- return this.formatting.underline;
645
- }
646
-
647
- /**
648
- * Gets whether the hyperlink is bold
649
- * @returns True if bold, false otherwise
650
- */
651
- getBold(): boolean {
652
- return this.formatting.bold ?? false;
653
- }
654
-
655
- /**
656
- * Gets whether the hyperlink is italic
657
- * @returns True if italic, false otherwise
658
- */
659
- getItalic(): boolean {
660
- return this.formatting.italic ?? false;
661
- }
662
-
663
- /**
664
- * Gets the font family
665
- * @returns Font name or undefined
666
- */
667
- getFont(): string | undefined {
668
- return this.formatting.font;
669
- }
670
-
671
- /**
672
- * Gets the font size
673
- * @returns Font size in points or undefined
674
- */
675
- getSize(): number | undefined {
676
- return this.formatting.size;
677
- }
678
-
679
- /**
680
- * Sets text color
681
- * @param color Color in hex format (e.g., '0563C1')
682
- * @returns This hyperlink for chaining
683
- */
684
- setColor(color: string): this {
685
- const previousValue = this.formatting.color;
686
- this.formatting.color = color;
687
- this.run = new Run(this.text, this.formatting);
688
- if (this.trackingContext?.isEnabled() && previousValue !== color) {
689
- this.trackingContext.trackHyperlinkChange(this, 'color', previousValue, color);
690
- }
691
- return this;
692
- }
693
-
694
- /**
695
- * Sets underline style
696
- * @param underline Underline style ('single', 'double', etc.)
697
- * @returns This hyperlink for chaining
698
- */
699
- setUnderline(underline: boolean | "single" | "double" | "dotted" | "thick" | "dash"): this {
700
- const previousValue = this.formatting.underline;
701
- this.formatting.underline = underline;
702
- this.run = new Run(this.text, this.formatting);
703
- if (this.trackingContext?.isEnabled() && previousValue !== underline) {
704
- this.trackingContext.trackHyperlinkChange(this, 'underline', previousValue, underline);
705
- }
706
- return this;
707
- }
708
-
709
- /**
710
- * Sets bold formatting
711
- * @param bold Bold state (default: true)
712
- * @returns This hyperlink for chaining
713
- */
714
- setBold(bold = true): this {
715
- const previousValue = this.formatting.bold;
716
- this.formatting.bold = bold;
717
- this.run = new Run(this.text, this.formatting);
718
- if (this.trackingContext?.isEnabled() && previousValue !== bold) {
719
- this.trackingContext.trackHyperlinkChange(this, 'bold', previousValue, bold);
720
- }
721
- return this;
722
- }
723
-
724
- /**
725
- * Sets italic formatting
726
- * @param italic Italic state (default: true)
727
- * @returns This hyperlink for chaining
728
- */
729
- setItalic(italic = true): this {
730
- const previousValue = this.formatting.italic;
731
- this.formatting.italic = italic;
732
- this.run = new Run(this.text, this.formatting);
733
- if (this.trackingContext?.isEnabled() && previousValue !== italic) {
734
- this.trackingContext.trackHyperlinkChange(this, 'italic', previousValue, italic);
735
- }
736
- return this;
737
- }
738
-
739
- /**
740
- * Sets font family
741
- * @param font Font name (e.g., 'Arial', 'Verdana')
742
- * @returns This hyperlink for chaining
743
- */
744
- setFont(font: string): this {
745
- const previousValue = this.formatting.font;
746
- this.formatting.font = font;
747
- this.run = new Run(this.text, this.formatting);
748
- if (this.trackingContext?.isEnabled() && previousValue !== font) {
749
- this.trackingContext.trackHyperlinkChange(this, 'font', previousValue, font);
750
- }
751
- return this;
752
- }
753
-
754
- /**
755
- * Sets font size
756
- * @param size Font size in points (e.g., 12, 14)
757
- * @returns This hyperlink for chaining
758
- */
759
- setSize(size: number): this {
760
- const previousValue = this.formatting.size;
761
- this.formatting.size = size;
762
- this.run = new Run(this.text, this.formatting);
763
- if (this.trackingContext?.isEnabled() && previousValue !== size) {
764
- this.trackingContext.trackHyperlinkChange(this, 'size', previousValue, size);
765
- }
766
- return this;
767
- }
768
-
769
- /**
770
- * Validates the hyperlink URL and optionally fixes common issues
771
- *
772
- * Performs validation and fixing of hyperlink URLs including:
773
- * - Checking URL accessibility (HTTP HEAD request for external links)
774
- * - Fixing common URL issues (missing protocol, double slashes, spaces)
775
- * - Validating internal bookmark references
776
- * - Detecting broken links
777
- *
778
- * **Note:** This method is async due to network requests for accessibility checks.
779
- *
780
- * @param options - Validation options
781
- * @returns Promise with validation results
782
- *
783
- * @example
784
- * ```typescript
785
- * // Basic URL fixing without network check
786
- * const result = await link.validateAndFix({
787
- * fixCommonIssues: true,
788
- * checkAccessibility: false
789
- * });
790
- * console.log(`Fixed: ${result.fixed.join(', ')}`);
791
- *
792
- * // Full validation with accessibility check
793
- * const validation = await link.validateAndFix({
794
- * checkAccessibility: true,
795
- * timeout: 5000
796
- * });
797
- * if (!validation.valid) {
798
- * console.log(`Issues: ${validation.issues.join(', ')}`);
799
- * }
800
- *
801
- * // Batch validate all hyperlinks in document
802
- * for (const { hyperlink } of doc.getHyperlinks()) {
803
- * const result = await hyperlink.validateAndFix();
804
- * if (result.fixed.length > 0) {
805
- * console.log(`Fixed ${hyperlink.getUrl()}: ${result.fixed.join(', ')}`);
806
- * }
807
- * }
808
- * ```
809
- */
810
- async validateAndFix(options?: {
811
- checkAccessibility?: boolean;
812
- fixCommonIssues?: boolean;
813
- timeout?: number;
814
- bookmarkManager?: { hasBookmark(name: string): boolean };
815
- }): Promise<{
816
- valid: boolean;
817
- issues: string[];
818
- fixed: string[];
819
- originalUrl?: string;
820
- fixedUrl?: string;
821
- }> {
822
- const {
823
- checkAccessibility = false,
824
- fixCommonIssues = true,
825
- timeout = 5000,
826
- bookmarkManager,
827
- } = options || {};
828
-
829
- const issues: string[] = [];
830
- const fixed: string[] = [];
831
- let fixedUrl = this.url;
832
- const originalUrl = this.url;
833
-
834
- // Internal link validation (bookmarks)
835
- if (this.anchor) {
836
- if (bookmarkManager) {
837
- const bookmarkExists = bookmarkManager.hasBookmark(this.anchor);
838
- if (!bookmarkExists) {
839
- issues.push(`Internal bookmark "${this.anchor}" not found`);
840
- }
841
- }
842
- return {
843
- valid: issues.length === 0,
844
- issues,
845
- fixed,
846
- originalUrl,
847
- };
848
- }
849
-
850
- // External link validation
851
- if (!this.url) {
852
- issues.push("No URL or anchor specified");
853
- return { valid: false, issues, fixed, originalUrl };
854
- }
855
-
856
- // Fix common issues
857
- if (fixCommonIssues && fixedUrl) {
858
- // Fix 1: Add missing protocol
859
- if (!(/^[a-z]+:\/\//i.exec(fixedUrl))) {
860
- fixedUrl = "https://" + fixedUrl;
861
- fixed.push("Added missing protocol (https://)");
862
- }
863
-
864
- // Fix 2: Fix double slashes (except after protocol)
865
- const protocolMatch = /^([a-z]+:\/\/)/i.exec(fixedUrl);
866
- if (protocolMatch?.[1]) {
867
- const protocol = protocolMatch[1];
868
- const rest = fixedUrl.substring(protocol.length);
869
- const fixedRest = rest.replace(/\/\//g, "/");
870
- if (rest !== fixedRest) {
871
- fixedUrl = protocol + fixedRest;
872
- fixed.push("Fixed double slashes");
873
- }
874
- }
875
-
876
- // Fix 3: Encode spaces
877
- if (fixedUrl.includes(" ")) {
878
- fixedUrl = fixedUrl.replace(/ /g, "%20");
879
- fixed.push("Encoded spaces as %20");
880
- }
881
-
882
- // Fix 4: Remove trailing slashes for non-root URLs
883
- if (/^https?:\/\/[^/]+\/.+\/$/.exec(fixedUrl)) {
884
- fixedUrl = fixedUrl.replace(/\/$/, "");
885
- fixed.push("Removed trailing slash");
886
- }
887
-
888
- // Fix 5: Fix common typos
889
- fixedUrl = fixedUrl.replace(/^http:\/\//i, "https://"); // Prefer HTTPS
890
- if (fixedUrl !== this.url && fixedUrl.startsWith("https://")) {
891
- fixed.push("Upgraded HTTP to HTTPS");
892
- }
893
-
894
- // Update URL if fixes were applied
895
- if (fixedUrl !== this.url) {
896
- this.setUrl(fixedUrl);
897
- }
898
- }
899
-
900
- // Check accessibility (HTTP HEAD request)
901
- if (checkAccessibility && fixedUrl?.match(/^https?:\/\//i)) {
902
- // Check if fetch is available (Node.js 18+ or browser)
903
- if (typeof fetch === "undefined") {
904
- issues.push(
905
- "Network validation unavailable: fetch API not supported in this environment"
906
- );
907
- } else {
908
- try {
909
- // Use fetch with AbortController for timeout
910
- const controller = new AbortController();
911
- const timeoutId = setTimeout(() => controller.abort(), timeout);
912
-
913
- const response = await fetch(fixedUrl, {
914
- method: "HEAD",
915
- signal: controller.signal,
916
- redirect: "follow",
917
- });
918
-
919
- clearTimeout(timeoutId);
920
-
921
- if (!response.ok) {
922
- issues.push(
923
- `HTTP ${response.status}: ${response.statusText || "Error"}`
924
- );
925
- }
926
- } catch (error: unknown) {
927
- // Type guard for error objects with name and message properties
928
- const isErrorWithName = (err: unknown): err is { name: string } => {
929
- return typeof err === "object" && err !== null && "name" in err;
930
- };
931
- const isErrorWithMessage = (
932
- err: unknown
933
- ): err is { message: string } => {
934
- return typeof err === "object" && err !== null && "message" in err;
935
- };
936
-
937
- if (isErrorWithName(error) && error.name === "AbortError") {
938
- issues.push(`Timeout after ${timeout}ms`);
939
- } else if (
940
- isErrorWithMessage(error) &&
941
- error.message?.includes("fetch")
942
- ) {
943
- issues.push(`Unreachable: ${error.message}`);
944
- } else if (isErrorWithMessage(error)) {
945
- issues.push(`Network error: ${error.message}`);
946
- } else {
947
- issues.push("Network error: Unknown error");
948
- }
949
- }
950
- }
951
- }
952
-
953
- return {
954
- valid: issues.length === 0,
955
- issues,
956
- fixed,
957
- originalUrl,
958
- fixedUrl: fixedUrl !== originalUrl ? fixedUrl : undefined,
959
- };
960
- }
961
-
962
- /**
963
- * Resets hyperlink formatting to standard style (Calibri, blue, underline)
964
- * This is useful for fixing corrupted hyperlinks from Google Docs or other sources
965
- * @returns this for method chaining
966
- */
967
- resetToStandardFormatting(): this {
968
- const standardFormatting: RunFormatting = {
969
- font: "Verdana",
970
- color: "0000FF", // Standard hyperlink blue
971
- underline: "single",
972
- // Clear any other formatting that might be causing issues
973
- bold: false,
974
- italic: false,
975
- strike: false,
976
- };
977
-
978
- this.setFormatting(standardFormatting);
979
- return this;
980
- }
981
-
982
- /**
983
- * Checks if this is an external link
984
- */
985
- isExternal(): boolean {
986
- return this.url !== undefined;
987
- }
988
-
989
- /**
990
- * Checks if this is an internal link (anchor)
991
- */
992
- isInternal(): boolean {
993
- return this.anchor !== undefined;
994
- }
995
-
996
- /**
997
- * Creates a deep copy of this hyperlink
998
- *
999
- * This is useful for preserving the original state before modifications,
1000
- * particularly when creating tracked changes (revisions) where both the
1001
- * old and new states need to be preserved.
1002
- *
1003
- * @returns A new Hyperlink instance with the same properties
1004
- *
1005
- * @example
1006
- * ```typescript
1007
- * // Clone before modifying for tracked changes
1008
- * const originalLink = hyperlink.clone();
1009
- * hyperlink.setUrl('https://new-url.com');
1010
- * hyperlink.setText('New Text');
1011
- *
1012
- * // Now originalLink has old URL/text, hyperlink has new
1013
- * const deletion = Revision.createDeletion(author, [originalLink]);
1014
- * const insertion = Revision.createInsertion(author, [hyperlink]);
1015
- * ```
1016
- */
1017
- clone(): Hyperlink {
1018
- const cloned = new Hyperlink({
1019
- url: this.url,
1020
- anchor: this.anchor,
1021
- text: this.text,
1022
- tooltip: this.tooltip,
1023
- relationshipId: this.relationshipId,
1024
- formatting: { ...this.formatting },
1025
- tgtFrame: this.tgtFrame,
1026
- history: this.history,
1027
- docLocation: this.docLocation,
1028
- });
1029
-
1030
- // Copy the run with its formatting
1031
- if (this.run) {
1032
- cloned.run = new Run(this.run.getText(), { ...this.run.getFormatting() });
1033
- }
1034
-
1035
- return cloned;
1036
- }
1037
-
1038
- /**
1039
- * Generates XML for the hyperlink
1040
- *
1041
- * **CRITICAL:** For external links, relationshipId MUST be set before calling toXML().
1042
- * This happens automatically when saving via Document.save(), but manual usage requires
1043
- * registering the hyperlink with RelationshipManager first.
1044
- *
1045
- * @throws {Error} If external link (has url) is missing relationshipId
1046
- * @throws {Error} If hyperlink has neither url nor anchor (empty hyperlink)
1047
- */
1048
- toXML(): XMLElement {
1049
- // VALIDATION: Hyperlink must have url OR anchor (unless it's an empty hyperlink with relationshipId)
1050
- if (!this.url && !this.anchor && !this.relationshipId) {
1051
- throw new Error(
1052
- "CRITICAL: Hyperlink must have either a URL (external link), anchor (internal link), or relationshipId. " +
1053
- "Cannot generate valid XML for hyperlink without destination."
1054
- );
1055
- }
1056
-
1057
- // VALIDATION: External links MUST have relationship ID
1058
- // Per ECMA-376 Part 1 §17.16.22, <w:hyperlink> with external target requires r:id attribute
1059
- if (this.url && !this.relationshipId) {
1060
- throw new Error(
1061
- `CRITICAL: External hyperlink to "${this.url}" is missing relationship ID. ` +
1062
- `This would create an invalid OpenXML document per ECMA-376 §17.16.22. ` +
1063
- `Solution: Use Document.save() which automatically registers relationships, ` +
1064
- `or manually call relationshipManager.addHyperlink(url) and set the relationship ID.`
1065
- );
1066
- }
1067
-
1068
- const attributes: Record<string, string> = {};
1069
-
1070
- // External link - add relationship ID
1071
- if (this.relationshipId) {
1072
- attributes["r:id"] = this.relationshipId;
1073
- }
1074
-
1075
- // Internal link - uses anchor
1076
- if (this.anchor) {
1077
- attributes["w:anchor"] = this.anchor;
1078
- }
1079
-
1080
- // Tooltip - explicitly escape attribute value for safety
1081
- // XMLBuilder will handle escaping, but we document this for clarity
1082
- if (this.tooltip) {
1083
- // Note: XMLBuilder.elementToString() will escape this via escapeXmlAttribute()
1084
- // when generating the actual XML string. We store the raw value here.
1085
- attributes["w:tooltip"] = this.tooltip;
1086
- }
1087
-
1088
- // Target frame attribute (e.g., "_blank" for new window)
1089
- if (this.tgtFrame) {
1090
- attributes["w:tgtFrame"] = this.tgtFrame;
1091
- }
1092
-
1093
- // History tracking attribute
1094
- if (this.history) {
1095
- attributes["w:history"] = this.history;
1096
- }
1097
-
1098
- // Document location attribute (ECMA-376 §17.16.22)
1099
- if (this.docLocation) {
1100
- attributes["w:docLocation"] = this.docLocation;
1101
- }
1102
-
1103
- // Empty/invisible hyperlinks have no children (self-closing element)
1104
- if (this._isEmpty) {
1105
- return {
1106
- name: "w:hyperlink",
1107
- attributes,
1108
- children: [],
1109
- };
1110
- }
1111
-
1112
- // Generate run XML
1113
- const runXml = this.run.toXML();
1114
-
1115
- return {
1116
- name: "w:hyperlink",
1117
- attributes,
1118
- children: [runXml],
1119
- };
1120
- }
1121
-
1122
- /**
1123
- * Creates an external hyperlink
1124
- * @param url The URL
1125
- * @param text Display text
1126
- * @param formatting Optional formatting
1127
- */
1128
- static createExternal(
1129
- url: string,
1130
- text: string,
1131
- formatting?: RunFormatting
1132
- ): Hyperlink {
1133
- return new Hyperlink({ url, text, formatting });
1134
- }
1135
-
1136
- /**
1137
- * Creates an internal hyperlink (to a bookmark)
1138
- * @param anchor Bookmark name
1139
- * @param text Display text
1140
- * @param formatting Optional formatting
1141
- */
1142
- static createInternal(
1143
- anchor: string,
1144
- text: string,
1145
- formatting?: RunFormatting
1146
- ): Hyperlink {
1147
- return new Hyperlink({ anchor, text, formatting });
1148
- }
1149
-
1150
- /**
1151
- * Creates a web link (convenience method for URLs)
1152
- * @param url The URL
1153
- * @param text Display text (defaults to URL)
1154
- * @param formatting Optional formatting
1155
- */
1156
- static createWebLink(
1157
- url: string,
1158
- text?: string,
1159
- formatting?: RunFormatting
1160
- ): Hyperlink {
1161
- return new Hyperlink({
1162
- url,
1163
- text: text || url,
1164
- formatting,
1165
- });
1166
- }
1167
-
1168
- /**
1169
- * Creates an email link
1170
- * @param email Email address
1171
- * @param text Display text (defaults to email)
1172
- * @param formatting Optional formatting
1173
- */
1174
- static createEmail(
1175
- email: string,
1176
- text?: string,
1177
- formatting?: RunFormatting
1178
- ): Hyperlink {
1179
- return new Hyperlink({
1180
- url: `mailto:${email}`,
1181
- text: text || email,
1182
- formatting,
1183
- });
1184
- }
1185
-
1186
- /**
1187
- * Creates a hyperlink with properties
1188
- * @param properties Hyperlink properties
1189
- */
1190
- static create(properties: HyperlinkProperties): Hyperlink {
1191
- return new Hyperlink(properties);
1192
- }
1193
- }
1
+ /**
2
+ * Hyperlink - Represents a hyperlink in a Word document
3
+ *
4
+ * Hyperlinks can be external (to websites, files) or internal (to bookmarks within the document).
5
+ * They are represented using the `<w:hyperlink>` element.
6
+ *
7
+ * ## Important: Relationship ID Requirement
8
+ *
9
+ * **External hyperlinks REQUIRE a relationship ID to be set before XML generation.**
10
+ * Per ECMA-376 Part 1 §17.16.22, `<w:hyperlink>` elements with external targets must have
11
+ * an `r:id` attribute that references a relationship in `word/_rels/document.xml.rels`.
12
+ *
13
+ * ### Correct Usage Pattern:
14
+ *
15
+ * ```typescript
16
+ * // RECOMMENDED: Use Document.save() - automatically handles relationships
17
+ * const doc = Document.create();
18
+ * const para = doc.createParagraph();
19
+ * para.addHyperlink(Hyperlink.createExternal('https://example.com', 'Link'));
20
+ * await doc.save('document.docx'); // ✅ Relationships auto-registered
21
+ * ```
22
+ *
23
+ * ### Manual Relationship Registration (Advanced):
24
+ *
25
+ * ```typescript
26
+ * const link = Hyperlink.createExternal('https://example.com', 'Link');
27
+ * const relationship = relationshipManager.addHyperlink('https://example.com');
28
+ * link.setRelationshipId(relationship.getId());
29
+ * link.toXML(); // ✅ Now valid
30
+ * ```
31
+ *
32
+ * ### What NOT to Do:
33
+ *
34
+ * ```typescript
35
+ * const link = Hyperlink.createExternal('https://example.com', 'Link');
36
+ * link.toXML(); // ❌ ERROR: Missing relationship ID
37
+ * ```
38
+ *
39
+ * ## Internal Hyperlinks
40
+ *
41
+ * Internal hyperlinks (bookmarks) do NOT require relationships:
42
+ *
43
+ * ```typescript
44
+ * const link = Hyperlink.createInternal('Section1', 'Go to Section 1');
45
+ * link.toXML(); // ✅ Valid - uses w:anchor attribute
46
+ * ```
47
+ *
48
+ * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-376/ | ECMA-376 Part 1 §17.16.22}
49
+ */
50
+
51
+ import { XMLElement } from '../xml/XMLBuilder';
52
+ import { Run, RunFormatting } from './Run';
53
+ import { Revision } from './Revision';
54
+ import { validateRunText } from '../utils/validation';
55
+ import { defaultLogger } from '../utils/logger';
56
+
57
+ /**
58
+ * Hyperlink properties
59
+ */
60
+ export interface HyperlinkProperties {
61
+ /** Hyperlink URL (for external links) */
62
+ url?: string;
63
+ /** Bookmark anchor (for internal links) */
64
+ anchor?: string;
65
+ /** Display text (optional for empty/invisible hyperlinks) */
66
+ text?: string;
67
+ /** Text formatting */
68
+ formatting?: RunFormatting;
69
+ /** Tooltip text */
70
+ tooltip?: string;
71
+ /** Relationship ID (set by Document when saving) */
72
+ relationshipId?: string;
73
+ /** Whether this is an empty/invisible hyperlink with no display text */
74
+ isEmpty?: boolean;
75
+ /** Target frame attribute (e.g., "_blank" for new window) */
76
+ tgtFrame?: string;
77
+ /** History tracking attribute */
78
+ history?: string;
79
+ /** Document location for within-document navigation in external files (ECMA-376 §17.16.22) */
80
+ docLocation?: string;
81
+ }
82
+
83
+ /**
84
+ * Represents a hyperlink
85
+ */
86
+ export class Hyperlink {
87
+ private url?: string;
88
+ private anchor?: string;
89
+ private text: string;
90
+ private run: Run;
91
+ private tooltip?: string;
92
+ private relationshipId?: string;
93
+ private formatting: RunFormatting;
94
+ /** Whether this is an empty/invisible hyperlink with no display text */
95
+ private _isEmpty = false;
96
+ /** Target frame attribute (e.g., "_blank" for new window) */
97
+ private tgtFrame?: string;
98
+ /** History tracking attribute */
99
+ private history?: string;
100
+ /** Document location for within-document navigation in external files */
101
+ private docLocation?: string;
102
+ /** Tracking context for automatic change tracking */
103
+ private trackingContext?: import('../tracking/TrackingContext').TrackingContext;
104
+ /** Parent paragraph reference for automatic tracking */
105
+ private _parentParagraph?: import('./Paragraph').Paragraph;
106
+ /** Baseline formatting captured before first tracked formatting change */
107
+ private _preTrackingFormatting?: RunFormatting;
108
+
109
+ /**
110
+ * Creates a new hyperlink
111
+ *
112
+ * **Note:** A hyperlink must have either a URL (external) or anchor (internal), but not both.
113
+ * If both are provided, the URL takes precedence and a warning is logged.
114
+ *
115
+ * @param properties Hyperlink properties
116
+ */
117
+ constructor(properties: HyperlinkProperties) {
118
+ this.url = properties.url;
119
+ this.anchor = properties.anchor;
120
+ this.tooltip = properties.tooltip;
121
+ this.relationshipId = properties.relationshipId;
122
+ this.tgtFrame = properties.tgtFrame;
123
+ this.history = properties.history;
124
+ this.docLocation = properties.docLocation;
125
+ this._isEmpty = properties.isEmpty ?? false;
126
+
127
+ // VALIDATION: Warn about hybrid links (url + anchor)
128
+ if (this.url && this.anchor) {
129
+ defaultLogger.warn(
130
+ `DocXML Warning: Hyperlink has both URL ("${this.url}") and anchor ("${this.anchor}"). ` +
131
+ `This is ambiguous per ECMA-376 spec. URL will take precedence. ` +
132
+ `Use Hyperlink.createExternal() or Hyperlink.createInternal() to avoid ambiguity.`
133
+ );
134
+ }
135
+
136
+ // Handle empty/invisible hyperlinks (no display text)
137
+ if (this._isEmpty) {
138
+ this.text = '';
139
+ this.formatting = {};
140
+ this.run = new Run('', {});
141
+ return;
142
+ }
143
+
144
+ // Text fallback: properties.text url 'Link'
145
+ // NOTE: Do NOT use anchor (bookmark ID) as display text - it should only be used for navigation
146
+ // Using bookmark IDs as visible text causes TOC corruption (Issue: TOC shows "HEADING=II.MNKE7E8NA385_" instead of proper headings)
147
+ this.text = properties.text || this.url || 'Link';
148
+
149
+ // Validate text for XML patterns
150
+ // Default to auto-cleaning XML patterns unless explicitly disabled (matches Run behavior)
151
+ const validation = validateRunText(this.text, {
152
+ context: 'Hyperlink text',
153
+ autoClean: properties.formatting?.cleanXmlFromText !== false,
154
+ warnToConsole: true,
155
+ });
156
+
157
+ // Use cleaned text if available and cleaning was requested
158
+ if (validation.cleanedText) {
159
+ this.text = validation.cleanedText;
160
+ }
161
+
162
+ // Create run with default hyperlink styling (Verdana 12pt blue underlined)
163
+ this.formatting = {
164
+ font: 'Verdana',
165
+ size: 12,
166
+ color: '0000FF', // Standard hyperlink blue
167
+ underline: 'single',
168
+ ...properties.formatting,
169
+ };
170
+
171
+ this.run = new Run(this.text, this.formatting);
172
+ }
173
+
174
+ /**
175
+ * Sets the tracking context for automatic change tracking.
176
+ * Called by Document when track changes is enabled.
177
+ * @internal
178
+ */
179
+ _setTrackingContext(context: import('../tracking/TrackingContext').TrackingContext): void {
180
+ this.trackingContext = context;
181
+ }
182
+
183
+ /**
184
+ * Sets the parent paragraph reference for automatic tracking.
185
+ * Called by Paragraph when hyperlink is added.
186
+ * @internal
187
+ */
188
+ _setParentParagraph(paragraph: import('./Paragraph').Paragraph): void {
189
+ this._parentParagraph = paragraph;
190
+ }
191
+
192
+ /**
193
+ * Gets the parent paragraph reference.
194
+ * @internal
195
+ */
196
+ _getParentParagraph(): import('./Paragraph').Paragraph | undefined {
197
+ return this._parentParagraph;
198
+ }
199
+
200
+ /**
201
+ * Applies an rPrChange to the inner Run, tracking the delta between
202
+ * the baseline formatting and the current formatting.
203
+ *
204
+ * On first call, captures `previousFormatting` as the baseline.
205
+ * Subsequent calls always compare against the same baseline so that
206
+ * multiple sequential formatting changes produce a single merged rPrChange.
207
+ *
208
+ * @internal
209
+ */
210
+ private _applyFormattingRPrChange(previousFormatting: RunFormatting): void {
211
+ // Capture baseline on first tracked formatting change
212
+ if (!this._preTrackingFormatting) {
213
+ this._preTrackingFormatting = { ...previousFormatting };
214
+ }
215
+
216
+ // Build previousProperties from the baseline (only changed keys)
217
+ const baseline = this._preTrackingFormatting;
218
+ const current = this.formatting;
219
+ const previousProperties: Partial<RunFormatting> = {};
220
+ let hasChanges = false;
221
+
222
+ for (const key of Object.keys(baseline) as (keyof RunFormatting)[]) {
223
+ if (baseline[key] !== current[key]) {
224
+ (previousProperties as Record<string, unknown>)[key] = baseline[key];
225
+ hasChanges = true;
226
+ }
227
+ }
228
+ // Also check for keys in current that weren't in baseline (new properties)
229
+ for (const key of Object.keys(current) as (keyof RunFormatting)[]) {
230
+ if (!(key in baseline) && current[key] !== undefined) {
231
+ // Property didn't exist before previous value is undefined
232
+ (previousProperties as Record<string, unknown>)[key] = undefined;
233
+ hasChanges = true;
234
+ }
235
+ }
236
+
237
+ if (!hasChanges) return;
238
+
239
+ const revisionId = this.trackingContext!.getRevisionManager().consumeNextId();
240
+ const author = this.trackingContext!.getAuthor();
241
+
242
+ this.run.setPropertyChangeRevision({
243
+ id: revisionId,
244
+ author,
245
+ date: new Date(),
246
+ previousProperties,
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Gets the hyperlink URL
252
+ */
253
+ getUrl(): string | undefined {
254
+ return this.url;
255
+ }
256
+
257
+ /**
258
+ * Gets the complete URL including any anchor fragment.
259
+ *
260
+ * For external links that also have an anchor (e.g., internal bookmark within external page),
261
+ * this returns the URL with the anchor appended as a fragment.
262
+ * For internal-only links (anchor without URL), returns undefined.
263
+ *
264
+ * Note: As of v7.2.0, DocumentParser automatically combines external URLs with anchors
265
+ * during parsing, so getUrl() typically returns the full URL. This method is provided
266
+ * for cases where URL and anchor are set separately via the API.
267
+ *
268
+ * @returns The complete URL with fragment, or undefined for internal-only links
269
+ *
270
+ * @example
271
+ * ```typescript
272
+ * // External link with anchor fragment
273
+ * const link = new Hyperlink({ url: 'https://example.com/', anchor: '!/view?id=123', text: 'Link' });
274
+ * link.getUrl(); // 'https://example.com/'
275
+ * link.getAnchor(); // '!/view?id=123'
276
+ * link.getFullUrl(); // 'https://example.com/#!/view?id=123'
277
+ *
278
+ * // External link without anchor
279
+ * const link2 = Hyperlink.createExternal('https://example.com/page', 'Link');
280
+ * link2.getFullUrl(); // 'https://example.com/page'
281
+ *
282
+ * // Internal link (bookmark reference)
283
+ * const link3 = Hyperlink.createInternal('Section1', 'Go to Section 1');
284
+ * link3.getFullUrl(); // undefined
285
+ * ```
286
+ */
287
+ getFullUrl(): string | undefined {
288
+ if (this.url && this.anchor) {
289
+ return this.url + '#' + this.anchor;
290
+ }
291
+ return this.url;
292
+ }
293
+
294
+ /**
295
+ * Gets the anchor (for internal links)
296
+ */
297
+ getAnchor(): string | undefined {
298
+ return this.anchor;
299
+ }
300
+
301
+ /**
302
+ * Returns whether this is an empty/invisible hyperlink (has no display text).
303
+ * Empty hyperlinks are self-closing elements in the XML.
304
+ */
305
+ isEmpty(): boolean {
306
+ return this._isEmpty;
307
+ }
308
+
309
+ /**
310
+ * Gets the target frame attribute (e.g., "_blank" for new window)
311
+ */
312
+ getTgtFrame(): string | undefined {
313
+ return this.tgtFrame;
314
+ }
315
+
316
+ /**
317
+ * Gets the history tracking attribute
318
+ */
319
+ getHistory(): string | undefined {
320
+ return this.history;
321
+ }
322
+
323
+ /**
324
+ * Sets the target frame attribute
325
+ * @param tgtFrame Target frame (e.g., "_blank" for new window)
326
+ */
327
+ setTgtFrame(tgtFrame: string | undefined): this {
328
+ this.tgtFrame = tgtFrame;
329
+ return this;
330
+ }
331
+
332
+ /**
333
+ * Sets the history tracking attribute
334
+ * @param history History value (e.g., "1" to add to history)
335
+ */
336
+ setHistory(history: string | undefined): this {
337
+ this.history = history;
338
+ return this;
339
+ }
340
+
341
+ /**
342
+ * Gets the document location attribute (ECMA-376 §17.16.22)
343
+ */
344
+ getDocLocation(): string | undefined {
345
+ return this.docLocation;
346
+ }
347
+
348
+ /**
349
+ * Sets the document location for within-document navigation in external files
350
+ * @param docLocation Location string
351
+ */
352
+ setDocLocation(docLocation: string | undefined): this {
353
+ this.docLocation = docLocation;
354
+ return this;
355
+ }
356
+
357
+ /**
358
+ * Gets the display text
359
+ *
360
+ * This method delegates to the internal run to ensure the returned text
361
+ * is always accurate and matches what will be in the generated XML,
362
+ * per ECMA-376 Part 1 §17.16.22.
363
+ *
364
+ * @returns The display text including any special characters (tabs, breaks, etc.)
365
+ */
366
+ getText(): string {
367
+ return this.run.getText();
368
+ }
369
+
370
+ /**
371
+ * Sets the display text
372
+ */
373
+ setText(text: string): this {
374
+ // Validate text for XML patterns
375
+ // Default to auto-cleaning unless explicitly disabled (matches Run behavior)
376
+ const validation = validateRunText(text, {
377
+ context: 'Hyperlink.setText',
378
+ autoClean: this.formatting.cleanXmlFromText !== false,
379
+ warnToConsole: true,
380
+ });
381
+
382
+ // Use cleaned text if available
383
+ const cleanedText = validation.cleanedText || text;
384
+
385
+ const previousValue = this.text;
386
+
387
+ // Skip if text unchanged
388
+ if (previousValue === cleanedText) {
389
+ return this;
390
+ }
391
+
392
+ // If tracking enabled AND has parent paragraph, create delete/insert revision pair
393
+ if (this.trackingContext?.isEnabled() && this._parentParagraph) {
394
+ const author = this.trackingContext.getAuthor();
395
+
396
+ // Clone current state for deletion (before applying changes)
397
+ const oldHyperlink = this.clone();
398
+
399
+ // Apply the change to this hyperlink
400
+ this.text = cleanedText;
401
+ this.run.setText(cleanedText);
402
+
403
+ // Create delete/insert revision pair
404
+ const deletion = Revision.createDeletion(author, [oldHyperlink]);
405
+ const insertion = Revision.createInsertion(author, [this]);
406
+
407
+ // Replace this hyperlink with the revision pair in parent paragraph
408
+ this._parentParagraph.replaceContent(this, [deletion, insertion]);
409
+
410
+ // Clear parent reference since we're now inside a revision
411
+ this._parentParagraph = undefined;
412
+
413
+ // Clear baseline formatting (fresh start inside insertion)
414
+ this._preTrackingFormatting = undefined;
415
+
416
+ return this;
417
+ }
418
+
419
+ // Non-tracking path (original behavior)
420
+ this.text = cleanedText;
421
+ this.run.setText(cleanedText);
422
+ return this;
423
+ }
424
+
425
+ /**
426
+ * Sets the internal run directly (for advanced use cases like TOC parsing)
427
+ * Used by DocumentParser to preserve run content (tabs, breaks, etc.)
428
+ * @param run - The run to use for this hyperlink
429
+ */
430
+ setRun(run: Run): this {
431
+ this.run = run;
432
+ this.text = run.getText();
433
+ return this;
434
+ }
435
+
436
+ /**
437
+ * Gets the tooltip
438
+ */
439
+ getTooltip(): string | undefined {
440
+ return this.tooltip;
441
+ }
442
+
443
+ /**
444
+ * Sets the tooltip
445
+ */
446
+ setTooltip(tooltip: string): this {
447
+ const previousValue = this.tooltip;
448
+
449
+ // Skip if tooltip unchanged
450
+ if (previousValue === tooltip) {
451
+ return this;
452
+ }
453
+
454
+ // If tracking enabled AND has parent paragraph, create delete/insert revision pair
455
+ // Tooltip is a w:hyperlink attribute — no OOXML property-change element exists for it
456
+ if (this.trackingContext?.isEnabled() && this._parentParagraph) {
457
+ const author = this.trackingContext.getAuthor();
458
+
459
+ // Clone current state for deletion (before applying changes)
460
+ const oldHyperlink = this.clone();
461
+
462
+ // Apply the change to this hyperlink
463
+ this.tooltip = tooltip;
464
+
465
+ // Create delete/insert revision pair
466
+ const deletion = Revision.createDeletion(author, [oldHyperlink]);
467
+ const insertion = Revision.createInsertion(author, [this]);
468
+
469
+ // Replace this hyperlink with the revision pair in parent paragraph
470
+ this._parentParagraph.replaceContent(this, [deletion, insertion]);
471
+
472
+ // Clear parent reference since we're now inside a revision
473
+ this._parentParagraph = undefined;
474
+
475
+ // Clear baseline formatting (fresh start inside insertion)
476
+ this._preTrackingFormatting = undefined;
477
+
478
+ return this;
479
+ }
480
+
481
+ // Non-tracking path
482
+ this.tooltip = tooltip;
483
+ return this;
484
+ }
485
+
486
+ /**
487
+ * Gets the relationship ID
488
+ */
489
+ getRelationshipId(): string | undefined {
490
+ return this.relationshipId;
491
+ }
492
+
493
+ /**
494
+ * Sets the relationship ID (called by Document during save)
495
+ */
496
+ setRelationshipId(id: string): this {
497
+ this.relationshipId = id;
498
+ return this;
499
+ }
500
+
501
+ /**
502
+ * Sets or updates the hyperlink URL
503
+ *
504
+ * When URL is updated, we mark that the relationship needs updating.
505
+ * The actual relationship update happens during Document.save() to ensure
506
+ * proper coordination with the RelationshipManager.
507
+ *
508
+ * **Important:** This method maintains the relationship ID but flags it for update.
509
+ * The RelationshipManager will update the existing relationship's target URL
510
+ * during save, preventing orphaned relationships per ECMA-376 §17.16.22.
511
+ *
512
+ * @param url - The new URL (or undefined to clear)
513
+ * @returns This hyperlink for chaining
514
+ * @throws {Error} If clearing URL would create empty hyperlink (no URL and no anchor)
515
+ *
516
+ * @example
517
+ * ```typescript
518
+ * const link = Hyperlink.createExternal('https://old.com', 'Link');
519
+ * link.setUrl('https://new.com'); // Marks for relationship update
520
+ * await doc.save('updated.docx'); // Updates relationship target
521
+ * ```
522
+ */
523
+ setUrl(url: string | undefined): this {
524
+ // Validate that clearing URL doesn't create empty hyperlink
525
+ if (!url && !this.anchor) {
526
+ throw new Error(
527
+ `Cannot set URL to undefined: Hyperlink "${this.run.getText()}" has no anchor. ` +
528
+ `Clearing the URL would create an invalid hyperlink per ECMA-376 §17.16.22. ` +
529
+ `Either provide a new URL or delete the hyperlink entirely.`
530
+ );
531
+ }
532
+
533
+ // Save old URL before updating (for text fallback logic)
534
+ const oldUrl = this.url;
535
+
536
+ // Skip if URL unchanged (optimization)
537
+ if (oldUrl === url) {
538
+ return this;
539
+ }
540
+
541
+ // If tracking enabled AND has parent paragraph, create revision pair
542
+ // OOXML has no w:hyperlinkChange element - Word tracks hyperlink changes as delete/insert pairs
543
+ if (this.trackingContext?.isEnabled() && this._parentParagraph) {
544
+ const author = this.trackingContext.getAuthor();
545
+
546
+ // Clone current state for deletion (before applying changes)
547
+ const oldHyperlink = this.clone();
548
+
549
+ // Apply the change to this hyperlink
550
+ this.url = url;
551
+ this.relationshipId = undefined;
552
+ if (this.run.getText() === oldUrl) {
553
+ this.text = url || this.anchor || 'Link';
554
+ this.run.setText(this.text);
555
+ }
556
+
557
+ // Create delete/insert revision pair
558
+ const deletion = Revision.createDeletion(author, [oldHyperlink]);
559
+ const insertion = Revision.createInsertion(author, [this]);
560
+
561
+ // Replace this hyperlink with the revision pair in parent paragraph
562
+ this._parentParagraph.replaceContent(this, [deletion, insertion]);
563
+
564
+ // Clear parent reference since we're now inside a revision
565
+ this._parentParagraph = undefined;
566
+
567
+ return this;
568
+ }
569
+
570
+ // Non-tracking path (original behavior)
571
+ this.url = url;
572
+
573
+ // Clear the relationship ID so it will be re-registered during save
574
+ // This ensures the relationship target is updated to point to the new URL
575
+ this.relationshipId = undefined;
576
+
577
+ // Update text ONLY if it was auto-generated from the old URL
578
+ // This preserves user-provided text (even if it's "Link")
579
+ // Use run.getText() to ensure we check the actual current text, not stale cache
580
+ if (this.run.getText() === oldUrl) {
581
+ this.text = url || this.anchor || 'Link';
582
+ this.run.setText(this.text);
583
+ }
584
+
585
+ return this;
586
+ }
587
+
588
+ /**
589
+ * Sets the anchor (for internal links)
590
+ * @param anchor Bookmark name to link to
591
+ * @returns This hyperlink for chaining
592
+ * @throws {Error} If clearing anchor would create empty hyperlink (no URL and no anchor)
593
+ * @example
594
+ * ```typescript
595
+ * const link = Hyperlink.createInternal('OldBookmark', 'Go there');
596
+ * link.setAnchor('NewBookmark'); // Update internal link target
597
+ * ```
598
+ */
599
+ setAnchor(anchor: string | undefined): this {
600
+ // Validate that clearing anchor doesn't create empty hyperlink
601
+ if (!anchor && !this.url) {
602
+ throw new Error(
603
+ `Cannot set anchor to undefined: Hyperlink "${this.run.getText()}" has no URL. ` +
604
+ `Clearing the anchor would create an invalid hyperlink per ECMA-376 §17.16.22. ` +
605
+ `Either provide a new anchor or delete the hyperlink entirely.`
606
+ );
607
+ }
608
+
609
+ // Save old anchor before updating
610
+ const oldAnchor = this.anchor;
611
+
612
+ // Skip if anchor unchanged (optimization)
613
+ if (oldAnchor === anchor) {
614
+ return this;
615
+ }
616
+
617
+ // If tracking enabled AND has parent paragraph, create revision pair
618
+ // OOXML has no w:hyperlinkChange element - Word tracks hyperlink changes as delete/insert pairs
619
+ if (this.trackingContext?.isEnabled() && this._parentParagraph) {
620
+ const author = this.trackingContext.getAuthor();
621
+
622
+ // Clone current state for deletion (before applying changes)
623
+ const oldHyperlink = this.clone();
624
+
625
+ // Apply the change to this hyperlink
626
+ this.anchor = anchor;
627
+ if (anchor && this.url) {
628
+ defaultLogger.warn(
629
+ `DocXML Warning: Setting anchor "${anchor}" on hyperlink that has URL "${this.url}". ` +
630
+ `Clearing URL to make this an internal link. Use separate hyperlinks for external and internal links.`
631
+ );
632
+ this.url = undefined;
633
+ this.relationshipId = undefined;
634
+ }
635
+ if (this.run.getText() === oldAnchor) {
636
+ this.text = anchor || this.url || 'Link';
637
+ this.run.setText(this.text);
638
+ }
639
+
640
+ // Create delete/insert revision pair
641
+ const deletion = Revision.createDeletion(author, [oldHyperlink]);
642
+ const insertion = Revision.createInsertion(author, [this]);
643
+
644
+ // Replace this hyperlink with the revision pair in parent paragraph
645
+ this._parentParagraph.replaceContent(this, [deletion, insertion]);
646
+
647
+ // Clear parent reference since we're now inside a revision
648
+ this._parentParagraph = undefined;
649
+
650
+ return this;
651
+ }
652
+
653
+ // Non-tracking path (original behavior)
654
+ this.anchor = anchor;
655
+
656
+ // If converting from external to internal, clear URL and relationship
657
+ if (anchor && this.url) {
658
+ defaultLogger.warn(
659
+ `DocXML Warning: Setting anchor "${anchor}" on hyperlink that has URL "${this.url}". ` +
660
+ `Clearing URL to make this an internal link. Use separate hyperlinks for external and internal links.`
661
+ );
662
+ this.url = undefined;
663
+ this.relationshipId = undefined;
664
+ }
665
+
666
+ // Update text ONLY if it was auto-generated from the old anchor
667
+ // Use run.getText() to ensure we check the actual current text, not stale cache
668
+ if (this.run.getText() === oldAnchor) {
669
+ this.text = anchor || this.url || 'Link';
670
+ this.run.setText(this.text);
671
+ }
672
+
673
+ return this;
674
+ }
675
+
676
+ /**
677
+ * Gets the run
678
+ */
679
+ getRun(): Run {
680
+ return this.run;
681
+ }
682
+
683
+ /**
684
+ * Sets run formatting
685
+ *
686
+ * @param formatting - The formatting to apply
687
+ * @param options - Optional settings
688
+ * @param options.replace - If true, replaces ALL existing formatting instead of merging.
689
+ * Use this when you want to clear inherited styles like characterStyle.
690
+ *
691
+ * @example
692
+ * ```typescript
693
+ * // Merge mode (default): adds/updates properties while preserving others
694
+ * hyperlink.setFormatting({ bold: true });
695
+ *
696
+ * // Replace mode: clears all existing formatting and applies only the new properties
697
+ * hyperlink.setFormatting({ font: "Verdana", size: 12 }, { replace: true });
698
+ * ```
699
+ */
700
+ setFormatting(formatting: RunFormatting, options?: { replace?: boolean }): this {
701
+ // Update stored formatting
702
+ const previousFormatting = { ...this.formatting };
703
+ if (options?.replace) {
704
+ // Replace mode: new formatting replaces ALL existing properties
705
+ this.formatting = { ...formatting };
706
+ } else {
707
+ // Merge mode (default, backwards-compatible): merge with existing
708
+ this.formatting = { ...this.formatting, ...formatting };
709
+ }
710
+ // Create new run with updated formatting, preserving current text
711
+ const currentText = this.run.getText();
712
+ this.run = new Run(currentText, this.formatting);
713
+ this.text = currentText; // Keep cache in sync
714
+ if (this.trackingContext?.isEnabled()) {
715
+ this._applyFormattingRPrChange(previousFormatting);
716
+ }
717
+ return this;
718
+ }
719
+
720
+ /**
721
+ * Gets run formatting (returns this hyperlink for fluent API)
722
+ * @returns This hyperlink for method chaining
723
+ *
724
+ * @example
725
+ * ```typescript
726
+ * hyperlink.getFormatting().setColor('0563C1').setUnderline('single');
727
+ * ```
728
+ */
729
+ getFormatting(): this {
730
+ return this;
731
+ }
732
+
733
+ /**
734
+ * Gets the raw formatting object (for direct access)
735
+ * @returns RunFormatting object
736
+ */
737
+ getRawFormatting(): RunFormatting {
738
+ return this.formatting;
739
+ }
740
+
741
+ // ============================================================================
742
+ // Individual Formatting Getters
743
+ // ============================================================================
744
+
745
+ /**
746
+ * Gets the text color
747
+ * @returns Color hex string or undefined
748
+ */
749
+ getColor(): string | undefined {
750
+ return this.formatting.color;
751
+ }
752
+
753
+ /**
754
+ * Gets the underline style
755
+ * @returns Underline style or undefined
756
+ */
757
+ getUnderline(): string | boolean | undefined {
758
+ return this.formatting.underline;
759
+ }
760
+
761
+ /**
762
+ * Gets whether the hyperlink is bold
763
+ * @returns True if bold, false otherwise
764
+ */
765
+ getBold(): boolean {
766
+ return this.formatting.bold ?? false;
767
+ }
768
+
769
+ /**
770
+ * Gets whether the hyperlink is italic
771
+ * @returns True if italic, false otherwise
772
+ */
773
+ getItalic(): boolean {
774
+ return this.formatting.italic ?? false;
775
+ }
776
+
777
+ /**
778
+ * Gets the font family
779
+ * @returns Font name or undefined
780
+ */
781
+ getFont(): string | undefined {
782
+ return this.formatting.font;
783
+ }
784
+
785
+ /**
786
+ * Gets the font size
787
+ * @returns Font size in points or undefined
788
+ */
789
+ getSize(): number | undefined {
790
+ return this.formatting.size;
791
+ }
792
+
793
+ /**
794
+ * Sets text color
795
+ * @param color Color in hex format (e.g., '0563C1')
796
+ * @returns This hyperlink for chaining
797
+ */
798
+ setColor(color: string): this {
799
+ const previousFormatting = { ...this.formatting };
800
+ this.formatting.color = color;
801
+ this.run = new Run(this.text, this.formatting);
802
+ if (this.trackingContext?.isEnabled() && previousFormatting.color !== color) {
803
+ this._applyFormattingRPrChange(previousFormatting);
804
+ }
805
+ return this;
806
+ }
807
+
808
+ /**
809
+ * Sets underline style
810
+ * @param underline Underline style ('single', 'double', etc.)
811
+ * @returns This hyperlink for chaining
812
+ */
813
+ setUnderline(underline: boolean | 'single' | 'double' | 'dotted' | 'thick' | 'dash'): this {
814
+ const previousFormatting = { ...this.formatting };
815
+ this.formatting.underline = underline;
816
+ this.run = new Run(this.text, this.formatting);
817
+ if (this.trackingContext?.isEnabled() && previousFormatting.underline !== underline) {
818
+ this._applyFormattingRPrChange(previousFormatting);
819
+ }
820
+ return this;
821
+ }
822
+
823
+ /**
824
+ * Sets bold formatting
825
+ * @param bold Bold state (default: true)
826
+ * @returns This hyperlink for chaining
827
+ */
828
+ setBold(bold = true): this {
829
+ const previousFormatting = { ...this.formatting };
830
+ this.formatting.bold = bold;
831
+ this.run = new Run(this.text, this.formatting);
832
+ if (this.trackingContext?.isEnabled() && previousFormatting.bold !== bold) {
833
+ this._applyFormattingRPrChange(previousFormatting);
834
+ }
835
+ return this;
836
+ }
837
+
838
+ /**
839
+ * Sets italic formatting
840
+ * @param italic Italic state (default: true)
841
+ * @returns This hyperlink for chaining
842
+ */
843
+ setItalic(italic = true): this {
844
+ const previousFormatting = { ...this.formatting };
845
+ this.formatting.italic = italic;
846
+ this.run = new Run(this.text, this.formatting);
847
+ if (this.trackingContext?.isEnabled() && previousFormatting.italic !== italic) {
848
+ this._applyFormattingRPrChange(previousFormatting);
849
+ }
850
+ return this;
851
+ }
852
+
853
+ /**
854
+ * Sets font family
855
+ * @param font Font name (e.g., 'Arial', 'Verdana')
856
+ * @returns This hyperlink for chaining
857
+ */
858
+ setFont(font: string): this {
859
+ const previousFormatting = { ...this.formatting };
860
+ this.formatting.font = font;
861
+ this.run = new Run(this.text, this.formatting);
862
+ if (this.trackingContext?.isEnabled() && previousFormatting.font !== font) {
863
+ this._applyFormattingRPrChange(previousFormatting);
864
+ }
865
+ return this;
866
+ }
867
+
868
+ /**
869
+ * Sets font size
870
+ * @param size Font size in points (e.g., 12, 14)
871
+ * @returns This hyperlink for chaining
872
+ */
873
+ setSize(size: number): this {
874
+ const previousFormatting = { ...this.formatting };
875
+ this.formatting.size = size;
876
+ this.run = new Run(this.text, this.formatting);
877
+ if (this.trackingContext?.isEnabled() && previousFormatting.size !== size) {
878
+ this._applyFormattingRPrChange(previousFormatting);
879
+ }
880
+ return this;
881
+ }
882
+
883
+ /**
884
+ * Validates the hyperlink URL and optionally fixes common issues
885
+ *
886
+ * Performs validation and fixing of hyperlink URLs including:
887
+ * - Checking URL accessibility (HTTP HEAD request for external links)
888
+ * - Fixing common URL issues (missing protocol, double slashes, spaces)
889
+ * - Validating internal bookmark references
890
+ * - Detecting broken links
891
+ *
892
+ * **Note:** This method is async due to network requests for accessibility checks.
893
+ *
894
+ * @param options - Validation options
895
+ * @returns Promise with validation results
896
+ *
897
+ * @example
898
+ * ```typescript
899
+ * // Basic URL fixing without network check
900
+ * const result = await link.validateAndFix({
901
+ * fixCommonIssues: true,
902
+ * checkAccessibility: false
903
+ * });
904
+ * console.log(`Fixed: ${result.fixed.join(', ')}`);
905
+ *
906
+ * // Full validation with accessibility check
907
+ * const validation = await link.validateAndFix({
908
+ * checkAccessibility: true,
909
+ * timeout: 5000
910
+ * });
911
+ * if (!validation.valid) {
912
+ * console.log(`Issues: ${validation.issues.join(', ')}`);
913
+ * }
914
+ *
915
+ * // Batch validate all hyperlinks in document
916
+ * for (const { hyperlink } of doc.getHyperlinks()) {
917
+ * const result = await hyperlink.validateAndFix();
918
+ * if (result.fixed.length > 0) {
919
+ * console.log(`Fixed ${hyperlink.getUrl()}: ${result.fixed.join(', ')}`);
920
+ * }
921
+ * }
922
+ * ```
923
+ */
924
+ async validateAndFix(options?: {
925
+ checkAccessibility?: boolean;
926
+ fixCommonIssues?: boolean;
927
+ timeout?: number;
928
+ bookmarkManager?: { hasBookmark(name: string): boolean };
929
+ }): Promise<{
930
+ valid: boolean;
931
+ issues: string[];
932
+ fixed: string[];
933
+ originalUrl?: string;
934
+ fixedUrl?: string;
935
+ }> {
936
+ const {
937
+ checkAccessibility = false,
938
+ fixCommonIssues = true,
939
+ timeout = 5000,
940
+ bookmarkManager,
941
+ } = options || {};
942
+
943
+ const issues: string[] = [];
944
+ const fixed: string[] = [];
945
+ let fixedUrl = this.url;
946
+ const originalUrl = this.url;
947
+
948
+ // Internal link validation (bookmarks)
949
+ if (this.anchor) {
950
+ if (bookmarkManager) {
951
+ const bookmarkExists = bookmarkManager.hasBookmark(this.anchor);
952
+ if (!bookmarkExists) {
953
+ issues.push(`Internal bookmark "${this.anchor}" not found`);
954
+ }
955
+ }
956
+ return {
957
+ valid: issues.length === 0,
958
+ issues,
959
+ fixed,
960
+ originalUrl,
961
+ };
962
+ }
963
+
964
+ // External link validation
965
+ if (!this.url) {
966
+ issues.push('No URL or anchor specified');
967
+ return { valid: false, issues, fixed, originalUrl };
968
+ }
969
+
970
+ // Fix common issues
971
+ if (fixCommonIssues && fixedUrl) {
972
+ // Fix 1: Add missing protocol
973
+ if (!/^[a-z]+:\/\//i.exec(fixedUrl)) {
974
+ fixedUrl = 'https://' + fixedUrl;
975
+ fixed.push('Added missing protocol (https://)');
976
+ }
977
+
978
+ // Fix 2: Fix double slashes (except after protocol)
979
+ const protocolMatch = /^([a-z]+:\/\/)/i.exec(fixedUrl);
980
+ if (protocolMatch?.[1]) {
981
+ const protocol = protocolMatch[1];
982
+ const rest = fixedUrl.substring(protocol.length);
983
+ const fixedRest = rest.replace(/\/\//g, '/');
984
+ if (rest !== fixedRest) {
985
+ fixedUrl = protocol + fixedRest;
986
+ fixed.push('Fixed double slashes');
987
+ }
988
+ }
989
+
990
+ // Fix 3: Encode spaces
991
+ if (fixedUrl.includes(' ')) {
992
+ fixedUrl = fixedUrl.replace(/ /g, '%20');
993
+ fixed.push('Encoded spaces as %20');
994
+ }
995
+
996
+ // Fix 4: Remove trailing slashes for non-root URLs
997
+ if (/^https?:\/\/[^/]+\/.+\/$/.exec(fixedUrl)) {
998
+ fixedUrl = fixedUrl.replace(/\/$/, '');
999
+ fixed.push('Removed trailing slash');
1000
+ }
1001
+
1002
+ // Fix 5: Fix common typos
1003
+ fixedUrl = fixedUrl.replace(/^http:\/\//i, 'https://'); // Prefer HTTPS
1004
+ if (fixedUrl !== this.url && fixedUrl.startsWith('https://')) {
1005
+ fixed.push('Upgraded HTTP to HTTPS');
1006
+ }
1007
+
1008
+ // Update URL if fixes were applied
1009
+ if (fixedUrl !== this.url) {
1010
+ this.setUrl(fixedUrl);
1011
+ }
1012
+ }
1013
+
1014
+ // Check accessibility (HTTP HEAD request)
1015
+ if (checkAccessibility && fixedUrl?.match(/^https?:\/\//i)) {
1016
+ // Check if fetch is available (Node.js 18+ or browser)
1017
+ if (typeof fetch === 'undefined') {
1018
+ issues.push('Network validation unavailable: fetch API not supported in this environment');
1019
+ } else {
1020
+ try {
1021
+ // Use fetch with AbortController for timeout
1022
+ const controller = new AbortController();
1023
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
1024
+
1025
+ const response = await fetch(fixedUrl, {
1026
+ method: 'HEAD',
1027
+ signal: controller.signal,
1028
+ redirect: 'follow',
1029
+ });
1030
+
1031
+ clearTimeout(timeoutId);
1032
+
1033
+ if (!response.ok) {
1034
+ issues.push(`HTTP ${response.status}: ${response.statusText || 'Error'}`);
1035
+ }
1036
+ } catch (error: unknown) {
1037
+ // Type guard for error objects with name and message properties
1038
+ const isErrorWithName = (err: unknown): err is { name: string } => {
1039
+ return typeof err === 'object' && err !== null && 'name' in err;
1040
+ };
1041
+ const isErrorWithMessage = (err: unknown): err is { message: string } => {
1042
+ return typeof err === 'object' && err !== null && 'message' in err;
1043
+ };
1044
+
1045
+ if (isErrorWithName(error) && error.name === 'AbortError') {
1046
+ issues.push(`Timeout after ${timeout}ms`);
1047
+ } else if (isErrorWithMessage(error) && error.message?.includes('fetch')) {
1048
+ issues.push(`Unreachable: ${error.message}`);
1049
+ } else if (isErrorWithMessage(error)) {
1050
+ issues.push(`Network error: ${error.message}`);
1051
+ } else {
1052
+ issues.push('Network error: Unknown error');
1053
+ }
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ return {
1059
+ valid: issues.length === 0,
1060
+ issues,
1061
+ fixed,
1062
+ originalUrl,
1063
+ fixedUrl: fixedUrl !== originalUrl ? fixedUrl : undefined,
1064
+ };
1065
+ }
1066
+
1067
+ /**
1068
+ * Resets hyperlink formatting to standard style (Calibri, blue, underline)
1069
+ * This is useful for fixing corrupted hyperlinks from Google Docs or other sources
1070
+ * @returns this for method chaining
1071
+ */
1072
+ resetToStandardFormatting(): this {
1073
+ const standardFormatting: RunFormatting = {
1074
+ font: 'Verdana',
1075
+ color: '0000FF', // Standard hyperlink blue
1076
+ underline: 'single',
1077
+ // Clear any other formatting that might be causing issues
1078
+ bold: false,
1079
+ italic: false,
1080
+ strike: false,
1081
+ };
1082
+
1083
+ this.setFormatting(standardFormatting);
1084
+ return this;
1085
+ }
1086
+
1087
+ /**
1088
+ * Checks if this is an external link
1089
+ */
1090
+ isExternal(): boolean {
1091
+ return this.url !== undefined;
1092
+ }
1093
+
1094
+ /**
1095
+ * Checks if this is an internal link (anchor)
1096
+ */
1097
+ isInternal(): boolean {
1098
+ return this.anchor !== undefined;
1099
+ }
1100
+
1101
+ /**
1102
+ * Creates a deep copy of this hyperlink
1103
+ *
1104
+ * This is useful for preserving the original state before modifications,
1105
+ * particularly when creating tracked changes (revisions) where both the
1106
+ * old and new states need to be preserved.
1107
+ *
1108
+ * @returns A new Hyperlink instance with the same properties
1109
+ *
1110
+ * @example
1111
+ * ```typescript
1112
+ * // Clone before modifying for tracked changes
1113
+ * const originalLink = hyperlink.clone();
1114
+ * hyperlink.setUrl('https://new-url.com');
1115
+ * hyperlink.setText('New Text');
1116
+ *
1117
+ * // Now originalLink has old URL/text, hyperlink has new
1118
+ * const deletion = Revision.createDeletion(author, [originalLink]);
1119
+ * const insertion = Revision.createInsertion(author, [hyperlink]);
1120
+ * ```
1121
+ */
1122
+ clone(): Hyperlink {
1123
+ const cloned = new Hyperlink({
1124
+ url: this.url,
1125
+ anchor: this.anchor,
1126
+ text: this.text,
1127
+ tooltip: this.tooltip,
1128
+ relationshipId: this.relationshipId,
1129
+ formatting: { ...this.formatting },
1130
+ tgtFrame: this.tgtFrame,
1131
+ history: this.history,
1132
+ docLocation: this.docLocation,
1133
+ });
1134
+
1135
+ // Copy the run with its formatting
1136
+ if (this.run) {
1137
+ cloned.run = new Run(this.run.getText(), { ...this.run.getFormatting() });
1138
+
1139
+ // Preserve rPrChange from the original run (formatting tracked changes)
1140
+ const existingRPrChange = this.run.getPropertyChangeRevision();
1141
+ if (existingRPrChange) {
1142
+ cloned.run.setPropertyChangeRevision({ ...existingRPrChange });
1143
+ }
1144
+ }
1145
+
1146
+ return cloned;
1147
+ }
1148
+
1149
+ /**
1150
+ * Generates XML for the hyperlink
1151
+ *
1152
+ * **CRITICAL:** For external links, relationshipId MUST be set before calling toXML().
1153
+ * This happens automatically when saving via Document.save(), but manual usage requires
1154
+ * registering the hyperlink with RelationshipManager first.
1155
+ *
1156
+ * @throws {Error} If external link (has url) is missing relationshipId
1157
+ * @throws {Error} If hyperlink has neither url nor anchor (empty hyperlink)
1158
+ */
1159
+ toXML(): XMLElement {
1160
+ // VALIDATION: Hyperlink must have url OR anchor (unless it's an empty hyperlink with relationshipId)
1161
+ if (!this.url && !this.anchor && !this.relationshipId) {
1162
+ throw new Error(
1163
+ 'CRITICAL: Hyperlink must have either a URL (external link), anchor (internal link), or relationshipId. ' +
1164
+ 'Cannot generate valid XML for hyperlink without destination.'
1165
+ );
1166
+ }
1167
+
1168
+ // VALIDATION: External links MUST have relationship ID
1169
+ // Per ECMA-376 Part 1 §17.16.22, <w:hyperlink> with external target requires r:id attribute
1170
+ if (this.url && !this.relationshipId) {
1171
+ throw new Error(
1172
+ `CRITICAL: External hyperlink to "${this.url}" is missing relationship ID. ` +
1173
+ `This would create an invalid OpenXML document per ECMA-376 §17.16.22. ` +
1174
+ `Solution: Use Document.save() which automatically registers relationships, ` +
1175
+ `or manually call relationshipManager.addHyperlink(url) and set the relationship ID.`
1176
+ );
1177
+ }
1178
+
1179
+ const attributes: Record<string, string> = {};
1180
+
1181
+ // External link - add relationship ID
1182
+ if (this.relationshipId) {
1183
+ attributes['r:id'] = this.relationshipId;
1184
+ }
1185
+
1186
+ // Internal link - uses anchor
1187
+ if (this.anchor) {
1188
+ attributes['w:anchor'] = this.anchor;
1189
+ }
1190
+
1191
+ // Tooltip - explicitly escape attribute value for safety
1192
+ // XMLBuilder will handle escaping, but we document this for clarity
1193
+ if (this.tooltip) {
1194
+ // Note: XMLBuilder.elementToString() will escape this via escapeXmlAttribute()
1195
+ // when generating the actual XML string. We store the raw value here.
1196
+ attributes['w:tooltip'] = this.tooltip;
1197
+ }
1198
+
1199
+ // Target frame attribute (e.g., "_blank" for new window)
1200
+ if (this.tgtFrame) {
1201
+ attributes['w:tgtFrame'] = this.tgtFrame;
1202
+ }
1203
+
1204
+ // History tracking attribute
1205
+ if (this.history) {
1206
+ attributes['w:history'] = this.history;
1207
+ }
1208
+
1209
+ // Document location attribute (ECMA-376 §17.16.22)
1210
+ if (this.docLocation) {
1211
+ attributes['w:docLocation'] = this.docLocation;
1212
+ }
1213
+
1214
+ // Empty/invisible hyperlinks have no children (self-closing element)
1215
+ if (this._isEmpty) {
1216
+ return {
1217
+ name: 'w:hyperlink',
1218
+ attributes,
1219
+ children: [],
1220
+ };
1221
+ }
1222
+
1223
+ // Generate run XML
1224
+ const runXml = this.run.toXML();
1225
+
1226
+ return {
1227
+ name: 'w:hyperlink',
1228
+ attributes,
1229
+ children: [runXml],
1230
+ };
1231
+ }
1232
+
1233
+ /**
1234
+ * Creates an external hyperlink
1235
+ * @param url The URL
1236
+ * @param text Display text
1237
+ * @param formatting Optional formatting
1238
+ */
1239
+ static createExternal(url: string, text: string, formatting?: RunFormatting): Hyperlink {
1240
+ return new Hyperlink({ url, text, formatting });
1241
+ }
1242
+
1243
+ /**
1244
+ * Creates an internal hyperlink (to a bookmark)
1245
+ * @param anchor Bookmark name
1246
+ * @param text Display text
1247
+ * @param formatting Optional formatting
1248
+ */
1249
+ static createInternal(anchor: string, text: string, formatting?: RunFormatting): Hyperlink {
1250
+ return new Hyperlink({ anchor, text, formatting });
1251
+ }
1252
+
1253
+ /**
1254
+ * Creates a web link (convenience method for URLs)
1255
+ * @param url The URL
1256
+ * @param text Display text (defaults to URL)
1257
+ * @param formatting Optional formatting
1258
+ */
1259
+ static createWebLink(url: string, text?: string, formatting?: RunFormatting): Hyperlink {
1260
+ return new Hyperlink({
1261
+ url,
1262
+ text: text || url,
1263
+ formatting,
1264
+ });
1265
+ }
1266
+
1267
+ /**
1268
+ * Creates an email link
1269
+ * @param email Email address
1270
+ * @param text Display text (defaults to email)
1271
+ * @param formatting Optional formatting
1272
+ */
1273
+ static createEmail(email: string, text?: string, formatting?: RunFormatting): Hyperlink {
1274
+ return new Hyperlink({
1275
+ url: `mailto:${email}`,
1276
+ text: text || email,
1277
+ formatting,
1278
+ });
1279
+ }
1280
+
1281
+ /**
1282
+ * Creates a hyperlink with properties
1283
+ * @param properties Hyperlink properties
1284
+ */
1285
+ static create(properties: HyperlinkProperties): Hyperlink {
1286
+ return new Hyperlink(properties);
1287
+ }
1288
+ }