1import { Actor, log } from 'apify';
2import { exec } from 'child_process';
3import { promisify } from 'util';
4import { writeFile, readFile, unlink, mkdir, rm } from 'fs/promises';
5import { existsSync } from 'fs';
6import path from 'path';
7import { randomUUID } from 'crypto';
8import os from 'os';
9import sharp from 'sharp';
10import { PDFDocument } from 'pdf-lib';
11
12const execAsync = promisify(exec);
13
14
15const DIAGRAM_PROMPTS = {
16 flowchart: {
17 syntax: 'flowchart TD',
18 instruction: 'Create a flowchart with nodes and arrows. Use decision diamonds {}, rectangular boxes [], and rounded boxes (). Connect with --> arrows.'
19 },
20 sequence: {
21 syntax: 'sequenceDiagram',
22 instruction: 'Create a sequence diagram showing interactions between participants. Use ->> for requests, -->> for responses, and Note over for annotations.'
23 },
24 er: {
25 syntax: 'erDiagram',
26 instruction: 'Create an ER diagram for database schema. Use ||--o{ for one-to-many, }|--|{ for many-to-many relationships. Define entity attributes.'
27 },
28 gantt: {
29 syntax: 'gantt',
30 instruction: 'Create a Gantt chart with dateFormat YYYY-MM-DD, title, sections, and tasks with durations like "task1 :a1, 2024-01-01, 30d".'
31 },
32 pie: {
33 syntax: 'pie title Chart Title',
34 instruction: 'Create a pie chart with "Label" : value format for each slice.'
35 },
36 state: {
37 syntax: 'stateDiagram-v2',
38 instruction: 'Create a state diagram with [*] for start/end states, --> for transitions, and state names in PascalCase.'
39 },
40 class: {
41 syntax: 'classDiagram',
42 instruction: 'Create a class diagram with class definitions, attributes (+public, -private), methods, and relationships (<|-- inheritance, *-- composition).'
43 },
44 mindmap: {
45 syntax: 'mindmap',
46 instruction: 'Create a mindmap with root node and indented child nodes. Use proper indentation for hierarchy.'
47 },
48 timeline: {
49 syntax: 'timeline',
50 instruction: 'Create a timeline with title and events. Format: "time period : event1 : event2".'
51 },
52 quadrant: {
53 syntax: 'quadrantChart',
54 instruction: 'Create a quadrant chart with title, x-axis, y-axis labels, and data points with [x, y] coordinates.'
55 },
56 gitgraph: {
57 syntax: 'gitGraph',
58 instruction: 'Create a git graph with commit, branch, checkout, and merge commands.'
59 },
60 sankey: {
61 syntax: 'sankey-beta',
62 instruction: 'Create a sankey diagram with source,target,value format for flows.'
63 },
64 xy: {
65 syntax: 'xychart-beta',
66 instruction: 'Create an XY chart with x-axis, y-axis, and line or bar data series.'
67 }
68};
69
70
71
72
73async function convertDescriptionToMermaid(description, diagramType, openaiApiKey) {
74 const diagramConfig = DIAGRAM_PROMPTS[diagramType];
75 if (!diagramConfig) {
76 throw new Error(`Unknown diagram type: ${diagramType}`);
77 }
78
79 const prompt = `Convert this description into valid Mermaid ${diagramType} diagram code.
80
81IMPORTANT RULES:
82- Output ONLY raw Mermaid code, nothing else
83- No markdown code blocks, no explanations, no comments
84- Start with: ${diagramConfig.syntax}
85- ${diagramConfig.instruction}
86
87Description: ${description}
88
89Mermaid code:`;
90
91 const response = await fetch('https://api.openai.com/v1/chat/completions', {
92 method: 'POST',
93 headers: {
94 'Content-Type': 'application/json',
95 'Authorization': `Bearer ${openaiApiKey}`,
96 },
97 body: JSON.stringify({
98 model: 'gpt-4o-mini',
99 messages: [
100 {
101 role: 'system',
102 content: 'You are a Mermaid diagram expert. You only output valid Mermaid code, nothing else. No markdown formatting, no explanations.'
103 },
104 {
105 role: 'user',
106 content: prompt
107 }
108 ],
109 temperature: 0.3,
110 max_tokens: 2000,
111 }),
112 });
113
114 if (!response.ok) {
115 const error = await response.text();
116 throw new Error(`OpenAI API error: ${response.status} - ${error}`);
117 }
118
119 const data = await response.json();
120 let mermaidCode = data.choices[0].message.content.trim();
121
122
123 mermaidCode = mermaidCode.replace(/^```mermaid\n?/i, '').replace(/^```\n?/i, '').replace(/\n?```$/i, '');
124
125 return mermaidCode;
126}
127
128
129await Actor.init();
130
131try {
132
133 const input = await Actor.getInput() ?? {};
134
135 const {
136 aiDiagramType = '',
137 aiDescription = '',
138 openaiApiKey = '',
139 diagrams = [],
140 mermaidCode = '',
141 outputFormat = 'png',
142 theme = 'default',
143 backgroundColor = 'white',
144 maxWidth = 0,
145 maxHeight = 0,
146 scale = 3,
147 } = input;
148
149
150 let usedAiMode = false;
151
152
153 let generatedMermaidCode = mermaidCode;
154 if (aiDiagramType && aiDescription && aiDescription.trim()) {
155 if (!openaiApiKey) {
156 throw new Error('AI Mode requires an OpenAI API Key. Get yours at platform.openai.com/api-keys');
157 }
158 log.info(`AI Mode: Generating ${aiDiagramType} diagram from description...`);
159 generatedMermaidCode = await convertDescriptionToMermaid(aiDescription.trim(), aiDiagramType, openaiApiKey);
160 usedAiMode = true;
161 log.info('Successfully generated Mermaid code from description');
162 log.debug(`Generated code:\n${generatedMermaidCode}`);
163 } else if (aiDiagramType && !aiDescription) {
164 throw new Error('AI Mode requires both diagram type AND description. Please fill in the description field.');
165 } else if (!aiDiagramType && aiDescription && aiDescription.trim()) {
166 throw new Error('AI Mode requires selecting a diagram type. Please choose from the dropdown.');
167 }
168
169
170 const validFormats = ['png', 'svg', 'pdf'];
171 if (!validFormats.includes(outputFormat)) {
172 throw new Error(`Invalid outputFormat "${outputFormat}". Must be one of: ${validFormats.join(', ')}`);
173 }
174
175
176 const validThemes = ['default', 'forest', 'dark', 'neutral', 'base'];
177 if (!validThemes.includes(theme)) {
178 throw new Error(`Invalid theme "${theme}". Must be one of: ${validThemes.join(', ')}`);
179 }
180
181
182 if (scale < 1 || scale > 5) {
183 throw new Error(`Scale must be between 1 and 5. Got: ${scale}`);
184 }
185
186
187 let diagramsToProcess = [];
188
189 if (diagrams && diagrams.length > 0) {
190 diagramsToProcess = diagrams
191 .filter(d => d && d.code && d.code.trim())
192 .map((d, i) => ({
193 code: d.code.trim(),
194 name: sanitizeFileName(d.name || `diagram-${i + 1}`),
195
196 outputFormat: d.outputFormat,
197 theme: d.theme,
198 backgroundColor: d.backgroundColor,
199 scale: d.scale,
200 maxWidth: d.maxWidth,
201 maxHeight: d.maxHeight,
202 }));
203 } else if (generatedMermaidCode && generatedMermaidCode.trim()) {
204
205 diagramsToProcess = [{
206 code: generatedMermaidCode.trim(),
207 name: usedAiMode ? `ai-${aiDiagramType}-diagram` : 'diagram',
208 }];
209 }
210
211 if (diagramsToProcess.length === 0) {
212 throw new Error('No valid input provided. Use AI mode (select diagram type + description) or paste Mermaid code directly.');
213 }
214
215 log.info(`Processing ${diagramsToProcess.length} diagram(s)`);
216
217
218 const tempDir = path.join(os.tmpdir(), 'mermaid-' + randomUUID());
219 if (!existsSync(tempDir)) {
220 await mkdir(tempDir, { recursive: true });
221 }
222
223
224 const puppeteerConfig = {
225 executablePath: process.env.APIFY_CHROME_EXECUTABLE_PATH || undefined,
226 args: ['--no-sandbox', '--disable-setuid-sandbox'],
227 };
228 const puppeteerConfigPath = path.join(tempDir, 'puppeteer-config.json');
229 await writeFile(puppeteerConfigPath, JSON.stringify(puppeteerConfig));
230
231
232 const kvStore = await Actor.openKeyValueStore();
233 const dataset = await Actor.openDataset();
234
235
236 const results = [];
237
238 for (const diagram of diagramsToProcess) {
239 const diagramId = randomUUID().slice(0, 8);
240
241
242 const dOutputFormat = diagram.outputFormat || outputFormat;
243 const dTheme = diagram.theme || theme;
244 const dBackgroundColor = diagram.backgroundColor || backgroundColor;
245 const dScale = diagram.scale || scale;
246 const dMaxWidth = diagram.maxWidth !== undefined ? diagram.maxWidth : maxWidth;
247 const dMaxHeight = diagram.maxHeight !== undefined ? diagram.maxHeight : maxHeight;
248
249 const inputFile = path.join(tempDir, `input-${diagramId}.mmd`);
250 const outputFile = path.join(tempDir, `output-${diagramId}.${dOutputFormat}`);
251
252 try {
253 log.info(`Processing diagram: ${diagram.name} (format: ${dOutputFormat}, theme: ${dTheme})`);
254
255
256 await writeFile(inputFile, diagram.code);
257
258
259 const diagramType = detectDiagramType(diagram.code);
260
261
262 const isDarkBg = isColorDark(dBackgroundColor);
263 const txtColor = isDarkBg ? '#ffffff' : '#333333';
264 const lnColor = isDarkBg ? '#ffffff' : '#333333';
265
266 const mermaidConfig = {
267 theme: dTheme,
268 themeVariables: {
269 background: dBackgroundColor,
270 lineColor: lnColor,
271 fontSize: '16px',
272 fontFamily: 'arial, sans-serif',
273 actorLineColor: lnColor,
274 signalColor: lnColor,
275 signalTextColor: txtColor,
276 labelTextColor: txtColor,
277 loopTextColor: txtColor,
278 noteTextColor: txtColor,
279 sequenceNumberColor: txtColor,
280 },
281 gantt: { titleTopMargin: 25, barHeight: 30, barGap: 8, topPadding: 75, leftPadding: 150, gridLineStartPadding: 50, fontSize: 14, sectionFontSize: 16, numberSectionStyles: 4, useWidth: 1400 },
282 sequence: { diagramMarginX: 50, diagramMarginY: 30, actorMargin: 100, width: 200, height: 70, boxMargin: 10, noteMargin: 20, messageMargin: 50, mirrorActors: true, useMaxWidth: false },
283 flowchart: { htmlLabels: true, curve: 'basis', padding: 25, nodeSpacing: 50, rankSpacing: 50, useMaxWidth: false },
284 er: { diagramPadding: 30, layoutDirection: 'TB', minEntityWidth: 120, minEntityHeight: 80, entityPadding: 20, useMaxWidth: false },
285 pie: { textPosition: 0.75, useMaxWidth: false },
286 mindmap: { padding: 25, useMaxWidth: false },
287 };
288
289 const configPath = path.join(tempDir, `config-${diagramId}.json`);
290 await writeFile(configPath, JSON.stringify(mermaidConfig));
291
292 const cssContent = `
293 body, html, #container, .mermaid, svg { background-color: ${dBackgroundColor} !important; }
294 .messageText, .loopText, .loopText > tspan, .labelText, .labelText > tspan { fill: ${txtColor} !important; }
295 .noteText, .noteText > tspan, .sequenceNumber { fill: ${txtColor} !important; }
296 .messageLine0, .messageLine1, .loopLine { stroke: ${lnColor} !important; }
297 .edgeLabel { background-color: ${dBackgroundColor} !important; }
298 .edgeLabel rect { fill: ${dBackgroundColor} !important; }
299 .edgePath .path { stroke: ${lnColor} !important; }
300 marker path { fill: ${lnColor} !important; }
301 .taskText, .taskTextOutsideRight, .sectionTitle, .titleText, .pieTitleText, .legend text { fill: ${txtColor} !important; }
302 `;
303 const cssPath = path.join(tempDir, `style-${diagramId}.css`);
304 await writeFile(cssPath, cssContent);
305
306
307 const mmdcArgs = [
308 'npx', 'mmdc',
309 '-i', inputFile,
310 '-o', outputFile,
311 '-c', configPath,
312 '-p', puppeteerConfigPath,
313 '-b', `"${dBackgroundColor}"`,
314 '-C', cssPath,
315 '-s', dScale.toString(),
316 ];
317
318
319
320
321 const command = mmdcArgs.join(' ');
322 log.debug(`Executing: ${command}`);
323
324 await execAsync(command, {
325 timeout: 120000,
326 env: { ...process.env, PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 'true' }
327 });
328
329
330 let outputBuffer = await readFile(outputFile);
331
332
333 if (dMaxWidth > 0 || dMaxHeight > 0) {
334 if (dOutputFormat === 'png') {
335
336 const resizeOptions = {
337 fit: 'contain',
338 background: dBackgroundColor === 'transparent'
339 ? { r: 0, g: 0, b: 0, alpha: 0 }
340 : dBackgroundColor
341 };
342
343 if (dMaxWidth > 0 && dMaxHeight > 0) {
344 outputBuffer = await sharp(outputBuffer)
345 .resize(dMaxWidth, dMaxHeight, resizeOptions)
346 .png()
347 .toBuffer();
348 log.info(`Resized PNG to ${dMaxWidth}x${dMaxHeight}`);
349 } else if (dMaxWidth > 0) {
350 outputBuffer = await sharp(outputBuffer)
351 .resize(dMaxWidth, null, resizeOptions)
352 .png()
353 .toBuffer();
354 log.info(`Resized PNG to max width ${dMaxWidth}`);
355 } else if (dMaxHeight > 0) {
356 outputBuffer = await sharp(outputBuffer)
357 .resize(null, dMaxHeight, resizeOptions)
358 .png()
359 .toBuffer();
360 log.info(`Resized PNG to max height ${dMaxHeight}`);
361 }
362 } else if (dOutputFormat === 'svg') {
363
364 let svgContent = outputBuffer.toString('utf8');
365
366
367 const widthMatch = svgContent.match(/width="([^"]+)"/);
368 const heightMatch = svgContent.match(/height="([^"]+)"/);
369
370 if (widthMatch && heightMatch) {
371 const currentWidth = parseFloat(widthMatch[1]);
372 const currentHeight = parseFloat(heightMatch[1]);
373
374
375 let newWidth = currentWidth;
376 let newHeight = currentHeight;
377
378 if (dMaxWidth > 0 && dMaxHeight > 0) {
379 const scaleW = dMaxWidth / currentWidth;
380 const scaleH = dMaxHeight / currentHeight;
381 const scaleRatio = Math.min(scaleW, scaleH);
382 newWidth = currentWidth * scaleRatio;
383 newHeight = currentHeight * scaleRatio;
384 } else if (dMaxWidth > 0) {
385 const scaleRatio = dMaxWidth / currentWidth;
386 newWidth = dMaxWidth;
387 newHeight = currentHeight * scaleRatio;
388 } else if (dMaxHeight > 0) {
389 const scaleRatio = dMaxHeight / currentHeight;
390 newHeight = dMaxHeight;
391 newWidth = currentWidth * scaleRatio;
392 }
393
394
395 svgContent = svgContent.replace(/width="[^"]+"/, `width="${Math.round(newWidth)}"`);
396 svgContent = svgContent.replace(/height="[^"]+"/, `height="${Math.round(newHeight)}"`);
397
398 outputBuffer = Buffer.from(svgContent, 'utf8');
399 log.info(`Resized SVG to ${Math.round(newWidth)}x${Math.round(newHeight)}`);
400 } else {
401 log.warning('Could not extract SVG dimensions, skipping resize');
402 }
403 } else if (dOutputFormat === 'pdf') {
404
405 const pdfDoc = await PDFDocument.load(outputBuffer);
406 const pages = pdfDoc.getPages();
407
408 if (pages.length > 0) {
409 const page = pages[0];
410 const { width: currentWidth, height: currentHeight } = page.getSize();
411
412
413 let newWidth = currentWidth;
414 let newHeight = currentHeight;
415
416 if (dMaxWidth > 0 && dMaxHeight > 0) {
417 const scaleW = dMaxWidth / currentWidth;
418 const scaleH = dMaxHeight / currentHeight;
419 const scaleRatio = Math.min(scaleW, scaleH);
420 newWidth = currentWidth * scaleRatio;
421 newHeight = currentHeight * scaleRatio;
422 } else if (dMaxWidth > 0) {
423 const scaleRatio = dMaxWidth / currentWidth;
424 newWidth = dMaxWidth;
425 newHeight = currentHeight * scaleRatio;
426 } else if (dMaxHeight > 0) {
427 const scaleRatio = dMaxHeight / currentHeight;
428 newHeight = dMaxHeight;
429 newWidth = currentWidth * scaleRatio;
430 }
431
432
433 const scaleX = newWidth / currentWidth;
434 const scaleY = newHeight / currentHeight;
435
436 page.setSize(newWidth, newHeight);
437 page.scaleContent(scaleX, scaleY);
438
439 outputBuffer = Buffer.from(await pdfDoc.save());
440 log.info(`Resized PDF to ${Math.round(newWidth)}x${Math.round(newHeight)}`);
441 }
442 }
443 }
444
445
446 const fileName = `${diagram.name}.${dOutputFormat}`;
447
448
449 const contentType = getContentType(dOutputFormat);
450 await kvStore.setValue(fileName, outputBuffer, { contentType });
451
452
453 const env = Actor.getEnv();
454 let publicUrl = null;
455 if (env.defaultKeyValueStoreId) {
456 publicUrl = `https://api.apify.com/v2/key-value-stores/${env.defaultKeyValueStoreId}/records/${encodeURIComponent(fileName)}`;
457 }
458
459 const result = {
460 success: true,
461 diagramName: diagram.name,
462 diagramType,
463 outputFormat: dOutputFormat,
464 fileName,
465 url: publicUrl,
466 size: outputBuffer.length,
467 theme: dTheme,
468 backgroundColor: dBackgroundColor,
469 scale: dScale,
470 maxWidth: dMaxWidth,
471 maxHeight: dMaxHeight,
472 generatedWithAi: usedAiMode,
473 aiDiagramType: usedAiMode ? aiDiagramType : null,
474 mermaidCode: diagram.code,
475 };
476
477 results.push(result);
478 await dataset.pushData(result);
479
480 log.info(`Successfully generated: ${fileName} (${formatBytes(outputBuffer.length)})`);
481
482
483 await Actor.charge({ eventName: 'diagram-generated', count: 1 });
484
485
486 await unlink(inputFile).catch(() => {});
487 await unlink(outputFile).catch(() => {});
488 await unlink(configPath).catch(() => {});
489 await unlink(cssPath).catch(() => {});
490
491 } catch (error) {
492 const errorResult = {
493 success: false,
494 diagramName: diagram.name,
495 error: error.message,
496 };
497 results.push(errorResult);
498 await dataset.pushData(errorResult);
499 log.error(`Failed to process diagram ${diagram.name}: ${error.message}`);
500 }
501 }
502
503
504 const successful = results.filter(r => r.success).length;
505 const failed = results.filter(r => !r.success).length;
506
507 log.info(`Completed! ${successful} successful, ${failed} failed out of ${results.length} total.`);
508
509
510 await kvStore.setValue('OUTPUT', {
511 success: failed === 0,
512 totalDiagrams: results.length,
513 successful,
514 failed,
515 results,
516 });
517
518
519 await rm(tempDir, { recursive: true, force: true }).catch(() => {});
520
521} catch (error) {
522 log.error(`Actor failed: ${error.message}`);
523 throw error;
524} finally {
525 await Actor.exit();
526}
527
528
529
530
531function detectDiagramType(code) {
532 const firstLine = code.trim().split('\n')[0].toLowerCase();
533
534 if (firstLine.startsWith('graph') || firstLine.startsWith('flowchart')) {
535 return 'flowchart';
536 } else if (firstLine.startsWith('sequencediagram')) {
537 return 'sequence';
538 } else if (firstLine.startsWith('classdiagram')) {
539 return 'class';
540 } else if (firstLine.startsWith('statediagram')) {
541 return 'state';
542 } else if (firstLine.startsWith('erdiagram')) {
543 return 'er';
544 } else if (firstLine.startsWith('gantt')) {
545 return 'gantt';
546 } else if (firstLine.startsWith('pie')) {
547 return 'pie';
548 } else if (firstLine.startsWith('journey')) {
549 return 'journey';
550 } else if (firstLine.startsWith('gitgraph')) {
551 return 'gitgraph';
552 } else if (firstLine.startsWith('mindmap')) {
553 return 'mindmap';
554 } else if (firstLine.startsWith('timeline')) {
555 return 'timeline';
556 } else if (firstLine.startsWith('quadrantchart')) {
557 return 'quadrant';
558 } else if (firstLine.startsWith('requirementdiagram')) {
559 return 'requirement';
560 } else if (firstLine.startsWith('c4context') || firstLine.startsWith('c4container') || firstLine.startsWith('c4component')) {
561 return 'c4';
562 } else if (firstLine.startsWith('sankey')) {
563 return 'sankey';
564 } else if (firstLine.startsWith('xychart')) {
565 return 'xychart';
566 } else if (firstLine.startsWith('block')) {
567 return 'block';
568 }
569
570 return 'unknown';
571}
572
573
574
575
576function getContentType(format) {
577 switch (format) {
578 case 'png': return 'image/png';
579 case 'svg': return 'image/svg+xml';
580 case 'pdf': return 'application/pdf';
581 default: return 'application/octet-stream';
582 }
583}
584
585
586
587
588function formatBytes(bytes) {
589 if (bytes === 0) return '0 Bytes';
590 const k = 1024;
591 const sizes = ['Bytes', 'KB', 'MB', 'GB'];
592 const i = Math.floor(Math.log(bytes) / Math.log(k));
593 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
594}
595
596
597
598
599function sanitizeFileName(name) {
600 return name
601 .replace(/[<>:"/\\|?*]/g, '-')
602 .replace(/\s+/g, '-')
603 .replace(/-+/g, '-')
604 .replace(/^-|-$/g, '')
605 .substring(0, 100)
606 || 'diagram';
607}
608
609
610
611
612function isColorDark(color) {
613
614 const darkColors = ['black', 'navy', 'darkblue', 'darkgreen', 'darkred', 'purple', 'maroon', 'indigo', 'darkslategray', 'darkslategrey', 'dimgray', 'dimgrey', 'brown', 'darkviolet', 'darkorchid', 'darkmagenta', 'darkgoldenrod', 'midnightblue', 'darkcyan', 'darkolivegreen', 'saddlebrown', 'sienna', 'teal', 'olive'];
615 const lightColors = ['white', 'transparent', 'ivory', 'lightyellow', 'lightcyan', 'lightgray', 'lightgrey', 'whitesmoke', 'snow', 'ghostwhite', 'floralwhite', 'aliceblue', 'azure', 'mintcream', 'honeydew', 'seashell', 'oldlace', 'linen', 'lavenderblush', 'lavender', 'beige', 'cornsilk', 'lemonchiffon', 'papayawhip', 'blanchedalmond', 'bisque', 'moccasin', 'navajowhite', 'peachpuff', 'mistyrose', 'pink', 'lightpink', 'lightsalmon', 'lightcoral', 'khaki', 'palegoldenrod', 'yellow', 'gold', 'orange', 'wheat', 'antiquewhite'];
616
617 const lowerColor = color.toLowerCase().trim();
618
619 if (darkColors.includes(lowerColor)) return true;
620 if (lightColors.includes(lowerColor)) return false;
621
622
623 let hex = lowerColor;
624 if (hex.startsWith('#')) {
625 hex = hex.slice(1);
626
627 if (hex.length === 3) {
628 hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
629 }
630 if (hex.length === 6) {
631 const r = parseInt(hex.slice(0, 2), 16);
632 const g = parseInt(hex.slice(2, 4), 16);
633 const b = parseInt(hex.slice(4, 6), 16);
634
635 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
636 return luminance < 0.5;
637 }
638 }
639
640
641 const rgbMatch = lowerColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
642 if (rgbMatch) {
643 const r = parseInt(rgbMatch[1], 10);
644 const g = parseInt(rgbMatch[2], 10);
645 const b = parseInt(rgbMatch[3], 10);
646 const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
647 return luminance < 0.5;
648 }
649
650
651 return false;
652}