• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bostrom / text-to-image / 4062174500

pending completion
4062174500

Pull #265

github

GitHub
Merge e55e94ac5 into 4d93cde9a
Pull Request #265: chore(deps-dev): bump @babel/core from 7.18.2 to 7.20.12

52 of 52 branches covered (100.0%)

117 of 117 relevant lines covered (100.0%)

215205.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/src/textToImage.ts
1
import { dirname, resolve } from 'path';
2
import { writeFileSync, mkdirSync, writeFile, mkdir } from 'fs';
3
import { promisify } from 'util';
4
import { createCanvas, registerFont, Canvas } from 'canvas';
5

6
const writeFileAsync = promisify(writeFile);
3✔
7
const mkdirAsync = promisify(mkdir);
3✔
8

9
interface GenerateOptions {
10
  bgColor?: string | CanvasGradient | CanvasPattern;
11
  customHeight?: number;
12
  bubbleTail?: { width: number; height: number };
13
  debug?: boolean;
14
  debugFilename?: string;
15
  fontFamily?: string;
16
  fontPath?: string;
17
  fontSize?: number;
18
  fontWeight?: string | number;
19
  lineHeight?: number;
20
  margin?: number;
21
  maxWidth?: number;
22
  textAlign?: CanvasTextAlign;
23
  textColor?: string;
24
  verticalAlign?: string;
25
}
26

27
type GenerateOptionsRequired = Required<GenerateOptions>;
28

29
const defaults = {
3✔
30
  bgColor: '#fff',
31
  customHeight: 0,
32
  bubbleTail: { width: 0, height: 0 },
33
  debug: false,
34
  debugFilename: '',
35
  fontFamily: 'Helvetica',
36
  fontPath: '',
37
  fontSize: 18,
38
  fontWeight: 'normal',
39
  lineHeight: 28,
40
  margin: 10,
41
  maxWidth: 400,
42
  textAlign: 'left' as const,
43
  textColor: '#000',
44
  verticalAlign: 'top',
45
};
46

