docxmlater 10.1.3 → 10.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. package/README.md +759 -754
  2. package/dist/constants/legacyCompatFlags.js +1 -1
  3. package/dist/constants/legacyCompatFlags.js.map +1 -1
  4. package/dist/constants/limits.js.map +1 -1
  5. package/dist/core/Document.d.ts +50 -50
  6. package/dist/core/Document.d.ts.map +1 -1
  7. package/dist/core/Document.js +483 -471
  8. package/dist/core/Document.js.map +1 -1
  9. package/dist/core/DocumentContent.d.ts +9 -9
  10. package/dist/core/DocumentContent.d.ts.map +1 -1
  11. package/dist/core/DocumentContent.js +1 -1
  12. package/dist/core/DocumentContent.js.map +1 -1
  13. package/dist/core/DocumentGenerator.d.ts +11 -11
  14. package/dist/core/DocumentGenerator.d.ts.map +1 -1
  15. package/dist/core/DocumentGenerator.js +251 -251
  16. package/dist/core/DocumentGenerator.js.map +1 -1
  17. package/dist/core/DocumentIdManager.js.map +1 -1
  18. package/dist/core/DocumentParser.d.ts +15 -15
  19. package/dist/core/DocumentParser.d.ts.map +1 -1
  20. package/dist/core/DocumentParser.js +2123 -2155
  21. package/dist/core/DocumentParser.js.map +1 -1
  22. package/dist/core/DocumentValidator.d.ts.map +1 -1
  23. package/dist/core/DocumentValidator.js +2 -5
  24. package/dist/core/DocumentValidator.js.map +1 -1
  25. package/dist/core/Relationship.js.map +1 -1
  26. package/dist/core/RelationshipManager.d.ts.map +1 -1
  27. package/dist/core/RelationshipManager.js +3 -3
  28. package/dist/core/RelationshipManager.js.map +1 -1
  29. package/dist/elements/AlternateContent.js.map +1 -1
  30. package/dist/elements/Bookmark.d.ts.map +1 -1
  31. package/dist/elements/Bookmark.js +3 -1
  32. package/dist/elements/Bookmark.js.map +1 -1
  33. package/dist/elements/BookmarkManager.d.ts.map +1 -1
  34. package/dist/elements/BookmarkManager.js.map +1 -1
  35. package/dist/elements/Comment.d.ts.map +1 -1
  36. package/dist/elements/Comment.js +9 -6
  37. package/dist/elements/Comment.js.map +1 -1
  38. package/dist/elements/CommentManager.d.ts.map +1 -1
  39. package/dist/elements/CommentManager.js +18 -17
  40. package/dist/elements/CommentManager.js.map +1 -1
  41. package/dist/elements/CommonTypes.d.ts +21 -21
  42. package/dist/elements/CommonTypes.d.ts.map +1 -1
  43. package/dist/elements/CommonTypes.js +56 -56
  44. package/dist/elements/CommonTypes.js.map +1 -1
  45. package/dist/elements/CustomXml.js.map +1 -1
  46. package/dist/elements/Endnote.d.ts.map +1 -1
  47. package/dist/elements/Endnote.js +6 -6
  48. package/dist/elements/Endnote.js.map +1 -1
  49. package/dist/elements/EndnoteManager.d.ts.map +1 -1
  50. package/dist/elements/EndnoteManager.js +6 -7
  51. package/dist/elements/EndnoteManager.js.map +1 -1
  52. package/dist/elements/Field.d.ts.map +1 -1
  53. package/dist/elements/Field.js +82 -25
  54. package/dist/elements/Field.js.map +1 -1
  55. package/dist/elements/FieldHelpers.d.ts.map +1 -1
  56. package/dist/elements/FieldHelpers.js.map +1 -1
  57. package/dist/elements/FontManager.d.ts.map +1 -1
  58. package/dist/elements/FontManager.js +1 -1
  59. package/dist/elements/FontManager.js.map +1 -1
  60. package/dist/elements/Footer.js +2 -2
  61. package/dist/elements/Footer.js.map +1 -1
  62. package/dist/elements/Footnote.d.ts.map +1 -1
  63. package/dist/elements/Footnote.js +6 -6
  64. package/dist/elements/Footnote.js.map +1 -1
  65. package/dist/elements/FootnoteManager.d.ts.map +1 -1
  66. package/dist/elements/FootnoteManager.js +6 -7
  67. package/dist/elements/FootnoteManager.js.map +1 -1
  68. package/dist/elements/Header.js +2 -2
  69. package/dist/elements/Header.js.map +1 -1
  70. package/dist/elements/HeaderFooterManager.js.map +1 -1
  71. package/dist/elements/Hyperlink.d.ts +5 -3
  72. package/dist/elements/Hyperlink.d.ts.map +1 -1
  73. package/dist/elements/Hyperlink.js +134 -76
  74. package/dist/elements/Hyperlink.js.map +1 -1
  75. package/dist/elements/Image.d.ts.map +1 -1
  76. package/dist/elements/Image.js +238 -106
  77. package/dist/elements/Image.js.map +1 -1
  78. package/dist/elements/ImageManager.d.ts.map +1 -1
  79. package/dist/elements/ImageManager.js +1 -1
  80. package/dist/elements/ImageManager.js.map +1 -1
  81. package/dist/elements/ImageRun.js +1 -1
  82. package/dist/elements/ImageRun.js.map +1 -1
  83. package/dist/elements/MathElement.js.map +1 -1
  84. package/dist/elements/Paragraph.d.ts +24 -24
  85. package/dist/elements/Paragraph.d.ts.map +1 -1
  86. package/dist/elements/Paragraph.js +181 -188
  87. package/dist/elements/Paragraph.js.map +1 -1
  88. package/dist/elements/PreservedElement.js.map +1 -1
  89. package/dist/elements/PropertyChangeTypes.d.ts.map +1 -1
  90. package/dist/elements/PropertyChangeTypes.js +6 -6
  91. package/dist/elements/PropertyChangeTypes.js.map +1 -1
  92. package/dist/elements/RangeMarker.d.ts.map +1 -1
  93. package/dist/elements/RangeMarker.js.map +1 -1
  94. package/dist/elements/Revision.d.ts.map +1 -1
  95. package/dist/elements/Revision.js +4 -5
  96. package/dist/elements/Revision.js.map +1 -1
  97. package/dist/elements/RevisionContent.js.map +1 -1
  98. package/dist/elements/RevisionManager.d.ts.map +1 -1
  99. package/dist/elements/RevisionManager.js +40 -48
  100. package/dist/elements/RevisionManager.js.map +1 -1
  101. package/dist/elements/Run.d.ts +16 -16
  102. package/dist/elements/Run.d.ts.map +1 -1
  103. package/dist/elements/Run.js +256 -238
  104. package/dist/elements/Run.js.map +1 -1
  105. package/dist/elements/Section.d.ts.map +1 -1
  106. package/dist/elements/Section.js +36 -11
  107. package/dist/elements/Section.js.map +1 -1
  108. package/dist/elements/Shape.d.ts.map +1 -1
  109. package/dist/elements/Shape.js.map +1 -1
  110. package/dist/elements/StructuredDocumentTag.d.ts +6 -6
  111. package/dist/elements/StructuredDocumentTag.d.ts.map +1 -1
  112. package/dist/elements/StructuredDocumentTag.js +99 -104
  113. package/dist/elements/StructuredDocumentTag.js.map +1 -1
  114. package/dist/elements/Table.d.ts +11 -11
  115. package/dist/elements/Table.d.ts.map +1 -1
  116. package/dist/elements/Table.js +102 -107
  117. package/dist/elements/Table.js.map +1 -1
  118. package/dist/elements/TableCell.d.ts +10 -10
  119. package/dist/elements/TableCell.d.ts.map +1 -1
  120. package/dist/elements/TableCell.js +105 -106
  121. package/dist/elements/TableCell.js.map +1 -1
  122. package/dist/elements/TableGridChange.d.ts.map +1 -1
  123. package/dist/elements/TableGridChange.js.map +1 -1
  124. package/dist/elements/TableOfContents.d.ts.map +1 -1
  125. package/dist/elements/TableOfContents.js +4 -4
  126. package/dist/elements/TableOfContents.js.map +1 -1
  127. package/dist/elements/TableOfContentsElement.js.map +1 -1
  128. package/dist/elements/TableRow.d.ts.map +1 -1
  129. package/dist/elements/TableRow.js +13 -6
  130. package/dist/elements/TableRow.js.map +1 -1
  131. package/dist/elements/TextBox.d.ts.map +1 -1
  132. package/dist/elements/TextBox.js +3 -5
  133. package/dist/elements/TextBox.js.map +1 -1
  134. package/dist/formatting/AbstractNumbering.d.ts +4 -4
  135. package/dist/formatting/AbstractNumbering.d.ts.map +1 -1
  136. package/dist/formatting/AbstractNumbering.js +54 -49
  137. package/dist/formatting/AbstractNumbering.js.map +1 -1
  138. package/dist/formatting/NumberingInstance.d.ts.map +1 -1
  139. package/dist/formatting/NumberingInstance.js +1 -3
  140. package/dist/formatting/NumberingInstance.js.map +1 -1
  141. package/dist/formatting/NumberingLevel.d.ts +5 -5
  142. package/dist/formatting/NumberingLevel.d.ts.map +1 -1
  143. package/dist/formatting/NumberingLevel.js +119 -125
  144. package/dist/formatting/NumberingLevel.js.map +1 -1
  145. package/dist/formatting/NumberingManager.d.ts.map +1 -1
  146. package/dist/formatting/NumberingManager.js +9 -9
  147. package/dist/formatting/NumberingManager.js.map +1 -1
  148. package/dist/formatting/Style.d.ts +11 -11
  149. package/dist/formatting/Style.d.ts.map +1 -1
  150. package/dist/formatting/Style.js +219 -247
  151. package/dist/formatting/Style.js.map +1 -1
  152. package/dist/formatting/StylesManager.d.ts +2 -2
  153. package/dist/formatting/StylesManager.d.ts.map +1 -1
  154. package/dist/formatting/StylesManager.js +96 -102
  155. package/dist/formatting/StylesManager.js.map +1 -1
  156. package/dist/helpers/CleanupHelper.d.ts +1 -1
  157. package/dist/helpers/CleanupHelper.d.ts.map +1 -1
  158. package/dist/helpers/CleanupHelper.js +6 -6
  159. package/dist/helpers/CleanupHelper.js.map +1 -1
  160. package/dist/images/ImageOptimizer.js +7 -7
  161. package/dist/images/ImageOptimizer.js.map +1 -1
  162. package/dist/index.d.ts +9 -9
  163. package/dist/index.d.ts.map +1 -1
  164. package/dist/index.js.map +1 -1
  165. package/dist/managers/DrawingManager.js.map +1 -1
  166. package/dist/tracking/DocumentTrackingContext.d.ts.map +1 -1
  167. package/dist/tracking/DocumentTrackingContext.js +23 -7
  168. package/dist/tracking/DocumentTrackingContext.js.map +1 -1
  169. package/dist/tracking/TrackingContext.d.ts.map +1 -1
  170. package/dist/tracking/TrackingContext.js.map +1 -1
  171. package/dist/types/compatibility-types.js.map +1 -1
  172. package/dist/types/formatting.js.map +1 -1
  173. package/dist/types/list-types.d.ts +6 -6
  174. package/dist/types/list-types.js.map +1 -1
  175. package/dist/types/settings-types.js.map +1 -1
  176. package/dist/types/styleConfig.d.ts +2 -2
  177. package/dist/types/styleConfig.js.map +1 -1
  178. package/dist/utils/ChangelogGenerator.d.ts.map +1 -1
  179. package/dist/utils/ChangelogGenerator.js +97 -101
  180. package/dist/utils/ChangelogGenerator.js.map +1 -1
  181. package/dist/utils/CompatibilityUpgrader.d.ts.map +1 -1
  182. package/dist/utils/CompatibilityUpgrader.js +1 -1
  183. package/dist/utils/CompatibilityUpgrader.js.map +1 -1
  184. package/dist/utils/InMemoryRevisionAcceptor.d.ts.map +1 -1
  185. package/dist/utils/InMemoryRevisionAcceptor.js +1 -6
  186. package/dist/utils/InMemoryRevisionAcceptor.js.map +1 -1
  187. package/dist/utils/MoveOperationHelper.d.ts.map +1 -1
  188. package/dist/utils/MoveOperationHelper.js +1 -1
  189. package/dist/utils/MoveOperationHelper.js.map +1 -1
  190. package/dist/utils/RevisionAwareProcessor.d.ts.map +1 -1
  191. package/dist/utils/RevisionAwareProcessor.js +2 -4
  192. package/dist/utils/RevisionAwareProcessor.js.map +1 -1
  193. package/dist/utils/RevisionWalker.d.ts.map +1 -1
  194. package/dist/utils/RevisionWalker.js +4 -12
  195. package/dist/utils/RevisionWalker.js.map +1 -1
  196. package/dist/utils/SelectiveRevisionAcceptor.d.ts.map +1 -1
  197. package/dist/utils/SelectiveRevisionAcceptor.js +2 -6
  198. package/dist/utils/SelectiveRevisionAcceptor.js.map +1 -1
  199. package/dist/utils/ShadingResolver.d.ts.map +1 -1
  200. package/dist/utils/ShadingResolver.js +1 -1
  201. package/dist/utils/ShadingResolver.js.map +1 -1
  202. package/dist/utils/acceptRevisions.d.ts.map +1 -1
  203. package/dist/utils/acceptRevisions.js +23 -12
  204. package/dist/utils/acceptRevisions.js.map +1 -1
  205. package/dist/utils/cnfStyleDecoder.d.ts +1 -1
  206. package/dist/utils/cnfStyleDecoder.d.ts.map +1 -1
  207. package/dist/utils/cnfStyleDecoder.js +40 -40
  208. package/dist/utils/cnfStyleDecoder.js.map +1 -1
  209. package/dist/utils/corruptionDetection.d.ts.map +1 -1
  210. package/dist/utils/corruptionDetection.js.map +1 -1
  211. package/dist/utils/dateFormatting.js.map +1 -1
  212. package/dist/utils/deepClone.js +1 -1
  213. package/dist/utils/deepClone.js.map +1 -1
  214. package/dist/utils/diagnostics.d.ts.map +1 -1
  215. package/dist/utils/diagnostics.js +1 -1
  216. package/dist/utils/diagnostics.js.map +1 -1
  217. package/dist/utils/errorHandling.js.map +1 -1
  218. package/dist/utils/formatting.d.ts.map +1 -1
  219. package/dist/utils/formatting.js +10 -2
  220. package/dist/utils/formatting.js.map +1 -1
  221. package/dist/utils/list-detection.d.ts +2 -2
  222. package/dist/utils/list-detection.d.ts.map +1 -1
  223. package/dist/utils/list-detection.js +21 -23
  224. package/dist/utils/list-detection.js.map +1 -1
  225. package/dist/utils/logger.d.ts.map +1 -1
  226. package/dist/utils/logger.js +12 -7
  227. package/dist/utils/logger.js.map +1 -1
  228. package/dist/utils/parsingHelpers.js.map +1 -1
  229. package/dist/utils/stripTrackedChanges.d.ts.map +1 -1
  230. package/dist/utils/stripTrackedChanges.js +3 -3
  231. package/dist/utils/stripTrackedChanges.js.map +1 -1
  232. package/dist/utils/textDiff.d.ts +1 -1
  233. package/dist/utils/textDiff.js +8 -8
  234. package/dist/utils/textDiff.js.map +1 -1
  235. package/dist/utils/units.js.map +1 -1
  236. package/dist/utils/validation.d.ts.map +1 -1
  237. package/dist/utils/validation.js +24 -7
  238. package/dist/utils/validation.js.map +1 -1
  239. package/dist/utils/xmlSanitization.d.ts.map +1 -1
  240. package/dist/utils/xmlSanitization.js +3 -3
  241. package/dist/utils/xmlSanitization.js.map +1 -1
  242. package/dist/validation/RevisionAutoFixer.d.ts.map +1 -1
  243. package/dist/validation/RevisionAutoFixer.js +5 -5
  244. package/dist/validation/RevisionAutoFixer.js.map +1 -1
  245. package/dist/validation/RevisionValidator.d.ts.map +1 -1
  246. package/dist/validation/RevisionValidator.js +7 -9
  247. package/dist/validation/RevisionValidator.js.map +1 -1
  248. package/dist/validation/ValidationRules.js +3 -3
  249. package/dist/validation/ValidationRules.js.map +1 -1
  250. package/dist/validation/index.js.map +1 -1
  251. package/dist/xml/XMLBuilder.d.ts +1 -1
  252. package/dist/xml/XMLBuilder.d.ts.map +1 -1
  253. package/dist/xml/XMLBuilder.js +98 -100
  254. package/dist/xml/XMLBuilder.js.map +1 -1
  255. package/dist/xml/XMLParser.d.ts.map +1 -1
  256. package/dist/xml/XMLParser.js +61 -66
  257. package/dist/xml/XMLParser.js.map +1 -1
  258. package/dist/zip/ZipHandler.d.ts.map +1 -1
  259. package/dist/zip/ZipHandler.js.map +1 -1
  260. package/dist/zip/ZipReader.d.ts.map +1 -1
  261. package/dist/zip/ZipReader.js +1 -3
  262. package/dist/zip/ZipReader.js.map +1 -1
  263. package/dist/zip/ZipWriter.d.ts +1 -1
  264. package/dist/zip/ZipWriter.d.ts.map +1 -1
  265. package/dist/zip/ZipWriter.js +28 -36
  266. package/dist/zip/ZipWriter.js.map +1 -1
  267. package/dist/zip/types.js +1 -1
  268. package/dist/zip/types.js.map +1 -1
  269. package/package.json +92 -92
  270. package/src/__tests__/helper-methods.test.ts +512 -512
  271. package/src/constants/legacyCompatFlags.ts +138 -138
  272. package/src/constants/limits.ts +50 -50
  273. package/src/core/Document.ts +985 -1145
  274. package/src/core/DocumentContent.ts +461 -467
  275. package/src/core/DocumentGenerator.ts +1133 -1104
  276. package/src/core/DocumentIdManager.ts +158 -158
  277. package/src/core/DocumentParser.ts +2347 -2716
  278. package/src/core/DocumentValidator.ts +363 -372
  279. package/src/core/Relationship.ts +367 -367
  280. package/src/core/RelationshipManager.ts +429 -428
  281. package/src/elements/AlternateContent.ts +42 -42
  282. package/src/elements/Bookmark.ts +212 -210
  283. package/src/elements/BookmarkManager.ts +247 -250
  284. package/src/elements/Comment.ts +356 -359
  285. package/src/elements/CommentManager.ts +499 -502
  286. package/src/elements/CommonTypes.ts +524 -549
  287. package/src/elements/CustomXml.ts +36 -36
  288. package/src/elements/Endnote.ts +221 -217
  289. package/src/elements/EndnoteManager.ts +246 -249
  290. package/src/elements/Field.ts +1292 -1233
  291. package/src/elements/FieldHelpers.ts +329 -333
  292. package/src/elements/FontManager.ts +336 -339
  293. package/src/elements/Footer.ts +269 -269
  294. package/src/elements/Footnote.ts +221 -217
  295. package/src/elements/FootnoteManager.ts +246 -249
  296. package/src/elements/Header.ts +269 -269
  297. package/src/elements/HeaderFooterManager.ts +219 -219
  298. package/src/elements/Hyperlink.ts +1288 -1193
  299. package/src/elements/Image.ts +1982 -1756
  300. package/src/elements/ImageManager.ts +437 -432
  301. package/src/elements/ImageRun.ts +59 -59
  302. package/src/elements/MathElement.ts +65 -65
  303. package/src/elements/Paragraph.ts +4347 -4287
  304. package/src/elements/PreservedElement.ts +53 -53
  305. package/src/elements/PropertyChangeTypes.ts +458 -442
  306. package/src/elements/RangeMarker.ts +382 -400
  307. package/src/elements/Revision.ts +1198 -1217
  308. package/src/elements/RevisionContent.ts +73 -73
  309. package/src/elements/RevisionManager.ts +1070 -1070
  310. package/src/elements/Run.ts +3103 -3073
  311. package/src/elements/Section.ts +1521 -1421
  312. package/src/elements/Shape.ts +884 -873
  313. package/src/elements/StructuredDocumentTag.ts +1176 -1207
  314. package/src/elements/Table.ts +2468 -2524
  315. package/src/elements/TableCell.ts +1617 -1621
  316. package/src/elements/TableGridChange.ts +149 -151
  317. package/src/elements/TableOfContents.ts +701 -691
  318. package/src/elements/TableOfContentsElement.ts +89 -89
  319. package/src/elements/TableRow.ts +960 -929
  320. package/src/elements/TextBox.ts +766 -768
  321. package/src/formatting/AbstractNumbering.ts +580 -579
  322. package/src/formatting/NumberingInstance.ts +295 -299
  323. package/src/formatting/NumberingLevel.ts +981 -1040
  324. package/src/formatting/NumberingManager.ts +833 -827
  325. package/src/formatting/Style.ts +1785 -1879
  326. package/src/formatting/StylesManager.ts +1090 -1130
  327. package/src/helpers/CleanupHelper.ts +524 -524
  328. package/src/images/ImageOptimizer.ts +274 -274
  329. package/src/index.ts +559 -554
  330. package/src/managers/DrawingManager.ts +319 -319
  331. package/src/tracking/DocumentTrackingContext.ts +687 -674
  332. package/src/tracking/TrackingContext.ts +175 -173
  333. package/src/types/compatibility-types.ts +49 -49
  334. package/src/types/formatting.ts +210 -210
  335. package/src/types/list-types.ts +14 -14
  336. package/src/types/settings-types.ts +59 -59
  337. package/src/types/styleConfig.ts +189 -189
  338. package/src/utils/ChangelogGenerator.ts +1583 -1581
  339. package/src/utils/CompatibilityUpgrader.ts +235 -237
  340. package/src/utils/InMemoryRevisionAcceptor.ts +691 -696
  341. package/src/utils/MoveOperationHelper.ts +233 -238
  342. package/src/utils/RevisionAwareProcessor.ts +518 -526
  343. package/src/utils/RevisionWalker.ts +427 -457
  344. package/src/utils/SelectiveRevisionAcceptor.ts +662 -683
  345. package/src/utils/ShadingResolver.ts +105 -107
  346. package/src/utils/acceptRevisions.ts +723 -714
  347. package/src/utils/cnfStyleDecoder.ts +212 -217
  348. package/src/utils/corruptionDetection.ts +346 -345
  349. package/src/utils/dateFormatting.ts +20 -20
  350. package/src/utils/deepClone.ts +77 -78
  351. package/src/utils/diagnostics.ts +125 -129
  352. package/src/utils/errorHandling.ts +80 -80
  353. package/src/utils/formatting.ts +220 -213
  354. package/src/utils/list-detection.ts +32 -42
  355. package/src/utils/logger.ts +412 -404
  356. package/src/utils/parsingHelpers.ts +190 -190
  357. package/src/utils/stripTrackedChanges.ts +356 -353
  358. package/src/utils/textDiff.ts +100 -100
  359. package/src/utils/units.ts +421 -421
  360. package/src/utils/validation.ts +553 -542
  361. package/src/utils/xmlSanitization.ts +179 -182
  362. package/src/validation/RevisionAutoFixer.ts +541 -542
  363. package/src/validation/RevisionValidator.ts +470 -460
  364. package/src/validation/ValidationRules.ts +338 -338
  365. package/src/validation/index.ts +30 -30
  366. package/src/xml/XMLBuilder.ts +857 -871
  367. package/src/xml/XMLParser.ts +877 -919
  368. package/src/zip/ZipHandler.ts +629 -637
  369. package/src/zip/ZipReader.ts +295 -299
  370. package/src/zip/ZipWriter.ts +374 -390
  371. package/src/zip/types.ts +116 -116
@@ -1,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
+ }