node-canvas.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. 'use strict'
  2. const { Readable } = require('node:stream')
  3. const {
  4. createCanvas: _createCanvas,
  5. Canvas,
  6. CanvasElement,
  7. SVGCanvas,
  8. GlobalFonts,
  9. Image,
  10. ImageData,
  11. Path2D,
  12. DOMPoint,
  13. DOMMatrix,
  14. DOMRect,
  15. loadImage,
  16. PDFDocument,
  17. SvgExportFlag,
  18. } = require('./index.js')
  19. // CanvasRenderingContext2D is not re-exported by index.js, grab from native bindings
  20. const { CanvasRenderingContext2D } = require('./js-binding')
  21. // node-canvas defaults JPEG quality to 0.75 across all paths.
  22. // @napi-rs/canvas native default is 0.92 (matching Blink/Chrome).
  23. const NODE_CANVAS_DEFAULT_QUALITY = 0.75
  24. // ---------------------------------------------------------------------------
  25. // Stream classes (node-canvas returns Node.js Readable streams)
  26. // ---------------------------------------------------------------------------
  27. class PNGStream extends Readable {
  28. constructor(canvas, options) {
  29. super()
  30. this._canvas = canvas
  31. this._options = options || {}
  32. this._done = false
  33. }
  34. _read() {
  35. if (this._done) return
  36. this._done = true
  37. this._canvas
  38. .encode('png')
  39. .then((buf) => {
  40. this.push(buf)
  41. this.push(null)
  42. })
  43. .catch((err) => {
  44. this.destroy(err)
  45. })
  46. }
  47. }
  48. class JPEGStream extends Readable {
  49. constructor(canvas, options) {
  50. super()
  51. this._canvas = canvas
  52. const opts = options || {}
  53. // node-canvas quality: 0-1 (default 0.75), encode() expects 0-100
  54. this._quality = Math.round((opts.quality != null ? opts.quality : NODE_CANVAS_DEFAULT_QUALITY) * 100)
  55. this._done = false
  56. }
  57. _read() {
  58. if (this._done) return
  59. this._done = true
  60. this._canvas
  61. .encode('jpeg', this._quality)
  62. .then((buf) => {
  63. this.push(buf)
  64. this.push(null)
  65. })
  66. .catch((err) => {
  67. this.destroy(err)
  68. })
  69. }
  70. }
  71. // ---------------------------------------------------------------------------
  72. // Quality normalization helpers
  73. // ---------------------------------------------------------------------------
  74. const MIME_FORMAT_MAP = {
  75. 'image/png': 'png',
  76. 'image/jpeg': 'jpeg',
  77. 'image/webp': 'webp',
  78. 'image/avif': 'avif',
  79. 'image/gif': 'gif',
  80. }
  81. /**
  82. * Extract quality from a node-canvas config object or number.
  83. * Returns the raw 0-1 quality value, defaulting to NODE_CANVAS_DEFAULT_QUALITY
  84. * for JPEG/WebP when not specified.
  85. *
  86. * @param {string} mime
  87. * @param {number|object|undefined} configOrQuality
  88. * @returns {number|undefined} 0-1 scale for JPEG/WebP, undefined for other mimes
  89. */
  90. function _extractQuality(mime, configOrQuality) {
  91. if (mime !== 'image/jpeg' && mime !== 'image/webp') return undefined
  92. if (configOrQuality == null) return NODE_CANVAS_DEFAULT_QUALITY
  93. if (typeof configOrQuality === 'number') return configOrQuality
  94. if (typeof configOrQuality === 'object' && configOrQuality.quality != null) {
  95. return configOrQuality.quality
  96. }
  97. return NODE_CANVAS_DEFAULT_QUALITY
  98. }
  99. // ---------------------------------------------------------------------------
  100. // Compat methods added to each canvas instance created via this module
  101. // ---------------------------------------------------------------------------
  102. // Keep references to the original prototype methods
  103. const _origToBuffer = CanvasElement.prototype.toBuffer
  104. const _origToDataURL = CanvasElement.prototype.toDataURL
  105. function _compatCreatePNGStream(options) {
  106. return new PNGStream(this, options)
  107. }
  108. function _compatCreateJPEGStream(options) {
  109. return new JPEGStream(this, options)
  110. }
  111. /**
  112. * node-canvas compatible toBuffer:
  113. * - toBuffer() → PNG (sync)
  114. * - toBuffer('image/png', config?) → PNG (sync)
  115. * - toBuffer('image/jpeg', config?) → JPEG (sync, quality 0-1)
  116. * - toBuffer('raw') → raw pixel data (sync)
  117. * - toBuffer(callback) → PNG (async)
  118. * - toBuffer(callback, mime, config?) → specified format (async)
  119. */
  120. function _compatToBuffer(mimeOrCallback, configOrQuality) {
  121. // Callback form: toBuffer(callback) or toBuffer(callback, mime, config)
  122. if (typeof mimeOrCallback === 'function') {
  123. const callback = mimeOrCallback
  124. const mime = typeof configOrQuality === 'string' ? configOrQuality : 'image/png'
  125. const config = arguments[2]
  126. if (mime === 'raw') {
  127. let buf
  128. try {
  129. buf = this.data()
  130. } catch (err) {
  131. callback(err)
  132. return
  133. }
  134. callback(null, buf)
  135. return
  136. }
  137. const format = MIME_FORMAT_MAP[mime]
  138. if (!format) {
  139. callback(new TypeError(`Unsupported MIME type "${mime}". Supported: ${Object.keys(MIME_FORMAT_MAP).join(', ')}`))
  140. return
  141. }
  142. const q = _extractQuality(mime, config)
  143. this.encode(format, q != null ? Math.round(q * 100) : undefined).then(
  144. (buf) => callback(null, buf),
  145. (err) => callback(err),
  146. )
  147. return
  148. }
  149. // Sync: no args → PNG
  150. if (mimeOrCallback === undefined) {
  151. return _origToBuffer.call(this, 'image/png')
  152. }
  153. // Sync: raw pixel data
  154. if (mimeOrCallback === 'raw') {
  155. return this.data()
  156. }
  157. // Sync: extract quality (0-1) and scale to 0-100 for native toBuffer
  158. const q = _extractQuality(mimeOrCallback, configOrQuality)
  159. return _origToBuffer.call(this, mimeOrCallback, q != null ? Math.round(q * 100) : undefined)
  160. }
  161. /**
  162. * node-canvas compatible toDataURL:
  163. * - toDataURL() → PNG data URL (sync)
  164. * - toDataURL('image/jpeg', quality) → JPEG data URL, quality 0-1 (sync)
  165. * - toDataURL(callback) → PNG data URL (async)
  166. * - toDataURL(mime, callback) → data URL (async)
  167. * - toDataURL(mime, quality, callback) → data URL with quality (async)
  168. */
  169. function _compatToDataURL(mimeOrCallback, qualityOrCallback) {
  170. // toDataURL(callback)
  171. if (typeof mimeOrCallback === 'function') {
  172. this.toDataURLAsync('image/png').then(
  173. (url) => mimeOrCallback(null, url),
  174. (err) => mimeOrCallback(err),
  175. )
  176. return
  177. }
  178. // toDataURL(mime, callback)
  179. if (typeof qualityOrCallback === 'function') {
  180. this.toDataURLAsync(mimeOrCallback, _extractQuality(mimeOrCallback, undefined)).then(
  181. (url) => qualityOrCallback(null, url),
  182. (err) => qualityOrCallback(err),
  183. )
  184. return
  185. }
  186. // toDataURL(mime, quality, callback)
  187. const cb = arguments[2]
  188. if (typeof cb === 'function') {
  189. // Native toDataURLAsync expects 0-1 (f64) — pass quality through as-is
  190. const quality = _extractQuality(mimeOrCallback, qualityOrCallback)
  191. this.toDataURLAsync(mimeOrCallback, quality).then(
  192. (url) => cb(null, url),
  193. (err) => cb(err),
  194. )
  195. return
  196. }
  197. // Sync form: native toDataURL expects 0-1 (f64) — pass quality through as-is
  198. const quality = _extractQuality(mimeOrCallback, qualityOrCallback)
  199. return _origToDataURL.call(this, mimeOrCallback, quality)
  200. }
  201. /**
  202. * Attach node-canvas compatible methods to a CanvasElement instance.
  203. */
  204. function _addCompatMethods(canvas) {
  205. canvas.createPNGStream = _compatCreatePNGStream
  206. canvas.createJPEGStream = _compatCreateJPEGStream
  207. canvas.toBuffer = _compatToBuffer
  208. canvas.toDataURL = _compatToDataURL
  209. return canvas
  210. }
  211. // ---------------------------------------------------------------------------
  212. // Public API: registerFont / deregisterAllFonts
  213. // ---------------------------------------------------------------------------
  214. // Track FontKeys from registerFont() so deregisterAllFonts() can remove
  215. // only user-registered fonts (matching node-canvas behavior which leaves
  216. // system fonts untouched).
  217. const _registeredFontKeys = []
  218. /**
  219. * Register a font file with the specified font face properties.
  220. * Compatible with node-canvas's registerFont(path, { family, weight?, style? }).
  221. *
  222. * Note: @napi-rs/canvas auto-detects weight and style from font file metadata
  223. * (matching browser behavior). The weight and style properties in fontFace are
  224. * accepted for API compatibility but the actual values are read from the font.
  225. *
  226. * @param {string} path Absolute path to the font file (.ttf, .otf, etc.)
  227. * @param {{ family: string, weight?: string, style?: string }} fontFace
  228. */
  229. function registerFont(path, fontFace) {
  230. if (!fontFace || typeof fontFace.family !== 'string') {
  231. throw new TypeError('registerFont requires a fontFace with a "family" property')
  232. }
  233. const key = GlobalFonts.registerFromPath(path, fontFace.family)
  234. if (!key) {
  235. throw new Error(`Failed to register font from "${path}" with family "${fontFace.family}"`)
  236. }
  237. _registeredFontKeys.push(key)
  238. }
  239. /**
  240. * Deregister all fonts previously registered via registerFont().
  241. * Compatible with node-canvas's deregisterAllFonts() which only removes
  242. * user-registered fonts and leaves system fonts untouched.
  243. */
  244. function deregisterAllFonts() {
  245. if (_registeredFontKeys.length > 0) {
  246. GlobalFonts.removeBatch(_registeredFontKeys)
  247. _registeredFontKeys.length = 0
  248. }
  249. }
  250. // ---------------------------------------------------------------------------
  251. // Public API: createCanvas / createImageData
  252. // ---------------------------------------------------------------------------
  253. /**
  254. * Create a new canvas instance with node-canvas compatible API.
  255. *
  256. * @param {number} width
  257. * @param {number} height
  258. * @param {'image'|'svg'} [type='image']
  259. * @returns {Canvas}
  260. */
  261. function createCanvas(width, height, type) {
  262. if (type === 'svg') {
  263. // SvgExportFlag enum requires a valid variant; NoPrettyXML (0x02) is the
  264. // least impactful on rendering behavior (only affects XML whitespace).
  265. return _createCanvas(width, height, SvgExportFlag.NoPrettyXML)
  266. }
  267. if (type === 'pdf') {
  268. throw new Error(
  269. 'createCanvas with type "pdf" is not supported. Use the PDFDocument class from @napi-rs/canvas directly.',
  270. )
  271. }
  272. if (type != null && type !== 'image') {
  273. throw new TypeError(`createCanvas: unknown type "${type}". Supported types: "image", "svg".`)
  274. }
  275. return _addCompatMethods(_createCanvas(width, height))
  276. }
  277. /**
  278. * Create an ImageData instance.
  279. * Compatible with node-canvas's createImageData().
  280. *
  281. * @param {Uint8ClampedArray|number} dataOrWidth
  282. * @param {number} widthOrHeight
  283. * @param {number} [height]
  284. * @returns {ImageData}
  285. */
  286. function createImageData(dataOrWidth, widthOrHeight, height) {
  287. if (typeof dataOrWidth === 'number') {
  288. return new ImageData(dataOrWidth, widthOrHeight)
  289. }
  290. if (height != null) {
  291. return new ImageData(dataOrWidth, widthOrHeight, height)
  292. }
  293. return new ImageData(dataOrWidth, widthOrHeight)
  294. }
  295. // ---------------------------------------------------------------------------
  296. // Canvas constructor wrapper
  297. // ---------------------------------------------------------------------------
  298. // In node-canvas, `new Canvas(w, h)` and `createCanvas(w, h)` produce
  299. // equivalent canvases. Wrap the native Canvas constructor so that
  300. // `new Canvas(w, h)` also gets compat methods, without polluting the
  301. // prototype (which would affect @napi-rs/canvas users who don't use
  302. // this compat layer).
  303. const CompatCanvas = new Proxy(Canvas, {
  304. construct(target, args) {
  305. const canvas = new target(...args)
  306. if (canvas instanceof CanvasElement) {
  307. return _addCompatMethods(canvas)
  308. }
  309. return canvas
  310. },
  311. })
  312. // ---------------------------------------------------------------------------
  313. // Exports (matches node-canvas export shape)
  314. // ---------------------------------------------------------------------------
  315. module.exports = {
  316. // Factory functions
  317. Canvas: CompatCanvas,
  318. createCanvas,
  319. createImageData,
  320. loadImage,
  321. registerFont,
  322. deregisterAllFonts,
  323. // Classes
  324. Image,
  325. ImageData,
  326. CanvasRenderingContext2D,
  327. Context2d: CanvasRenderingContext2D,
  328. PNGStream,
  329. JPEGStream,
  330. Path2D,
  331. // Geometry
  332. DOMPoint,
  333. DOMMatrix,
  334. DOMRect,
  335. // @napi-rs/canvas extras (available but not part of node-canvas)
  336. GlobalFonts,
  337. PDFDocument,
  338. CanvasElement,
  339. SVGCanvas,
  340. }