47
const createTextData = (
3✔
48
  text: string,
49
  config: GenerateOptionsRequired,
50
  canvas?: Canvas,
51
) => {
52
  const {
53
    bgColor,
54
    fontFamily,
55
    fontPath,
56
    fontSize,
57
    fontWeight,
58
    lineHeight,
59
    maxWidth,
60
    textAlign,
61
    textColor,
62
  } = config;
210✔
63

64
  // Register a custom font
65
  if (fontPath) {
210✔
66
    registerFont(fontPath, { family: fontFamily });
6✔
67
  }
68

69
  // Use the supplied canvas (which should have a suitable width and height)
70
  // for the final image
71
  // OR
72
  // create a temporary canvas just for measuring how long the canvas needs to be
73
  const textCanvas = canvas || createCanvas(maxWidth, 100);
210✔
74
  const textContext = textCanvas.getContext('2d');
210✔
75

76
  // set the text alignment and start position
77
  let textX = 0;
210✔
78
  let textY = 0;
210✔
79

80
  if (['center'].includes(textAlign.toLowerCase())) {
210✔
81
    textX = maxWidth / 2;
12✔
82
  }
83
  if (['right', 'end'].includes(textAlign.toLowerCase())) {
210✔
84
    textX = maxWidth;
6✔
85
  }
86
  textContext.textAlign = textAlign;
210✔
87

88
  // set background color
89
  textContext.fillStyle = bgColor;
210✔
90
  textContext.fillRect(0, 0, textCanvas.width, textCanvas.height);
210✔
91

92
  // set text styles
93
  textContext.fillStyle = textColor;
210✔
94
  textContext.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
210✔
95
  textContext.textBaseline = 'top';
210✔
96

97
  // split the text into words
98
  const words = text.split(' ');
210✔
99
  let wordCount = words.length;
210✔
100

101
  // the start of the first line
102
  let line = '';
210✔
103
  const addNewLines = [];
210✔
104

105
  for (let n = 0; n < wordCount; n += 1) {
210✔
106
    let word: string = words[n];
8,148✔
107

108
    if (/\n/.test(words[n])) {
8,148✔
109
      const parts = words[n].split('\n');
276✔
110
      // use the first word before the newline(s)
111
      word = parts.shift() || '';
276✔
112
      // mark the next word as beginning with newline
113
      addNewLines.push(n + 1);
276✔
114
      // return the rest of the parts to the words array at the same index
115
      words.splice(n + 1, 0, parts.join('\n'));
276✔
116
      wordCount += 1;
276✔
117
    }
118

119
    // append one word to the line and see
120
    // if its width exceeds the maxWidth
121
    // also trim the testLine since `line` will be empty in the beginning,
122
    // causing a leading white space character otherwise
123
    const testLine = `${line} ${word}`.replace(/^ +/, '').replace(/ +$/, '');
8,148✔
124
    const testLineWidth = textContext.measureText(testLine).width;
8,148✔
125

126
    // if the line is marked as starting with a newline
127
    // OR if the line is too long, add a newline
128
    if (addNewLines.indexOf(n) > -1 || (testLineWidth > maxWidth && n > 0)) {
8,148✔
129
      // if the line exceeded the width with one additional word
130
      // just paint the line without the word
131
      textContext.fillText(line, textX, textY);
1,260✔
132

133
      // start a new line with the last word
134
      // and add the following (if this word was a newline word)
135
      line = word;
1,260✔
136

137
      // move the pen down
138
      textY += lineHeight;
1,260✔
139
    } else {
140
      // if not exceeded, just continue
141
      line = testLine;
6,888✔
142
    }
143
  }
144

145
  // paint the last line
146
  textContext.fillText(line, textX, textY);
210✔
147

148
  // increase the size of the text layer by the line height,
149
  // but in case the line height is less than the font size
150
  // we increase by font size in order to prevent clipping
151
  const height = textY + Math.max(lineHeight, fontSize);
210✔
152

153
  return {
210✔
154
    textHeight: height,
155
    textData: textContext.getImageData(0, 0, maxWidth, height),
156
  };
157
};
158

