Turn a message into a quote image.
See every option as a picture →
Renders locally — no API, no browser, no headless Chrome. Japanese line breaking, Twemoji, Discord and Misskey custom emoji, and fonts that download themselves are all built in.
npm install makeitaquoteimport { writeFile } from 'node:fs/promises'
import { MiQ } from 'makeitaquote'
const png = await new MiQ()
.setText('吾輩は猫である。名前はまだ無い。')
.setAvatar('https://example.com/avatar.png')
.setUsername('otoneko.')
.setDisplayName('音猫。')
.toBuffer('png')
await writeFile('quote.png', png)CommonJS works too — every entry point ships both require and import:
const { writeFile } = require('node:fs/promises')
const { MiQ } = require('makeitaquote')
new MiQ()
.setText('吾輩は猫である。名前はまだ無い。')
.setAvatar('https://example.com/avatar.png')
.setUsername('otoneko.')
.setDisplayName('音猫。')
.toBuffer('png')
.then((png) => writeFile('quote.png', png))Requires Node.js 22 or newer.
- Discord bots — the one thing most people are here for
- Misskey notes — quoting a note, and MFM
- X (Twitter) — quoting a tweet, via FxTwitter or the official API
- Markdown — plain CommonMark, for anything else
- Conversations — several messages as one image
- Themes — six presets, and how to change them
- Colors — every notation, including transparency
- Size — scaling, and fitting to the avatar
- Fonts — automatic downloads, and the licence rules
- Emoji — Twemoji, Discord, Misskey
- Text — wrapping, kinsoku, overflow
- Output — formats and streams
- Using an external API instead of rendering locally
- Errors · Platform support
- Migrating from v8
- Author · Licence
import { AttachmentBuilder } from 'discord.js'
import { MiQ } from 'makeitaquote'
const png = await new MiQ().setFromMessage(message).toBuffer('png')
await message.reply({
files: [new AttachmentBuilder(png, { name: 'quote.png' })],
})// CommonJS
const { AttachmentBuilder } = require('discord.js')
const { MiQ } = require('makeitaquote')
new MiQ().setFromMessage(message).toBuffer('png').then((png) => {
message.reply({ files: [new AttachmentBuilder(png, { name: 'quote.png' })] })
})setFromMessage() takes the content, the name and the avatar off the message.
It accepts anything shaped like a Discord message, so discord.js v13, v14 and
discord.js-selfbot-v13 all work without this package depending on any of them.
By default it uses what a reader of that server saw — the per-server avatar and nickname. Either can be switched to the account-wide version:
new MiQ().setFromMessage(message, { avatar: 'global', name: 'global' })| Option | Default | Alternative |
|---|---|---|
avatar |
'guild' — per-server avatar |
'global' — account avatar |
name |
'nickname' — server nickname |
'global' — account name |
stripDiscordMarkdown |
false — quoted exactly as written |
true — **bold** becomes bold |
resolveMentions |
true — <@id> becomes @name |
false — quoted as the raw token |
Whichever avatar or name you choose, the other is still the fallback, so a message with only one of them always renders.
message.content normally comes through untouched — **bold** is quoted with
its asterisks and all, since that is what was actually typed. Opt into
plain text with stripDiscordMarkdown: true, or call the exported
stripDiscordMarkdown() yourself on any text:
import { stripDiscordMarkdown } from 'makeitaquote'
stripDiscordMarkdown('**bold**, *italic*, ~~strike~~, `code`') // → 'bold, italic, strike, code'It handles what Discord message content actually renders — bold, italic,
underline, strikethrough, spoilers, code (inline and fenced), block quotes
(> at the start of a line, same as Discord), headers, subtext (-# ) and
list markers (-, *, 1.) — and honours a backslash escape. A code span
keeps markdown inside it literal, so `**x**` keeps its asterisks
(a backslash escape is the one exception — `\*x\*` still resolves to
*x*, since Discord's own client is the only thing that treats a code
span's contents as fully inert).
[text](url)-style masked links are left alone: they only render as
clickable links in embeds, webhooks and messages a bot sent, never in a
message a person typed themselves — which is the case this function exists
for — so stripping the brackets there would show something the reader's
screen never did.
This runs on discomd, Discord's
own dialect rather than CommonMark. A generic Markdown stripper gets real
things wrong here, like reading __x__ as bold instead of underline, or
deleting the URL out of a [text](url) Discord never turned into a link in
the first place.
Discord writes several things as markup only the client expands. All of them are resolved by default:
| Written as | Becomes |
|---|---|
<@id>, <@!id> |
@nickname |
<@&id> |
@role |
<#id> |
#channel |
</name:id>, </name sub:id> |
/name sub |
<t:1618935630:F> |
Tuesday, 20 April 2021 at 16:20 |
<id:customize> |
Channels & Roles |
Names come from message.mentions, which discord.js populates for you —
nothing to configure. A mention it has no name for (someone who has since
left) is left exactly as written rather than guessed at; the rest carry
everything they need in the token. Set resolveMentions: false to quote the
raw tokens instead.
A <t:…> timestamp is the one token whose text depends on who is looking —
Discord renders it in the reader's own locale and zone. An image has no
reader to ask, so it uses UTC and en-GB:
new MiQ().setFromMessage(message, {
resolveMentions: { locale: 'ja-JP', timeZone: 'Asia/Tokyo' },
})setFromNote() reads a note the way setFromMessage() reads a message. It
takes what the API returns, unchanged:
const note = await fetch('https://misskey.example/api/notes/show', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ noteId }),
}).then((r) => r.json())
const png = await new MiQ({ misskey: 'https://misskey.example' })
.setFromNote(note)
.toBuffer('png')The display name goes over the handle, which is written @user locally and
@user@host for a remote author — exactly as Misskey writes it.
| Option | Default |
|---|---|
stripMfm |
true — $[jelly x] becomes x |
preferCw |
false — quotes the note, not the content warning |
MFM is stripped by default, unlike Discord's markdown, because the markup
differs: **bold** still reads as its own text with the asterisks left in,
while $[jelly ぷりん] does not — the function name and brackets are
scaffolding that was never meant to be read.
stripMfm() is exported on its own too, and handles decoration functions
(including nested ones), <b>/<i>/<s>/<small>, <center>, quotes,
code, maths and links. Custom emoji, mentions and hashtags are deliberately
left alone — the emoji layer draws the first, and unlike Discord, a Misskey
mention is written @user@host in the note already, so there is no id to
resolve.
MiQConversation has setFromNotes() for the same thing across several
notes.
setFromTweet() reads a tweet the way setFromMessage() reads a message —
but unlike Discord or Misskey, neither of X's two practical APIs hands back
something TweetLike accepts as-is, so an adapter comes with each:
import { FxTwitterV2 } from 'fxtwitter/v2'
import { fromFxTwitterStatus } from 'makeitaquote'
const { status } = await new FxTwitterV2().getStatus(tweetId)
const png = await new MiQ().setFromTweet(fromFxTwitterStatus(status)).toBuffer('png')fxtwitter needs no API key and
returns the author inline, which is the easier path. For the official API,
fromTwitterApiV2Tweet() combines a tweet with the separate includes.users
entry twitter-api-v2 (or
any client with the same response shape) returns it in:
import { TwitterApi } from 'twitter-api-v2'
import { fromTwitterApiV2Tweet } from 'makeitaquote'
const { data: tweet, includes } = await client.v2.singleTweet(tweetId, {
expansions: ['author_id'],
'user.fields': ['profile_image_url'],
})
const png = await new MiQ()
.setFromTweet(fromTwitterApiV2Tweet(tweet, includes))
.toBuffer('png')Neither library is a dependency of this package — both adapters take a
structural subset of the real response shape, the same as MessageLike, so
any object with those fields works, whether or not the library that produced
it is actually installed.
There is nothing here for either adapter to strip: X does not expand a
tweet's t.co links or @handle mentions into anything else in its own
timeline, so the text goes through exactly as written, the same way a
Discord @everyone needs no resolving.
For a source that is neither Discord, Misskey nor X — a blog post, a GitHub
comment, a Mastodon toot — stripMarkdown() strips plain CommonMark (plus
the common GFM extras: strikethrough, tables, task lists). .setText() has
no built-in option for it, unlike setFromMessage/setFromNote, since it
takes a bare string with no source to opt out of stripping from:
import { stripMarkdown } from 'makeitaquote'
new MiQ().setText(stripMarkdown(text))
stripMarkdown('**bold**, *italic*, ~~strike~~, [a link](url)')
// → 'bold, italic, strike, a link'A link or image keeps its label/alt text and drops the URL — that is what a reader saw, not the address behind it — and raw inline/block HTML is dropped rather than rendered or left as literal tag text. A list item becomes one line, a table becomes tab-separated cells, and a hard line break (two trailing spaces) becomes a real one.
This is built on markdown-it
rather than a local approximation, the same reasoning as
stripDiscordMarkdown() and stripMfm(): CommonMark has enough corners —
reference-style [label][ref] links, loose vs. tight lists, a fenced code
block's language tag — that matching a real implementation is worth the
dependency.
MiQ quotes one message. MiQConversation renders several as one image — a
message log, not a quote — each with its own avatar, name and wrapped text:
const png = await new MiQConversation()
.addMessage({ username: 'otoneko.', displayName: '音猫。', text: '吾輩は猫である。' })
.addMessage({ username: 'otoneko.', displayName: '音猫。', text: '名前はまだ無い。' })
.addMessage({ username: 'someone', text: 'Cats are liquid, by volume.' })
.toBuffer('png')Consecutive messages from the same username collapse onto one avatar and
name, the same way Discord's own client groups them.
Straight from real messages, the same way MiQ#setFromMessage() reads one —
content, name, avatar, and the same avatar / name / stripDiscordMarkdown
/ resolveMentions options:
new MiQConversation().setFromMessages(messages) // messages: an array, oldest firstA separate class rather than an array mode on MiQ, because it has none of a
quote's per-field theming — two built-in looks, not the full Theme system:
new MiQConversation({ theme: 'light', width: 500 })| Option | Default |
|---|---|
theme |
'dark' — 'light' is the other |
width |
600 — height follows the content |
Custom emoji, Twemoji and Misskey emoji all work inside a message the same
way they do in MiQ, through the same misskey option.
| Preset | ||
|---|---|---|
dark |
default | Black, avatar left, quote right — the original look |
light |
The same on white | |
color |
dark, but the avatar keeps its color |
|
portrait |
Avatar fills the canvas and fades down, quote over the bottom | |
portrait-light |
The same on white | |
custom |
Everything transparent, for you to color in |
new MiQ({ theme: 'portrait' }) // at construction
new MiQ().setTheme('light') // or laterChange any part of one without repeating the rest:
await new MiQ()
.setText('…')
.setTheme({
extends: 'light',
background: '#FFF8E7',
text: { color: '#2B2B2B', align: 'left' },
avatar: { grayscale: false, position: 'right' },
})
.toBuffer('webp', { quality: 90 })Sizes inside a theme are fractions of the canvas when between 0 and 1, and pixels when larger. That is what makes scaling a true zoom.
themes and palettes are exported too — the full resolved Theme object
behind each preset name, and the dark/light colour pairs those are built
from, for a custom theme that wants to start from one rather than repeat its
hex codes. defineTheme(name | input) runs the same preset-and-extends
resolution .setTheme() does, if you want the resolved Theme itself rather
than to render with it.
.setTheme({ avatar: { position: 'right' } })The quote area, the gradient and the watermark all mirror automatically —
text.area and watermark.position default to 'auto', which derives them
from where the avatar is. Only set text.area yourself if you want to place
the quote by hand.
.setTheme({ avatar: { shape: 'circle' } })Clips the avatar, and its fallback tile, to the largest circle that fits the
box — the default 'rectangle' uses the whole box instead. On a wide or tall
box that leaves background showing at the sides or top and bottom, the same as
a round profile picture would anywhere else.
await new MiQ({ theme: 'portrait' })
.setText('猫は液体である')
.setAvatar(avatarUrl)
.setUsername('otoneko.')
.toBuffer('png')The stacked layout draws the avatar full-bleed, fades it downwards, and puts
large quote marks, the quote, a rule and the attribution over the bottom. It is
not limited to tall canvases — { extends: 'portrait', width: 1280, height: 720 }
works too.
.setTheme({ text: { weight: 'bold' }, displayName: { weight: 600 } })Every text element takes a weight: 'normal', 'bold', or 100–900.
Fonts registered at runtime often expose only their regular face, so
boldwould otherwise do nothing at all. Bold is detected per family and emulated by stroking the glyphs when there is no real bold face — so it works whatever font you use. Ask for a real one withfonts.use(family, { weights: [400, 700] }).
Both are off by default.
.setTheme({ quoteMark: { display: 'inline' } }) // “like this”
.setTheme({ quoteMark: { display: 'inline', chars: ['「', '」'] } })
.setTheme({ quoteMark: { display: 'block' } }) // large, above
.setTheme({ divider: { enabled: true } }) // rule belowEvery color accepts any of these:
'#RGB' '#RGBA' '#RRGGBB' '#RRGGBBAA' hex, with or without alpha
0xRRGGBB 0xRRGGBBAA numbers
[r, g, b] [r, g, b, a] channels 0–255, alpha 0–1 or 0–255
'transparent' 'rebeccapurple' 'red' any CSS colour name
'rgb(…)' 'hsl(…)' 'hwb(…)' 'lab(…)' CSS colour functions, and more —
'lch(…)' 'oklab(…)' 'oklch(…)' 'color(…)' anything culori itself parses
Strings go through culori, which
brings the 148 CSS colour names and the whole CSS Color 4 function set,
converted to RGB for you. The number and array forms are this package's own.
A number cannot carry a leading zero byte —
0x00FF0000is0xFF0000— so write those as strings, where the length is part of the value.
The same parser is exported, for anything outside a theme that needs to read or normalize a color:
import { isTransparent, parseColor, toCSS, toHex } from 'makeitaquote'
parseColor('rebeccapurple') // { r: 102, g: 51, b: 153, a: 1 }
toCSS({ r: 255, g: 0, b: 0, a: 0.5 }) // 'rgba(255, 0, 0, 0.5)'
toHex({ r: 255, g: 0, b: 0, a: 1 }) // '#FF0000FF'
isTransparent(parseColor('transparent')) // trueThe custom preset begins fully transparent, so the only colors in the image
are the ones you name:
await new MiQ()
.setText('…')
.setAvatar(avatarUrl)
.setTheme({
extends: 'custom',
background: '#1A1B26',
text: { color: '#C0CAF5' },
displayName: { color: '#7AA2F7' },
username: { color: '#565F89' },
watermark: { color: '#414868' },
})
.toBuffer('png')Leave background alone and you get a PNG with a transparent background, ready
to composite. Anything left transparent is not drawn at all.
.setTheme({
backgroundImage: { source: bannerUrl, fit: 'cover', opacity: 0.4 },
})Drawn over background and behind everything else — source takes the same
things setAvatar() does (a URL, a local path, a Buffer). fit is 'cover'
(crops to fill) or 'contain' (fits whole, letterboxed in background).
null (the default on every preset) means no image.
The avatar-fade gradient is skipped automatically once a background image is set — it fades the avatar into a flat background color, which a canvas gradient does by painting an opaque wash over most of the canvas, and that would hide the image. There's no way to have both at once.
The default canvas is 1200×630 landscape, 630×790 portrait — the size the real Make it a Quote bot itself renders at, not a round ratio.
.setScale(2) // 2400×1260, the same image at twice the resolution
.setScale(0.5) // halfsetScale() is a genuine zoom: because theme sizes are fractions, nothing is
re-composed, only re-rendered.
To keep the avatar at its native resolution and let the canvas follow:
new MiQ({ sizeToAvatar: 'height' }) // or 'width'For a different shape, set it on the theme:
.setTheme({ width: 1280, height: 720 })
setSize(width, height)still works but is deprecated: it changes the aspect ratio without moving anything else, so the avatar, gradient and text drift out of proportion. It emits a Node deprecation warning, silenced by--no-deprecationlike any other.
Nothing to configure. If the system has no font for the text, the first render fetches one, caches it, and never fetches it again. The default is M PLUS Rounded 1c, with Noto Sans JP behind it for anything it doesn't cover.
Name any font and it is fetched on demand:
.setTheme({ text: { font: 'Dela Gothic One, Noto Sans JP, sans-serif' } })Fonts are resolved through the Google Fonts API on each cold start, so you get the current release rather than a version frozen into this package.
A Latin-only display font no longer turns Japanese into boxes. The chosen font is used for everything it covers, and the rest falls through to a font that has the glyphs:
.setTheme({ text: { font: 'Vina Sans' } })
// "Vina Sans と日本語" → Latin in Vina Sans, Japanese in the fallbackAny Google Fonts family works. These are the ones fonts.catalogue() lists:
| Japanese | M PLUS Rounded 1c · Noto Sans JP · Dela Gothic One · DotGothic16 · Hachi Maru Pop · Rampart One · Reggae One · RocknRoll One · Zen Old Mincho · Yuji Syuku · Yusei Magic |
| Latin | Inconsolata · Exo 2 · Bruno Ace SC · Poltawski Nowy · Vina Sans · Dancing Script |
Fonts are only ever fetched from Google Fonts, which distributes exclusively under the SIL Open Font License, Apache 2.0 or the Ubuntu Font Licence — all of which allow rendering text into images freely.
That is also the licence check. A paid font, or one with unclear terms, is not on Google Fonts, so it cannot be fetched by name and this package will not look elsewhere for it:
"Jiyu no Tsubasa" is not distributed through Google Fonts; its licence is
unclear. Download it yourself and register it with fonts.registerFromPath().
Fonts you have licensed yourself load directly, with no restriction or check:
fonts.registerFromPath('./fonts/Licensed.otf', 'Licensed Font')
await fonts.registerFromURL('https://example.com/font.ttf', 'Remote Font')Rendering text produces an image, not a copy of the font, and every licence above permits that. See THIRD-PARTY-NOTICES.md, including the Twemoji attribution requirement if you publish what you generate.
await fonts.use('Dela Gothic One') // fetch by name
await fonts.use('Noto Sans JP', { weights: [400, 700] }) // with a real bold
await fonts.ensureDefaults() // fetch up front
fonts.catalogue() // the list above
await fonts.resolve('Vina Sans') // where Google serves it, without downloading
fonts.cacheInfo() // what is cached, and how large
new MiQ({ autoFont: false }) // never fetch
new MiQ({ autoFont: { online: false } }) // the same
new MiQ({ autoFont: false, strictFonts: true }) // and throw if missing
new MiQ({ autoFont: false, onAssetError: 'throw' }) // same, via the shared option
new MiQ({ autoFont: false, onAssetError: 'ignore' }) // fall back with no warningThe cache lives at $MIQ_FONT_CACHE_DIR, then $XDG_CACHE_HOME, then
~/.cache/makeitaquote/fonts (%LOCALAPPDATA% on Windows). Pre-populate it and
the package works entirely offline.
Everything above is also exported as its own function — useFont,
ensureDefaultFonts, resolveGoogleFont, FONT_CATALOGUE, isCatalogued,
resolveCacheDir, DEFAULT_FONT_FAMILIES, FALLBACK_FAMILY — for the rare
case fonts.* doesn't cover; fonts itself is a thin wrapper over them.
Docker — bake the fonts in so containers never fetch at runtime:
ENV MIQ_FONT_CACHE_DIR=/app/.fonts
RUN node -e "import('makeitaquote').then(m => m.fonts.ensureDefaults())"| Source | Written as | Needs setup |
|---|---|---|
| Twemoji | any Unicode emoji | no |
| Discord | <:name:id>, <a:name:id> |
no |
| Misskey | :name@host: |
no |
| Misskey | :name: |
an instance to resolve against |
Images are cached in memory, concurrent requests for the same emoji are shared, and a failed fetch draws the source text instead of failing the render. Animated emoji are drawn as their first frame.
:name@host: carries its own host and works out of the box. A bare :name:
needs somewhere to point:
new MiQ({ misskey: 'https://misskey.example' })
new MiQ({ misskey: ['https://one.example', 'https://two.example'] })With several instances, each is tried in turn and the first that actually serves the emoji wins — useful for a bot spanning more than one.
Anything that doesn't resolve is drawn exactly as written, so ordinary text is never mangled. A shortcode only counts as emoji when it doesn't follow an ASCII alphanumeric and its name isn't purely numeric:
12:30:45 the inner :30: follows a digit
https://a.test/ the :// isn't a name
key:value:other :value: follows a letter
:2024: purely numeric
:a: too short
To ignore federated emoji entirely:
new MiQ({ misskey: { instance: 'https://misskey.example', remote: false } })configureEmojiCache({ maxEntries: 512, ttlMs: 7_200_000 })Avatars get their own, separate cache — handy when the same user is quoted several times in a row. Its default TTL is much shorter (five minutes) since an avatar is one person's current picture, not a shared asset:
configureAvatarCache({ maxEntries: 128, ttlMs: 60_000 })emojiCacheInfo()/avatarCacheInfo() report what's cached (entries,
failures, in-flight requests); clearEmojiCache()/clearAvatarCache() empty
one without waiting out its TTL.
Japanese wraps at phrase boundaries using BudouX, with kinsoku rules applied, and the font size shrinks until the quote fits its box.
.setTheme({ text: { phraseBreak: false } }) // break per character instead
.setTheme({ text: { overflow: 'shrink' } }) // let it spill rather than trim
.setTheme({ text: { overflow: 'error' } }) // throw insteadoverflow defaults to 'ellipsis'. Long unbreakable runs — URLs, for
instance — are split at grapheme boundaries rather than allowed to overflow.
await miq.toBuffer('png') // Buffer
await miq.toBuffer('jpeg', { quality: 90 }) // png | jpeg | jpg | webp | avif
await miq.toBuffer('jpg') // 'jpg' is an alias for 'jpeg'
await miq.toStream('png') // Readable
await miq.toDataURL('png') // data:image/png;base64,…
await miq.render() // the Canvas, to draw on yourselfIf you would rather not render locally at all:
import { VoidsMiQ } from 'makeitaquote/api'
const url = await new VoidsMiQ().setText('Hello World!').toURL()
const png = await new VoidsMiQ().setText('Hello World!').toBuffer()The two endpoints do different things, so the method picks one:
toURL() |
toBuffer() |
|
|---|---|---|
| Endpoint | /fakequote |
/fakequotebeta |
| Returns | a hosted image URL | the image bytes |
| Round trips | 1 | 1 |
| Stored on their server | yes | no |
toBuffer({ hosted: true }) uploads and then downloads it back — two round
trips, only useful if you specifically want the bytes of the hosted image.
Importing makeitaquote/api does not load the rendering stack, so it also
works on platforms @napi-rs/canvas has no binary for.
The Voids API is not operated by this package's developer. Please don't open issues here about it being down.
Everything thrown extends MiQError:
MiQError
├─ ValidationError bad input (carries .field)
├─ FontNotAvailableError no font, with strictFonts or onAssetError: 'throw'
├─ AssetFetchError an avatar, emoji or font could not be fetched
├─ RenderError drawing failed, or text could not be made to fit
└─ VoidsApiError the API refused or failed (.status, .body, .endpoint)
A missing emoji, avatar or font never throws by default — the image degrades
instead. All three follow onAssetError; strictFonts is a font-specific
override for it.
@napi-rs/canvas ships prebuilt binaries for macOS (x64/arm64), Linux
(x64/arm64/arm, glibc and musl), Windows (x64/arm64) and Android arm64.
Nothing to compile, Alpine included.
Node.js is the tested runtime (22+). Bun loads the native binding fine
and both entry points (ESM and CJS) render correctly — it isn't part of CI,
so treat it as working rather than officially supported. Deno hasn't been
verified: its Node-API compatibility for native addons like this one is still
maturing, and it needs --allow-ffi/--allow-read for the binding and font
files besides.
On a platform without a binary, use makeitaquote/api.
v9 is a rewrite: the API changed, and images render locally by default. See MIGRATING.md for the full guide, including the v8 → v9 method table.
otoneko. https://github.com/otnc
MIT — see LICENSE.
Third-party assets are fetched at runtime: fonts from Google Fonts (OFL / Apache 2.0 / UFL) and emoji from Twemoji, which is CC-BY 4.0 and requires attribution if you publish the images:
Emoji graphics by Twemoji (CC-BY 4.0).
THIRD-PARTY-NOTICES.md has the details.