@ghuts/memegen 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Meme Generator Sharp contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ Font notice: the bundled Anton font in assets/Anton-Regular.ttf is licensed
24
+ separately under the SIL Open Font License 1.1. See assets/OFL-Anton.txt.
package/README.md ADDED
@@ -0,0 +1,251 @@
1
+ # Memegen
2
+
3
+ A JavaScript module for creating memes with [Sharp](https://sharp.pixelplumbing.com/), automatic text wrapping, shrink-to-fit captions, and a bundled meme-style font.
4
+
5
+ Memegen takes a background image, keeps the original image size, and draws top and bottom captions that stay inside the image bounds. It works as an ESM module and as a command-line tool.
6
+
7
+ ## Features
8
+
9
+ - Keeps the output size equal to the background image size.
10
+ - Supports top and bottom caption text.
11
+ - Automatically wraps long text.
12
+ - Automatically shrinks text until it fits the caption area.
13
+ - Can choose readable text color from the background (`textColor: 'auto'`).
14
+ - Classic meme outline and subtle shadow.
15
+ - Bundled Anton font from Google Fonts, so servers do not need system fonts installed.
16
+ - Outputs `png`, `jpg/jpeg`, or `webp`.
17
+ - Usable from code or from the CLI.
18
+
19
+ ## Installation
20
+
21
+ From npm:
22
+
23
+ ```bash
24
+ npm install @ghuts/memegen
25
+ ```
26
+
27
+ From a local clone:
28
+
29
+ ```bash
30
+ git clone <repo-url>
31
+ cd memegen
32
+ npm install
33
+ ```
34
+
35
+ ## Quick Start
36
+
37
+ ```js
38
+ import { generateMemeFile } from '@ghuts/memegen';
39
+
40
+ await generateMemeFile({
41
+ input: 'background.jpg',
42
+ output: 'output/meme.png',
43
+ topText: 'WHEN THE CODE WORKS',
44
+ bottomText: 'BUT YOU DO NOT KNOW WHY'
45
+ });
46
+ ```
47
+
48
+ The output image will use the original dimensions of `background.jpg`.
49
+
50
+ ## Return a Buffer
51
+
52
+ ```js
53
+ import { readFile } from 'node:fs/promises';
54
+ import { generateMeme } from '@ghuts/memegen';
55
+
56
+ const background = await readFile('background.jpg');
57
+
58
+ const memeBuffer = await generateMeme({
59
+ background,
60
+ topText: 'TOP TEXT',
61
+ bottomText: 'BOTTOM TEXT',
62
+ format: 'png'
63
+ });
64
+ ```
65
+
66
+ You can send the returned `Buffer` directly from an API route, bot, or serverless function.
67
+
68
+ ## CLI
69
+
70
+ ```bash
71
+ npx @ghuts/memegen \
72
+ --image background.jpg \
73
+ --top "WHEN THE BUILD PASSES" \
74
+ --bottom "ON THE FIRST TRY" \
75
+ --out output/meme.png
76
+ ```
77
+
78
+ From a local checkout:
79
+
80
+ ```bash
81
+ node cli.js \
82
+ --image background.jpg \
83
+ --top "WHEN THE BUILD PASSES" \
84
+ --bottom "ON THE FIRST TRY" \
85
+ --out output/meme.png
86
+ ```
87
+
88
+ ## API
89
+
90
+ ### `generateMemeFile(options)`
91
+
92
+ Renders a meme and writes it to disk.
93
+
94
+ ```js
95
+ const result = await generateMemeFile({
96
+ input: 'background.jpg',
97
+ output: 'output/meme.png',
98
+ topText: 'TOP TEXT',
99
+ bottomText: 'BOTTOM TEXT'
100
+ });
101
+ ```
102
+
103
+ Returns:
104
+
105
+ ```js
106
+ {
107
+ output: 'output/meme.png',
108
+ buffer: Buffer,
109
+ width: 600,
110
+ height: 600,
111
+ format: 'png',
112
+ top: { lines: ['TOP TEXT'], fontSize: 82, ... },
113
+ bottom: { lines: ['BOTTOM TEXT'], fontSize: 76, ... }
114
+ }
115
+ ```
116
+
117
+ ### `generateMeme(options)`
118
+
119
+ Renders a meme and returns a `Buffer`.
120
+
121
+ ```js
122
+ const memeBuffer = await generateMeme({
123
+ input: 'background.jpg',
124
+ topText: 'TOP',
125
+ bottomText: 'BOTTOM'
126
+ });
127
+ ```
128
+
129
+ ### `renderMeme(options)`
130
+
131
+ Renders a meme and returns the image buffer plus layout metadata.
132
+
133
+ ```js
134
+ const result = await renderMeme({
135
+ input: 'background.jpg',
136
+ topText: 'TOP',
137
+ bottomText: 'BOTTOM'
138
+ });
139
+
140
+ console.log(result.width, result.height, result.top.lines);
141
+ ```
142
+
143
+ ### `layoutMemeText(options)`
144
+
145
+ Calculates the text layout without rendering an image. This is useful for tests and previews.
146
+
147
+ ```js
148
+ const layout = layoutMemeText({
149
+ width: 600,
150
+ height: 600,
151
+ topText: 'THIS IS A LONG TOP CAPTION',
152
+ bottomText: 'THIS LONG BOTTOM CAPTION WILL WRAP AUTOMATICALLY'
153
+ });
154
+
155
+ console.log(layout.bottom.lines);
156
+ ```
157
+
158
+ ## Options
159
+
160
+ | Option | Type | Default | Description |
161
+ | --- | --- | --- | --- |
162
+ | `input` / `image` / `background` | `string | Buffer | Uint8Array` | required | Background image source. |
163
+ | `output` / `out` | `string` | required for `generateMemeFile` | Output path. |
164
+ | `topText` / `top` | `string` | `''` | Top caption. |
165
+ | `bottomText` / `bottom` | `string` | `''` | Bottom caption. |
166
+ | `uppercase` | `boolean` | `true` | Convert captions to uppercase. |
167
+ | `format` | `png | jpg | jpeg | webp` | `png` | Output format. |
168
+ | `quality` | `number` | `92` | JPEG/WebP quality. |
169
+ | `textColor` | `auto | string` | `auto` | Caption fill color. |
170
+ | `strokeColor` | `auto | string` | `auto` | Caption outline color. |
171
+ | `strokeWidthRatio` | `number` | `0.075` | Outline width relative to font size. |
172
+ | `shadow` | `boolean` | `true` | Enables drop shadow. |
173
+ | `paddingRatio` | `number` | `0.045` | Caption padding relative to image size. |
174
+ | `captionHeightRatio` | `number` | `0.25` | Height of the top and bottom caption areas. |
175
+ | `fontSizeRatio` | `number` | `0.145` | Starting font size ratio. |
176
+ | `minFontSizeRatio` | `number` | `0.018` | Minimum font size ratio. |
177
+ | `lineHeight` | `number` | `0.92` | Caption line height. |
178
+ | `fontPath` | `string | Buffer | Uint8Array | false` | bundled Anton | Custom TTF/OTF font source. |
179
+
180
+ ## Fonts
181
+
182
+ Default font:
183
+
184
+ - `assets/Anton-Regular.ttf`
185
+ - Source: Google Fonts, Anton family
186
+ - License: SIL Open Font License 1.1
187
+ - License file: `assets/OFL-Anton.txt`
188
+
189
+ Memegen converts text into SVG paths with `opentype.js`, so the rendered result stays consistent even when the host machine has no matching system font.
190
+
191
+ Use a custom font:
192
+
193
+ ```js
194
+ await generateMemeFile({
195
+ input: 'background.jpg',
196
+ output: 'output/custom-font.png',
197
+ topText: 'CUSTOM',
198
+ bottomText: 'FONT',
199
+ fontPath: './fonts/MyFont.ttf'
200
+ });
201
+ ```
202
+
203
+ Disable embedded font paths and let SVG use a system font family:
204
+
205
+ ```js
206
+ await generateMemeFile({
207
+ input: 'background.jpg',
208
+ output: 'output/system-font.png',
209
+ topText: 'SYSTEM',
210
+ bottomText: 'FONT',
211
+ fontPath: false,
212
+ fontFamily: 'Impact, Arial Black, sans-serif'
213
+ });
214
+ ```
215
+
216
+ ## Development
217
+
218
+ ```bash
219
+ npm install
220
+ npm run check
221
+ npm test
222
+ npm run demo
223
+ ```
224
+
225
+ Scripts:
226
+
227
+ - `npm run check`: syntax-checks the main files.
228
+ - `npm test`: runs a render smoke test and checks text bounds.
229
+ - `npm run demo`: creates `output/demo-meme.png`.
230
+
231
+ `node_modules/` and `output/` are ignored so the repository stays clean.
232
+
233
+ ## Publish Checklist
234
+
235
+ Before publishing or pushing a release:
236
+
237
+ ```bash
238
+ rm -rf node_modules output
239
+ npm install
240
+ npm run check
241
+ npm test
242
+ npm pack --dry-run
243
+ ```
244
+
245
+ The package should contain only source files, font assets, license files, type definitions, examples, tests, and documentation.
246
+
247
+ ## License
248
+
249
+ Code: MIT. See `LICENSE`.
250
+
251
+ Bundled font: Anton, SIL Open Font License 1.1. See `assets/OFL-Anton.txt`.
Binary file
@@ -0,0 +1,93 @@
1
+ Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
package/cli.js ADDED
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { generateMemeFile } from './index.js';
4
+
5
+ function printHelp() {
6
+ console.log(`
7
+ Meme Generator Sharp Module
8
+
9
+ Usage:
10
+ node cli.js --image background.jpg --top "WHEN THE BUILD PASSES" --bottom "ON THE FIRST TRY" --out output/meme.png
11
+
12
+ Options:
13
+ --image <file> Background image. Alias: --input.
14
+ --top <text> Top text.
15
+ --bottom <text> Bottom text.
16
+ --out <file> Output file. Default: output/meme.png.
17
+ --format <format> png, jpg, jpeg, or webp. Default: inferred from --out or png.
18
+ --text-color <hex> Text color, or auto. Default: auto.
19
+ --stroke-color <hex> Stroke color, or auto. Default: auto.
20
+ --font <file> TTF/OTF font path. Default: bundled Anton.
21
+ --font-ratio <num> Text size ratio. Default: 0.145.
22
+ --caption-ratio <n> Top/bottom text box height ratio. Default: 0.25.
23
+ --no-caps Keep original letter casing.
24
+ --no-shadow Disable subtle shadow.
25
+ --help Show this help.
26
+ `);
27
+ }
28
+
29
+ async function main() {
30
+ const { values: cliOptions } = parseArgs({
31
+ options: {
32
+ image: { type: 'string' },
33
+ input: { type: 'string' },
34
+ top: { type: 'string', default: '' },
35
+ bottom: { type: 'string', default: '' },
36
+ out: { type: 'string', default: 'output/meme.png' },
37
+ format: { type: 'string' },
38
+ 'text-color': { type: 'string', default: 'auto' },
39
+ 'stroke-color': { type: 'string', default: 'auto' },
40
+ font: { type: 'string' },
41
+ 'font-ratio': { type: 'string' },
42
+ 'caption-ratio': { type: 'string' },
43
+ 'no-caps': { type: 'boolean', default: false },
44
+ 'no-shadow': { type: 'boolean', default: false },
45
+ help: { type: 'boolean', short: 'h', default: false }
46
+ }
47
+ });
48
+
49
+ if (cliOptions.help) {
50
+ printHelp();
51
+ return;
52
+ }
53
+
54
+ const input = cliOptions.image ?? cliOptions.input;
55
+ if (!input) {
56
+ printHelp();
57
+ throw new Error('Missing --image or --input.');
58
+ }
59
+
60
+ const output = cliOptions.out;
61
+ const inferredFormat = output.match(/\.(png|jpe?g|webp)$/i)?.[1]?.toLowerCase();
62
+
63
+ const result = await generateMemeFile({
64
+ input,
65
+ output,
66
+ topText: cliOptions.top,
67
+ bottomText: cliOptions.bottom,
68
+ format: cliOptions.format ?? inferredFormat ?? 'png',
69
+ textColor: cliOptions['text-color'],
70
+ strokeColor: cliOptions['stroke-color'],
71
+ fontPath: cliOptions.font,
72
+ fontSizeRatio: cliOptions['font-ratio'] === undefined ? undefined : Number(cliOptions['font-ratio']),
73
+ captionHeightRatio: cliOptions['caption-ratio'] === undefined ? undefined : Number(cliOptions['caption-ratio']),
74
+ uppercase: !cliOptions['no-caps'],
75
+ shadow: !cliOptions['no-shadow']
76
+ });
77
+
78
+ console.log(`OK: ${result.output}`);
79
+ console.log(`Size: ${result.width}x${result.height}`);
80
+ console.log(`Top lines: ${result.top.lines.length} | Bottom lines: ${result.bottom.lines.length}`);
81
+ }
82
+
83
+ main().catch((error) => {
84
+ console.error(`ERROR: ${error.message}`);
85
+ process.exit(1);
86
+ });
@@ -0,0 +1,27 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import sharp from 'sharp';
3
+ import { generateMemeFile } from '../index.js';
4
+
5
+ await mkdir('output', { recursive: true });
6
+
7
+ const backgroundSvg = `
8
+ <svg width="600" height="600" viewBox="0 0 600 600" xmlns="http://www.w3.org/2000/svg">
9
+ <rect width="600" height="600" fill="#ffffff"/>
10
+ <rect y="370" width="600" height="230" fill="#f0f2f4"/>
11
+ <circle cx="135" cy="170" r="78" fill="#31343a"/>
12
+ <circle cx="455" cy="310" r="104" fill="#d8a334"/>
13
+ <path d="M0 430 C120 365 210 475 325 408 C440 340 492 410 600 372 L600 600 L0 600 Z" fill="#2f645f"/>
14
+ </svg>`;
15
+
16
+ const background = await sharp(Buffer.from(backgroundSvg)).png().toBuffer();
17
+
18
+ await generateMemeFile({
19
+ background,
20
+ output: 'output/demo-meme.png',
21
+ topText: 'when the demo works',
22
+ bottomText: 'ship it with confidence',
23
+ textColor: '#ffffff',
24
+ strokeColor: '#000000'
25
+ });
26
+
27
+ console.log('OK: output/demo-meme.png');
package/index.d.ts ADDED
@@ -0,0 +1,80 @@
1
+ export type MemeImageInput = string | Buffer | Uint8Array;
2
+
3
+ export type MemeFormat = 'png' | 'jpg' | 'jpeg' | 'webp';
4
+
5
+ export interface MemeGeneratorOptions {
6
+ input?: MemeImageInput;
7
+ image?: MemeImageInput;
8
+ background?: MemeImageInput;
9
+ output?: string;
10
+ out?: string;
11
+ topText?: string;
12
+ top?: string;
13
+ bottomText?: string;
14
+ bottom?: string;
15
+ uppercase?: boolean;
16
+ fontPath?: string | Buffer | Uint8Array | false | null;
17
+ fontFamily?: string;
18
+ textColor?: 'auto' | string;
19
+ strokeColor?: 'auto' | string;
20
+ strokeWidthRatio?: number;
21
+ shadow?: boolean;
22
+ paddingRatio?: number;
23
+ captionHeightRatio?: number;
24
+ fontSizeRatio?: number;
25
+ minFontSizeRatio?: number;
26
+ lineHeight?: number;
27
+ format?: MemeFormat;
28
+ quality?: number;
29
+ autoColorThreshold?: number;
30
+ }
31
+
32
+ export interface CaptionZone {
33
+ x: number;
34
+ y: number;
35
+ width: number;
36
+ height: number;
37
+ }
38
+
39
+ export interface CaptionLayout {
40
+ text: string;
41
+ lines: string[];
42
+ fontSize: number;
43
+ lineHeightPx: number;
44
+ blockHeight: number;
45
+ maxLineWidth: number;
46
+ zone: CaptionZone;
47
+ }
48
+
49
+ export interface RenderResult {
50
+ buffer: Buffer;
51
+ width: number;
52
+ height: number;
53
+ format: 'png' | 'jpeg' | 'webp';
54
+ top: CaptionLayout;
55
+ bottom: CaptionLayout;
56
+ }
57
+
58
+ export interface FileRenderResult extends RenderResult {
59
+ output: string;
60
+ }
61
+
62
+ export interface LayoutOnlyOptions extends Omit<MemeGeneratorOptions, 'input' | 'image' | 'background' | 'output' | 'out'> {
63
+ width: number;
64
+ height: number;
65
+ }
66
+
67
+ export interface LayoutOnlyResult {
68
+ width: number;
69
+ height: number;
70
+ top: CaptionLayout;
71
+ bottom: CaptionLayout;
72
+ }
73
+
74
+ export function generateMeme(options: MemeGeneratorOptions): Promise<Buffer>;
75
+
76
+ export function generateMemeFile(options: MemeGeneratorOptions & { output?: string; out?: string }): Promise<FileRenderResult>;
77
+
78
+ export function renderMeme(options: MemeGeneratorOptions): Promise<RenderResult>;
79
+
80
+ export function layoutMemeText(options: LayoutOnlyOptions): LayoutOnlyResult;
package/index.js ADDED
@@ -0,0 +1,503 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import opentype from 'opentype.js';
5
+ import sharp from 'sharp';
6
+
7
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
8
+ const DEFAULT_FONT_PATH = path.join(MODULE_DIR, 'assets', 'Anton-Regular.ttf');
9
+ const DEFAULT_FONT_NAME = 'Anton';
10
+
11
+ const DEFAULTS = {
12
+ topText: '',
13
+ bottomText: '',
14
+ uppercase: true,
15
+ fontPath: DEFAULT_FONT_PATH,
16
+ fontFamily: DEFAULT_FONT_NAME,
17
+ textColor: 'auto',
18
+ strokeColor: 'auto',
19
+ strokeWidthRatio: 0.075,
20
+ shadow: true,
21
+ paddingRatio: 0.045,
22
+ captionHeightRatio: 0.25,
23
+ fontSizeRatio: 0.145,
24
+ minFontSizeRatio: 0.018,
25
+ lineHeight: 0.92,
26
+ format: 'png',
27
+ quality: 92,
28
+ autoColorThreshold: 150
29
+ };
30
+
31
+ const SUPPORTED_FORMATS = new Set(['png', 'jpeg', 'jpg', 'webp']);
32
+
33
+ export async function generateMeme(options) {
34
+ const result = await renderMeme(options);
35
+ return result.buffer;
36
+ }
37
+
38
+ export async function generateMemeFile(options) {
39
+ const output = options?.output ?? options?.out;
40
+ if (!output) {
41
+ throw new Error('generateMemeFile requires output or out path.');
42
+ }
43
+
44
+ const result = await renderMeme(options);
45
+ await mkdir(path.dirname(path.resolve(output)), { recursive: true });
46
+ await writeFile(output, result.buffer);
47
+
48
+ return {
49
+ ...result,
50
+ output
51
+ };
52
+ }
53
+
54
+ export async function renderMeme(options = {}) {
55
+ const settings = normalizeOptions(options);
56
+ settings.font = await loadFont(settings.fontPath);
57
+ const background = await loadBackground(settings.background);
58
+ const normalizedImage = await sharp(background).rotate().toBuffer();
59
+ const metadata = await sharp(normalizedImage).metadata();
60
+ const width = metadata.width;
61
+ const height = metadata.height;
62
+
63
+ if (!width || !height) {
64
+ throw new Error('Background image has no readable width/height.');
65
+ }
66
+
67
+ const raw = await sharp(normalizedImage)
68
+ .ensureAlpha()
69
+ .raw()
70
+ .toBuffer({ resolveWithObject: true });
71
+
72
+ const overlay = createMemeSvg({
73
+ width,
74
+ height,
75
+ raw,
76
+ settings
77
+ });
78
+
79
+ const pipeline = sharp(normalizedImage).composite([
80
+ {
81
+ input: Buffer.from(overlay),
82
+ top: 0,
83
+ left: 0
84
+ }
85
+ ]);
86
+
87
+ const format = settings.format === 'jpg' ? 'jpeg' : settings.format;
88
+ const buffer = await encodeImage(pipeline, format, settings.quality);
89
+
90
+ return {
91
+ buffer,
92
+ width,
93
+ height,
94
+ format,
95
+ top: layoutCaption({
96
+ text: settings.topText,
97
+ zone: getCaptionZones(width, height, settings).top,
98
+ settings
99
+ }),
100
+ bottom: layoutCaption({
101
+ text: settings.bottomText,
102
+ zone: getCaptionZones(width, height, settings).bottom,
103
+ settings
104
+ })
105
+ };
106
+ }
107
+
108
+ export function layoutMemeText(options = {}) {
109
+ const settings = normalizeOptions({
110
+ ...options,
111
+ background: options.background ?? Buffer.from([0])
112
+ }, { skipBackground: true });
113
+ const width = positiveNumber(options.width, 'width');
114
+ const height = positiveNumber(options.height, 'height');
115
+ const zones = getCaptionZones(width, height, settings);
116
+
117
+ return {
118
+ width,
119
+ height,
120
+ top: layoutCaption({ text: settings.topText, zone: zones.top, settings }),
121
+ bottom: layoutCaption({ text: settings.bottomText, zone: zones.bottom, settings })
122
+ };
123
+ }
124
+
125
+ function normalizeOptions(options, flags = {}) {
126
+ const settings = {
127
+ ...DEFAULTS,
128
+ ...options,
129
+ topText: options.topText ?? options.top ?? DEFAULTS.topText,
130
+ bottomText: options.bottomText ?? options.bottom ?? DEFAULTS.bottomText,
131
+ background: options.background ?? options.image ?? options.input
132
+ };
133
+
134
+ if (!flags.skipBackground && !settings.background) {
135
+ throw new Error('Background image is required. Use background, image, or input.');
136
+ }
137
+
138
+ settings.format = String(settings.format ?? DEFAULTS.format).toLowerCase();
139
+ if (!SUPPORTED_FORMATS.has(settings.format)) {
140
+ throw new Error(`Unsupported format "${settings.format}". Use png, jpeg, jpg, or webp.`);
141
+ }
142
+
143
+ settings.quality = clampNumber(settings.quality, 1, 100, DEFAULTS.quality);
144
+ settings.fontPath = settings.fontPath ?? DEFAULTS.fontPath;
145
+ settings.fontFamily = settings.fontFamily || DEFAULTS.fontFamily;
146
+ settings.paddingRatio = clampNumber(settings.paddingRatio, 0, 0.2, DEFAULTS.paddingRatio);
147
+ settings.captionHeightRatio = clampNumber(settings.captionHeightRatio, 0.08, 0.48, DEFAULTS.captionHeightRatio);
148
+ settings.fontSizeRatio = clampNumber(settings.fontSizeRatio, 0.03, 0.3, DEFAULTS.fontSizeRatio);
149
+ settings.minFontSizeRatio = clampNumber(settings.minFontSizeRatio, 0.004, 0.08, DEFAULTS.minFontSizeRatio);
150
+ settings.strokeWidthRatio = clampNumber(settings.strokeWidthRatio, 0, 0.2, DEFAULTS.strokeWidthRatio);
151
+ settings.lineHeight = clampNumber(settings.lineHeight, 0.75, 1.5, DEFAULTS.lineHeight);
152
+ settings.autoColorThreshold = clampNumber(settings.autoColorThreshold, 1, 254, DEFAULTS.autoColorThreshold);
153
+
154
+ return settings;
155
+ }
156
+
157
+ async function loadBackground(background) {
158
+ if (Buffer.isBuffer(background)) return background;
159
+ if (background instanceof Uint8Array) return Buffer.from(background);
160
+ if (typeof background === 'string') return readFile(background);
161
+ throw new Error('Background must be a file path, Buffer, or Uint8Array.');
162
+ }
163
+
164
+ async function loadFont(fontPath) {
165
+ if (fontPath === false || fontPath === null) return null;
166
+
167
+ const fontBuffer = Buffer.isBuffer(fontPath) || fontPath instanceof Uint8Array
168
+ ? Buffer.from(fontPath)
169
+ : await readFile(fontPath);
170
+ const arrayBuffer = fontBuffer.buffer.slice(
171
+ fontBuffer.byteOffset,
172
+ fontBuffer.byteOffset + fontBuffer.byteLength
173
+ );
174
+
175
+ return opentype.parse(arrayBuffer);
176
+ }
177
+
178
+ async function encodeImage(pipeline, format, quality) {
179
+ if (format === 'jpeg') return pipeline.jpeg({ quality }).toBuffer();
180
+ if (format === 'webp') return pipeline.webp({ quality }).toBuffer();
181
+ return pipeline.png().toBuffer();
182
+ }
183
+
184
+ function createMemeSvg({ width, height, raw, settings }) {
185
+ const zones = getCaptionZones(width, height, settings);
186
+ const top = buildCaptionSvg({
187
+ text: settings.topText,
188
+ zone: zones.top,
189
+ position: 'top',
190
+ raw,
191
+ settings
192
+ });
193
+ const bottom = buildCaptionSvg({
194
+ text: settings.bottomText,
195
+ zone: zones.bottom,
196
+ position: 'bottom',
197
+ raw,
198
+ settings
199
+ });
200
+
201
+ return `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
202
+ <defs>
203
+ <filter id="memeShadow" x="-20%" y="-20%" width="140%" height="140%">
204
+ <feDropShadow dx="0" dy="${formatNumber(Math.max(1, width * 0.003))}" stdDeviation="${formatNumber(Math.max(1, width * 0.004))}" flood-color="#000000" flood-opacity="0.48"/>
205
+ </filter>
206
+ </defs>
207
+ ${top}
208
+ ${bottom}
209
+ </svg>`;
210
+ }
211
+
212
+ function buildCaptionSvg({ text, zone, position, raw, settings }) {
213
+ const layout = layoutCaption({ text, zone, settings });
214
+ if (!layout.text) return '';
215
+
216
+ const colors = resolveCaptionColors({ zone, raw, settings });
217
+ const shadow = settings.shadow ? ' filter="url(#memeShadow)"' : '';
218
+ const strokeWidth = Math.max(0, layout.fontSize * settings.strokeWidthRatio);
219
+ const yStart = zone.y + (zone.height - layout.blockHeight) / 2 + layout.fontSize * 0.82;
220
+
221
+ if (!settings.font) {
222
+ const tspans = layout.lines.map((line, index) => {
223
+ const y = yStart + index * layout.lineHeightPx;
224
+ return `<tspan x="${formatNumber(zone.x + zone.width / 2)}" y="${formatNumber(y)}">${escapeXml(line)}</tspan>`;
225
+ }).join('');
226
+
227
+ return `<text data-position="${position}"
228
+ font-family="${escapeXml(settings.fontFamily)}"
229
+ font-size="${formatNumber(layout.fontSize)}"
230
+ text-anchor="middle"
231
+ fill="${escapeXml(colors.fill)}"
232
+ stroke="${escapeXml(colors.stroke)}"
233
+ stroke-width="${formatNumber(strokeWidth)}"
234
+ stroke-linejoin="round"
235
+ paint-order="stroke fill"${shadow}>${tspans}</text>`;
236
+ }
237
+
238
+ const paths = layout.lines.map((line, index) => {
239
+ const y = yStart + index * layout.lineHeightPx;
240
+ const lineWidth = measureText(line, layout.fontSize, settings);
241
+ const x = zone.x + (zone.width - lineWidth) / 2;
242
+ const glyphPath = settings.font.getPath(line, x, y, layout.fontSize);
243
+ return `<path d="${glyphPath.toPathData(2)}"/>`;
244
+ }).join('');
245
+
246
+ return `<g data-position="${position}"
247
+ fill="${escapeXml(colors.fill)}"
248
+ stroke="${escapeXml(colors.stroke)}"
249
+ stroke-width="${formatNumber(strokeWidth)}"
250
+ stroke-linejoin="round"
251
+ paint-order="stroke fill"${shadow}>${paths}</g>`;
252
+ }
253
+
254
+ function getCaptionZones(width, height, settings) {
255
+ const padding = Math.round(Math.min(width, height) * settings.paddingRatio);
256
+ const zoneHeight = Math.round(height * settings.captionHeightRatio);
257
+ const zoneWidth = Math.max(1, width - padding * 2);
258
+
259
+ return {
260
+ top: {
261
+ x: padding,
262
+ y: padding,
263
+ width: zoneWidth,
264
+ height: Math.max(1, zoneHeight)
265
+ },
266
+ bottom: {
267
+ x: padding,
268
+ y: Math.max(padding, height - padding - zoneHeight),
269
+ width: zoneWidth,
270
+ height: Math.max(1, zoneHeight)
271
+ }
272
+ };
273
+ }
274
+
275
+ function layoutCaption({ text, zone, settings }) {
276
+ const normalized = normalizeText(text, settings.uppercase);
277
+ if (!normalized) {
278
+ return {
279
+ text: '',
280
+ lines: [],
281
+ fontSize: 0,
282
+ lineHeightPx: 0,
283
+ blockHeight: 0,
284
+ maxLineWidth: 0,
285
+ zone
286
+ };
287
+ }
288
+
289
+ const base = Math.min(zone.width, zone.height * 2.2);
290
+ const maxFontSize = Math.max(4, Math.floor(base * settings.fontSizeRatio * 2.2));
291
+ const minFontSize = Math.max(4, Math.floor(Math.min(zone.width, zone.height) * settings.minFontSizeRatio));
292
+ let low = Math.min(minFontSize, maxFontSize);
293
+ let high = Math.max(minFontSize, maxFontSize);
294
+ let best = makeCaptionLayout(normalized, zone, low, settings);
295
+
296
+ while (low <= high) {
297
+ const mid = Math.floor((low + high) / 2);
298
+ const candidate = makeCaptionLayout(normalized, zone, mid, settings);
299
+
300
+ if (candidate.fits) {
301
+ best = candidate;
302
+ low = mid + 1;
303
+ } else {
304
+ high = mid - 1;
305
+ }
306
+ }
307
+
308
+ return {
309
+ text: normalized,
310
+ lines: best.lines,
311
+ fontSize: best.fontSize,
312
+ lineHeightPx: best.lineHeightPx,
313
+ blockHeight: best.blockHeight,
314
+ maxLineWidth: best.maxLineWidth,
315
+ zone
316
+ };
317
+ }
318
+
319
+ function makeCaptionLayout(text, zone, fontSize, settings) {
320
+ const lines = wrapText(text, zone.width, fontSize, settings);
321
+ const lineHeightPx = fontSize * settings.lineHeight;
322
+ const blockHeight = lines.length * lineHeightPx;
323
+ const maxLineWidth = lines.reduce((max, line) => {
324
+ return Math.max(max, measureText(line, fontSize, settings));
325
+ }, 0);
326
+
327
+ return {
328
+ lines,
329
+ fontSize,
330
+ lineHeightPx,
331
+ blockHeight,
332
+ maxLineWidth,
333
+ fits: maxLineWidth <= zone.width && blockHeight <= zone.height
334
+ };
335
+ }
336
+
337
+ function wrapText(text, maxWidth, fontSize, settings) {
338
+ const lines = [];
339
+ const paragraphs = text.split('\n');
340
+
341
+ for (const paragraph of paragraphs) {
342
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
343
+ if (words.length === 0) {
344
+ lines.push('');
345
+ continue;
346
+ }
347
+
348
+ let current = '';
349
+ for (const word of words) {
350
+ const next = current ? `${current} ${word}` : word;
351
+ if (measureText(next, fontSize, settings) <= maxWidth) {
352
+ current = next;
353
+ continue;
354
+ }
355
+
356
+ if (current) {
357
+ lines.push(current);
358
+ current = '';
359
+ }
360
+
361
+ if (measureText(word, fontSize, settings) <= maxWidth) {
362
+ current = word;
363
+ continue;
364
+ }
365
+
366
+ const chunks = splitLongWord(word, maxWidth, fontSize, settings);
367
+ lines.push(...chunks.slice(0, -1));
368
+ current = chunks.at(-1) ?? '';
369
+ }
370
+
371
+ if (current) lines.push(current);
372
+ }
373
+
374
+ return lines.filter((line, index) => line || index === 0);
375
+ }
376
+
377
+ function splitLongWord(word, maxWidth, fontSize, settings) {
378
+ const chunks = [];
379
+ let current = '';
380
+
381
+ for (const char of word) {
382
+ const next = current + char;
383
+ if (!current || measureText(next, fontSize, settings) <= maxWidth) {
384
+ current = next;
385
+ continue;
386
+ }
387
+
388
+ chunks.push(current);
389
+ current = char;
390
+ }
391
+
392
+ if (current) chunks.push(current);
393
+ return chunks;
394
+ }
395
+
396
+ function measureText(text, fontSize, settings) {
397
+ if (settings?.font) {
398
+ return settings.font.getAdvanceWidth(String(text), fontSize);
399
+ }
400
+
401
+ let units = 0;
402
+
403
+ for (const char of String(text)) {
404
+ units += charWidthUnit(char);
405
+ }
406
+
407
+ return units * fontSize * 1.08;
408
+ }
409
+
410
+ function charWidthUnit(char) {
411
+ if (/\s/.test(char)) return 0.34;
412
+ if (/[ilI.,'!:;|]/.test(char)) return 0.34;
413
+ if (/[fjrt()\[\]{}]/.test(char)) return 0.46;
414
+ if (/[mwMW@#%&]/.test(char)) return 0.92;
415
+ if (/[A-Z0-9]/.test(char)) return 0.66;
416
+ if (char.codePointAt(0) > 0x2e7f) return 1;
417
+ return 0.58;
418
+ }
419
+
420
+ function resolveCaptionColors({ zone, raw, settings }) {
421
+ if (settings.textColor !== 'auto') {
422
+ return {
423
+ fill: settings.textColor,
424
+ stroke: settings.strokeColor === 'auto' ? oppositeColor(settings.textColor) : settings.strokeColor
425
+ };
426
+ }
427
+
428
+ const luminance = averageLuminance(raw, zone);
429
+ const fill = luminance < settings.autoColorThreshold ? '#ffffff' : '#000000';
430
+ const stroke = settings.strokeColor === 'auto' ? oppositeColor(fill) : settings.strokeColor;
431
+
432
+ return { fill, stroke };
433
+ }
434
+
435
+ function averageLuminance(raw, zone) {
436
+ const { data, info } = raw;
437
+ const channels = info.channels;
438
+ const xStart = Math.max(0, Math.floor(zone.x));
439
+ const yStart = Math.max(0, Math.floor(zone.y));
440
+ const xEnd = Math.min(info.width, Math.ceil(zone.x + zone.width));
441
+ const yEnd = Math.min(info.height, Math.ceil(zone.y + zone.height));
442
+ const sampleStep = Math.max(1, Math.floor(Math.sqrt(((xEnd - xStart) * (yEnd - yStart)) / 1200)));
443
+ let total = 0;
444
+ let count = 0;
445
+
446
+ for (let y = yStart; y < yEnd; y += sampleStep) {
447
+ for (let x = xStart; x < xEnd; x += sampleStep) {
448
+ const index = (y * info.width + x) * channels;
449
+ const red = data[index];
450
+ const green = data[index + 1];
451
+ const blue = data[index + 2];
452
+ total += 0.2126 * red + 0.7152 * green + 0.0722 * blue;
453
+ count += 1;
454
+ }
455
+ }
456
+
457
+ return count ? total / count : 255;
458
+ }
459
+
460
+ function oppositeColor(color) {
461
+ if (String(color).toLowerCase() === '#000000' || String(color).toLowerCase() === 'black') {
462
+ return '#ffffff';
463
+ }
464
+ return '#000000';
465
+ }
466
+
467
+ function normalizeText(text, uppercase) {
468
+ const value = String(text ?? '')
469
+ .replace(/\r\n?/g, '\n')
470
+ .split('\n')
471
+ .map((line) => line.trim().replace(/\s+/g, ' '))
472
+ .join('\n')
473
+ .trim();
474
+
475
+ return uppercase ? value.toUpperCase() : value;
476
+ }
477
+
478
+ function positiveNumber(value, name) {
479
+ const number = Number(value);
480
+ if (!Number.isFinite(number) || number <= 0) {
481
+ throw new Error(`${name} must be a positive number.`);
482
+ }
483
+ return number;
484
+ }
485
+
486
+ function clampNumber(value, min, max, fallback) {
487
+ const number = Number(value);
488
+ if (!Number.isFinite(number)) return fallback;
489
+ return Math.min(max, Math.max(min, number));
490
+ }
491
+
492
+ function escapeXml(value) {
493
+ return String(value)
494
+ .replace(/&/g, '&amp;')
495
+ .replace(/</g, '&lt;')
496
+ .replace(/>/g, '&gt;')
497
+ .replace(/"/g, '&quot;')
498
+ .replace(/'/g, '&apos;');
499
+ }
500
+
501
+ function formatNumber(value) {
502
+ return Number.parseFloat(Number(value).toFixed(3)).toString();
503
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@ghuts/memegen",
3
+ "version": "1.0.0",
4
+ "description": "A JavaScript module for creating memes with Sharp, bundled Anton font, automatic text wrapping, and shrink-to-fit captions.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "memegen": "cli.js",
16
+ "meme-generator": "cli.js"
17
+ },
18
+ "files": [
19
+ "assets/",
20
+ "cli.js",
21
+ "examples/",
22
+ "index.d.ts",
23
+ "index.js",
24
+ "LICENSE",
25
+ "README.md",
26
+ "scripts/"
27
+ ],
28
+ "scripts": {
29
+ "check": "node --check index.js && node --check cli.js && node --check examples/demo.js",
30
+ "demo": "node examples/demo.js",
31
+ "test": "node scripts/smoke-test.js"
32
+ },
33
+ "keywords": [
34
+ "meme",
35
+ "meme-generator",
36
+ "sharp",
37
+ "image",
38
+ "caption",
39
+ "canvas",
40
+ "nodejs"
41
+ ],
42
+ "author": "ghuts",
43
+ "license": "MIT AND OFL-1.1",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/yeweroooo/memegen.git"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/yeweroooo/memegen/issues"
50
+ },
51
+ "homepage": "https://github.com/yeweroooo/memegen#readme",
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "sideEffects": false,
56
+ "dependencies": {
57
+ "opentype.js": "^2.0.0",
58
+ "sharp": "^0.34.5"
59
+ },
60
+ "engines": {
61
+ "node": ">=18"
62
+ }
63
+ }
@@ -0,0 +1,44 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import sharp from 'sharp';
3
+ import { layoutMemeText, renderMeme } from '../index.js';
4
+
5
+ const background = await sharp({
6
+ create: {
7
+ width: 640,
8
+ height: 360,
9
+ channels: 3,
10
+ background: '#f8f8f8'
11
+ }
12
+ }).png().toBuffer();
13
+
14
+ const rendered = await renderMeme({
15
+ background,
16
+ topText: 'when the render works',
17
+ bottomText: 'long captions should wrap safely',
18
+ format: 'png'
19
+ });
20
+
21
+ assert.equal(rendered.width, 640);
22
+ assert.equal(rendered.height, 360);
23
+ assert.equal(rendered.format, 'png');
24
+ assert.ok(rendered.buffer.length > 0);
25
+ assert.ok(rendered.top.lines.length >= 1);
26
+ assert.ok(rendered.bottom.lines.length >= 1);
27
+ assert.ok(rendered.top.maxLineWidth <= rendered.top.zone.width);
28
+ assert.ok(rendered.bottom.maxLineWidth <= rendered.bottom.zone.width);
29
+
30
+ const metadata = await sharp(rendered.buffer).metadata();
31
+ assert.equal(metadata.width, 640);
32
+ assert.equal(metadata.height, 360);
33
+
34
+ const layout = layoutMemeText({
35
+ width: 640,
36
+ height: 360,
37
+ topText: 'a very long top sentence that must fit',
38
+ bottomText: 'a very long bottom sentence that must wrap without crossing image bounds'
39
+ });
40
+
41
+ assert.ok(layout.top.maxLineWidth <= layout.top.zone.width);
42
+ assert.ok(layout.bottom.maxLineWidth <= layout.bottom.zone.width);
43
+
44
+ console.log('OK: smoke test passed');