159
const createImageCanvas = (content: string, conf: GenerateOptionsRequired) => {
3✔
160
  // First pass: measure the text so we can create a canvas
161
  // big enough to fit the text. This has to be done since we can't
162
  // resize the canvas on the fly without losing the settings of the 2D context
163
  // https://github.com/Automattic/node-canvas/issues/1625
164
  const { textHeight } = createTextData(
105✔
165
    content,
166
    // max width of text itself must be the image max width reduced by left-right margins
167
    <GenerateOptionsRequired>{
168
      maxWidth: conf.maxWidth - conf.margin * 2,
169
      fontSize: conf.fontSize,
170
      lineHeight: conf.lineHeight,
171
      bgColor: conf.bgColor,
172
      textColor: conf.textColor,
173
      fontFamily: conf.fontFamily,
174
      fontPath: conf.fontPath,
175
      fontWeight: conf.fontWeight,
176
      textAlign: conf.textAlign,
177
    },
178
  );
179

180
  const textHeightWithMargins = textHeight + conf.margin * 2;
105✔
181

182
  if (conf.customHeight && conf.customHeight < textHeightWithMargins) {
105✔
183
    // eslint-disable-next-line no-console
184
    console.warn('Text is longer than customHeight, clipping will occur.');
3✔
185
  }
186

187
  // Second pass: we now know the height of the text on the canvas,
188
  // so let's create the final canvas with the given height and width
189
  // and pass that to createTextData so we can get the text data from it
190
  const height = conf.customHeight || textHeightWithMargins;
105✔
191
  const canvas = createCanvas(conf.maxWidth, height + conf.bubbleTail.height);
105✔
192

193
  const { textData } = createTextData(
105✔
194
    content,
195
    // max width of text itself must be the image max width reduced by left-right margins
196
    <GenerateOptionsRequired>{
197
      maxWidth: conf.maxWidth - conf.margin * 2,
198
      fontSize: conf.fontSize,
199
      lineHeight: conf.lineHeight,
200
      bgColor: conf.bgColor,
201
      textColor: conf.textColor,
202
      fontFamily: conf.fontFamily,
203
      fontPath: conf.fontPath,
204
      fontWeight: conf.fontWeight,
205
      textAlign: conf.textAlign,
206
    },
207
    canvas,
208
  );
209
  const ctx = canvas.getContext('2d');
105✔
210

211
  // the canvas will have the text from the first pass on it,
212
  // so start by clearing the whole canvas and start from a clean slate
213
  ctx.clearRect(0, 0, canvas.width, canvas.height);
105✔
214
  ctx.globalAlpha = 1;
105✔
215
  ctx.fillStyle = conf.bgColor;
105✔
216
  ctx.fillRect(0, 0, canvas.width, height);
105✔
217

218
  if (conf.bubbleTail.width && conf.bubbleTail.height) {
105✔
219
    ctx.beginPath();
3✔
220
    ctx.moveTo(canvas.width / 2 - conf.bubbleTail.width / 2, height);
3✔
221
    ctx.lineTo(canvas.width / 2, canvas.height);
3✔
222
    ctx.lineTo(canvas.width / 2 + conf.bubbleTail.width / 2, height);
3✔
223
    ctx.closePath();
3✔
224
    ctx.fillStyle = conf.bgColor;
3✔
225
    ctx.fill();
3✔
226
  }
227

228
  const textX = conf.margin;
105✔
229
  let textY = conf.margin;
105✔
230
  if (conf.customHeight && conf.verticalAlign === 'center') {
105✔
231
    textY =
6✔
232
      // divide the leftover whitespace by 2
233
      (conf.customHeight - textData.height) / 2 +
234
      // offset for the extra space under the last line to make bottom and top whitespace equal
235
      // but only up until the bottom of the text
236
      // (i.e. don't consider a linheight less than the font size)
237
      Math.max(0, (conf.lineHeight - conf.fontSize) / 2);
238
  }
239

240
  ctx.putImageData(textData, textX, textY);
105✔
241

242
  return canvas;
105✔
243
};
244

245
export const generate = async (
3✔
246
  content: string,
247
  config: GenerateOptions,
248
): Promise<string> => {
249
  const conf = { ...defaults, ...config };
93✔
250
  const canvas = createImageCanvas(content, conf);
93✔
251
  const dataUrl = canvas.toDataURL();
93✔
252

253
  if (conf.debug) {
93✔
254
    const fileName =
255
      conf.debugFilename ||
6✔
256
      `${new Date().toISOString().replace(/[\W.]/g, '')}.png`;
257
    await mkdirAsync(resolve(dirname(fileName)), { recursive: true });
6✔
258
    await writeFileAsync(fileName, canvas.toBuffer());
6✔
259
  }
260

261
  return dataUrl;
93✔
262
};
263

264
export const generateSync = (
3✔
265
  content: string,
266
  config: GenerateOptions,
267
): string => {
268
  const conf: GenerateOptionsRequired = { ...defaults, ...config };
12✔
269
  const canvas = createImageCanvas(content, conf);
12✔
270
  const dataUrl = canvas.toDataURL();
12✔
271

272
  if (conf.debug) {
12✔
273
    const fileName =
274
      conf.debugFilename ||
9✔
275
      `${new Date().toISOString().replace(/[\W.]/g, '')}.png`;
276
    mkdirSync(resolve(dirname(fileName)), { recursive: true });
9✔
277
    writeFileSync(fileName, canvas.toBuffer());
9✔
278
  }
279

280
  return dataUrl;
12✔
281
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc