docxmlater 10.1.4 → 10.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (372) 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 +51 -50
  6. package/dist/core/Document.d.ts.map +1 -1
  7. package/dist/core/Document.js +486 -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 +1 -0
  146. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  147. package/dist/formatting/NumberingManager.js +27 -9
  148. package/dist/formatting/NumberingManager.js.map +1 -1
  149. package/dist/formatting/Style.d.ts +11 -11
  150. package/dist/formatting/Style.d.ts.map +1 -1
  151. package/dist/formatting/Style.js +219 -247
  152. package/dist/formatting/Style.js.map +1 -1
  153. package/dist/formatting/StylesManager.d.ts +2 -2
  154. package/dist/formatting/StylesManager.d.ts.map +1 -1
  155. package/dist/formatting/StylesManager.js +96 -102
  156. package/dist/formatting/StylesManager.js.map +1 -1
  157. package/dist/helpers/CleanupHelper.d.ts +1 -1
  158. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  159. package/dist/helpers/CleanupHelper.js +6 -6
  160. package/dist/helpers/CleanupHelper.js.map +1 -1
  161. package/dist/images/ImageOptimizer.js +7 -7
  162. package/dist/images/ImageOptimizer.js.map +1 -1
  163. package/dist/index.d.ts +9 -9
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js.map +1 -1
  166. package/dist/managers/DrawingManager.js.map +1 -1
  167. package/dist/tracking/DocumentTrackingContext.d.ts.map +1 -1
  168. package/dist/tracking/DocumentTrackingContext.js +23 -7
  169. package/dist/tracking/DocumentTrackingContext.js.map +1 -1
  170. package/dist/tracking/TrackingContext.d.ts.map +1 -1
  171. package/dist/tracking/TrackingContext.js.map +1 -1
  172. package/dist/types/compatibility-types.js.map +1 -1
  173. package/dist/types/formatting.js.map +1 -1
  174. package/dist/types/list-types.d.ts +6 -6
  175. package/dist/types/list-types.js.map +1 -1
  176. package/dist/types/settings-types.js.map +1 -1
  177. package/dist/types/styleConfig.d.ts +2 -2
  178. package/dist/types/styleConfig.js.map +1 -1
  179. package/dist/utils/ChangelogGenerator.d.ts.map +1 -1
  180. package/dist/utils/ChangelogGenerator.js +97 -101
  181. package/dist/utils/ChangelogGenerator.js.map +1 -1
  182. package/dist/utils/CompatibilityUpgrader.d.ts.map +1 -1
  183. package/dist/utils/CompatibilityUpgrader.js +1 -1
  184. package/dist/utils/CompatibilityUpgrader.js.map +1 -1
  185. package/dist/utils/InMemoryRevisionAcceptor.d.ts.map +1 -1
  186. package/dist/utils/InMemoryRevisionAcceptor.js +1 -6
  187. package/dist/utils/InMemoryRevisionAcceptor.js.map +1 -1
  188. package/dist/utils/MoveOperationHelper.d.ts.map +1 -1
  189. package/dist/utils/MoveOperationHelper.js +1 -1
  190. package/dist/utils/MoveOperationHelper.js.map +1 -1
  191. package/dist/utils/RevisionAwareProcessor.d.ts.map +1 -1
  192. package/dist/utils/RevisionAwareProcessor.js +2 -4
  193. package/dist/utils/RevisionAwareProcessor.js.map +1 -1
  194. package/dist/utils/RevisionWalker.d.ts.map +1 -1
  195. package/dist/utils/RevisionWalker.js +4 -12
  196. package/dist/utils/RevisionWalker.js.map +1 -1
  197. package/dist/utils/SelectiveRevisionAcceptor.d.ts.map +1 -1
  198. package/dist/utils/SelectiveRevisionAcceptor.js +2 -6
  199. package/dist/utils/SelectiveRevisionAcceptor.js.map +1 -1
  200. package/dist/utils/ShadingResolver.d.ts.map +1 -1
  201. package/dist/utils/ShadingResolver.js +1 -1
  202. package/dist/utils/ShadingResolver.js.map +1 -1
  203. package/dist/utils/acceptRevisions.d.ts.map +1 -1
  204. package/dist/utils/acceptRevisions.js +23 -12
  205. package/dist/utils/acceptRevisions.js.map +1 -1
  206. package/dist/utils/cnfStyleDecoder.d.ts +1 -1
  207. package/dist/utils/cnfStyleDecoder.d.ts.map +1 -1
  208. package/dist/utils/cnfStyleDecoder.js +40 -40
  209. package/dist/utils/cnfStyleDecoder.js.map +1 -1
  210. package/dist/utils/corruptionDetection.d.ts.map +1 -1
  211. package/dist/utils/corruptionDetection.js.map +1 -1
  212. package/dist/utils/dateFormatting.js.map +1 -1
  213. package/dist/utils/deepClone.js +1 -1
  214. package/dist/utils/deepClone.js.map +1 -1
  215. package/dist/utils/diagnostics.d.ts.map +1 -1
  216. package/dist/utils/diagnostics.js +1 -1
  217. package/dist/utils/diagnostics.js.map +1 -1
  218. package/dist/utils/errorHandling.js.map +1 -1
  219. package/dist/utils/formatting.d.ts.map +1 -1
  220. package/dist/utils/formatting.js +10 -2
  221. package/dist/utils/formatting.js.map +1 -1
  222. package/dist/utils/list-detection.d.ts +2 -2
  223. package/dist/utils/list-detection.d.ts.map +1 -1
  224. package/dist/utils/list-detection.js +21 -23
  225. package/dist/utils/list-detection.js.map +1 -1
  226. package/dist/utils/logger.d.ts.map +1 -1
  227. package/dist/utils/logger.js +12 -7
  228. package/dist/utils/logger.js.map +1 -1
  229. package/dist/utils/parsingHelpers.js.map +1 -1
  230. package/dist/utils/stripTrackedChanges.d.ts.map +1 -1
  231. package/dist/utils/stripTrackedChanges.js +3 -3
  232. package/dist/utils/stripTrackedChanges.js.map +1 -1
  233. package/dist/utils/textDiff.d.ts +1 -1
  234. package/dist/utils/textDiff.js +8 -8
  235. package/dist/utils/textDiff.js.map +1 -1
  236. package/dist/utils/units.js.map +1 -1
  237. package/dist/utils/validation.d.ts.map +1 -1
  238. package/dist/utils/validation.js +24 -7
  239. package/dist/utils/validation.js.map +1 -1
  240. package/dist/utils/xmlSanitization.d.ts.map +1 -1
  241. package/dist/utils/xmlSanitization.js +3 -3
  242. package/dist/utils/xmlSanitization.js.map +1 -1
  243. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  244. package/dist/validation/RevisionAutoFixer.js +5 -5
  245. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  246. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  247. package/dist/validation/RevisionValidator.js +7 -9
  248. package/dist/validation/RevisionValidator.js.map +1 -1
  249. package/dist/validation/ValidationRules.js +3 -3
  250. package/dist/validation/ValidationRules.js.map +1 -1
  251. package/dist/validation/index.js.map +1 -1
  252. package/dist/xml/XMLBuilder.d.ts +1 -1
  253. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  254. package/dist/xml/XMLBuilder.js +98 -100
  255. package/dist/xml/XMLBuilder.js.map +1 -1
  256. package/dist/xml/XMLParser.d.ts.map +1 -1
  257. package/dist/xml/XMLParser.js +61 -66
  258. package/dist/xml/XMLParser.js.map +1 -1
  259. package/dist/zip/ZipHandler.d.ts.map +1 -1
  260. package/dist/zip/ZipHandler.js.map +1 -1
  261. package/dist/zip/ZipReader.d.ts.map +1 -1
  262. package/dist/zip/ZipReader.js +1 -3
  263. package/dist/zip/ZipReader.js.map +1 -1
  264. package/dist/zip/ZipWriter.d.ts +1 -1
  265. package/dist/zip/ZipWriter.d.ts.map +1 -1
  266. package/dist/zip/ZipWriter.js +28 -36
  267. package/dist/zip/ZipWriter.js.map +1 -1
  268. package/dist/zip/types.js +1 -1
  269. package/dist/zip/types.js.map +1 -1
  270. package/package.json +92 -92
  271. package/src/__tests__/helper-methods.test.ts +512 -512
  272. package/src/constants/legacyCompatFlags.ts +138 -138
  273. package/src/constants/limits.ts +50 -50
  274. package/src/core/Document.ts +1010 -1145
  275. package/src/core/DocumentContent.ts +461 -467
  276. package/src/core/DocumentGenerator.ts +1133 -1104
  277. package/src/core/DocumentIdManager.ts +158 -158
  278. package/src/core/DocumentParser.ts +2347 -2716
  279. package/src/core/DocumentValidator.ts +363 -372
  280. package/src/core/Relationship.ts +367 -367
  281. package/src/core/RelationshipManager.ts +429 -428
  282. package/src/elements/AlternateContent.ts +42 -42
  283. package/src/elements/Bookmark.ts +212 -210
  284. package/src/elements/BookmarkManager.ts +247 -250
  285. package/src/elements/Comment.ts +356 -359
  286. package/src/elements/CommentManager.ts +499 -502
  287. package/src/elements/CommonTypes.ts +524 -549
  288. package/src/elements/CustomXml.ts +36 -36
  289. package/src/elements/Endnote.ts +221 -217
  290. package/src/elements/EndnoteManager.ts +246 -249
  291. package/src/elements/Field.ts +1292 -1233
  292. package/src/elements/FieldHelpers.ts +329 -333
  293. package/src/elements/FontManager.ts +336 -339
  294. package/src/elements/Footer.ts +269 -269
  295. package/src/elements/Footnote.ts +221 -217
  296. package/src/elements/FootnoteManager.ts +246 -249
  297. package/src/elements/Header.ts +269 -269
  298. package/src/elements/HeaderFooterManager.ts +219 -219
  299. package/src/elements/Hyperlink.ts +1288 -1193
  300. package/src/elements/Image.ts +1982 -1756
  301. package/src/elements/ImageManager.ts +437 -432
  302. package/src/elements/ImageRun.ts +59 -59
  303. package/src/elements/MathElement.ts +65 -65
  304. package/src/elements/Paragraph.ts +4347 -4287
  305. package/src/elements/PreservedElement.ts +53 -53
  306. package/src/elements/PropertyChangeTypes.ts +458 -442
  307. package/src/elements/RangeMarker.ts +382 -400
  308. package/src/elements/Revision.ts +1198 -1217
  309. package/src/elements/RevisionContent.ts +73 -73
  310. package/src/elements/RevisionManager.ts +1070 -1070
  311. package/src/elements/Run.ts +3103 -3073
  312. package/src/elements/Section.ts +1521 -1421
  313. package/src/elements/Shape.ts +884 -873
  314. package/src/elements/StructuredDocumentTag.ts +1176 -1207
  315. package/src/elements/Table.ts +2468 -2524
  316. package/src/elements/TableCell.ts +1617 -1621
  317. package/src/elements/TableGridChange.ts +149 -151
  318. package/src/elements/TableOfContents.ts +701 -691
  319. package/src/elements/TableOfContentsElement.ts +89 -89
  320. package/src/elements/TableRow.ts +960 -929
  321. package/src/elements/TextBox.ts +766 -768
  322. package/src/formatting/AbstractNumbering.ts +580 -579
  323. package/src/formatting/NumberingInstance.ts +295 -299
  324. package/src/formatting/NumberingLevel.ts +981 -1040
  325. package/src/formatting/NumberingManager.ts +875 -827
  326. package/src/formatting/Style.ts +1785 -1879
  327. package/src/formatting/StylesManager.ts +1090 -1130
  328. package/src/helpers/CleanupHelper.ts +524 -524
  329. package/src/images/ImageOptimizer.ts +274 -274
  330. package/src/index.ts +559 -554
  331. package/src/managers/DrawingManager.ts +319 -319
  332. package/src/tracking/DocumentTrackingContext.ts +687 -674
  333. package/src/tracking/TrackingContext.ts +175 -173
  334. package/src/types/compatibility-types.ts +49 -49
  335. package/src/types/formatting.ts +210 -210
  336. package/src/types/list-types.ts +14 -14
  337. package/src/types/settings-types.ts +59 -59
  338. package/src/types/styleConfig.ts +189 -189
  339. package/src/utils/ChangelogGenerator.ts +1583 -1581
  340. package/src/utils/CompatibilityUpgrader.ts +235 -237
  341. package/src/utils/InMemoryRevisionAcceptor.ts +691 -696
  342. package/src/utils/MoveOperationHelper.ts +233 -238
  343. package/src/utils/RevisionAwareProcessor.ts +518 -526
  344. package/src/utils/RevisionWalker.ts +427 -457
  345. package/src/utils/SelectiveRevisionAcceptor.ts +662 -683
  346. package/src/utils/ShadingResolver.ts +105 -107
  347. package/src/utils/acceptRevisions.ts +723 -714
  348. package/src/utils/cnfStyleDecoder.ts +212 -217
  349. package/src/utils/corruptionDetection.ts +346 -345
  350. package/src/utils/dateFormatting.ts +20 -20
  351. package/src/utils/deepClone.ts +77 -78
  352. package/src/utils/diagnostics.ts +125 -129
  353. package/src/utils/errorHandling.ts +80 -80
  354. package/src/utils/formatting.ts +220 -213
  355. package/src/utils/list-detection.ts +32 -42
  356. package/src/utils/logger.ts +412 -404
  357. package/src/utils/parsingHelpers.ts +190 -190
  358. package/src/utils/stripTrackedChanges.ts +356 -353
  359. package/src/utils/textDiff.ts +100 -100
  360. package/src/utils/units.ts +421 -421
  361. package/src/utils/validation.ts +553 -542
  362. package/src/utils/xmlSanitization.ts +179 -182
  363. package/src/validation/RevisionAutoFixer.ts +541 -542
  364. package/src/validation/RevisionValidator.ts +470 -460
  365. package/src/validation/ValidationRules.ts +338 -338
  366. package/src/validation/index.ts +30 -30
  367. package/src/xml/XMLBuilder.ts +857 -871
  368. package/src/xml/XMLParser.ts +877 -919
  369. package/src/zip/ZipHandler.ts +629 -637
  370. package/src/zip/ZipReader.ts +295 -299
  371. package/src/zip/ZipWriter.ts +374 -390
  372. package/src/zip/types.ts +116 -116
