@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 +24 -0
- package/README.md +251 -0
- package/assets/Anton-Regular.ttf +0 -0
- package/assets/OFL-Anton.txt +93 -0
- package/cli.js +86 -0
- package/examples/demo.js +27 -0
- package/index.d.ts +80 -0
- package/index.js +503 -0
- package/package.json +63 -0
- package/scripts/smoke-test.js +44 -0
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
|
+
});
|
package/examples/demo.js
ADDED
|
@@ -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, '&')
|
|
495
|
+
.replace(/</g, '<')
|
|
496
|
+
.replace(/>/g, '>')
|
|
497
|
+
.replace(/"/g, '"')
|
|
498
|
+
.replace(/'/g, ''');
|
|
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');
|