@@ -1,1756 +1,1982 @@
1
- /**
2
- * Image - Represents an embedded image in a Word document
3
- *
4
- * Images use DrawingML (a:) and WordprocessingML Drawing (wp:) namespaces
5
- * for proper positioning and formatting in Word documents.
6
- */
7
-
8
- import { promises as fs } from 'fs';
9
- import { defaultLogger } from '../utils/logger';
10
- import { inchesToEmus, UNITS } from '../utils/units';
11
- import { XMLBuilder, XMLElement } from '../xml/XMLBuilder';
12
-
13
- /**
14
- * Supported image formats
15
- */
16
- export type ImageFormat = 'png' | 'jpeg' | 'jpg' | 'gif' | 'bmp' | 'tiff' | 'svg' | 'emf' | 'wmf';
17
-
18
- /**
19
- * Preset geometry shape type (ECMA-376 §20.1.9.18)
20
- */
21
- export type PresetGeometry = 'rect' | 'roundRect' | 'ellipse' | string;
22
-
23
- /**
24
- * Blip compression state (ECMA-376 §20.1.8.15)
25
- */
26
- export type BlipCompressionState = 'none' | 'print' | 'email' | 'hqprint' | 'screen';
27
-
28
- /**
29
- * Picture lock attribute names (ECMA-376 §20.1.2.2.31)
30
- */
31
- export type PicLockAttribute = 'noChangeAspect' | 'noChangeArrowheads' | 'noSelect' | 'noMove'
32
- | 'noResize' | 'noEditPoints' | 'noAdjustHandles' | 'noRot' | 'noChangeShapeType'
33
- | 'noCrop' | 'noGrp';
34
-
35
- /**
36
- * Non-visual picture properties (ECMA-376 §19.3.1.12)
37
- */
38
- export interface PicNonVisualProperties {
39
- id: string;
40
- name: string;
41
- descr: string;
42
- }
43
-
44
- /**
45
- * Image border definition (full a:ln support per ECMA-376)
46
- */
47
- export interface ImageBorder {
48
- /** Border width in points */
49
- width: number;
50
- /** Line cap style */
51
- cap?: 'flat' | 'rnd' | 'sq';
52
- /** Compound line type */
53
- compound?: 'sng' | 'dbl' | 'thickThin' | 'thinThick' | 'tri';
54
- /** Alignment relative to shape */
55
- alignment?: 'ctr' | 'in';
56
- /** Fill specification */
57
- fill?: {
58
- type: 'srgbClr' | 'schemeClr';
59
- value: string;
60
- modifiers?: { name: string; val: string }[];
61
- };
62
- /** Raw XML for non-solid fills (gradFill, pattFill, etc.) */
63
- rawFillXml?: string;
64
- /** Preset dash pattern */
65
- dashPattern?: string;
66
- /** Line join style */
67
- join?: 'round' | 'bevel' | 'miter';
68
- /** Miter limit (percentage * 1000) */
69
- miterLimit?: number;
70
- /** Head end decoration */
71
- headEnd?: { type?: string; width?: string; length?: string };
72
- /** Tail end decoration */
73
- tailEnd?: { type?: string; width?: string; length?: string };
74
- }
75
-
76
- /**
77
- * Image extent (dimensions)
78
- */
79
- export interface ImageExtent {
80
- /** Width in EMUs */
81
- width: number;
82
- /** Height in EMUs */
83
- height: number;
84
- }
85
-
86
- /**
87
- * Effect extent (additional space for shadows, reflections, glows)
88
- * Specifies additional space to add to each edge to prevent clipping of effects
89
- */
90
- export interface EffectExtent {
91
- /** Left extent in EMUs */
92
- left: number;
93
- /** Top extent in EMUs */
94
- top: number;
95
- /** Right extent in EMUs */
96
- right: number;
97
- /** Bottom extent in EMUs */
98
- bottom: number;
99
- }
100
-
101
- /**
102
- * Text wrapping type
103
- */
104
- export type WrapType = 'square' | 'tight' | 'through' | 'topAndBottom' | 'none';
105
-
106
- /**
107
- * Text wrapping side
108
- */
109
- export type WrapSide = 'bothSides' | 'left' | 'right' | 'largest';
110
-
111
- /**
112
- * Text wrap settings
113
- */
114
- export interface TextWrapSettings {
115
- /** Wrap type */
116
- type: WrapType;
117
- /** Which side(s) to wrap text */
118
- side?: WrapSide;
119
- /** Distance from top in EMUs */
120
- distanceTop?: number;
121
- /** Distance from bottom in EMUs */
122
- distanceBottom?: number;
123
- /** Distance from left in EMUs */
124
- distanceLeft?: number;
125
- /** Distance from right in EMUs */
126
- distanceRight?: number;
127
- }
128
-
129
- /**
130
- * Position anchor type (what to position relative to)
131
- */
132
- export type PositionAnchor = 'page' | 'margin' | 'column' | 'character' | 'paragraph';
133
-
134
- /**
135
- * Horizontal alignment options
136
- */
137
- export type HorizontalAlignment = 'left' | 'center' | 'right' | 'inside' | 'outside';
138
-
139
- /**
140
- * Vertical alignment options
141
- */
142
- export type VerticalAlignment = 'top' | 'center' | 'bottom' | 'inside' | 'outside';
143
-
144
- /**
145
- * Image position configuration
146
- */
147
- export interface ImagePosition {
148
- /** Horizontal positioning */
149
- horizontal: {
150
- /** Anchor point */
151
- anchor: PositionAnchor;
152
- /** Offset in EMUs (absolute positioning) */
153
- offset?: number;
154
- /** Alignment (relative positioning) */
155
- alignment?: HorizontalAlignment;
156
- };
157
- /** Vertical positioning */
158
- vertical: {
159
- /** Anchor point */
160
- anchor: PositionAnchor;
161
- /** Offset in EMUs (absolute positioning) */
162
- offset?: number;
163
- /** Alignment (relative positioning) */
164
- alignment?: VerticalAlignment;
165
- };
166
- }
167
-
168
- /**
169
- * Image anchor configuration (floating images)
170
- */
171
- export interface ImageAnchor {
172
- /** Position behind text */
173
- behindDoc: boolean;
174
- /** Lock anchor (prevent movement) */
175
- locked: boolean;
176
- /** Layout in table cell */
177
- layoutInCell: boolean;
178
- /** Allow overlap with other objects */
179
- allowOverlap: boolean;
180
- /** Z-order (higher = in front) */
181
- relativeHeight: number;
182
- /** Use simple positioning (wp:simplePos coordinates) */
183
- simplePos?: boolean;
184
- /** Distance from text - top (EMUs) */
185
- distT?: number;
186
- /** Distance from text - bottom (EMUs) */
187
- distB?: number;
188
- /** Distance from text - left (EMUs) */
189
- distL?: number;
190
- /** Distance from text - right (EMUs) */
191
- distR?: number;
192
- }
193
-
194
- /**
195
- * Image crop settings (percentage-based)
196
- */
197
- export interface ImageCrop {
198
- /** Left crop percentage (0-100) */
199
- left: number;
200
- /** Top crop percentage (0-100) */
201
- top: number;
202
- /** Right crop percentage (0-100) */
203
- right: number;
204
- /** Bottom crop percentage (0-100) */
205
- bottom: number;
206
- }
207
-
208
- /**
209
- * Image visual effects
210
- */
211
- export interface ImageEffects {
212
- /** Brightness adjustment (-100 to +100) */
213
- brightness?: number;
214
- /** Contrast adjustment (-100 to +100) */
215
- contrast?: number;
216
- /** Convert to grayscale */
217
- grayscale?: boolean;
218
- /** Transparency (0-100, percentage) via a:alphaModFix */
219
- transparency?: number;
220
- }
221
-
222
- /**
223
- * Image properties
224
- */
225
- export interface ImageProperties {
226
- /** Image source (file path or buffer) */
227
- source: string | Buffer;
228
- /** Image width in EMUs (optional - will auto-detect) */
229
- width?: number;
230
- /** Image height in EMUs (optional - will auto-detect) */
231
- height?: number;
232
- /** Maintain aspect ratio when resizing */
233
- maintainAspectRatio?: boolean;
234
- /** Alt text / description */
235
- description?: string;
236
- /** Image name/title */
237
- name?: string;
238
- /** Image title (wp:docPr title attribute for accessibility) */
239
- title?: string;
240
- /** Relationship ID (will be set by ImageManager) */
241
- relationshipId?: string;
242
- /** Effect extent (space for shadows/glows) */
243
- effectExtent?: EffectExtent;
244
- /** Text wrapping configuration */
245
- wrap?: TextWrapSettings;
246
- /** Position configuration (floating images) */
247
- position?: ImagePosition;
248
- /** Anchor configuration (floating images) */
249
- anchor?: ImageAnchor;
250
- /** Crop settings */
251
- crop?: ImageCrop;
252
- /** Visual effects */
253
- effects?: ImageEffects;
254
- /** Border settings */
255
- border?: ImageBorder | { width: number };
256
- /** Rotation angle in degrees (0-360) */
257
- rotation?: number;
258
- /** Horizontal flip (ECMA-376 §20.1.7.6) */
259
- flipH?: boolean;
260
- /** Vertical flip (ECMA-376 §20.1.7.6) */
261
- flipV?: boolean;
262
- /** Preset geometry shape (ECMA-376 §20.1.9.18) */
263
- presetGeometry?: PresetGeometry;
264
- /** Blip compression state (ECMA-376 §20.1.8.15) */
265
- compressionState?: BlipCompressionState;
266
- /** Black-and-white mode (ECMA-376 §20.1.2.2.35) */
267
- bwMode?: string;
268
- /** Inline distance from text - top (EMUs, ECMA-376 §20.4.2.8) */
269
- inlineDistT?: number;
270
- /** Inline distance from text - bottom (EMUs) */
271
- inlineDistB?: number;
272
- /** Inline distance from text - left (EMUs) */
273
- inlineDistL?: number;
274
- /** Inline distance from text - right (EMUs) */
275
- inlineDistR?: number;
276
- /** Whether aspect ratio lock is enabled (ECMA-376 §20.4.2.4) */
277
- noChangeAspect?: boolean;
278
- /** Hidden attribute on docPr (ECMA-376 §20.4.2.3) */
279
- hidden?: boolean;
280
- /** BlipFill DPI override (ECMA-376 §20.1.8.14) */
281
- blipFillDpi?: number;
282
- /** BlipFill rotate with shape flag (ECMA-376 §20.1.8.14) */
283
- blipFillRotWithShape?: boolean;
284
- /** Picture locks (ECMA-376 §20.1.2.2.31) */
285
- picLocks?: Partial<Record<PicLockAttribute, boolean>>;
286
- /** Non-visual picture properties (ECMA-376 §19.3.1.12) */
287
- picNonVisualProps?: PicNonVisualProperties;
288
- /** Whether image is linked (r:link) vs embedded (r:embed) */
289
- isLinked?: boolean;
290
- /** SVG relationship ID for Word 365 dual-relationship approach */
291
- svgRelationshipId?: string;
292
- }
293
-
294
- /**
295
- * Image validation result
296
- */
297
- export interface ValidationResult {
298
- valid: boolean;
299
- error?: string;
300
- }
301
-
302
- export class Image {
303
- private source: string | Buffer;
304
- private width: number;
305
- private height: number;
306
- private description: string;
307
- private name: string;
308
- private title?: string;
309
- private relationshipId?: string;
310
- private imageData?: Buffer;
311
- private extension: string;
312
- private docPrId = 1;
313
- private dpi = 96; // Default DPI
314
-
315
- // Advanced image properties
316
- private effectExtent?: EffectExtent;
317
- private wrap?: TextWrapSettings;
318
- private position?: ImagePosition;
319
- private anchor?: ImageAnchor;
320
- private crop?: ImageCrop;
321
- private effects?: ImageEffects;
322
- private rotation = 0;
323
- private flipH = false;
324
- private flipV = false;
325
- private border?: ImageBorder;
326
-
327
- // Group A: Simple attribute preservation (ECMA-376 compliance)
328
- private presetGeometry: PresetGeometry = 'rect';
329
- private compressionState: BlipCompressionState = 'none';
330
- private bwMode = 'auto';
331
- private inlineDistT = 0;
332
- private inlineDistB = 0;
333
- private inlineDistL = 0;
334
- private inlineDistR = 0;
335
- private noChangeAspect = true;
336
- private hidden = false;
337
- private blipFillDpi?: number;
338
- private blipFillRotWithShape?: boolean;
339
- private picLocks: Partial<Record<PicLockAttribute, boolean>> = {
340
- noChangeAspect: true,
341
- noChangeArrowheads: true,
342
- };
343
- private picNonVisualProps: PicNonVisualProperties = { id: '0', name: '', descr: '' };
344
- private isLinked = false;
345
- private svgRelationshipId?: string;
346
-
347
- // Group B: Raw XML passthrough for complex subtrees
348
- private _rawPassthrough = new Map<string, string>();
349
-
350
- /**
351
- * Creates a new image from file path (async factory)
352
- * @param path File path
353
- * @param properties Additional properties
354
- * @returns Promise<Image>
355
- */
356
- static async fromFile(path: string, properties: Partial<ImageProperties> = {}): Promise<Image> {
357
- const image = new Image({ source: path, ...properties });
358
- await image.loadImageDataForDimensions();
359
- return image;
360
- }
361
-
362
- /**
363
- * Creates a new image from buffer (async factory)
364
- * Supports both modern and legacy API signatures
365
- *
366
- * @param buffer Image buffer
367
- * @param mimeTypeOrProperties MIME type string ('png', 'jpeg', etc.) or properties object
368
- * @param width Optional width in EMUs (legacy API)
369
- * @param height Optional height in EMUs (legacy API)
370
- * @returns Promise<Image>
371
- *
372
- * @example
373
- * // Modern API (recommended)
374
- * const img = await Image.fromBuffer(buffer, { mimeType: 'png', width: 914400, height: 914400 });
375
- *
376
- * // Legacy API (still supported)
377
- * const img = await Image.fromBuffer(buffer, 'png', 914400, 914400);
378
- */
379
- static async fromBuffer(
380
- buffer: Buffer,
381
- mimeTypeOrProperties?: string | Partial<ImageProperties>,
382
- width?: number,
383
- height?: number
384
- ): Promise<Image> {
385
- let properties: Partial<ImageProperties>;
386
-
387
- // Detect API signature
388
- if (typeof mimeTypeOrProperties === 'string') {
389
- // Legacy 4-parameter signature: fromBuffer(buffer, 'png', 914400, 914400)
390
- // Note: mimeType is ignored - extension is auto-detected from buffer
391
- properties = {
392
- width: width,
393
- height: height
394
- };
395
- } else {
396
- // Modern API: fromBuffer(buffer, { width: 914400, height: 914400 })
397
- properties = mimeTypeOrProperties || {};
398
- }
399
-
400
- const image = new Image({ source: buffer, ...properties });
401
- await image.loadImageDataForDimensions();
402
- return image;
403
- }
404
-
405
- /**
406
- * Unified create method for images (async factory)
407
- * @param properties Image properties including source (path or buffer)
408
- * @returns Promise<Image>
409
- */
410
- static async create(properties: ImageProperties): Promise<Image> {
411
- if (Buffer.isBuffer(properties.source)) {
412
- return Image.fromBuffer(properties.source, properties);
413
- } else if (typeof properties.source === 'string') {
414
- return Image.fromFile(properties.source, properties);
415
- } else {
416
- throw new Error('Invalid source: must be file path or Buffer');
417
- }
418
- }
419
-
420
- /**
421
- * Private constructor
422
- * @param properties Image properties
423
- */
424
- private constructor(properties: ImageProperties) {
425
- this.source = properties.source;
426
- this.description = properties.description || 'Image';
427
- this.name = properties.name || 'image';
428
- this.title = properties.title;
429
- this.relationshipId = properties.relationshipId;
430
-
431
- // Detect image extension
432
- this.extension = this.detectExtension();
433
-
434
- // Set default dimensions (6 inches x 4 inches) if not provided
435
- this.width = properties.width || inchesToEmus(6);
436
- this.height = properties.height || inchesToEmus(4);
437
-
438
- // Initialize advanced properties
439
- this.effectExtent = properties.effectExtent;
440
- this.wrap = properties.wrap;
441
- this.position = properties.position;
442
- this.anchor = properties.anchor;
443
- this.crop = properties.crop;
444
- this.effects = properties.effects;
445
- // Border: accept both legacy { width } and full ImageBorder
446
- if (properties.border) {
447
- this.border = properties.border as ImageBorder;
448
- }
449
- // Apply rotation if provided (normalize to 0-360)
450
- if (properties.rotation !== undefined && properties.rotation !== 0) {
451
- this.rotation = ((properties.rotation % 360) + 360) % 360;
452
- }
453
- // Apply flip attributes (ECMA-376 §20.1.7.6)
454
- this.flipH = properties.flipH || false;
455
- this.flipV = properties.flipV || false;
456
-
457
- // Group A: Simple attribute preservation
458
- if (properties.presetGeometry !== undefined) this.presetGeometry = properties.presetGeometry;
459
- if (properties.compressionState !== undefined) this.compressionState = properties.compressionState;
460
- if (properties.bwMode !== undefined) this.bwMode = properties.bwMode;
461
- if (properties.inlineDistT !== undefined) this.inlineDistT = properties.inlineDistT;
462
- if (properties.inlineDistB !== undefined) this.inlineDistB = properties.inlineDistB;
463
- if (properties.inlineDistL !== undefined) this.inlineDistL = properties.inlineDistL;
464
- if (properties.inlineDistR !== undefined) this.inlineDistR = properties.inlineDistR;
465
- if (properties.noChangeAspect !== undefined) this.noChangeAspect = properties.noChangeAspect;
466
- if (properties.hidden !== undefined) this.hidden = properties.hidden;
467
- if (properties.blipFillDpi !== undefined) this.blipFillDpi = properties.blipFillDpi;
468
- if (properties.blipFillRotWithShape !== undefined) this.blipFillRotWithShape = properties.blipFillRotWithShape;
469
- if (properties.picLocks !== undefined) this.picLocks = properties.picLocks;
470
- if (properties.picNonVisualProps !== undefined) this.picNonVisualProps = properties.picNonVisualProps;
471
- if (properties.isLinked !== undefined) this.isLinked = properties.isLinked;
472
- if (properties.svgRelationshipId !== undefined) this.svgRelationshipId = properties.svgRelationshipId;
473
-
474
- // Set default DPI
475
- this.dpi = 96;
476
- }
477
-
478
- /**
479
- * Loads image data temporarily for dimension detection only
480
- * Data is released after detection to save memory
481
- * @private
482
- */
483
- private async loadImageDataForDimensions(): Promise<void> {
484
- let tempData: Buffer | undefined;
485
-
486
- try {
487
- if (Buffer.isBuffer(this.source)) {
488
- tempData = this.source;
489
- } else if (typeof this.source === 'string') {
490
- await fs.access(this.source);
491
- tempData = await fs.readFile(this.source);
492
- }
493
-
494
- if (tempData) {
495
- this.imageData = tempData; // Temporarily store
496
-
497
- // Only auto-detect dimensions if they weren't explicitly provided
498
- // This preserves wp:extent values from parsed documents
499
- const defaultWidth = inchesToEmus(6);
500
- const defaultHeight = inchesToEmus(4);
501
- const hasExplicitDimensions = this.width !== defaultWidth || this.height !== defaultHeight;
502
-
503
- if (!hasExplicitDimensions) {
504
- const dimensions = this.detectDimensions();
505
- if (dimensions) {
506
- this.dpi = this.detectDPI() || 96;
507
- const emuPerInch = 914400;
508
- const pixelsPerInch = this.dpi;
509
- this.width = Math.round((dimensions.width / pixelsPerInch) * emuPerInch);
510
- this.height = Math.round((dimensions.height / pixelsPerInch) * emuPerInch);
511
- }
512
- }
513
-
514
- if (typeof this.source === 'string') {
515
- this.imageData = undefined; // Release
516
- }
517
- }
518
- } catch (error: unknown) {
519
- const message = error instanceof Error ? error.message : String(error);
520
- defaultLogger.error(`Failed to load image for dimensions: ${message}`);
521
- throw new Error(`Image loading failed: ${message}`);
522
- }
523
- }
524
-
525
- /**
526
- * Ensures image data is loaded (lazy loading)
527
- */
528
- async ensureDataLoaded(): Promise<void> {
529
- if (this.imageData) return;
530
-
531
- try {
532
- if (Buffer.isBuffer(this.source)) {
533
- this.imageData = this.source;
534
- } else if (typeof this.source === 'string') {
535
- await fs.access(this.source);
536
- this.imageData = await fs.readFile(this.source);
537
- } else {
538
- throw new Error('Invalid image source');
539
- }
540
- } catch (error: unknown) {
541
- const message = error instanceof Error ? error.message : String(error);
542
- defaultLogger.error(`Failed to load image data: ${message}`);
543
- throw new Error(`Image data loading failed: ${message}`);
544
- }
545
- }
546
-
547
- /**
548
- * Releases image data from memory
549
- */
550
- releaseData(): void {
551
- if (typeof this.source === 'string') {
552
- this.imageData = undefined;
553
- }
554
- }
555
-
556
- /**
557
- * Validates the image data integrity
558
- */
559
- validateImageData(): ValidationResult {
560
- // Skip validation for linked images (no data in package)
561
- if (this.isLinked) {
562
- return { valid: true };
563
- }
564
-
565
- if (!this.imageData || this.imageData.length === 0) {
566
- return { valid: false, error: 'Empty image data' };
567
- }
568
-
569
- // Skip signature validation for text-based formats (SVG)
570
- if (this.extension === 'svg') {
571
- return { valid: true };
572
- }
573
-
574
- const signatures: Record<string, number[]> = {
575
- png: [0x89, 0x50, 0x4E, 0x47],
576
- jpg: [0xFF, 0xD8],
577
- jpeg: [0xFF, 0xD8],
578
- gif: [0x47, 0x49, 0x46],
579
- bmp: [0x42, 0x4D],
580
- tiff: [0x49, 0x49, 0x2A, 0x00],
581
- tif: [0x49, 0x49, 0x2A, 0x00]
582
- };
583
-
584
- // EMF: check for ENHMETAHEADER signature at offset 40
585
- if (this.extension === 'emf') {
586
- if (this.imageData.length >= 44 &&
587
- this.imageData[40] === 0x20 && this.imageData[41] === 0x45 &&
588
- this.imageData[42] === 0x4D && this.imageData[43] === 0x46) {
589
- return { valid: true };
590
- }
591
- return { valid: false, error: 'Invalid EMF signature' };
592
- }
593
-
594
- // WMF: check for placeable or standard header
595
- if (this.extension === 'wmf') {
596
- if (this.imageData.length >= 4) {
597
- // Placeable WMF
598
- if (this.imageData[0] === 0xD7 && this.imageData[1] === 0xCD &&
599
- this.imageData[2] === 0xC6 && this.imageData[3] === 0x9A) {
600
- return { valid: true };
601
- }
602
- // Standard WMF
603
- if (this.imageData[0] === 0x01 && this.imageData[1] === 0x00 &&
604
- this.imageData[2] === 0x09 && this.imageData[3] === 0x00) {
605
- return { valid: true };
606
- }
607
- }
608
- return { valid: false, error: 'Invalid WMF signature' };
609
- }
610
-
611
- const sig = signatures[this.extension];
612
- if (sig) {
613
- for (let i = 0; i < sig.length; i++) {
614
- if (this.imageData[i] !== sig[i]) {
615
- return { valid: false, error: `Invalid ${this.extension.toUpperCase()} signature` };
616
- }
617
- }
618
- }
619
-
620
- return { valid: true };
621
- }
622
-
623
- /**
624
- * Detects image extension from source (path or buffer)
625
- */
626
- private detectExtension(): string {
627
- // Try path-based detection first
628
- if (typeof this.source === 'string') {
629
- const match = /\.([a-z]+)$/i.exec(this.source);
630
- if (match?.[1]) {
631
- return match[1].toLowerCase();
632
- }
633
- }
634
-
635
- // Buffer-based detection using magic bytes
636
- if (Buffer.isBuffer(this.source) && this.source.length >= 4) {
637
- const buf = this.source;
638
- // PNG
639
- if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return 'png';
640
- // JPEG
641
- if (buf[0] === 0xFF && buf[1] === 0xD8) return 'jpeg';
642
- // GIF
643
- if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'gif';
644
- // BMP
645
- if (buf[0] === 0x42 && buf[1] === 0x4D) return 'bmp';
646
- // TIFF LE
647
- if (buf[0] === 0x49 && buf[1] === 0x49 && buf[2] === 0x2A && buf[3] === 0x00) return 'tiff';
648
- // TIFF BE
649
- if (buf[0] === 0x4D && buf[1] === 0x4D && buf[2] === 0x00 && buf[3] === 0x2A) return 'tiff';
650
- // EMF: byte 0 = 0x01,0x00,0x00,0x00 AND ' EMF' at offset 40
651
- if (buf.length >= 44 && buf[0] === 0x01 && buf[1] === 0x00 && buf[2] === 0x00 && buf[3] === 0x00 &&
652
- buf[40] === 0x20 && buf[41] === 0x45 && buf[42] === 0x4D && buf[43] === 0x46) return 'emf';
653
- // WMF placeable
654
- if (buf[0] === 0xD7 && buf[1] === 0xCD && buf[2] === 0xC6 && buf[3] === 0x9A) return 'wmf';
655
- // WMF standard
656
- if (buf[0] === 0x01 && buf[1] === 0x00 && buf[2] === 0x09 && buf[3] === 0x00) return 'wmf';
657
- // SVG: starts with '<' or UTF-8 BOM + '<'
658
- if (buf[0] === 0x3C || (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF && buf.length > 3 && buf[3] === 0x3C)) return 'svg';
659
- }
660
-
661
- return 'png';
662
- }
663
-
664
- /**
665
- * Attempts to detect image dimensions from buffer
666
- */
667
- private detectDimensions(): { width: number; height: number } | null {
668
- if (!this.imageData || this.imageData.length < 24) return null;
669
-
670
- if (this.imageData[0] === 0x89 && this.imageData[1] === 0x50 && this.imageData[2] === 0x4e && this.imageData[3] === 0x47) {
671
- return this.detectPngDimensions();
672
- }
673
- if (this.imageData[0] === 0xff && this.imageData[1] === 0xd8) {
674
- return this.detectJpegDimensions();
675
- }
676
- if (this.imageData[0] === 0x47 && this.imageData[1] === 0x49 && this.imageData[2] === 0x46) {
677
- return this.detectGifDimensions();
678
- }
679
- if (this.imageData[0] === 0x42 && this.imageData[1] === 0x4d) {
680
- return this.detectBmpDimensions();
681
- }
682
- if ((this.imageData[0] === 0x49 && this.imageData[1] === 0x49 && this.imageData[2] === 0x2a) ||
683
- (this.imageData[0] === 0x4d && this.imageData[1] === 0x4d && this.imageData[2] === 0x00)) {
684
- return this.detectTiffDimensions();
685
- }
686
- // EMF: ENHMETAHEADER has ' EMF' at offset 40
687
- if (this.imageData.length >= 44 &&
688
- this.imageData[40] === 0x20 && this.imageData[41] === 0x45 &&
689
- this.imageData[42] === 0x4D && this.imageData[43] === 0x46) {
690
- return this.detectEmfDimensions();
691
- }
692
- // WMF placeable
693
- if (this.imageData[0] === 0xD7 && this.imageData[1] === 0xCD &&
694
- this.imageData[2] === 0xC6 && this.imageData[3] === 0x9A) {
695
- return this.detectWmfDimensions();
696
- }
697
- // SVG (text-based)
698
- if (this.imageData[0] === 0x3C ||
699
- (this.imageData[0] === 0xEF && this.imageData[1] === 0xBB && this.imageData[2] === 0xBF)) {
700
- return this.detectSvgDimensions();
701
- }
702
- return null;
703
- }
704
-
705
- // Dimension detection helpers (as before, keeping them the same)
706
-
707
- private detectPngDimensions(): { width: number; height: number } | null {
708
- if (!this.imageData || this.imageData.length < 24) return null;
709
- const width = this.imageData.readUInt32BE(16);
710
- const height = this.imageData.readUInt32BE(20);
711
- return { width, height };
712
- }
713
-
714
- private detectGifDimensions(): { width: number; height: number } | null {
715
- if (!this.imageData || this.imageData.length < 10) return null;
716
- const width = this.imageData.readUInt16LE(6);
717
- const height = this.imageData.readUInt16LE(8);
718
- if (width > 0 && height > 0) return { width, height };
719
- return null;
720
- }
721
-
722
- private detectBmpDimensions(): { width: number; height: number } | null {
723
- if (!this.imageData || this.imageData.length < 26) return null;
724
- const width = this.imageData.readInt32LE(18);
725
- const height = Math.abs(this.imageData.readInt32LE(22));
726
- if (width > 0 && height > 0) return { width, height };
727
- return null;
728
- }
729
-
730
- private detectTiffDimensions(): { width: number; height: number } | null {
731
- // Implementation as before
732
- if (!this.imageData || this.imageData.length < 14) return null;
733
- const isLittleEndian = this.imageData[0] === 0x49;
734
- const ifdOffset = isLittleEndian ? this.imageData.readUInt32LE(4) : this.imageData.readUInt32BE(4);
735
- if (ifdOffset + 14 > this.imageData.length) return null;
736
- const numEntries = isLittleEndian ? this.imageData.readUInt16LE(ifdOffset) : this.imageData.readUInt16BE(ifdOffset);
737
- let width = 0;
738
- let height = 0;
739
- for (let i = 0; i < numEntries; i++) {
740
- const entryOffset = ifdOffset + 2 + i * 12;
741
- if (entryOffset + 12 > this.imageData.length) break;
742
- const tag = isLittleEndian ? this.imageData.readUInt16LE(entryOffset) : this.imageData.readUInt16BE(entryOffset);
743
- const value = isLittleEndian ? this.imageData.readUInt32LE(entryOffset + 8) : this.imageData.readUInt32BE(entryOffset + 8);
744
- if (tag === 256) width = value;
745
- if (tag === 257) height = value;
746
- if (width > 0 && height > 0) break;
747
- }
748
- if (width > 0 && height > 0) return { width, height };
749
- return null;
750
- }
751
-
752
- private detectJpegDimensions(): { width: number; height: number } | null {
753
- // Implementation as before
754
- if (!this.imageData || this.imageData.length < 12) return null;
755
- let offset = 2;
756
- while (offset < this.imageData.length - 1) {
757
- if (this.imageData[offset] !== 0xff) break;
758
- const marker = this.imageData[offset + 1];
759
- if (marker === undefined) break;
760
- if (marker === 0x00 || marker === 0xff) {
761
- offset++;
762
- continue;
763
- }
764
- const isSOF = (marker >= 0xc0 && marker <= 0xcf) && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
765
- if (isSOF) {
766
- if (offset + 9 > this.imageData.length) break;
767
- const height = this.imageData.readUInt16BE(offset + 5);
768
- const width = this.imageData.readUInt16BE(offset + 7);
769
- if (width > 0 && height > 0) return { width, height };
770
- }
771
- if (marker === 0xda || marker === 0xd9) break;
772
- const segmentLength = this.imageData.readUInt16BE(offset + 2);
773
- if (segmentLength < 2 || offset + 2 + segmentLength > this.imageData.length) break;
774
- offset += 2 + segmentLength;
775
- }
776
- return null;
777
- }
778
-
779
- /**
780
- * Detects SVG dimensions from width/height attributes or viewBox
781
- */
782
- private detectSvgDimensions(): { width: number; height: number } | null {
783
- if (!this.imageData) return null;
784
- try {
785
- const svgText = this.imageData.toString('utf-8').substring(0, 2000);
786
- // Try width/height attributes on <svg> element
787
- const widthMatch = /<svg[^>]*\bwidth\s*=\s*["']?(\d+(?:\.\d+)?)/i.exec(svgText);
788
- const heightMatch = /<svg[^>]*\bheight\s*=\s*["']?(\d+(?:\.\d+)?)/i.exec(svgText);
789
- if (widthMatch?.[1] && heightMatch?.[1]) {
790
- return { width: Math.round(parseFloat(widthMatch[1])), height: Math.round(parseFloat(heightMatch[1])) };
791
- }
792
- // Try viewBox attribute
793
- const viewBoxMatch = /<svg[^>]*\bviewBox\s*=\s*["']?\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)/i.exec(svgText);
794
- if (viewBoxMatch?.[1] && viewBoxMatch?.[2]) {
795
- return { width: Math.round(parseFloat(viewBoxMatch[1])), height: Math.round(parseFloat(viewBoxMatch[2])) };
796
- }
797
- } catch {
798
- // SVG parsing failed
799
- }
800
- return null;
801
- }
802
-
803
- /**
804
- * Detects EMF dimensions from ENHMETAHEADER rclFrame (offsets 24-36, 0.01mm units)
805
- */
806
- private detectEmfDimensions(): { width: number; height: number } | null {
807
- if (!this.imageData || this.imageData.length < 40) return null;
808
- try {
809
- // rclFrame: left(24), top(28), right(32), bottom(36) in 0.01mm units
810
- const left = this.imageData.readInt32LE(24);
811
- const top = this.imageData.readInt32LE(28);
812
- const right = this.imageData.readInt32LE(32);
813
- const bottom = this.imageData.readInt32LE(36);
814
- const widthMm = (right - left) / 100;
815
- const heightMm = (bottom - top) / 100;
816
- // Convert mm to pixels at 96 DPI (1 inch = 25.4mm)
817
- const width = Math.round((widthMm / 25.4) * 96);
818
- const height = Math.round((heightMm / 25.4) * 96);
819
- if (width > 0 && height > 0) return { width, height };
820
- } catch {
821
- // EMF header parsing failed
822
- }
823
- return null;
824
- }
825
-
826
- /**
827
- * Detects WMF dimensions from placeable WMF header bounding box (offsets 6-14)
828
- */
829
- private detectWmfDimensions(): { width: number; height: number } | null {
830
- if (!this.imageData || this.imageData.length < 22) return null;
831
- try {
832
- // Placeable WMF header: left(6), top(8), right(10), bottom(12), inch(14)
833
- const left = this.imageData.readInt16LE(6);
834
- const top = this.imageData.readInt16LE(8);
835
- const right = this.imageData.readInt16LE(10);
836
- const bottom = this.imageData.readInt16LE(12);
837
- const unitsPerInch = this.imageData.readUInt16LE(14);
838
- if (unitsPerInch > 0) {
839
- const width = Math.round(((right - left) / unitsPerInch) * 96);
840
- const height = Math.round(((bottom - top) / unitsPerInch) * 96);
841
- if (width > 0 && height > 0) return { width, height };
842
- }
843
- } catch {
844
- // WMF header parsing failed
845
- }
846
- return null;
847
- }
848
-
849
- /**
850
- * Gets the image data buffer asynchronously
851
- */
852
- async getImageDataAsync(): Promise<Buffer> {
853
- await this.ensureDataLoaded();
854
- if (!this.imageData) throw new Error('Failed to load image data');
855
- return this.imageData;
856
- }
857
-
858
- /**
859
- * Gets the image data buffer synchronously
860
- */
861
- getImageData(): Buffer {
862
- if (!this.imageData) throw new Error('Image data not loaded. Call ensureDataLoaded first.');
863
- return this.imageData;
864
- }
865
-
866
- getExtension(): string {
867
- return this.extension;
868
- }
869
-
870
- getDPI(): number {
871
- return this.dpi;
872
- }
873
-
874
- getWidth(): number {
875
- return this.width;
876
- }
877
-
878
- getHeight(): number {
879
- return this.height;
880
- }
881
-
882
- getImageDataSafe(): Buffer | null {
883
- return this.imageData ?? null;
884
- }
885
-
886
- setWidth(width: number, maintainAspectRatio = true): this {
887
- if (maintainAspectRatio && this.height > 0) {
888
- const ratio = this.height / this.width;
889
- this.height = Math.round(width * ratio);
890
- }
891
- this.width = width;
892
- return this;
893
- }
894
-
895
- setHeight(height: number, maintainAspectRatio = true): this {
896
- if (maintainAspectRatio && this.width > 0) {
897
- const ratio = this.width / this.height;
898
- this.width = Math.round(height * ratio);
899
- }
900
- this.height = height;
901
- return this;
902
- }
903
-
904
- setSize(width: number, height: number): this {
905
- this.width = width;
906
- this.height = height;
907
- return this;
908
- }
909
-
910
- async updateImageData(newSource: string | Buffer): Promise<void> {
911
- this.source = newSource;
912
- this.imageData = undefined;
913
- await this.loadImageDataForDimensions();
914
- this.extension = this.detectExtension();
915
- this.dpi = this.detectDPI() || 96;
916
- }
917
-
918
- setRelationshipId(relationshipId: string): this {
919
- this.relationshipId = relationshipId;
920
- return this;
921
- }
922
-
923
- getRelationshipId(): string | undefined {
924
- return this.relationshipId;
925
- }
926
-
927
- setDocPrId(id: number): this {
928
- this.docPrId = id;
929
- return this;
930
- }
931
-
932
- setAltText(altText: string): this {
933
- this.description = altText;
934
- return this;
935
- }
936
-
937
- getAltText(): string {
938
- return this.description;
939
- }
940
-
941
- setTitle(title: string): this {
942
- this.title = title;
943
- return this;
944
- }
945
-
946
- getTitle(): string | undefined {
947
- return this.title;
948
- }
949
-
950
- rotate(degrees: number): this {
951
- this.rotation = ((degrees % 360) + 360) % 360;
952
- if (this.rotation === 90 || this.rotation === 270) {
953
- [this.width, this.height] = [this.height, this.width];
954
- }
955
- return this;
956
- }
957
-
958
- getRotation(): number {
959
- return this.rotation;
960
- }
961
-
962
- setFlipH(flip: boolean): this {
963
- this.flipH = flip;
964
- return this;
965
- }
966
-
967
- getFlipH(): boolean {
968
- return this.flipH;
969
- }
970
-
971
- setFlipV(flip: boolean): this {
972
- this.flipV = flip;
973
- return this;
974
- }
975
-
976
- getFlipV(): boolean {
977
- return this.flipV;
978
- }
979
-
980
- // --- Group A: Simple attribute getters/setters ---
981
-
982
- getPresetGeometry(): PresetGeometry { return this.presetGeometry; }
983
- setPresetGeometry(geom: PresetGeometry): this { this.presetGeometry = geom; return this; }
984
-
985
- getCompressionState(): BlipCompressionState { return this.compressionState; }
986
- setCompressionState(state: BlipCompressionState): this { this.compressionState = state; return this; }
987
-
988
- getBwMode(): string { return this.bwMode; }
989
- setBwMode(mode: string): this { this.bwMode = mode; return this; }
990
-
991
- getInlineDistT(): number { return this.inlineDistT; }
992
- getInlineDistB(): number { return this.inlineDistB; }
993
- getInlineDistL(): number { return this.inlineDistL; }
994
- getInlineDistR(): number { return this.inlineDistR; }
995
- setInlineDist(distT: number, distB: number, distL: number, distR: number): this {
996
- this.inlineDistT = distT;
997
- this.inlineDistB = distB;
998
- this.inlineDistL = distL;
999
- this.inlineDistR = distR;
1000
- return this;
1001
- }
1002
-
1003
- getNoChangeAspect(): boolean { return this.noChangeAspect; }
1004
- setNoChangeAspect(val: boolean): this { this.noChangeAspect = val; return this; }
1005
-
1006
- getHidden(): boolean { return this.hidden; }
1007
- setHidden(val: boolean): this { this.hidden = val; return this; }
1008
-
1009
- getBlipFillDpi(): number | undefined { return this.blipFillDpi; }
1010
- setBlipFillDpi(dpi: number | undefined): this { this.blipFillDpi = dpi; return this; }
1011
-
1012
- getBlipFillRotWithShape(): boolean | undefined { return this.blipFillRotWithShape; }
1013
- setBlipFillRotWithShape(val: boolean | undefined): this { this.blipFillRotWithShape = val; return this; }
1014
-
1015
- getPicLocks(): Partial<Record<PicLockAttribute, boolean>> { return { ...this.picLocks }; }
1016
- setPicLocks(locks: Partial<Record<PicLockAttribute, boolean>>): this { this.picLocks = locks; return this; }
1017
-
1018
- getPicNonVisualProps(): PicNonVisualProperties { return { ...this.picNonVisualProps }; }
1019
- setPicNonVisualProps(props: PicNonVisualProperties): this { this.picNonVisualProps = props; return this; }
1020
-
1021
- getIsLinked(): boolean { return this.isLinked; }
1022
- setIsLinked(val: boolean): this { this.isLinked = val; return this; }
1023
-
1024
- getSvgRelationshipId(): string | undefined { return this.svgRelationshipId; }
1025
- setSvgRelationshipId(id: string | undefined): this { this.svgRelationshipId = id; return this; }
1026
-
1027
- // --- Group B: Raw passthrough storage ---
1028
-
1029
- /** @internal */
1030
- _setRawPassthrough(slot: string, xml: string): void {
1031
- this._rawPassthrough.set(slot, xml);
1032
- }
1033
-
1034
- /** @internal */
1035
- _getRawPassthrough(slot: string): string | undefined {
1036
- return this._rawPassthrough.get(slot);
1037
- }
1038
-
1039
- /** @internal */
1040
- _hasRawPassthrough(slot: string): boolean {
1041
- return this._rawPassthrough.has(slot);
1042
- }
1043
-
1044
- // --- Group C: Enhanced border ---
1045
-
1046
- getBorder(): ImageBorder | undefined { return this.border; }
1047
-
1048
- setEffectExtent(left: number, top: number, right: number, bottom: number): this {
1049
- this.effectExtent = { left, top, right, bottom };
1050
- return this;
1051
- }
1052
-
1053
- getEffectExtent(): EffectExtent | undefined {
1054
- return this.effectExtent;
1055
- }
1056
-
1057
- setWrap(type: WrapType, side?: WrapSide, distances?: { top?: number; bottom?: number; left?: number; right?: number }): this {
1058
- this.wrap = {
1059
- type,
1060
- side,
1061
- distanceTop: distances?.top,
1062
- distanceBottom: distances?.bottom,
1063
- distanceLeft: distances?.left,
1064
- distanceRight: distances?.right,
1065
- };
1066
- return this;
1067
- }
1068
-
1069
- getWrap(): TextWrapSettings | undefined {
1070
- return this.wrap;
1071
- }
1072
-
1073
- /**
1074
- * Validates a position offset value
1075
- * @param offset - Offset value in EMUs
1076
- * @param axis - 'horizontal' or 'vertical' for error messages
1077
- * @throws {Error} If offset exceeds maximum reasonable value
1078
- * @private
1079
- */
1080
- private validatePositionOffset(offset: number | undefined, axis: string): void {
1081
- if (offset === undefined) return;
1082
-
1083
- // Maximum reasonable offset: 50 inches = 45,720,000 EMUs
1084
- const MAX_OFFSET_EMUS = 45720000;
1085
- if (Math.abs(offset) > MAX_OFFSET_EMUS) {
1086
- throw new Error(
1087
- `Invalid ${axis} position offset: ${offset} EMUs exceeds maximum of ${MAX_OFFSET_EMUS} EMUs (50 inches).`
1088
- );
1089
- }
1090
- }
1091
-
1092
- /**
1093
- * Sets the position for a floating image
1094
- *
1095
- * Position can be specified using either:
1096
- * - Absolute offset (in EMUs from the anchor point)
1097
- * - Relative alignment (left, center, right / top, center, bottom)
1098
- *
1099
- * @param horizontal - Horizontal positioning configuration
1100
- * @param vertical - Vertical positioning configuration
1101
- * @returns This image for chaining
1102
- * @throws {Error} If offset values exceed maximum
1103
- *
1104
- * @example
1105
- * ```typescript
1106
- * // Absolute positioning (100,000 EMUs from page edges)
1107
- * image.setPosition(
1108
- * { anchor: 'page', offset: 100000 },
1109
- * { anchor: 'page', offset: 100000 }
1110
- * );
1111
- *
1112
- * // Relative alignment (centered on page)
1113
- * image.setPosition(
1114
- * { anchor: 'page', alignment: 'center' },
1115
- * { anchor: 'page', alignment: 'center' }
1116
- * );
1117
- * ```
1118
- */
1119
- setPosition(horizontal: ImagePosition['horizontal'], vertical: ImagePosition['vertical']): this {
1120
- // Validate offset values
1121
- this.validatePositionOffset(horizontal.offset, 'horizontal');
1122
- this.validatePositionOffset(vertical.offset, 'vertical');
1123
-
1124
- this.position = { horizontal, vertical };
1125
- return this;
1126
- }
1127
-
1128
- getPosition(): ImagePosition | undefined {
1129
- return this.position;
1130
- }
1131
-
1132
- /**
1133
- * Validates the current image position configuration
1134
- *
1135
- * Checks for common configuration issues:
1136
- * - Missing anchor when offset is used
1137
- * - Conflicting offset and alignment values
1138
- * - Invalid combinations
1139
- *
1140
- * @returns Validation result with details
1141
- *
1142
- * @example
1143
- * ```typescript
1144
- * const result = image.validatePosition();
1145
- * if (!result.isValid) {
1146
- * console.log(result.warnings); // Array of warning messages
1147
- * }
1148
- * ```
1149
- */
1150
- validatePosition(): {
1151
- isValid: boolean;
1152
- warnings: string[];
1153
- } {
1154
- const warnings: string[] = [];
1155
-
1156
- if (!this.position) {
1157
- return { isValid: true, warnings };
1158
- }
1159
-
1160
- // Check if both offset and alignment are specified (unusual but not invalid)
1161
- if (this.position.horizontal.offset !== undefined && this.position.horizontal.alignment) {
1162
- warnings.push(
1163
- 'Horizontal position has both offset and alignment. Word will use alignment and ignore offset.'
1164
- );
1165
- }
1166
-
1167
- if (this.position.vertical.offset !== undefined && this.position.vertical.alignment) {
1168
- warnings.push(
1169
- 'Vertical position has both offset and alignment. Word will use alignment and ignore offset.'
1170
- );
1171
- }
1172
-
1173
- // Check for floating image without anchor settings
1174
- if (this.position && !this.anchor) {
1175
- warnings.push(
1176
- 'Position is set but anchor is not. Consider setting anchor properties for proper floating behavior.'
1177
- );
1178
- }
1179
-
1180
- return {
1181
- isValid: warnings.length === 0,
1182
- warnings,
1183
- };
1184
- }
1185
-
1186
- setAnchor(options: ImageAnchor): this {
1187
- this.anchor = options;
1188
- return this;
1189
- }
1190
-
1191
- getAnchor(): ImageAnchor | undefined {
1192
- return this.anchor;
1193
- }
1194
-
1195
- setCrop(left: number, top: number, right: number, bottom: number): this {
1196
- const clamp = (val: number) => Math.max(0, Math.min(100, val));
1197
- this.crop = { left: clamp(left), top: clamp(top), right: clamp(right), bottom: clamp(bottom) };
1198
- return this;
1199
- }
1200
-
1201
- getCrop(): ImageCrop | undefined {
1202
- return this.crop;
1203
- }
1204
-
1205
- setEffects(options: ImageEffects): this {
1206
- const clamp = (val?: number) => val !== undefined ? Math.max(-100, Math.min(100, val)) : undefined;
1207
- this.effects = { brightness: clamp(options.brightness), contrast: clamp(options.contrast), grayscale: options.grayscale, transparency: options.transparency !== undefined ? Math.max(0, Math.min(100, options.transparency)) : undefined };
1208
- return this;
1209
- }
1210
-
1211
- getEffects(): ImageEffects | undefined {
1212
- return this.effects;
1213
- }
1214
-
1215
- private detectDPI(): number | undefined {
1216
- if (!this.imageData) return undefined;
1217
-
1218
- try {
1219
- if (this.extension === 'png') {
1220
- const physIndex = this.imageData.indexOf(Buffer.from([0x70, 0x48, 0x59, 0x73]));
1221
- if (physIndex !== -1 && physIndex + 12 < this.imageData.length) {
1222
- const xPixelsPerMeter = this.imageData.readUInt32BE(physIndex + 4);
1223
- const yPixelsPerMeter = this.imageData.readUInt32BE(physIndex + 8);
1224
- const unit = this.imageData[physIndex + 12];
1225
- if (unit === 1) {
1226
- const dpiX = Math.round(xPixelsPerMeter * 0.0254);
1227
- const dpiY = Math.round(yPixelsPerMeter * 0.0254);
1228
- return Math.min(dpiX, dpiY);
1229
- }
1230
- }
1231
- } else if (this.extension === 'jpg' || this.extension === 'jpeg') {
1232
- let offset = 2;
1233
- while (offset < this.imageData.length) {
1234
- if (this.imageData[offset] !== 0xFF) break;
1235
- const marker = this.imageData[offset + 1];
1236
- if (marker === 0xE0) {
1237
- const length = this.imageData.readUInt16BE(offset + 2);
1238
- if (length >= 16 && this.imageData.slice(offset + 4, offset + 9).toString('ascii') === 'JFIF\0') {
1239
- const units = this.imageData[offset + 11];
1240
- const xDensity = this.imageData.readUInt16BE(offset + 12);
1241
- const yDensity = this.imageData.readUInt16BE(offset + 14);
1242
- if (units === 1) return Math.min(xDensity, yDensity);
1243
- if (units === 2) return Math.min(Math.round(xDensity * 2.54), Math.round(yDensity * 2.54));
1244
- }
1245
- offset += 2 + length;
1246
- continue;
1247
- }
1248
- offset += 2 + this.imageData.readUInt16BE(offset + 2);
1249
- }
1250
- }
1251
- } catch (error: unknown) {
1252
- const message = error instanceof Error ? error.message : String(error);
1253
- defaultLogger.warn(`DPI detection failed: ${message}`);
1254
- }
1255
- return undefined;
1256
- }
1257
-
1258
- isFloating(): boolean {
1259
- return !!this.anchor || !!this.position;
1260
- }
1261
-
1262
- floatTopLeft(marginTop = 0, marginLeft = 0): this {
1263
- this.setPosition(
1264
- { anchor: 'page', offset: marginLeft },
1265
- { anchor: 'page', offset: marginTop }
1266
- );
1267
- this.setAnchor({
1268
- behindDoc: false,
1269
- locked: false,
1270
- layoutInCell: true,
1271
- allowOverlap: true,
1272
- relativeHeight: 251658240
1273
- });
1274
- this.setWrap('square', 'bothSides');
1275
- return this;
1276
- }
1277
-
1278
- floatTopRight(marginTop = 0, marginRight = 0): this {
1279
- this.setPosition(
1280
- { anchor: 'page', alignment: 'right', offset: -marginRight },
1281
- { anchor: 'page', offset: marginTop }
1282
- );
1283
- this.setAnchor({
1284
- behindDoc: false,
1285
- locked: false,
1286
- layoutInCell: true,
1287
- allowOverlap: true,
1288
- relativeHeight: 251658240
1289
- });
1290
- this.setWrap('square', 'bothSides');
1291
- return this;
1292
- }
1293
-
1294
- floatCenter(): this {
1295
- this.setPosition(
1296
- { anchor: 'page', alignment: 'center' },
1297
- { anchor: 'page', alignment: 'center' }
1298
- );
1299
- this.setAnchor({
1300
- behindDoc: false,
1301
- locked: false,
1302
- layoutInCell: true,
1303
- allowOverlap: true,
1304
- relativeHeight: 251658240
1305
- });
1306
- this.setWrap('square', 'bothSides');
1307
- return this;
1308
- }
1309
-
1310
- setBehindText(behind = true): this {
1311
- if (this.anchor) {
1312
- this.anchor.behindDoc = behind;
1313
- } else {
1314
- this.setAnchor({
1315
- behindDoc: behind,
1316
- locked: false,
1317
- layoutInCell: true,
1318
- allowOverlap: true,
1319
- relativeHeight: 251658240
1320
- });
1321
- }
1322
- return this;
1323
- }
1324
-
1325
- /**
1326
- * Applies a border around the image
1327
- * @param thicknessOrOptions Border thickness in points (number) or full ImageBorder options
1328
- * @returns This image for chaining
1329
- *
1330
- * Note: effectExtent is set to accommodate the border width so it renders
1331
- * properly without being clipped. The border is drawn centered on the image
1332
- * edge, so half the border width extends outside the image bounds.
1333
- */
1334
- setBorder(thicknessOrOptions: number | ImageBorder = 2): this {
1335
- if (typeof thicknessOrOptions === 'number') {
1336
- this.border = { width: thicknessOrOptions };
1337
- } else {
1338
- this.border = thicknessOrOptions;
1339
- }
1340
-
1341
- // Calculate space needed for border (half-width on each side)
1342
- // Border is drawn centered on the edge
1343
- const borderEmu = this.border.width * UNITS.EMUS_PER_POINT;
1344
- const halfBorderEmu = Math.ceil(borderEmu / 2);
1345
-
1346
- // Ensure effectExtent has at least enough space for the border
1347
- if (!this.effectExtent) {
1348
- this.effectExtent = { left: 0, top: 0, right: 0, bottom: 0 };
1349
- }
1350
- this.effectExtent.left = Math.max(this.effectExtent.left, halfBorderEmu);
1351
- this.effectExtent.top = Math.max(this.effectExtent.top, halfBorderEmu);
1352
- this.effectExtent.right = Math.max(this.effectExtent.right, halfBorderEmu);
1353
- this.effectExtent.bottom = Math.max(this.effectExtent.bottom, halfBorderEmu);
1354
-
1355
- return this;
1356
- }
1357
-
1358
- /**
1359
- * Removes the border from the image
1360
- * @returns This image for chaining
1361
- */
1362
- removeBorder(): this {
1363
- this.border = undefined;
1364
- return this;
1365
- }
1366
-
1367
- /**
1368
- * @deprecated Use setBorder() instead. This method will be removed in a future version.
1369
- * Applies a 2-point black border around the image.
1370
- * @returns This image for chaining
1371
- */
1372
- applyTwoPixelBlackBorder(): this {
1373
- return this.setBorder(2);
1374
- }
1375
-
1376
- toXML(): XMLElement {
1377
- const isFloating = this.isFloating();
1378
-
1379
- // Common elements - must include wp: namespace prefix
1380
- const extent = XMLBuilder.wp('extent', { cx: this.width.toString(), cy: this.height.toString() });
1381
-
1382
- // --- Build blip element with effects ---
1383
- const blipChildren: XMLElement[] = [];
1384
-
1385
- // Add luminance effect for brightness/contrast (per ECMA-376 §20.1.8.43)
1386
- if (this.effects?.brightness !== undefined || this.effects?.contrast !== undefined) {
1387
- const lumAttrs: Record<string, string> = {};
1388
- if (this.effects.brightness !== undefined) {
1389
- lumAttrs.bright = Math.round(this.effects.brightness * 1000).toString();
1390
- }
1391
- if (this.effects.contrast !== undefined) {
1392
- lumAttrs.contrast = Math.round(this.effects.contrast * 1000).toString();
1393
- }
1394
- blipChildren.push(XMLBuilder.aSelf('lum', lumAttrs));
1395
- }
1396
-
1397
- // Add grayscale effect (per ECMA-376 §20.1.8.37)
1398
- if (this.effects?.grayscale) {
1399
- blipChildren.push(XMLBuilder.aSelf('grayscl'));
1400
- }
1401
-
1402
- // Add transparency effect via a:alphaModFix (ECMA-376 §20.1.8.4)
1403
- if (this.effects?.transparency !== undefined && this.effects.transparency > 0) {
1404
- // transparency is 0-100%, alphaModFix amt is in 1/1000ths of percent
1405
- // e.g., 50% transparency = 50000 amt (= 50% opacity)
1406
- const amt = Math.round((100 - this.effects.transparency) * 1000);
1407
- blipChildren.push(XMLBuilder.aSelf('alphaModFix', { amt: amt.toString() }));
1408
- }
1409
-
1410
- // Group B: Inject raw blip effects passthrough (a:clrChange, a:duotone, etc.)
1411
- if (this._rawPassthrough.has('blip-effects')) {
1412
- blipChildren.push({ name: '__rawXml', rawXml: this._rawPassthrough.get('blip-effects')! } as XMLElement);
1413
- }
1414
-
1415
- // Group B: Inject raw blip extLst passthrough (must come last per schema)
1416
- if (this._rawPassthrough.has('blip-extLst')) {
1417
- blipChildren.push({ name: '__rawXml', rawXml: this._rawPassthrough.get('blip-extLst')! } as XMLElement);
1418
- } else if (this.svgRelationshipId) {
1419
- // SVG dual-relationship: add asvg:svgBlip reference in extLst
1420
- blipChildren.push({
1421
- name: '__rawXml',
1422
- rawXml: `<a:extLst><a:ext uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}"><asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="${this.svgRelationshipId}"/></a:ext></a:extLst>`,
1423
- } as XMLElement);
1424
- }
1425
-
1426
- // Build blip attributes: r:embed or r:link, cstate
1427
- const blipAttrs: Record<string, string | undefined> = {
1428
- cstate: this.compressionState,
1429
- };
1430
- if (this.isLinked) {
1431
- blipAttrs['r:link'] = this.relationshipId;
1432
- } else {
1433
- blipAttrs['r:embed'] = this.relationshipId;
1434
- }
1435
-
1436
- const blip = blipChildren.length > 0
1437
- ? XMLBuilder.a('blip', blipAttrs, blipChildren)
1438
- : XMLBuilder.a('blip', blipAttrs);
1439
-
1440
- // --- Build transform (a:xfrm) ---
1441
- const xfrmAttrs: Record<string, string> | undefined = (() => {
1442
- const attrs: Record<string, string> = {};
1443
- if (this.rotation > 0) attrs.rot = Math.round(this.rotation * 60000).toString();
1444
- if (this.flipH) attrs.flipH = '1';
1445
- if (this.flipV) attrs.flipV = '1';
1446
- return Object.keys(attrs).length > 0 ? attrs : undefined;
1447
- })();
1448
- const xfrm = XMLBuilder.a('xfrm', xfrmAttrs, [
1449
- XMLBuilder.a('off', { x: '0', y: '0' }),
1450
- XMLBuilder.a('ext', { cx: this.width.toString(), cy: this.height.toString() })
1451
- ]);
1452
-
1453
- // --- Build shape properties (pic:spPr) ---
1454
- const spPrChildren: XMLElement[] = [xfrm];
1455
-
1456
- // Geometry: use passthrough for custGeom or prstGeom with avLst, otherwise default
1457
- if (this._rawPassthrough.has('geometry')) {
1458
- spPrChildren.push({ name: '__rawXml', rawXml: this._rawPassthrough.get('geometry')! } as XMLElement);
1459
- } else {
1460
- spPrChildren.push(XMLBuilder.a('prstGeom', { prst: this.presetGeometry }, [
1461
- XMLBuilder.a('avLst')
1462
- ]));
1463
- }
1464
-
1465
- // Border (a:ln) - full model (Group C)
1466
- if (this.border) {
1467
- // Add noFill element before the border line (required by Word)
1468
- spPrChildren.push(XMLBuilder.a('noFill'));
1469
-
1470
- const ptToEmu = 12700;
1471
- const widthEmu = this.border.width * ptToEmu;
1472
- const lnAttrs: Record<string, string> = { w: widthEmu.toString() };
1473
- if (this.border.cap) lnAttrs.cap = this.border.cap;
1474
- if (this.border.compound) lnAttrs.cmpd = this.border.compound;
1475
- if (this.border.alignment) lnAttrs.algn = this.border.alignment;
1476
-
1477
- const lnChildren: XMLElement[] = [];
1478
-
1479
- // Fill
1480
- if (this.border.rawFillXml) {
1481
- lnChildren.push({ name: '__rawXml', rawXml: this.border.rawFillXml } as XMLElement);
1482
- } else if (this.border.fill) {
1483
- const colorChildren: XMLElement[] = [];
1484
- if (this.border.fill.modifiers) {
1485
- for (const mod of this.border.fill.modifiers) {
1486
- colorChildren.push(XMLBuilder.aSelf(mod.name, { val: mod.val }));
1487
- }
1488
- }
1489
- const colorEl = colorChildren.length > 0
1490
- ? XMLBuilder.a(this.border.fill.type, { val: this.border.fill.value }, colorChildren)
1491
- : XMLBuilder.a(this.border.fill.type, { val: this.border.fill.value });
1492
- lnChildren.push(XMLBuilder.a('solidFill', undefined, [colorEl]));
1493
- } else {
1494
- // Default: scheme color tx1 (backward compat)
1495
- lnChildren.push(XMLBuilder.a('solidFill', undefined, [
1496
- XMLBuilder.a('schemeClr', { val: 'tx1' })
1497
- ]));
1498
- }
1499
-
1500
- // Dash pattern
1501
- if (this.border.dashPattern) {
1502
- lnChildren.push(XMLBuilder.aSelf('prstDash', { val: this.border.dashPattern }));
1503
- }
1504
-
1505
- // Join
1506
- if (this.border.join === 'round') {
1507
- lnChildren.push(XMLBuilder.aSelf('round'));
1508
- } else if (this.border.join === 'bevel') {
1509
- lnChildren.push(XMLBuilder.aSelf('bevel'));
1510
- } else if (this.border.join === 'miter') {
1511
- const miterAttrs: Record<string, string> = {};
1512
- if (this.border.miterLimit) miterAttrs.lim = this.border.miterLimit.toString();
1513
- lnChildren.push(XMLBuilder.aSelf('miter', miterAttrs));
1514
- }
1515
-
1516
- // Head/tail end
1517
- if (this.border.headEnd) {
1518
- lnChildren.push(XMLBuilder.aSelf('headEnd', this.border.headEnd as Record<string, string>));
1519
- }
1520
- if (this.border.tailEnd) {
1521
- lnChildren.push(XMLBuilder.aSelf('tailEnd', this.border.tailEnd as Record<string, string>));
1522
- }
1523
-
1524
- spPrChildren.push(XMLBuilder.a('ln', lnAttrs, lnChildren));
1525
- }
1526
-
1527
- // Group B: Inject raw spPr effects passthrough (effectLst, scene3d, sp3d, etc.)
1528
- if (this._rawPassthrough.has('spPr-effects')) {
1529
- spPrChildren.push({ name: '__rawXml', rawXml: this._rawPassthrough.get('spPr-effects')! } as XMLElement);
1530
- }
1531
-
1532
- // --- Build pic:cNvPr with passthrough ---
1533
- const cNvPrChildren: XMLElement[] = [];
1534
- if (this._rawPassthrough.has('cNvPr-extra')) {
1535
- cNvPrChildren.push({ name: '__rawXml', rawXml: this._rawPassthrough.get('cNvPr-extra')! } as XMLElement);
1536
- }
1537
- const cNvPr = cNvPrChildren.length > 0
1538
- ? XMLBuilder.pic('cNvPr', {
1539
- id: this.picNonVisualProps.id,
1540
- name: this.picNonVisualProps.name,
1541
- descr: this.picNonVisualProps.descr,
1542
- }, cNvPrChildren)
1543
- : XMLBuilder.pic('cNvPr', {
1544
- id: this.picNonVisualProps.id,
1545
- name: this.picNonVisualProps.name,
1546
- descr: this.picNonVisualProps.descr,
1547
- });
1548
-
1549
- // --- Build picLocks from map ---
1550
- const picLocksAttrs: Record<string, string> = {};
1551
- for (const [key, val] of Object.entries(this.picLocks)) {
1552
- if (val) picLocksAttrs[key] = '1';
1553
- }
1554
-
1555
- // --- Build blipFill ---
1556
- const blipFillAttrs: Record<string, string> = {};
1557
- if (this.blipFillDpi !== undefined) blipFillAttrs.dpi = this.blipFillDpi.toString();
1558
- if (this.blipFillRotWithShape !== undefined) blipFillAttrs.rotWithShape = this.blipFillRotWithShape ? '1' : '0';
1559
-
1560
- const blipFillChildren: XMLElement[] = [blip];
1561
- // Crop values are stored as percentages (0-100), serialized as per-mille (0-100000)
1562
- if (this.crop) {
1563
- blipFillChildren.push(XMLBuilder.a('srcRect', {
1564
- l: Math.round(this.crop.left * 1000).toString(),
1565
- t: Math.round(this.crop.top * 1000).toString(),
1566
- r: Math.round(this.crop.right * 1000).toString(),
1567
- b: Math.round(this.crop.bottom * 1000).toString(),
1568
- }));
1569
- }
1570
- // Group B: Use tile passthrough instead of stretch when present
1571
- if (this._rawPassthrough.has('blipFill-extra')) {
1572
- blipFillChildren.push({ name: '__rawXml', rawXml: this._rawPassthrough.get('blipFill-extra')! } as XMLElement);
1573
- } else {
1574
- blipFillChildren.push(XMLBuilder.a('stretch', undefined, [XMLBuilder.a('fillRect')]));
1575
- }
1576
-
1577
- const blipFillAttrsObj = Object.keys(blipFillAttrs).length > 0 ? blipFillAttrs : undefined;
1578
-
1579
- const graphicData = XMLBuilder.a('graphicData', { uri: 'http://schemas.openxmlformats.org/drawingml/2006/picture' }, [
1580
- XMLBuilder.pic('pic', undefined, [
1581
- XMLBuilder.pic('nvPicPr', undefined, [
1582
- cNvPr,
1583
- XMLBuilder.pic('cNvPicPr', undefined, [
1584
- XMLBuilder.a('picLocks', Object.keys(picLocksAttrs).length > 0 ? picLocksAttrs : undefined)
1585
- ])
1586
- ]),
1587
- XMLBuilder.pic('blipFill', blipFillAttrsObj, blipFillChildren),
1588
- XMLBuilder.pic('spPr', { bwMode: this.bwMode }, spPrChildren)
1589
- ])
1590
- ]);
1591
-
1592
- const graphic = XMLBuilder.a('graphic', undefined, [graphicData]);
1593
-
1594
- // --- Build docPr element (shared between inline and floating) ---
1595
- const buildDocPr = (idVal: string | number): XMLElement => {
1596
- const attrs: Record<string, any> = { id: idVal, name: this.name, descr: this.description };
1597
- if (this.title) attrs.title = this.title;
1598
- if (this.hidden) attrs.hidden = '1';
1599
- const children: XMLElement[] = [];
1600
- if (this._rawPassthrough.has('docPr-extra')) {
1601
- children.push({ name: '__rawXml', rawXml: this._rawPassthrough.get('docPr-extra')! } as XMLElement);
1602
- }
1603
- return children.length > 0
1604
- ? XMLBuilder.wp('docPr', attrs, children)
1605
- : XMLBuilder.wp('docPr', attrs);
1606
- };
1607
-
1608
- // --- Build cNvGraphicFramePr element (shared between inline and floating) ---
1609
- const buildCNvGraphicFramePr = (): XMLElement => {
1610
- return XMLBuilder.wp('cNvGraphicFramePr', undefined, [
1611
- XMLBuilder.a('graphicFrameLocks', {
1612
- 'xmlns:a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
1613
- noChangeAspect: this.noChangeAspect ? '1' : '0',
1614
- })
1615
- ]);
1616
- };
1617
-
1618
- if (isFloating) {
1619
- // Floating image (anchor)
1620
- const positionHChildren: XMLElement[] = [];
1621
- if (this.position?.horizontal.alignment) {
1622
- positionHChildren.push(XMLBuilder.wp('align', undefined, [this.position.horizontal.alignment]));
1623
- } else {
1624
- positionHChildren.push(XMLBuilder.wp('posOffset', undefined, [(this.position?.horizontal.offset || 0).toString()]));
1625
- }
1626
- const positionH = XMLBuilder.wp('positionH', { relativeFrom: this.position?.horizontal.anchor || 'page' }, positionHChildren);
1627
-
1628
- const positionVChildren: XMLElement[] = [];
1629
- if (this.position?.vertical.alignment) {
1630
- positionVChildren.push(XMLBuilder.wp('align', undefined, [this.position.vertical.alignment]));
1631
- } else {
1632
- positionVChildren.push(XMLBuilder.wp('posOffset', undefined, [(this.position?.vertical.offset || 0).toString()]));
1633
- }
1634
- const positionV = XMLBuilder.wp('positionV', { relativeFrom: this.position?.vertical.anchor || 'page' }, positionVChildren);
1635
-
1636
- // Effect extent for floating images (required by Word)
1637
- const floatEffectExt = this.effectExtent || { left: 0, top: 0, right: 0, bottom: 0 };
1638
- const effectExtentElement = XMLBuilder.wp('effectExtent', {
1639
- t: floatEffectExt.top.toString(),
1640
- r: floatEffectExt.right.toString(),
1641
- b: floatEffectExt.bottom.toString(),
1642
- l: floatEffectExt.left.toString()
1643
- });
1644
-
1645
- const anchorChildren: XMLElement[] = [
1646
- positionH,
1647
- positionV,
1648
- extent,
1649
- effectExtentElement
1650
- ];
1651
-
1652
- // Wrap element (required by CT_Anchor per ECMA-376 — defaults to wrapNone)
1653
- if (this.wrap) {
1654
- const wrapAttrs: Record<string, any> = {};
1655
- if (this.wrap.distanceTop !== undefined) wrapAttrs.distT = this.wrap.distanceTop;
1656
- if (this.wrap.distanceBottom !== undefined) wrapAttrs.distB = this.wrap.distanceBottom;
1657
- if (this.wrap.distanceLeft !== undefined) wrapAttrs.distL = this.wrap.distanceLeft;
1658
- if (this.wrap.distanceRight !== undefined) wrapAttrs.distR = this.wrap.distanceRight;
1659
- if (this.wrap.side) wrapAttrs.wrapText = this.wrap.side;
1660
-
1661
- let wrapElementName: string;
1662
- switch (this.wrap.type) {
1663
- case 'square': wrapElementName = 'wrapSquare'; break;
1664
- case 'tight': wrapElementName = 'wrapTight'; break;
1665
- case 'through': wrapElementName = 'wrapThrough'; break;
1666
- case 'topAndBottom': wrapElementName = 'wrapTopAndBottom'; break;
1667
- case 'none': wrapElementName = 'wrapNone'; break;
1668
- default: wrapElementName = 'wrapSquare';
1669
- }
1670
-
1671
- // Group B: Include wrap polygon passthrough as children
1672
- const wrapChildren: XMLElement[] = [];
1673
- if (this._rawPassthrough.has('wrap-polygon')) {
1674
- wrapChildren.push({ name: '__rawXml', rawXml: this._rawPassthrough.get('wrap-polygon')! } as XMLElement);
1675
- } else if (wrapElementName === 'wrapTight' || wrapElementName === 'wrapThrough') {
1676
- // CT_WrapTight/CT_WrapThrough require a wp:wrapPolygon child.
1677
- // Generate a default rectangular polygon covering the full image extents.
1678
- wrapChildren.push({
1679
- name: 'wp:wrapPolygon',
1680
- attributes: { edited: '0' },
1681
- children: [
1682
- { name: 'wp:start', attributes: { x: '0', y: '0' }, selfClosing: true },
1683
- { name: 'wp:lineTo', attributes: { x: '21600', y: '0' }, selfClosing: true },
1684
- { name: 'wp:lineTo', attributes: { x: '21600', y: '21600' }, selfClosing: true },
1685
- { name: 'wp:lineTo', attributes: { x: '0', y: '21600' }, selfClosing: true },
1686
- { name: 'wp:lineTo', attributes: { x: '0', y: '0' }, selfClosing: true },
1687
- ],
1688
- });
1689
- }
1690
-
1691
- anchorChildren.push(
1692
- wrapChildren.length > 0
1693
- ? XMLBuilder.wp(wrapElementName, wrapAttrs, wrapChildren)
1694
- : XMLBuilder.wp(wrapElementName, wrapAttrs)
1695
- );
1696
- } else {
1697
- // Default: wrapNone (required choice element per CT_Anchor)
1698
- anchorChildren.push(XMLBuilder.wp('wrapNone', {}));
1699
- }
1700
-
1701
- anchorChildren.push(buildDocPr(this.docPrId));
1702
- anchorChildren.push(buildCNvGraphicFramePr());
1703
- anchorChildren.push(graphic);
1704
-
1705
- // Group B: Inject anchor extras (wp14:sizeRelH, wp14:sizeRelV)
1706
- if (this._rawPassthrough.has('anchor-extra')) {
1707
- anchorChildren.push({ name: '__rawXml', rawXml: this._rawPassthrough.get('anchor-extra')! } as XMLElement);
1708
- }
1709
-
1710
- // Build anchor attributes including simplePos and distance from text
1711
- const anchorAttrs: Record<string, any> = {
1712
- behindDoc: this.anchor?.behindDoc ? 1 : 0,
1713
- locked: this.anchor?.locked ? 1 : 0,
1714
- layoutInCell: this.anchor?.layoutInCell ? 1 : 0,
1715
- allowOverlap: this.anchor?.allowOverlap ? 1 : 0,
1716
- relativeHeight: this.anchor?.relativeHeight,
1717
- simplePos: this.anchor?.simplePos ? '1' : '0',
1718
- distT: (this.anchor?.distT ?? 0).toString(),
1719
- distB: (this.anchor?.distB ?? 0).toString(),
1720
- distL: (this.anchor?.distL ?? 0).toString(),
1721
- distR: (this.anchor?.distR ?? 0).toString(),
1722
- };
1723
- if (this.hidden) anchorAttrs.hidden = '1';
1724
-
1725
- // Add wp:simplePos child element (required by ECMA-376 even when simplePos="0")
1726
- anchorChildren.unshift(XMLBuilder.wp('simplePos', { x: '0', y: '0' }));
1727
-
1728
- return XMLBuilder.w('drawing', undefined, [
1729
- XMLBuilder.wp('anchor', anchorAttrs, anchorChildren)
1730
- ]);
1731
- } else {
1732
- // Inline image
1733
- const effectExt = this.effectExtent || { left: 0, top: 0, right: 0, bottom: 0 };
1734
-
1735
- return XMLBuilder.w('drawing', undefined, [
1736
- XMLBuilder.wp('inline', {
1737
- distT: this.inlineDistT.toString(),
1738
- distB: this.inlineDistB.toString(),
1739
- distL: this.inlineDistL.toString(),
1740
- distR: this.inlineDistR.toString(),
1741
- }, [
1742
- extent,
1743
- XMLBuilder.wp('effectExtent', {
1744
- t: effectExt.top.toString(),
1745
- r: effectExt.right.toString(),
1746
- b: effectExt.bottom.toString(),
1747
- l: effectExt.left.toString()
1748
- }),
1749
- buildDocPr(this.docPrId.toString()),
1750
- buildCNvGraphicFramePr(),
1751
- graphic
1752
- ])
1753
- ]);
1754
- }
1755
- }
1756
- }
1
+ /**
2
+ * Image - Represents an embedded image in a Word document
3
+ *
4
+ * Images use DrawingML (a:) and WordprocessingML Drawing (wp:) namespaces
5
+ * for proper positioning and formatting in Word documents.
6
+ */
7
+
8
+ import { promises as fs } from 'fs';
9
+ import { defaultLogger } from '../utils/logger';
10
+ import { inchesToEmus, UNITS } from '../utils/units';
11
+ import { XMLBuilder, XMLElement } from '../xml/XMLBuilder';
12
+
13
+ /**
14
+ * Supported image formats
15
+ */
16
+ export type ImageFormat = 'png' | 'jpeg' | 'jpg' | 'gif' | 'bmp' | 'tiff' | 'svg' | 'emf' | 'wmf';
17
+
18
+ /**
19
+ * Preset geometry shape type (ECMA-376 §20.1.9.18)
20
+ */
21
+ export type PresetGeometry = 'rect' | 'roundRect' | 'ellipse' | string;
22
+
23
+ /**
24
+ * Blip compression state (ECMA-376 §20.1.8.15)
25
+ */
26
+ export type BlipCompressionState = 'none' | 'print' | 'email' | 'hqprint' | 'screen';
27
+
28
+ /**
29
+ * Picture lock attribute names (ECMA-376 §20.1.2.2.31)
30
+ */
31
+ export type PicLockAttribute =
32
+ | 'noChangeAspect'
33
+ | 'noChangeArrowheads'
34
+ | 'noSelect'
35
+ | 'noMove'
36
+ | 'noResize'
37
+ | 'noEditPoints'
38
+ | 'noAdjustHandles'
39
+ | 'noRot'
40
+ | 'noChangeShapeType'
41
+ | 'noCrop'
42
+ | 'noGrp';
43
+
44
+ /**
45
+ * Non-visual picture properties (ECMA-376 §19.3.1.12)
46
+ */
47
+ export interface PicNonVisualProperties {
48
+ id: string;
49
+ name: string;
50
+ descr: string;
51
+ }
52
+
53
+ /**
54
+ * Image border definition (full a:ln support per ECMA-376)
55
+ */
56
+ export interface ImageBorder {
57
+ /** Border width in points */
58
+ width: number;
59
+ /** Line cap style */
60
+ cap?: 'flat' | 'rnd' | 'sq';
61
+ /** Compound line type */
62
+ compound?: 'sng' | 'dbl' | 'thickThin' | 'thinThick' | 'tri';
63
+ /** Alignment relative to shape */
64
+ alignment?: 'ctr' | 'in';
65
+ /** Fill specification */
66
+ fill?: {
67
+ type: 'srgbClr' | 'schemeClr';
68
+ value: string;
69
+ modifiers?: { name: string; val: string }[];
70
+ };
71
+ /** Raw XML for non-solid fills (gradFill, pattFill, etc.) */
72
+ rawFillXml?: string;
73
+ /** Preset dash pattern */
74
+ dashPattern?: string;
75
+ /** Line join style */
76
+ join?: 'round' | 'bevel' | 'miter';
77
+ /** Miter limit (percentage * 1000) */
78
+ miterLimit?: number;
79
+ /** Head end decoration */
80
+ headEnd?: { type?: string; width?: string; length?: string };
81
+ /** Tail end decoration */
82
+ tailEnd?: { type?: string; width?: string; length?: string };
83
+ }
84
+
85
+ /**
86
+ * Image extent (dimensions)
87
+ */
88
+ export interface ImageExtent {
89
+ /** Width in EMUs */
90
+ width: number;
91
+ /** Height in EMUs */
92
+ height: number;
93
+ }
94
+
95
+ /**
96
+ * Effect extent (additional space for shadows, reflections, glows)
97
+ * Specifies additional space to add to each edge to prevent clipping of effects
98
+ */
99
+ export interface EffectExtent {
100
+ /** Left extent in EMUs */
101
+ left: number;
102
+ /** Top extent in EMUs */
103
+ top: number;
104
+ /** Right extent in EMUs */
105
+ right: number;
106
+ /** Bottom extent in EMUs */
107
+ bottom: number;
108
+ }
109
+
110
+ /**
111
+ * Text wrapping type
112
+ */
113
+ export type WrapType = 'square' | 'tight' | 'through' | 'topAndBottom' | 'none';
114
+
115
+ /**
116
+ * Text wrapping side
117
+ */
118
+ export type WrapSide = 'bothSides' | 'left' | 'right' | 'largest';
119
+
120
+ /**
121
+ * Text wrap settings
122
+ */
123
+ export interface TextWrapSettings {
124
+ /** Wrap type */
125
+ type: WrapType;
126
+ /** Which side(s) to wrap text */
127
+ side?: WrapSide;
128
+ /** Distance from top in EMUs */
129
+ distanceTop?: number;
130
+ /** Distance from bottom in EMUs */
131
+ distanceBottom?: number;
132
+ /** Distance from left in EMUs */
133
+ distanceLeft?: number;
134
+ /** Distance from right in EMUs */
135
+ distanceRight?: number;
136
+ }
137
+
138
+ /**
139
+ * Position anchor type (what to position relative to)
140
+ */
141
+ export type PositionAnchor = 'page' | 'margin' | 'column' | 'character' | 'paragraph';
142
+
143
+ /**
144
+ * Horizontal alignment options
145
+ */
146
+ export type HorizontalAlignment = 'left' | 'center' | 'right' | 'inside' | 'outside';
147
+
148
+ /**
149
+ * Vertical alignment options
150
+ */
151
+ export type VerticalAlignment = 'top' | 'center' | 'bottom' | 'inside' | 'outside';
152
+
153
+ /**
154
+ * Image position configuration
155
+ */
156
+ export interface ImagePosition {
157
+ /** Horizontal positioning */
158
+ horizontal: {
159
+ /** Anchor point */
160
+ anchor: PositionAnchor;
161
+ /** Offset in EMUs (absolute positioning) */
162
+ offset?: number;
163
+ /** Alignment (relative positioning) */
164
+ alignment?: HorizontalAlignment;
165
+ };
166
+ /** Vertical positioning */
167
+ vertical: {
168
+ /** Anchor point */
169
+ anchor: PositionAnchor;
170
+ /** Offset in EMUs (absolute positioning) */
171
+ offset?: number;
172
+ /** Alignment (relative positioning) */
173
+ alignment?: VerticalAlignment;
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Image anchor configuration (floating images)
179
+ */
180
+ export interface ImageAnchor {
181
+ /** Position behind text */
182
+ behindDoc: boolean;
183
+ /** Lock anchor (prevent movement) */
184
+ locked: boolean;
185
+ /** Layout in table cell */
186
+ layoutInCell: boolean;
187
+ /** Allow overlap with other objects */
188
+ allowOverlap: boolean;
189
+ /** Z-order (higher = in front) */
190
+ relativeHeight: number;
191
+ /** Use simple positioning (wp:simplePos coordinates) */
192
+ simplePos?: boolean;
193
+ /** Distance from text - top (EMUs) */
194
+ distT?: number;
195
+ /** Distance from text - bottom (EMUs) */
196
+ distB?: number;
197
+ /** Distance from text - left (EMUs) */
198
+ distL?: number;
199
+ /** Distance from text - right (EMUs) */
200
+ distR?: number;
201
+ }
202
+
203
+ /**
204
+ * Image crop settings (percentage-based)
205
+ */
206
+ export interface ImageCrop {
207
+ /** Left crop percentage (0-100) */
208
+ left: number;
209
+ /** Top crop percentage (0-100) */
210
+ top: number;
211
+ /** Right crop percentage (0-100) */
212
+ right: number;
213
+ /** Bottom crop percentage (0-100) */
214
+ bottom: number;
215
+ }
216
+
217
+ /**
218
+ * Image visual effects
219
+ */
220
+ export interface ImageEffects {
221
+ /** Brightness adjustment (-100 to +100) */
222
+ brightness?: number;
223
+ /** Contrast adjustment (-100 to +100) */
224
+ contrast?: number;
225
+ /** Convert to grayscale */
226
+ grayscale?: boolean;
227
+ /** Transparency (0-100, percentage) via a:alphaModFix */
228
+ transparency?: number;
229
+ }
230
+
231
+ /**
232
+ * Image properties
233
+ */
234
+ export interface ImageProperties {
235
+ /** Image source (file path or buffer) */
236
+ source: string | Buffer;
237
+ /** Image width in EMUs (optional - will auto-detect) */
238
+ width?: number;
239
+ /** Image height in EMUs (optional - will auto-detect) */
240
+ height?: number;
241
+ /** Maintain aspect ratio when resizing */
242
+ maintainAspectRatio?: boolean;
243
+ /** Alt text / description */
244
+ description?: string;
245
+ /** Image name/title */
246
+ name?: string;
247
+ /** Image title (wp:docPr title attribute for accessibility) */
248
+ title?: string;
249
+ /** Relationship ID (will be set by ImageManager) */
250
+ relationshipId?: string;
251
+ /** Effect extent (space for shadows/glows) */
252
+ effectExtent?: EffectExtent;
253
+ /** Text wrapping configuration */
254
+ wrap?: TextWrapSettings;
255
+ /** Position configuration (floating images) */
256
+ position?: ImagePosition;
257
+ /** Anchor configuration (floating images) */
258
+ anchor?: ImageAnchor;
259
+ /** Crop settings */
260
+ crop?: ImageCrop;
261
+ /** Visual effects */
262
+ effects?: ImageEffects;
263
+ /** Border settings */
264
+ border?: ImageBorder | { width: number };
265
+ /** Rotation angle in degrees (0-360) */
266
+ rotation?: number;
267
+ /** Horizontal flip (ECMA-376 §20.1.7.6) */
268
+ flipH?: boolean;
269
+ /** Vertical flip (ECMA-376 §20.1.7.6) */
270
+ flipV?: boolean;
271
+ /** Preset geometry shape (ECMA-376 §20.1.9.18) */
272
+ presetGeometry?: PresetGeometry;
273
+ /** Blip compression state (ECMA-376 §20.1.8.15) */
274
+ compressionState?: BlipCompressionState;
275
+ /** Black-and-white mode (ECMA-376 §20.1.2.2.35) */
276
+ bwMode?: string;
277
+ /** Inline distance from text - top (EMUs, ECMA-376 §20.4.2.8) */
278
+ inlineDistT?: number;
279
+ /** Inline distance from text - bottom (EMUs) */
280
+ inlineDistB?: number;
281
+ /** Inline distance from text - left (EMUs) */
282
+ inlineDistL?: number;
283
+ /** Inline distance from text - right (EMUs) */
284
+ inlineDistR?: number;
285
+ /** Whether aspect ratio lock is enabled (ECMA-376 §20.4.2.4) */
286
+ noChangeAspect?: boolean;
287
+ /** Hidden attribute on docPr (ECMA-376 §20.4.2.3) */
288
+ hidden?: boolean;
289
+ /** BlipFill DPI override (ECMA-376 §20.1.8.14) */
290
+ blipFillDpi?: number;
291
+ /** BlipFill rotate with shape flag (ECMA-376 §20.1.8.14) */
292
+ blipFillRotWithShape?: boolean;
293
+ /** Picture locks (ECMA-376 §20.1.2.2.31) */
294
+ picLocks?: Partial<Record<PicLockAttribute, boolean>>;
295
+ /** Non-visual picture properties (ECMA-376 §19.3.1.12) */
296
+ picNonVisualProps?: PicNonVisualProperties;
297
+ /** Whether image is linked (r:link) vs embedded (r:embed) */
298
+ isLinked?: boolean;
299
+ /** SVG relationship ID for Word 365 dual-relationship approach */
300
+ svgRelationshipId?: string;
301
+ }
302
+
303
+ /**
304
+ * Image validation result
305
+ */
306
+ export interface ValidationResult {
307
+ valid: boolean;
308
+ error?: string;
309
+ }
310
+
311
+ export class Image {
312
+ private source: string | Buffer;
313
+ private width: number;
314
+ private height: number;
315
+ private description: string;
316
+ private name: string;
317
+ private title?: string;
318
+ private relationshipId?: string;
319
+ private imageData?: Buffer;
320
+ private extension: string;
321
+ private docPrId = 1;
322
+ private dpi = 96; // Default DPI
323
+
324
+ // Advanced image properties
325
+ private effectExtent?: EffectExtent;
326
+ private wrap?: TextWrapSettings;
327
+ private position?: ImagePosition;
328
+ private anchor?: ImageAnchor;
329
+ private crop?: ImageCrop;
330
+ private effects?: ImageEffects;
331
+ private rotation = 0;
332
+ private flipH = false;
333
+ private flipV = false;
334
+ private border?: ImageBorder;
335
+
336
+ // Group A: Simple attribute preservation (ECMA-376 compliance)
337
+ private presetGeometry: PresetGeometry = 'rect';
338
+ private compressionState: BlipCompressionState = 'none';
339
+ private bwMode = 'auto';
340
+ private inlineDistT = 0;
341
+ private inlineDistB = 0;
342
+ private inlineDistL = 0;
343
+ private inlineDistR = 0;
344
+ private noChangeAspect = true;
345
+ private hidden = false;
346
+ private blipFillDpi?: number;
347
+ private blipFillRotWithShape?: boolean;
348
+ private picLocks: Partial<Record<PicLockAttribute, boolean>> = {
349
+ noChangeAspect: true,
350
+ noChangeArrowheads: true,
351
+ };
352
+ private picNonVisualProps: PicNonVisualProperties = { id: '0', name: '', descr: '' };
353
+ private isLinked = false;
354
+ private svgRelationshipId?: string;
355
+
356
+ // Group B: Raw XML passthrough for complex subtrees
357
+ private _rawPassthrough = new Map<string, string>();
358
+
359
+ /**
360
+ * Creates a new image from file path (async factory)
361
+ * @param path File path
362
+ * @param properties Additional properties
363
+ * @returns Promise<Image>
364
+ */
365
+ static async fromFile(path: string, properties: Partial<ImageProperties> = {}): Promise<Image> {
366
+ const image = new Image({ source: path, ...properties });
367
+ await image.loadImageDataForDimensions();
368
+ return image;
369
+ }
370
+
371
+ /**
372
+ * Creates a new image from buffer (async factory)
373
+ * Supports both modern and legacy API signatures
374
+ *
375
+ * @param buffer Image buffer
376
+ * @param mimeTypeOrProperties MIME type string ('png', 'jpeg', etc.) or properties object
377
+ * @param width Optional width in EMUs (legacy API)
378
+ * @param height Optional height in EMUs (legacy API)
379
+ * @returns Promise<Image>
380
+ *
381
+ * @example
382
+ * // Modern API (recommended)
383
+ * const img = await Image.fromBuffer(buffer, { mimeType: 'png', width: 914400, height: 914400 });
384
+ *
385
+ * // Legacy API (still supported)
386
+ * const img = await Image.fromBuffer(buffer, 'png', 914400, 914400);
387
+ */
388
+ static async fromBuffer(
389
+ buffer: Buffer,
390
+ mimeTypeOrProperties?: string | Partial<ImageProperties>,
391
+ width?: number,
392
+ height?: number
393
+ ): Promise<Image> {
394
+ let properties: Partial<ImageProperties>;
395
+
396
+ // Detect API signature
397
+ if (typeof mimeTypeOrProperties === 'string') {
398
+ // Legacy 4-parameter signature: fromBuffer(buffer, 'png', 914400, 914400)
399
+ // Note: mimeType is ignored - extension is auto-detected from buffer
400
+ properties = {
401
+ width: width,
402
+ height: height,
403
+ };
404
+ } else {
405
+ // Modern API: fromBuffer(buffer, { width: 914400, height: 914400 })
406
+ properties = mimeTypeOrProperties || {};
407
+ }
408
+
409
+ const image = new Image({ source: buffer, ...properties });
410
+ await image.loadImageDataForDimensions();
411
+ return image;
412
+ }
413
+
414
+ /**
415
+ * Unified create method for images (async factory)
416
+ * @param properties Image properties including source (path or buffer)
417
+ * @returns Promise<Image>
418
+ */
419
+ static async create(properties: ImageProperties): Promise<Image> {
420
+ if (Buffer.isBuffer(properties.source)) {
421
+ return Image.fromBuffer(properties.source, properties);
422
+ } else if (typeof properties.source === 'string') {
423
+ return Image.fromFile(properties.source, properties);
424
+ } else {
425
+ throw new Error('Invalid source: must be file path or Buffer');
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Private constructor
431
+ * @param properties Image properties
432
+ */
433
+ private constructor(properties: ImageProperties) {
434
+ this.source = properties.source;
435
+ this.description = properties.description || 'Image';
436
+ this.name = properties.name || 'image';
437
+ this.title = properties.title;
438
+ this.relationshipId = properties.relationshipId;
439
+
440
+ // Detect image extension
441
+ this.extension = this.detectExtension();
442
+
443
+ // Set default dimensions (6 inches x 4 inches) if not provided
444
+ this.width = properties.width || inchesToEmus(6);
445
+ this.height = properties.height || inchesToEmus(4);
446
+
447
+ // Initialize advanced properties
448
+ this.effectExtent = properties.effectExtent;
449
+ this.wrap = properties.wrap;
450
+ this.position = properties.position;
451
+ this.anchor = properties.anchor;
452
+ this.crop = properties.crop;
453
+ this.effects = properties.effects;
454
+ // Border: accept both legacy { width } and full ImageBorder
455
+ if (properties.border) {
456
+ this.border = properties.border as ImageBorder;
457
+ }
458
+ // Apply rotation if provided (normalize to 0-360)
459
+ if (properties.rotation !== undefined && properties.rotation !== 0) {
460
+ this.rotation = ((properties.rotation % 360) + 360) % 360;
461
+ }
462
+ // Apply flip attributes (ECMA-376 §20.1.7.6)
463
+ this.flipH = properties.flipH || false;
464
+ this.flipV = properties.flipV || false;
465
+
466
+ // Group A: Simple attribute preservation
467
+ if (properties.presetGeometry !== undefined) this.presetGeometry = properties.presetGeometry;
468
+ if (properties.compressionState !== undefined)
469
+ this.compressionState = properties.compressionState;
470
+ if (properties.bwMode !== undefined) this.bwMode = properties.bwMode;
471
+ if (properties.inlineDistT !== undefined) this.inlineDistT = properties.inlineDistT;
472
+ if (properties.inlineDistB !== undefined) this.inlineDistB = properties.inlineDistB;
473
+ if (properties.inlineDistL !== undefined) this.inlineDistL = properties.inlineDistL;
474
+ if (properties.inlineDistR !== undefined) this.inlineDistR = properties.inlineDistR;
475
+ if (properties.noChangeAspect !== undefined) this.noChangeAspect = properties.noChangeAspect;
476
+ if (properties.hidden !== undefined) this.hidden = properties.hidden;
477
+ if (properties.blipFillDpi !== undefined) this.blipFillDpi = properties.blipFillDpi;
478
+ if (properties.blipFillRotWithShape !== undefined)
479
+ this.blipFillRotWithShape = properties.blipFillRotWithShape;
480
+ if (properties.picLocks !== undefined) this.picLocks = properties.picLocks;
481
+ if (properties.picNonVisualProps !== undefined)
482
+ this.picNonVisualProps = properties.picNonVisualProps;
483
+ if (properties.isLinked !== undefined) this.isLinked = properties.isLinked;
484
+ if (properties.svgRelationshipId !== undefined)
485
+ this.svgRelationshipId = properties.svgRelationshipId;
486
+
487
+ // Set default DPI
488
+ this.dpi = 96;
489
+ }
490
+
491
+ /**
492
+ * Loads image data temporarily for dimension detection only
493
+ * Data is released after detection to save memory
494
+ * @private
495
+ */
496
+ private async loadImageDataForDimensions(): Promise<void> {
497
+ let tempData: Buffer | undefined;
498
+
499
+ try {
500
+ if (Buffer.isBuffer(this.source)) {
501
+ tempData = this.source;
502
+ } else if (typeof this.source === 'string') {
503
+ await fs.access(this.source);
504
+ tempData = await fs.readFile(this.source);
505
+ }
506
+
507
+ if (tempData) {
508
+ this.imageData = tempData; // Temporarily store
509
+
510
+ // Only auto-detect dimensions if they weren't explicitly provided
511
+ // This preserves wp:extent values from parsed documents
512
+ const defaultWidth = inchesToEmus(6);
513
+ const defaultHeight = inchesToEmus(4);
514
+ const hasExplicitDimensions = this.width !== defaultWidth || this.height !== defaultHeight;
515
+
516
+ if (!hasExplicitDimensions) {
517
+ const dimensions = this.detectDimensions();
518
+ if (dimensions) {
519
+ this.dpi = this.detectDPI() || 96;
520
+ const emuPerInch = 914400;
521
+ const pixelsPerInch = this.dpi;
522
+ this.width = Math.round((dimensions.width / pixelsPerInch) * emuPerInch);
523
+ this.height = Math.round((dimensions.height / pixelsPerInch) * emuPerInch);
524
+ }
525
+ }
526
+
527
+ if (typeof this.source === 'string') {
528
+ this.imageData = undefined; // Release
529
+ }
530
+ }
531
+ } catch (error: unknown) {
532
+ const message = error instanceof Error ? error.message : String(error);
533
+ defaultLogger.error(`Failed to load image for dimensions: ${message}`);
534
+ throw new Error(`Image loading failed: ${message}`);
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Ensures image data is loaded (lazy loading)
540
+ */
541
+ async ensureDataLoaded(): Promise<void> {
542
+ if (this.imageData) return;
543
+
544
+ try {
545
+ if (Buffer.isBuffer(this.source)) {
546
+ this.imageData = this.source;
547
+ } else if (typeof this.source === 'string') {
548
+ await fs.access(this.source);
549
+ this.imageData = await fs.readFile(this.source);
550
+ } else {
551
+ throw new Error('Invalid image source');
552
+ }
553
+ } catch (error: unknown) {
554
+ const message = error instanceof Error ? error.message : String(error);
555
+ defaultLogger.error(`Failed to load image data: ${message}`);
556
+ throw new Error(`Image data loading failed: ${message}`);
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Releases image data from memory
562
+ */
563
+ releaseData(): void {
564
+ if (typeof this.source === 'string') {
565
+ this.imageData = undefined;
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Validates the image data integrity
571
+ */
572
+ validateImageData(): ValidationResult {
573
+ // Skip validation for linked images (no data in package)
574
+ if (this.isLinked) {
575
+ return { valid: true };
576
+ }
577
+
578
+ if (!this.imageData || this.imageData.length === 0) {
579
+ return { valid: false, error: 'Empty image data' };
580
+ }
581
+
582
+ // Skip signature validation for text-based formats (SVG)
583
+ if (this.extension === 'svg') {
584
+ return { valid: true };
585
+ }
586
+
587
+ const signatures: Record<string, number[]> = {
588
+ png: [0x89, 0x50, 0x4e, 0x47],
589
+ jpg: [0xff, 0xd8],
590
+ jpeg: [0xff, 0xd8],
591
+ gif: [0x47, 0x49, 0x46],
592
+ bmp: [0x42, 0x4d],
593
+ tiff: [0x49, 0x49, 0x2a, 0x00],
594
+ tif: [0x49, 0x49, 0x2a, 0x00],
595
+ };
596
+
597
+ // EMF: check for ENHMETAHEADER signature at offset 40
598
+ if (this.extension === 'emf') {
599
+ if (
600
+ this.imageData.length >= 44 &&
601
+ this.imageData[40] === 0x20 &&
602
+ this.imageData[41] === 0x45 &&
603
+ this.imageData[42] === 0x4d &&
604
+ this.imageData[43] === 0x46
605
+ ) {
606
+ return { valid: true };
607
+ }
608
+ return { valid: false, error: 'Invalid EMF signature' };
609
+ }
610
+
611
+ // WMF: check for placeable or standard header
612
+ if (this.extension === 'wmf') {
613
+ if (this.imageData.length >= 4) {
614
+ // Placeable WMF
615
+ if (
616
+ this.imageData[0] === 0xd7 &&
617
+ this.imageData[1] === 0xcd &&
618
+ this.imageData[2] === 0xc6 &&
619
+ this.imageData[3] === 0x9a
620
+ ) {
621
+ return { valid: true };
622
+ }
623
+ // Standard WMF
624
+ if (
625
+ this.imageData[0] === 0x01 &&
626
+ this.imageData[1] === 0x00 &&
627
+ this.imageData[2] === 0x09 &&
628
+ this.imageData[3] === 0x00
629
+ ) {
630
+ return { valid: true };
631
+ }
632
+ }
633
+ return { valid: false, error: 'Invalid WMF signature' };
634
+ }
635
+
636
+ const sig = signatures[this.extension];
637
+ if (sig) {
638
+ for (let i = 0; i < sig.length; i++) {
639
+ if (this.imageData[i] !== sig[i]) {
640
+ return { valid: false, error: `Invalid ${this.extension.toUpperCase()} signature` };
641
+ }
642
+ }
643
+ }
644
+
645
+ return { valid: true };
646
+ }
647
+
648
+ /**
649
+ * Detects image extension from source (path or buffer)
650
+ */
651
+ private detectExtension(): string {
652
+ // Try path-based detection first
653
+ if (typeof this.source === 'string') {
654
+ const match = /\.([a-z]+)$/i.exec(this.source);
655
+ if (match?.[1]) {
656
+ return match[1].toLowerCase();
657
+ }
658
+ }
659
+
660
+ // Buffer-based detection using magic bytes
661
+ if (Buffer.isBuffer(this.source) && this.source.length >= 4) {
662
+ const buf = this.source;
663
+ // PNG
664
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'png';
665
+ // JPEG
666
+ if (buf[0] === 0xff && buf[1] === 0xd8) return 'jpeg';
667
+ // GIF
668
+ if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'gif';
669
+ // BMP
670
+ if (buf[0] === 0x42 && buf[1] === 0x4d) return 'bmp';
671
+ // TIFF LE
672
+ if (buf[0] === 0x49 && buf[1] === 0x49 && buf[2] === 0x2a && buf[3] === 0x00) return 'tiff';
673
+ // TIFF BE
674
+ if (buf[0] === 0x4d && buf[1] === 0x4d && buf[2] === 0x00 && buf[3] === 0x2a) return 'tiff';
675
+ // EMF: byte 0 = 0x01,0x00,0x00,0x00 AND ' EMF' at offset 40
676
+ if (
677
+ buf.length >= 44 &&
678
+ buf[0] === 0x01 &&
679
+ buf[1] === 0x00 &&
680
+ buf[2] === 0x00 &&
681
+ buf[3] === 0x00 &&
682
+ buf[40] === 0x20 &&
683
+ buf[41] === 0x45 &&
684
+ buf[42] === 0x4d &&
685
+ buf[43] === 0x46
686
+ )
687
+ return 'emf';
688
+ // WMF placeable
689
+ if (buf[0] === 0xd7 && buf[1] === 0xcd && buf[2] === 0xc6 && buf[3] === 0x9a) return 'wmf';
690
+ // WMF standard
691
+ if (buf[0] === 0x01 && buf[1] === 0x00 && buf[2] === 0x09 && buf[3] === 0x00) return 'wmf';
692
+ // SVG: starts with '<' or UTF-8 BOM + '<'
693
+ if (
694
+ buf[0] === 0x3c ||
695
+ (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf && buf.length > 3 && buf[3] === 0x3c)
696
+ )
697
+ return 'svg';
698
+ }
699
+
700
+ return 'png';
701
+ }
702
+
703
+ /**
704
+ * Attempts to detect image dimensions from buffer
705
+ */
706
+ private detectDimensions(): { width: number; height: number } | null {
707
+ if (!this.imageData || this.imageData.length < 24) return null;
708
+
709
+ if (
710
+ this.imageData[0] === 0x89 &&
711
+ this.imageData[1] === 0x50 &&
712
+ this.imageData[2] === 0x4e &&
713
+ this.imageData[3] === 0x47
714
+ ) {
715
+ return this.detectPngDimensions();
716
+ }
717
+ if (this.imageData[0] === 0xff && this.imageData[1] === 0xd8) {
718
+ return this.detectJpegDimensions();
719
+ }
720
+ if (this.imageData[0] === 0x47 && this.imageData[1] === 0x49 && this.imageData[2] === 0x46) {
721
+ return this.detectGifDimensions();
722
+ }
723
+ if (this.imageData[0] === 0x42 && this.imageData[1] === 0x4d) {
724
+ return this.detectBmpDimensions();
725
+ }
726
+ if (
727
+ (this.imageData[0] === 0x49 && this.imageData[1] === 0x49 && this.imageData[2] === 0x2a) ||
728
+ (this.imageData[0] === 0x4d && this.imageData[1] === 0x4d && this.imageData[2] === 0x00)
729
+ ) {
730
+ return this.detectTiffDimensions();
731
+ }
732
+ // EMF: ENHMETAHEADER has ' EMF' at offset 40
733
+ if (
734
+ this.imageData.length >= 44 &&
735
+ this.imageData[40] === 0x20 &&
736
+ this.imageData[41] === 0x45 &&
737
+ this.imageData[42] === 0x4d &&
738
+ this.imageData[43] === 0x46
739
+ ) {
740
+ return this.detectEmfDimensions();
741
+ }
742
+ // WMF placeable
743
+ if (
744
+ this.imageData[0] === 0xd7 &&
745
+ this.imageData[1] === 0xcd &&
746
+ this.imageData[2] === 0xc6 &&
747
+ this.imageData[3] === 0x9a
748
+ ) {
749
+ return this.detectWmfDimensions();
750
+ }
751
+ // SVG (text-based)
752
+ if (
753
+ this.imageData[0] === 0x3c ||
754
+ (this.imageData[0] === 0xef && this.imageData[1] === 0xbb && this.imageData[2] === 0xbf)
755
+ ) {
756
+ return this.detectSvgDimensions();
757
+ }
758
+ return null;
759
+ }
760
+
761
+ // Dimension detection helpers (as before, keeping them the same)
762
+
763
+ private detectPngDimensions(): { width: number; height: number } | null {
764
+ if (!this.imageData || this.imageData.length < 24) return null;
765
+ const width = this.imageData.readUInt32BE(16);
766
+ const height = this.imageData.readUInt32BE(20);
767
+ return { width, height };
768
+ }
769
+
770
+ private detectGifDimensions(): { width: number; height: number } | null {
771
+ if (!this.imageData || this.imageData.length < 10) return null;
772
+ const width = this.imageData.readUInt16LE(6);
773
+ const height = this.imageData.readUInt16LE(8);
774
+ if (width > 0 && height > 0) return { width, height };
775
+ return null;
776
+ }
777
+
778
+ private detectBmpDimensions(): { width: number; height: number } | null {
779
+ if (!this.imageData || this.imageData.length < 26) return null;
780
+ const width = this.imageData.readInt32LE(18);
781
+ const height = Math.abs(this.imageData.readInt32LE(22));
782
+ if (width > 0 && height > 0) return { width, height };
783
+ return null;
784
+ }
785
+
786
+ private detectTiffDimensions(): { width: number; height: number } | null {
787
+ // Implementation as before
788
+ if (!this.imageData || this.imageData.length < 14) return null;
789
+ const isLittleEndian = this.imageData[0] === 0x49;
790
+ const ifdOffset = isLittleEndian
791
+ ? this.imageData.readUInt32LE(4)
792
+ : this.imageData.readUInt32BE(4);
793
+ if (ifdOffset + 14 > this.imageData.length) return null;
794
+ const numEntries = isLittleEndian
795
+ ? this.imageData.readUInt16LE(ifdOffset)
796
+ : this.imageData.readUInt16BE(ifdOffset);
797
+ let width = 0;
798
+ let height = 0;
799
+ for (let i = 0; i < numEntries; i++) {
800
+ const entryOffset = ifdOffset + 2 + i * 12;
801
+ if (entryOffset + 12 > this.imageData.length) break;
802
+ const tag = isLittleEndian
803
+ ? this.imageData.readUInt16LE(entryOffset)
804
+ : this.imageData.readUInt16BE(entryOffset);
805
+ const value = isLittleEndian
806
+ ? this.imageData.readUInt32LE(entryOffset + 8)
807
+ : this.imageData.readUInt32BE(entryOffset + 8);
808
+ if (tag === 256) width = value;
809
+ if (tag === 257) height = value;
810
+ if (width > 0 && height > 0) break;
811
+ }
812
+ if (width > 0 && height > 0) return { width, height };
813
+ return null;
814
+ }
815
+
816
+ private detectJpegDimensions(): { width: number; height: number } | null {
817
+ // Implementation as before
818
+ if (!this.imageData || this.imageData.length < 12) return null;
819
+ let offset = 2;
820
+ while (offset < this.imageData.length - 1) {
821
+ if (this.imageData[offset] !== 0xff) break;
822
+ const marker = this.imageData[offset + 1];
823
+ if (marker === undefined) break;
824
+ if (marker === 0x00 || marker === 0xff) {
825
+ offset++;
826
+ continue;
827
+ }
828
+ const isSOF =
829
+ marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
830
+ if (isSOF) {
831
+ if (offset + 9 > this.imageData.length) break;
832
+ const height = this.imageData.readUInt16BE(offset + 5);
833
+ const width = this.imageData.readUInt16BE(offset + 7);
834
+ if (width > 0 && height > 0) return { width, height };
835
+ }
836
+ if (marker === 0xda || marker === 0xd9) break;
837
+ const segmentLength = this.imageData.readUInt16BE(offset + 2);
838
+ if (segmentLength < 2 || offset + 2 + segmentLength > this.imageData.length) break;
839
+ offset += 2 + segmentLength;
840
+ }
841
+ return null;
842
+ }
843
+
844
+ /**
845
+ * Detects SVG dimensions from width/height attributes or viewBox
846
+ */
847
+ private detectSvgDimensions(): { width: number; height: number } | null {
848
+ if (!this.imageData) return null;
849
+ try {
850
+ const svgText = this.imageData.toString('utf-8').substring(0, 2000);
851
+ // Try width/height attributes on <svg> element
852
+ const widthMatch = /<svg[^>]*\bwidth\s*=\s*["']?(\d+(?:\.\d+)?)/i.exec(svgText);
853
+ const heightMatch = /<svg[^>]*\bheight\s*=\s*["']?(\d+(?:\.\d+)?)/i.exec(svgText);
854
+ if (widthMatch?.[1] && heightMatch?.[1]) {
855
+ return {
856
+ width: Math.round(parseFloat(widthMatch[1])),
857
+ height: Math.round(parseFloat(heightMatch[1])),
858
+ };
859
+ }
860
+ // Try viewBox attribute
861
+ const viewBoxMatch =
862
+ /<svg[^>]*\bviewBox\s*=\s*["']?\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)/i.exec(svgText);
863
+ if (viewBoxMatch?.[1] && viewBoxMatch?.[2]) {
864
+ return {
865
+ width: Math.round(parseFloat(viewBoxMatch[1])),
866
+ height: Math.round(parseFloat(viewBoxMatch[2])),
867
+ };
868
+ }
869
+ } catch {
870
+ // SVG parsing failed
871
+ }
872
+ return null;
873
+ }
874
+
875
+ /**
876
+ * Detects EMF dimensions from ENHMETAHEADER rclFrame (offsets 24-36, 0.01mm units)
877
+ */
878
+ private detectEmfDimensions(): { width: number; height: number } | null {
879
+ if (!this.imageData || this.imageData.length < 40) return null;
880
+ try {
881
+ // rclFrame: left(24), top(28), right(32), bottom(36) in 0.01mm units
882
+ const left = this.imageData.readInt32LE(24);
883
+ const top = this.imageData.readInt32LE(28);
884
+ const right = this.imageData.readInt32LE(32);
885
+ const bottom = this.imageData.readInt32LE(36);
886
+ const widthMm = (right - left) / 100;
887
+ const heightMm = (bottom - top) / 100;
888
+ // Convert mm to pixels at 96 DPI (1 inch = 25.4mm)
889
+ const width = Math.round((widthMm / 25.4) * 96);
890
+ const height = Math.round((heightMm / 25.4) * 96);
891
+ if (width > 0 && height > 0) return { width, height };
892
+ } catch {
893
+ // EMF header parsing failed
894
+ }
895
+ return null;
896
+ }
897
+
898
+ /**
899
+ * Detects WMF dimensions from placeable WMF header bounding box (offsets 6-14)
900
+ */
901
+ private detectWmfDimensions(): { width: number; height: number } | null {
902
+ if (!this.imageData || this.imageData.length < 22) return null;
903
+ try {
904
+ // Placeable WMF header: left(6), top(8), right(10), bottom(12), inch(14)
905
+ const left = this.imageData.readInt16LE(6);
906
+ const top = this.imageData.readInt16LE(8);
907
+ const right = this.imageData.readInt16LE(10);
908
+ const bottom = this.imageData.readInt16LE(12);
909
+ const unitsPerInch = this.imageData.readUInt16LE(14);
910
+ if (unitsPerInch > 0) {
911
+ const width = Math.round(((right - left) / unitsPerInch) * 96);
912
+ const height = Math.round(((bottom - top) / unitsPerInch) * 96);
913
+ if (width > 0 && height > 0) return { width, height };
914
+ }
915
+ } catch {
916
+ // WMF header parsing failed
917
+ }
918
+ return null;
919
+ }
920
+
921
+ /**
922
+ * Gets the image data buffer asynchronously
923
+ */
924
+ async getImageDataAsync(): Promise<Buffer> {
925
+ await this.ensureDataLoaded();
926
+ if (!this.imageData) throw new Error('Failed to load image data');
927
+ return this.imageData;
928
+ }
929
+
930
+ /**
931
+ * Gets the image data buffer synchronously
932
+ */
933
+ getImageData(): Buffer {
934
+ if (!this.imageData) throw new Error('Image data not loaded. Call ensureDataLoaded first.');
935
+ return this.imageData;
936
+ }
937
+
938
+ getExtension(): string {
939
+ return this.extension;
940
+ }
941
+
942
+ getDPI(): number {
943
+ return this.dpi;
944
+ }
945
+
946
+ getWidth(): number {
947
+ return this.width;
948
+ }
949
+
950
+ getHeight(): number {
951
+ return this.height;
952
+ }
953
+
954
+ getImageDataSafe(): Buffer | null {
955
+ return this.imageData ?? null;
956
+ }
957
+
958
+ setWidth(width: number, maintainAspectRatio = true): this {
959
+ if (maintainAspectRatio && this.height > 0) {
960
+ const ratio = this.height / this.width;
961
+ this.height = Math.round(width * ratio);
962
+ }
963
+ this.width = width;
964
+ return this;
965
+ }
966
+
967
+ setHeight(height: number, maintainAspectRatio = true): this {
968
+ if (maintainAspectRatio && this.width > 0) {
969
+ const ratio = this.width / this.height;
970
+ this.width = Math.round(height * ratio);
971
+ }
972
+ this.height = height;
973
+ return this;
974
+ }
975
+
976
+ setSize(width: number, height: number): this {
977
+ this.width = width;
978
+ this.height = height;
979
+ return this;
980
+ }
981
+
982
+ async updateImageData(newSource: string | Buffer): Promise<void> {
983
+ this.source = newSource;
984
+ this.imageData = undefined;
985
+ await this.loadImageDataForDimensions();
986
+ this.extension = this.detectExtension();
987
+ this.dpi = this.detectDPI() || 96;
988
+ }
989
+
990
+ setRelationshipId(relationshipId: string): this {
991
+ this.relationshipId = relationshipId;
992
+ return this;
993
+ }
994
+
995
+ getRelationshipId(): string | undefined {
996
+ return this.relationshipId;
997
+ }
998
+
999
+ setDocPrId(id: number): this {
1000
+ this.docPrId = id;
1001
+ return this;
1002
+ }
1003
+
1004
+ setAltText(altText: string): this {
1005
+ this.description = altText;
1006
+ return this;
1007
+ }
1008
+
1009
+ getAltText(): string {
1010
+ return this.description;
1011
+ }
1012
+
1013
+ setTitle(title: string): this {
1014
+ this.title = title;
1015
+ return this;
1016
+ }
1017
+
1018
+ getTitle(): string | undefined {
1019
+ return this.title;
1020
+ }
1021
+
1022
+ rotate(degrees: number): this {
1023
+ this.rotation = ((degrees % 360) + 360) % 360;
1024
+ if (this.rotation === 90 || this.rotation === 270) {
1025
+ [this.width, this.height] = [this.height, this.width];
1026
+ }
1027
+ return this;
1028
+ }
1029
+
1030
+ getRotation(): number {
1031
+ return this.rotation;
1032
+ }
1033
+
1034
+ setFlipH(flip: boolean): this {
1035
+ this.flipH = flip;
1036
+ return this;
1037
+ }
1038
+
1039
+ getFlipH(): boolean {
1040
+ return this.flipH;
1041
+ }
1042
+
1043
+ setFlipV(flip: boolean): this {
1044
+ this.flipV = flip;
1045
+ return this;
1046
+ }
1047
+
1048
+ getFlipV(): boolean {
1049
+ return this.flipV;
1050
+ }
1051
+
1052
+ // --- Group A: Simple attribute getters/setters ---
1053
+
1054
+ getPresetGeometry(): PresetGeometry {
1055
+ return this.presetGeometry;
1056
+ }
1057
+ setPresetGeometry(geom: PresetGeometry): this {
1058
+ this.presetGeometry = geom;
1059
+ return this;
1060
+ }
1061
+
1062
+ getCompressionState(): BlipCompressionState {
1063
+ return this.compressionState;
1064
+ }
1065
+ setCompressionState(state: BlipCompressionState): this {
1066
+ this.compressionState = state;
1067
+ return this;
1068
+ }
1069
+
1070
+ getBwMode(): string {
1071
+ return this.bwMode;
1072
+ }
1073
+ setBwMode(mode: string): this {
1074
+ this.bwMode = mode;
1075
+ return this;
1076
+ }
1077
+
1078
+ getInlineDistT(): number {
1079
+ return this.inlineDistT;
1080
+ }
1081
+ getInlineDistB(): number {
1082
+ return this.inlineDistB;
1083
+ }
1084
+ getInlineDistL(): number {
1085
+ return this.inlineDistL;
1086
+ }
1087
+ getInlineDistR(): number {
1088
+ return this.inlineDistR;
1089
+ }
1090
+ setInlineDist(distT: number, distB: number, distL: number, distR: number): this {
1091
+ this.inlineDistT = distT;
1092
+ this.inlineDistB = distB;
1093
+ this.inlineDistL = distL;
1094
+ this.inlineDistR = distR;
1095
+ return this;
1096
+ }
1097
+
1098
+ getNoChangeAspect(): boolean {
1099
+ return this.noChangeAspect;
1100
+ }
1101
+ setNoChangeAspect(val: boolean): this {
1102
+ this.noChangeAspect = val;
1103
+ return this;
1104
+ }
1105
+
1106
+ getHidden(): boolean {
1107
+ return this.hidden;
1108
+ }
1109
+ setHidden(val: boolean): this {
1110
+ this.hidden = val;
1111
+ return this;
1112
+ }
1113
+
1114
+ getBlipFillDpi(): number | undefined {
1115
+ return this.blipFillDpi;
1116
+ }
1117
+ setBlipFillDpi(dpi: number | undefined): this {
1118
+ this.blipFillDpi = dpi;
1119
+ return this;
1120
+ }
1121
+
1122
+ getBlipFillRotWithShape(): boolean | undefined {
1123
+ return this.blipFillRotWithShape;
1124
+ }
1125
+ setBlipFillRotWithShape(val: boolean | undefined): this {
1126
+ this.blipFillRotWithShape = val;
1127
+ return this;
1128
+ }
1129
+
1130
+ getPicLocks(): Partial<Record<PicLockAttribute, boolean>> {
1131
+ return { ...this.picLocks };
1132
+ }
1133
+ setPicLocks(locks: Partial<Record<PicLockAttribute, boolean>>): this {
1134
+ this.picLocks = locks;
1135
+ return this;
1136
+ }
1137
+
1138
+ getPicNonVisualProps(): PicNonVisualProperties {
1139
+ return { ...this.picNonVisualProps };
1140
+ }
1141
+ setPicNonVisualProps(props: PicNonVisualProperties): this {
1142
+ this.picNonVisualProps = props;
1143
+ return this;
1144
+ }
1145
+
1146
+ getIsLinked(): boolean {
1147
+ return this.isLinked;
1148
+ }
1149
+ setIsLinked(val: boolean): this {
1150
+ this.isLinked = val;
1151
+ return this;
1152
+ }
1153
+
1154
+ getSvgRelationshipId(): string | undefined {
1155
+ return this.svgRelationshipId;
1156
+ }
1157
+ setSvgRelationshipId(id: string | undefined): this {
1158
+ this.svgRelationshipId = id;
1159
+ return this;
1160
+ }
1161
+
1162
+ // --- Group B: Raw passthrough storage ---
1163
+
1164
+ /** @internal */
1165
+ _setRawPassthrough(slot: string, xml: string): void {
1166
+ this._rawPassthrough.set(slot, xml);
1167
+ }
1168
+
1169
+ /** @internal */
1170
+ _getRawPassthrough(slot: string): string | undefined {
1171
+ return this._rawPassthrough.get(slot);
1172
+ }
1173
+
1174
+ /** @internal */
1175
+ _hasRawPassthrough(slot: string): boolean {
1176
+ return this._rawPassthrough.has(slot);
1177
+ }
1178
+
1179
+ // --- Group C: Enhanced border ---
1180
+
1181
+ getBorder(): ImageBorder | undefined {
1182
+ return this.border;
1183
+ }
1184
+
1185
+ setEffectExtent(left: number, top: number, right: number, bottom: number): this {
1186
+ this.effectExtent = { left, top, right, bottom };
1187
+ return this;
1188
+ }
1189
+
1190
+ getEffectExtent(): EffectExtent | undefined {
1191
+ return this.effectExtent;
1192
+ }
1193
+
1194
+ setWrap(
1195
+ type: WrapType,
1196
+ side?: WrapSide,
1197
+ distances?: { top?: number; bottom?: number; left?: number; right?: number }
1198
+ ): this {
1199
+ this.wrap = {
1200
+ type,
1201
+ side,
1202
+ distanceTop: distances?.top,
1203
+ distanceBottom: distances?.bottom,
1204
+ distanceLeft: distances?.left,
1205
+ distanceRight: distances?.right,
1206
+ };
1207
+ return this;
1208
+ }
1209
+
1210
+ getWrap(): TextWrapSettings | undefined {
1211
+ return this.wrap;
1212
+ }
1213
+
1214
+ /**
1215
+ * Validates a position offset value
1216
+ * @param offset - Offset value in EMUs
1217
+ * @param axis - 'horizontal' or 'vertical' for error messages
1218
+ * @throws {Error} If offset exceeds maximum reasonable value
1219
+ * @private
1220
+ */
1221
+ private validatePositionOffset(offset: number | undefined, axis: string): void {
1222
+ if (offset === undefined) return;
1223
+
1224
+ // Maximum reasonable offset: 50 inches = 45,720,000 EMUs
1225
+ const MAX_OFFSET_EMUS = 45720000;
1226
+ if (Math.abs(offset) > MAX_OFFSET_EMUS) {
1227
+ throw new Error(
1228
+ `Invalid ${axis} position offset: ${offset} EMUs exceeds maximum of ${MAX_OFFSET_EMUS} EMUs (50 inches).`
1229
+ );
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Sets the position for a floating image
1235
+ *
1236
+ * Position can be specified using either:
1237
+ * - Absolute offset (in EMUs from the anchor point)
1238
+ * - Relative alignment (left, center, right / top, center, bottom)
1239
+ *
1240
+ * @param horizontal - Horizontal positioning configuration
1241
+ * @param vertical - Vertical positioning configuration
1242
+ * @returns This image for chaining
1243
+ * @throws {Error} If offset values exceed maximum
1244
+ *
1245
+ * @example
1246
+ * ```typescript
1247
+ * // Absolute positioning (100,000 EMUs from page edges)
1248
+ * image.setPosition(
1249
+ * { anchor: 'page', offset: 100000 },
1250
+ * { anchor: 'page', offset: 100000 }
1251
+ * );
1252
+ *
1253
+ * // Relative alignment (centered on page)
1254
+ * image.setPosition(
1255
+ * { anchor: 'page', alignment: 'center' },
1256
+ * { anchor: 'page', alignment: 'center' }
1257
+ * );
1258
+ * ```
1259
+ */
1260
+ setPosition(horizontal: ImagePosition['horizontal'], vertical: ImagePosition['vertical']): this {
1261
+ // Validate offset values
1262
+ this.validatePositionOffset(horizontal.offset, 'horizontal');
1263
+ this.validatePositionOffset(vertical.offset, 'vertical');
1264
+
1265
+ this.position = { horizontal, vertical };
1266
+ return this;
1267
+ }
1268
+
1269
+ getPosition(): ImagePosition | undefined {
1270
+ return this.position;
1271
+ }
1272
+
1273
+ /**
1274
+ * Validates the current image position configuration
1275
+ *
1276
+ * Checks for common configuration issues:
1277
+ * - Missing anchor when offset is used
1278
+ * - Conflicting offset and alignment values
1279
+ * - Invalid combinations
1280
+ *
1281
+ * @returns Validation result with details
1282
+ *
1283
+ * @example
1284
+ * ```typescript
1285
+ * const result = image.validatePosition();
1286
+ * if (!result.isValid) {
1287
+ * console.log(result.warnings); // Array of warning messages
1288
+ * }
1289
+ * ```
1290
+ */
1291
+ validatePosition(): {
1292
+ isValid: boolean;
1293
+ warnings: string[];
1294
+ } {
1295
+ const warnings: string[] = [];
1296
+
1297
+ if (!this.position) {
1298
+ return { isValid: true, warnings };
1299
+ }
1300
+
1301
+ // Check if both offset and alignment are specified (unusual but not invalid)
1302
+ if (this.position.horizontal.offset !== undefined && this.position.horizontal.alignment) {
1303
+ warnings.push(
1304
+ 'Horizontal position has both offset and alignment. Word will use alignment and ignore offset.'
1305
+ );
1306
+ }
1307
+
1308
+ if (this.position.vertical.offset !== undefined && this.position.vertical.alignment) {
1309
+ warnings.push(
1310
+ 'Vertical position has both offset and alignment. Word will use alignment and ignore offset.'
1311
+ );
1312
+ }
1313
+
1314
+ // Check for floating image without anchor settings
1315
+ if (this.position && !this.anchor) {
1316
+ warnings.push(
1317
+ 'Position is set but anchor is not. Consider setting anchor properties for proper floating behavior.'
1318
+ );
1319
+ }
1320
+
1321
+ return {
1322
+ isValid: warnings.length === 0,
1323
+ warnings,
1324
+ };
1325
+ }
1326
+
1327
+ setAnchor(options: ImageAnchor): this {
1328
+ this.anchor = options;
1329
+ return this;
1330
+ }
1331
+
1332
+ getAnchor(): ImageAnchor | undefined {
1333
+ return this.anchor;
1334
+ }
1335
+
1336
+ setCrop(left: number, top: number, right: number, bottom: number): this {
1337
+ const clamp = (val: number) => Math.max(0, Math.min(100, val));
1338
+ this.crop = { left: clamp(left), top: clamp(top), right: clamp(right), bottom: clamp(bottom) };
1339
+ return this;
1340
+ }
1341
+
1342
+ getCrop(): ImageCrop | undefined {
1343
+ return this.crop;
1344
+ }
1345
+
1346
+ setEffects(options: ImageEffects): this {
1347
+ const clamp = (val?: number) =>
1348
+ val !== undefined ? Math.max(-100, Math.min(100, val)) : undefined;
1349
+ this.effects = {
1350
+ brightness: clamp(options.brightness),
1351
+ contrast: clamp(options.contrast),
1352
+ grayscale: options.grayscale,
1353
+ transparency:
1354
+ options.transparency !== undefined
1355
+ ? Math.max(0, Math.min(100, options.transparency))
1356
+ : undefined,
1357
+ };
1358
+ return this;
1359
+ }
1360
+
1361
+ getEffects(): ImageEffects | undefined {
1362
+ return this.effects;
1363
+ }
1364
+
1365
+ private detectDPI(): number | undefined {
1366
+ if (!this.imageData) return undefined;
1367
+
1368
+ try {
1369
+ if (this.extension === 'png') {
1370
+ const physIndex = this.imageData.indexOf(Buffer.from([0x70, 0x48, 0x59, 0x73]));
1371
+ if (physIndex !== -1 && physIndex + 12 < this.imageData.length) {
1372
+ const xPixelsPerMeter = this.imageData.readUInt32BE(physIndex + 4);
1373
+ const yPixelsPerMeter = this.imageData.readUInt32BE(physIndex + 8);
1374
+ const unit = this.imageData[physIndex + 12];
1375
+ if (unit === 1) {
1376
+ const dpiX = Math.round(xPixelsPerMeter * 0.0254);
1377
+ const dpiY = Math.round(yPixelsPerMeter * 0.0254);
1378
+ return Math.min(dpiX, dpiY);
1379
+ }
1380
+ }
1381
+ } else if (this.extension === 'jpg' || this.extension === 'jpeg') {
1382
+ let offset = 2;
1383
+ while (offset < this.imageData.length) {
1384
+ if (this.imageData[offset] !== 0xff) break;
1385
+ const marker = this.imageData[offset + 1];
1386
+ if (marker === 0xe0) {
1387
+ const length = this.imageData.readUInt16BE(offset + 2);
1388
+ if (
1389
+ length >= 16 &&
1390
+ this.imageData.slice(offset + 4, offset + 9).toString('ascii') === 'JFIF\0'
1391
+ ) {
1392
+ const units = this.imageData[offset + 11];
1393
+ const xDensity = this.imageData.readUInt16BE(offset + 12);
1394
+ const yDensity = this.imageData.readUInt16BE(offset + 14);
1395
+ if (units === 1) return Math.min(xDensity, yDensity);
1396
+ if (units === 2)
1397
+ return Math.min(Math.round(xDensity * 2.54), Math.round(yDensity * 2.54));
1398
+ }
1399
+ offset += 2 + length;
1400
+ continue;
1401
+ }
1402
+ offset += 2 + this.imageData.readUInt16BE(offset + 2);
1403
+ }
1404
+ }
1405
+ } catch (error: unknown) {
1406
+ const message = error instanceof Error ? error.message : String(error);
1407
+ defaultLogger.warn(`DPI detection failed: ${message}`);
1408
+ }
1409
+ return undefined;
1410
+ }
1411
+
1412
+ isFloating(): boolean {
1413
+ return !!this.anchor || !!this.position;
1414
+ }
1415
+
1416
+ floatTopLeft(marginTop = 0, marginLeft = 0): this {
1417
+ this.setPosition({ anchor: 'page', offset: marginLeft }, { anchor: 'page', offset: marginTop });
1418
+ this.setAnchor({
1419
+ behindDoc: false,
1420
+ locked: false,
1421
+ layoutInCell: true,
1422
+ allowOverlap: true,
1423
+ relativeHeight: 251658240,
1424
+ });
1425
+ this.setWrap('square', 'bothSides');
1426
+ return this;
1427
+ }
1428
+
1429
+ floatTopRight(marginTop = 0, marginRight = 0): this {
1430
+ this.setPosition(
1431
+ { anchor: 'page', alignment: 'right', offset: -marginRight },
1432
+ { anchor: 'page', offset: marginTop }
1433
+ );
1434
+ this.setAnchor({
1435
+ behindDoc: false,
1436
+ locked: false,
1437
+ layoutInCell: true,
1438
+ allowOverlap: true,
1439
+ relativeHeight: 251658240,
1440
+ });
1441
+ this.setWrap('square', 'bothSides');
1442
+ return this;
1443
+ }
1444
+
1445
+ floatCenter(): this {
1446
+ this.setPosition(
1447
+ { anchor: 'page', alignment: 'center' },
1448
+ { anchor: 'page', alignment: 'center' }
1449
+ );
1450
+ this.setAnchor({
1451
+ behindDoc: false,
1452
+ locked: false,
1453
+ layoutInCell: true,
1454
+ allowOverlap: true,
1455
+ relativeHeight: 251658240,
1456
+ });
1457
+ this.setWrap('square', 'bothSides');
1458
+ return this;
1459
+ }
1460
+
1461
+ setBehindText(behind = true): this {
1462
+ if (this.anchor) {
1463
+ this.anchor.behindDoc = behind;
1464
+ } else {
1465
+ this.setAnchor({
1466
+ behindDoc: behind,
1467
+ locked: false,
1468
+ layoutInCell: true,
1469
+ allowOverlap: true,
1470
+ relativeHeight: 251658240,
1471
+ });
1472
+ }
1473
+ return this;
1474
+ }
1475
+
1476
+ /**
1477
+ * Applies a border around the image
1478
+ * @param thicknessOrOptions Border thickness in points (number) or full ImageBorder options
1479
+ * @returns This image for chaining
1480
+ *
1481
+ * Note: effectExtent is set to accommodate the border width so it renders
1482
+ * properly without being clipped. The border is drawn centered on the image
1483
+ * edge, so half the border width extends outside the image bounds.
1484
+ */
1485
+ setBorder(thicknessOrOptions: number | ImageBorder = 2): this {
1486
+ if (typeof thicknessOrOptions === 'number') {
1487
+ this.border = { width: thicknessOrOptions };
1488
+ } else {
1489
+ this.border = thicknessOrOptions;
1490
+ }
1491
+
1492
+ // Calculate space needed for border (half-width on each side)
1493
+ // Border is drawn centered on the edge
1494
+ const borderEmu = this.border.width * UNITS.EMUS_PER_POINT;
1495
+ const halfBorderEmu = Math.ceil(borderEmu / 2);
1496
+
1497
+ // Ensure effectExtent has at least enough space for the border
1498
+ if (!this.effectExtent) {
1499
+ this.effectExtent = { left: 0, top: 0, right: 0, bottom: 0 };
1500
+ }
1501
+ this.effectExtent.left = Math.max(this.effectExtent.left, halfBorderEmu);
1502
+ this.effectExtent.top = Math.max(this.effectExtent.top, halfBorderEmu);
1503
+ this.effectExtent.right = Math.max(this.effectExtent.right, halfBorderEmu);
1504
+ this.effectExtent.bottom = Math.max(this.effectExtent.bottom, halfBorderEmu);
1505
+
1506
+ return this;
1507
+ }
1508
+
1509
+ /**
1510
+ * Removes the border from the image
1511
+ * @returns This image for chaining
1512
+ */
1513
+ removeBorder(): this {
1514
+ this.border = undefined;
1515
+ return this;
1516
+ }
1517
+
1518
+ /**
1519
+ * @deprecated Use setBorder() instead. This method will be removed in a future version.
1520
+ * Applies a 2-point black border around the image.
1521
+ * @returns This image for chaining
1522
+ */
1523
+ applyTwoPixelBlackBorder(): this {
1524
+ return this.setBorder(2);
1525
+ }
1526
+
1527
+ toXML(): XMLElement {
1528
+ const isFloating = this.isFloating();
1529
+
1530
+ // Common elements - must include wp: namespace prefix
1531
+ const extent = XMLBuilder.wp('extent', {
1532
+ cx: this.width.toString(),
1533
+ cy: this.height.toString(),
1534
+ });
1535
+
1536
+ // --- Build blip element with effects ---
1537
+ const blipChildren: XMLElement[] = [];
1538
+
1539
+ // Add luminance effect for brightness/contrast (per ECMA-376 §20.1.8.43)
1540
+ if (this.effects?.brightness !== undefined || this.effects?.contrast !== undefined) {
1541
+ const lumAttrs: Record<string, string> = {};
1542
+ if (this.effects.brightness !== undefined) {
1543
+ lumAttrs.bright = Math.round(this.effects.brightness * 1000).toString();
1544
+ }
1545
+ if (this.effects.contrast !== undefined) {
1546
+ lumAttrs.contrast = Math.round(this.effects.contrast * 1000).toString();
1547
+ }
1548
+ blipChildren.push(XMLBuilder.aSelf('lum', lumAttrs));
1549
+ }
1550
+
1551
+ // Add grayscale effect (per ECMA-376 §20.1.8.37)
1552
+ if (this.effects?.grayscale) {
1553
+ blipChildren.push(XMLBuilder.aSelf('grayscl'));
1554
+ }
1555
+
1556
+ // Add transparency effect via a:alphaModFix (ECMA-376 §20.1.8.4)
1557
+ if (this.effects?.transparency !== undefined && this.effects.transparency > 0) {
1558
+ // transparency is 0-100%, alphaModFix amt is in 1/1000ths of percent
1559
+ // e.g., 50% transparency = 50000 amt (= 50% opacity)
1560
+ const amt = Math.round((100 - this.effects.transparency) * 1000);
1561
+ blipChildren.push(XMLBuilder.aSelf('alphaModFix', { amt: amt.toString() }));
1562
+ }
1563
+
1564
+ // Group B: Inject raw blip effects passthrough (a:clrChange, a:duotone, etc.)
1565
+ if (this._rawPassthrough.has('blip-effects')) {
1566
+ blipChildren.push({
1567
+ name: '__rawXml',
1568
+ rawXml: this._rawPassthrough.get('blip-effects')!,
1569
+ } as XMLElement);
1570
+ }
1571
+
1572
+ // Group B: Inject raw blip extLst passthrough (must come last per schema)
1573
+ if (this._rawPassthrough.has('blip-extLst')) {
1574
+ blipChildren.push({
1575
+ name: '__rawXml',
1576
+ rawXml: this._rawPassthrough.get('blip-extLst')!,
1577
+ } as XMLElement);
1578
+ } else if (this.svgRelationshipId) {
1579
+ // SVG dual-relationship: add asvg:svgBlip reference in extLst
1580
+ blipChildren.push({
1581
+ name: '__rawXml',
1582
+ rawXml: `<a:extLst><a:ext uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}"><asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="${this.svgRelationshipId}"/></a:ext></a:extLst>`,
1583
+ } as XMLElement);
1584
+ }
1585
+
1586
+ // Build blip attributes: r:embed or r:link, cstate
1587
+ const blipAttrs: Record<string, string | undefined> = {
1588
+ cstate: this.compressionState,
1589
+ };
1590
+ if (this.isLinked) {
1591
+ blipAttrs['r:link'] = this.relationshipId;
1592
+ } else {
1593
+ blipAttrs['r:embed'] = this.relationshipId;
1594
+ }
1595
+
1596
+ const blip =
1597
+ blipChildren.length > 0
1598
+ ? XMLBuilder.a('blip', blipAttrs, blipChildren)
1599
+ : XMLBuilder.a('blip', blipAttrs);
1600
+
1601
+ // --- Build transform (a:xfrm) ---
1602
+ const xfrmAttrs: Record<string, string> | undefined = (() => {
1603
+ const attrs: Record<string, string> = {};
1604
+ if (this.rotation > 0) attrs.rot = Math.round(this.rotation * 60000).toString();
1605
+ if (this.flipH) attrs.flipH = '1';
1606
+ if (this.flipV) attrs.flipV = '1';
1607
+ return Object.keys(attrs).length > 0 ? attrs : undefined;
1608
+ })();
1609
+ const xfrm = XMLBuilder.a('xfrm', xfrmAttrs, [
1610
+ XMLBuilder.a('off', { x: '0', y: '0' }),
1611
+ XMLBuilder.a('ext', { cx: this.width.toString(), cy: this.height.toString() }),
1612
+ ]);
1613
+
1614
+ // --- Build shape properties (pic:spPr) ---
1615
+ const spPrChildren: XMLElement[] = [xfrm];
1616
+
1617
+ // Geometry: use passthrough for custGeom or prstGeom with avLst, otherwise default
1618
+ if (this._rawPassthrough.has('geometry')) {
1619
+ spPrChildren.push({
1620
+ name: '__rawXml',
1621
+ rawXml: this._rawPassthrough.get('geometry')!,
1622
+ } as XMLElement);
1623
+ } else {
1624
+ spPrChildren.push(
1625
+ XMLBuilder.a('prstGeom', { prst: this.presetGeometry }, [XMLBuilder.a('avLst')])
1626
+ );
1627
+ }
1628
+
1629
+ // Border (a:ln) - full model (Group C)
1630
+ if (this.border) {
1631
+ // Add noFill element before the border line (required by Word)
1632
+ spPrChildren.push(XMLBuilder.a('noFill'));
1633
+
1634
+ const ptToEmu = 12700;
1635
+ const widthEmu = this.border.width * ptToEmu;
1636
+ const lnAttrs: Record<string, string> = { w: widthEmu.toString() };
1637
+ if (this.border.cap) lnAttrs.cap = this.border.cap;
1638
+ if (this.border.compound) lnAttrs.cmpd = this.border.compound;
1639
+ if (this.border.alignment) lnAttrs.algn = this.border.alignment;
1640
+
1641
+ const lnChildren: XMLElement[] = [];
1642
+
1643
+ // Fill
1644
+ if (this.border.rawFillXml) {
1645
+ lnChildren.push({ name: '__rawXml', rawXml: this.border.rawFillXml } as XMLElement);
1646
+ } else if (this.border.fill) {
1647
+ const colorChildren: XMLElement[] = [];
1648
+ if (this.border.fill.modifiers) {
1649
+ for (const mod of this.border.fill.modifiers) {
1650
+ colorChildren.push(XMLBuilder.aSelf(mod.name, { val: mod.val }));
1651
+ }
1652
+ }
1653
+ const colorEl =
1654
+ colorChildren.length > 0
1655
+ ? XMLBuilder.a(this.border.fill.type, { val: this.border.fill.value }, colorChildren)
1656
+ : XMLBuilder.a(this.border.fill.type, { val: this.border.fill.value });
1657
+ lnChildren.push(XMLBuilder.a('solidFill', undefined, [colorEl]));
1658
+ } else {
1659
+ // Default: scheme color tx1 (backward compat)
1660
+ lnChildren.push(
1661
+ XMLBuilder.a('solidFill', undefined, [XMLBuilder.a('schemeClr', { val: 'tx1' })])
1662
+ );
1663
+ }
1664
+
1665
+ // Dash pattern
1666
+ if (this.border.dashPattern) {
1667
+ lnChildren.push(XMLBuilder.aSelf('prstDash', { val: this.border.dashPattern }));
1668
+ }
1669
+
1670
+ // Join
1671
+ if (this.border.join === 'round') {
1672
+ lnChildren.push(XMLBuilder.aSelf('round'));
1673
+ } else if (this.border.join === 'bevel') {
1674
+ lnChildren.push(XMLBuilder.aSelf('bevel'));
1675
+ } else if (this.border.join === 'miter') {
1676
+ const miterAttrs: Record<string, string> = {};
1677
+ if (this.border.miterLimit) miterAttrs.lim = this.border.miterLimit.toString();
1678
+ lnChildren.push(XMLBuilder.aSelf('miter', miterAttrs));
1679
+ }
1680
+
1681
+ // Head/tail end
1682
+ if (this.border.headEnd) {
1683
+ lnChildren.push(XMLBuilder.aSelf('headEnd', this.border.headEnd as Record<string, string>));
1684
+ }
1685
+ if (this.border.tailEnd) {
1686
+ lnChildren.push(XMLBuilder.aSelf('tailEnd', this.border.tailEnd as Record<string, string>));
1687
+ }
1688
+
1689
+ spPrChildren.push(XMLBuilder.a('ln', lnAttrs, lnChildren));
1690
+ }
1691
+
1692
+ // Group B: Inject raw spPr effects passthrough (effectLst, scene3d, sp3d, etc.)
1693
+ if (this._rawPassthrough.has('spPr-effects')) {
1694
+ spPrChildren.push({
1695
+ name: '__rawXml',
1696
+ rawXml: this._rawPassthrough.get('spPr-effects')!,
1697
+ } as XMLElement);
1698
+ }
1699
+
1700
+ // --- Build pic:cNvPr with passthrough ---
1701
+ const cNvPrChildren: XMLElement[] = [];
1702
+ if (this._rawPassthrough.has('cNvPr-extra')) {
1703
+ cNvPrChildren.push({
1704
+ name: '__rawXml',
1705
+ rawXml: this._rawPassthrough.get('cNvPr-extra')!,
1706
+ } as XMLElement);
1707
+ }
1708
+ const cNvPr =
1709
+ cNvPrChildren.length > 0
1710
+ ? XMLBuilder.pic(
1711
+ 'cNvPr',
1712
+ {
1713
+ id: this.picNonVisualProps.id,
1714
+ name: this.picNonVisualProps.name,
1715
+ descr: this.picNonVisualProps.descr,
1716
+ },
1717
+ cNvPrChildren
1718
+ )
1719
+ : XMLBuilder.pic('cNvPr', {
1720
+ id: this.picNonVisualProps.id,
1721
+ name: this.picNonVisualProps.name,
1722
+ descr: this.picNonVisualProps.descr,
1723
+ });
1724
+
1725
+ // --- Build picLocks from map ---
1726
+ const picLocksAttrs: Record<string, string> = {};
1727
+ for (const [key, val] of Object.entries(this.picLocks)) {
1728
+ if (val) picLocksAttrs[key] = '1';
1729
+ }
1730
+
1731
+ // --- Build blipFill ---
1732
+ const blipFillAttrs: Record<string, string> = {};
1733
+ if (this.blipFillDpi !== undefined) blipFillAttrs.dpi = this.blipFillDpi.toString();
1734
+ if (this.blipFillRotWithShape !== undefined)
1735
+ blipFillAttrs.rotWithShape = this.blipFillRotWithShape ? '1' : '0';
1736
+
1737
+ const blipFillChildren: XMLElement[] = [blip];
1738
+ // Crop values are stored as percentages (0-100), serialized as per-mille (0-100000)
1739
+ if (this.crop) {
1740
+ blipFillChildren.push(
1741
+ XMLBuilder.a('srcRect', {
1742
+ l: Math.round(this.crop.left * 1000).toString(),
1743
+ t: Math.round(this.crop.top * 1000).toString(),
1744
+ r: Math.round(this.crop.right * 1000).toString(),
1745
+ b: Math.round(this.crop.bottom * 1000).toString(),
1746
+ })
1747
+ );
1748
+ }
1749
+ // Group B: Use tile passthrough instead of stretch when present
1750
+ if (this._rawPassthrough.has('blipFill-extra')) {
1751
+ blipFillChildren.push({
1752
+ name: '__rawXml',
1753
+ rawXml: this._rawPassthrough.get('blipFill-extra')!,
1754
+ } as XMLElement);
1755
+ } else {
1756
+ blipFillChildren.push(XMLBuilder.a('stretch', undefined, [XMLBuilder.a('fillRect')]));
1757
+ }
1758
+
1759
+ const blipFillAttrsObj = Object.keys(blipFillAttrs).length > 0 ? blipFillAttrs : undefined;
1760
+
1761
+ const graphicData = XMLBuilder.a(
1762
+ 'graphicData',
1763
+ { uri: 'http://schemas.openxmlformats.org/drawingml/2006/picture' },
1764
+ [
1765
+ XMLBuilder.pic('pic', undefined, [
1766
+ XMLBuilder.pic('nvPicPr', undefined, [
1767
+ cNvPr,
1768
+ XMLBuilder.pic('cNvPicPr', undefined, [
1769
+ XMLBuilder.a(
1770
+ 'picLocks',
1771
+ Object.keys(picLocksAttrs).length > 0 ? picLocksAttrs : undefined
1772
+ ),
1773
+ ]),
1774
+ ]),
1775
+ XMLBuilder.pic('blipFill', blipFillAttrsObj, blipFillChildren),
1776
+ XMLBuilder.pic('spPr', { bwMode: this.bwMode }, spPrChildren),
1777
+ ]),
1778
+ ]
1779
+ );
1780
+
1781
+ const graphic = XMLBuilder.a('graphic', undefined, [graphicData]);
1782
+
1783
+ // --- Build docPr element (shared between inline and floating) ---
1784
+ const buildDocPr = (idVal: string | number): XMLElement => {
1785
+ const attrs: Record<string, any> = { id: idVal, name: this.name, descr: this.description };
1786
+ if (this.title) attrs.title = this.title;
1787
+ if (this.hidden) attrs.hidden = '1';
1788
+ const children: XMLElement[] = [];
1789
+ if (this._rawPassthrough.has('docPr-extra')) {
1790
+ children.push({
1791
+ name: '__rawXml',
1792
+ rawXml: this._rawPassthrough.get('docPr-extra')!,
1793
+ } as XMLElement);
1794
+ }
1795
+ return children.length > 0
1796
+ ? XMLBuilder.wp('docPr', attrs, children)
1797
+ : XMLBuilder.wp('docPr', attrs);
1798
+ };
1799
+
1800
+ // --- Build cNvGraphicFramePr element (shared between inline and floating) ---
1801
+ const buildCNvGraphicFramePr = (): XMLElement => {
1802
+ return XMLBuilder.wp('cNvGraphicFramePr', undefined, [
1803
+ XMLBuilder.a('graphicFrameLocks', {
1804
+ 'xmlns:a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
1805
+ noChangeAspect: this.noChangeAspect ? '1' : '0',
1806
+ }),
1807
+ ]);
1808
+ };
1809
+
1810
+ if (isFloating) {
1811
+ // Floating image (anchor)
1812
+ const positionHChildren: XMLElement[] = [];
1813
+ if (this.position?.horizontal.alignment) {
1814
+ positionHChildren.push(
1815
+ XMLBuilder.wp('align', undefined, [this.position.horizontal.alignment])
1816
+ );
1817
+ } else {
1818
+ positionHChildren.push(
1819
+ XMLBuilder.wp('posOffset', undefined, [
1820
+ (this.position?.horizontal.offset || 0).toString(),
1821
+ ])
1822
+ );
1823
+ }
1824
+ const positionH = XMLBuilder.wp(
1825
+ 'positionH',
1826
+ { relativeFrom: this.position?.horizontal.anchor || 'page' },
1827
+ positionHChildren
1828
+ );
1829
+
1830
+ const positionVChildren: XMLElement[] = [];
1831
+ if (this.position?.vertical.alignment) {
1832
+ positionVChildren.push(
1833
+ XMLBuilder.wp('align', undefined, [this.position.vertical.alignment])
1834
+ );
1835
+ } else {
1836
+ positionVChildren.push(
1837
+ XMLBuilder.wp('posOffset', undefined, [(this.position?.vertical.offset || 0).toString()])
1838
+ );
1839
+ }
1840
+ const positionV = XMLBuilder.wp(
1841
+ 'positionV',
1842
+ { relativeFrom: this.position?.vertical.anchor || 'page' },
1843
+ positionVChildren
1844
+ );
1845
+
1846
+ // Effect extent for floating images (required by Word)
1847
+ const floatEffectExt = this.effectExtent || { left: 0, top: 0, right: 0, bottom: 0 };
1848
+ const effectExtentElement = XMLBuilder.wp('effectExtent', {
1849
+ t: floatEffectExt.top.toString(),
1850
+ r: floatEffectExt.right.toString(),
1851
+ b: floatEffectExt.bottom.toString(),
1852
+ l: floatEffectExt.left.toString(),
1853
+ });
1854
+
1855
+ const anchorChildren: XMLElement[] = [positionH, positionV, extent, effectExtentElement];
1856
+
1857
+ // Wrap element (required by CT_Anchor per ECMA-376 — defaults to wrapNone)
1858
+ if (this.wrap) {
1859
+ const wrapAttrs: Record<string, any> = {};
1860
+ if (this.wrap.distanceTop !== undefined) wrapAttrs.distT = this.wrap.distanceTop;
1861
+ if (this.wrap.distanceBottom !== undefined) wrapAttrs.distB = this.wrap.distanceBottom;
1862
+ if (this.wrap.distanceLeft !== undefined) wrapAttrs.distL = this.wrap.distanceLeft;
1863
+ if (this.wrap.distanceRight !== undefined) wrapAttrs.distR = this.wrap.distanceRight;
1864
+ if (this.wrap.side) wrapAttrs.wrapText = this.wrap.side;
1865
+
1866
+ let wrapElementName: string;
1867
+ switch (this.wrap.type) {
1868
+ case 'square':
1869
+ wrapElementName = 'wrapSquare';
1870
+ break;
1871
+ case 'tight':
1872
+ wrapElementName = 'wrapTight';
1873
+ break;
1874
+ case 'through':
1875
+ wrapElementName = 'wrapThrough';
1876
+ break;
1877
+ case 'topAndBottom':
1878
+ wrapElementName = 'wrapTopAndBottom';
1879
+ break;
1880
+ case 'none':
1881
+ wrapElementName = 'wrapNone';
1882
+ break;
1883
+ default:
1884
+ wrapElementName = 'wrapSquare';
1885
+ }
1886
+
1887
+ // Group B: Include wrap polygon passthrough as children
1888
+ const wrapChildren: XMLElement[] = [];
1889
+ if (this._rawPassthrough.has('wrap-polygon')) {
1890
+ wrapChildren.push({
1891
+ name: '__rawXml',
1892
+ rawXml: this._rawPassthrough.get('wrap-polygon')!,
1893
+ } as XMLElement);
1894
+ } else if (wrapElementName === 'wrapTight' || wrapElementName === 'wrapThrough') {
1895
+ // CT_WrapTight/CT_WrapThrough require a wp:wrapPolygon child.
1896
+ // Generate a default rectangular polygon covering the full image extents.
1897
+ wrapChildren.push({
1898
+ name: 'wp:wrapPolygon',
1899
+ attributes: { edited: '0' },
1900
+ children: [
1901
+ { name: 'wp:start', attributes: { x: '0', y: '0' }, selfClosing: true },
1902
+ { name: 'wp:lineTo', attributes: { x: '21600', y: '0' }, selfClosing: true },
1903
+ { name: 'wp:lineTo', attributes: { x: '21600', y: '21600' }, selfClosing: true },
1904
+ { name: 'wp:lineTo', attributes: { x: '0', y: '21600' }, selfClosing: true },
1905
+ { name: 'wp:lineTo', attributes: { x: '0', y: '0' }, selfClosing: true },
1906
+ ],
1907
+ });
1908
+ }
1909
+
1910
+ anchorChildren.push(
1911
+ wrapChildren.length > 0
1912
+ ? XMLBuilder.wp(wrapElementName, wrapAttrs, wrapChildren)
1913
+ : XMLBuilder.wp(wrapElementName, wrapAttrs)
1914
+ );
1915
+ } else {
1916
+ // Default: wrapNone (required choice element per CT_Anchor)
1917
+ anchorChildren.push(XMLBuilder.wp('wrapNone', {}));
1918
+ }
1919
+
1920
+ anchorChildren.push(buildDocPr(this.docPrId));
1921
+ anchorChildren.push(buildCNvGraphicFramePr());
1922
+ anchorChildren.push(graphic);
1923
+
1924
+ // Group B: Inject anchor extras (wp14:sizeRelH, wp14:sizeRelV)
1925
+ if (this._rawPassthrough.has('anchor-extra')) {
1926
+ anchorChildren.push({
1927
+ name: '__rawXml',
1928
+ rawXml: this._rawPassthrough.get('anchor-extra')!,
1929
+ } as XMLElement);
1930
+ }
1931
+
1932
+ // Build anchor attributes including simplePos and distance from text
1933
+ const anchorAttrs: Record<string, any> = {
1934
+ behindDoc: this.anchor?.behindDoc ? 1 : 0,
1935
+ locked: this.anchor?.locked ? 1 : 0,
1936
+ layoutInCell: this.anchor?.layoutInCell ? 1 : 0,
1937
+ allowOverlap: this.anchor?.allowOverlap ? 1 : 0,
1938
+ relativeHeight: this.anchor?.relativeHeight,
1939
+ simplePos: this.anchor?.simplePos ? '1' : '0',
1940
+ distT: (this.anchor?.distT ?? 0).toString(),
1941
+ distB: (this.anchor?.distB ?? 0).toString(),
1942
+ distL: (this.anchor?.distL ?? 0).toString(),
1943
+ distR: (this.anchor?.distR ?? 0).toString(),
1944
+ };
1945
+ if (this.hidden) anchorAttrs.hidden = '1';
1946
+
1947
+ // Add wp:simplePos child element (required by ECMA-376 even when simplePos="0")
1948
+ anchorChildren.unshift(XMLBuilder.wp('simplePos', { x: '0', y: '0' }));
1949
+
1950
+ return XMLBuilder.w('drawing', undefined, [
1951
+ XMLBuilder.wp('anchor', anchorAttrs, anchorChildren),
1952
+ ]);
1953
+ } else {
1954
+ // Inline image
1955
+ const effectExt = this.effectExtent || { left: 0, top: 0, right: 0, bottom: 0 };
1956
+
1957
+ return XMLBuilder.w('drawing', undefined, [
1958
+ XMLBuilder.wp(
1959
+ 'inline',
1960
+ {
1961
+ distT: this.inlineDistT.toString(),
1962
+ distB: this.inlineDistB.toString(),
1963
+ distL: this.inlineDistL.toString(),
1964
+ distR: this.inlineDistR.toString(),
1965
+ },
1966
+ [
1967
+ extent,
1968
+ XMLBuilder.wp('effectExtent', {
1969
+ t: effectExt.top.toString(),
1970
+ r: effectExt.right.toString(),
1971
+ b: effectExt.bottom.toString(),
1972
+ l: effectExt.left.toString(),
1973
+ }),
1974
+ buildDocPr(this.docPrId.toString()),
1975
+ buildCNvGraphicFramePr(),
1976
+ graphic,
1977
+ ]
1978
+ ),
1979
+ ]);
1980
+ }
1981
+ }
1982
+ }