mcp-image-title-server 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.
Files changed (3) hide show
  1. package/README.md +532 -0
  2. package/index.js +2105 -0
  3. package/package.json +48 -0
package/index.js ADDED
@@ -0,0 +1,2105 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ } from '@modelcontextprotocol/sdk/types.js';
9
+ import { Canvas, loadImage, FontLibrary } from 'skia-canvas';
10
+ import sharp from 'sharp';
11
+ import fs from 'fs/promises';
12
+ import { constants as fsConstants } from 'fs';
13
+ import path from 'path';
14
+ import { homedir } from 'os';
15
+
16
+ // Parse command line arguments
17
+ function parseCommandLineArgs() {
18
+ const args = process.argv.slice(2);
19
+ const parsedArgs = {};
20
+
21
+ for (let i = 0; i < args.length; i++) {
22
+ const arg = args[i];
23
+
24
+ // Handle --config=<path> format
25
+ if (arg.startsWith('--config=')) {
26
+ parsedArgs.config = arg.substring('--config='.length);
27
+ }
28
+ // Handle --config <path> format
29
+ else if (arg === '--config' && i + 1 < args.length) {
30
+ parsedArgs.config = args[i + 1];
31
+ i++; // Skip next argument
32
+ }
33
+ }
34
+
35
+ return parsedArgs;
36
+ }
37
+
38
+ // Load configuration from file
39
+ let globalConfig = {};
40
+ async function loadConfig() {
41
+ const cliArgs = parseCommandLineArgs();
42
+ const configPaths = [];
43
+
44
+ // Priority 1: Command line argument
45
+ if (cliArgs.config) {
46
+ configPaths.push(cliArgs.config);
47
+ }
48
+
49
+ // Priority 2: Environment variable
50
+ if (process.env.MCP_IMAGE_TITLE_CONFIG) {
51
+ configPaths.push(process.env.MCP_IMAGE_TITLE_CONFIG);
52
+ }
53
+
54
+ // Priority 3 & 4: Default locations
55
+ configPaths.push(
56
+ path.join(process.cwd(), '.mcp-image-title.json'),
57
+ path.join(homedir(), '.mcp-image-title.json')
58
+ );
59
+
60
+ for (const configPath of configPaths) {
61
+ try {
62
+ const configData = await fs.readFile(configPath, 'utf-8');
63
+ globalConfig = JSON.parse(configData);
64
+ console.error(`Loaded configuration from: ${configPath}`);
65
+ return;
66
+ } catch (error) {
67
+ // Config file doesn't exist or is invalid, continue
68
+ }
69
+ }
70
+ }
71
+
72
+ // Load config at startup
73
+ await loadConfig();
74
+
75
+ // Path resolution helpers
76
+ function resolveWithDefaultDirectory(inputPath, defaultDirectory) {
77
+ if (!inputPath) return inputPath;
78
+
79
+ // If path is absolute or starts with ./ or ../, use it as-is
80
+ if (path.isAbsolute(inputPath) || inputPath.startsWith('./') || inputPath.startsWith('../')) {
81
+ return inputPath;
82
+ }
83
+
84
+ // If defaultDirectory is set, prepend it
85
+ if (defaultDirectory) {
86
+ return path.join(defaultDirectory, inputPath);
87
+ }
88
+
89
+ return inputPath;
90
+ }
91
+
92
+ function resolveBackgroundPath(backgroundPath) {
93
+ const defaults = globalConfig.defaults || {};
94
+ return resolveWithDefaultDirectory(backgroundPath, defaults.backgroundsDirectory);
95
+ }
96
+
97
+ function resolveOutputPath(outputPath) {
98
+ const defaults = globalConfig.defaults || {};
99
+ return resolveWithDefaultDirectory(outputPath, defaults.outputsDirectory);
100
+ }
101
+
102
+ function resolveFontPath(fontPath) {
103
+ const defaults = globalConfig.defaults || {};
104
+ return resolveWithDefaultDirectory(fontPath, defaults.fontsDirectory);
105
+ }
106
+
107
+ // Font loading functionality
108
+ const loadedFonts = new Map();
109
+
110
+ async function loadCustomFont(fontPath, fontFamily) {
111
+ // Resolve font path with default directory
112
+ const resolvedPath = resolveFontPath(fontPath);
113
+
114
+ // Check cache
115
+ const cacheKey = `${fontFamily}:${resolvedPath}`;
116
+ if (loadedFonts.has(cacheKey)) {
117
+ return true;
118
+ }
119
+
120
+ try {
121
+ // Check file exists
122
+ await fs.access(resolvedPath);
123
+
124
+ // Check file extension
125
+ const ext = path.extname(resolvedPath).toLowerCase();
126
+ if (!['.ttf', '.otf'].includes(ext)) {
127
+ throw new Error(`Unsupported font format: ${ext}. Supported formats: .ttf, .otf`);
128
+ }
129
+
130
+ // Register font with FontLibrary
131
+ FontLibrary.use(fontFamily, [resolvedPath]);
132
+
133
+ // Add to cache
134
+ loadedFonts.set(cacheKey, true);
135
+ console.error(`Loaded custom font: ${fontFamily} from ${resolvedPath}`);
136
+
137
+ return true;
138
+ } catch (error) {
139
+ throw new Error(`Failed to load custom font: ${error.message}`);
140
+ }
141
+ }
142
+
143
+ async function loadCustomFonts(fontPaths, fontFamily) {
144
+ if (!Array.isArray(fontPaths)) {
145
+ fontPaths = [fontPaths];
146
+ }
147
+
148
+ // Resolve all font paths with default directory
149
+ const resolvedPaths = fontPaths.map(fp => resolveFontPath(fp));
150
+
151
+ // Check cache
152
+ const cacheKey = `${fontFamily}:${resolvedPaths.join('|')}`;
153
+ if (loadedFonts.has(cacheKey)) {
154
+ return true;
155
+ }
156
+
157
+ try {
158
+ // Verify all paths exist
159
+ for (const resolvedPath of resolvedPaths) {
160
+ await fs.access(resolvedPath);
161
+
162
+ const ext = path.extname(resolvedPath).toLowerCase();
163
+ if (!['.ttf', '.otf'].includes(ext)) {
164
+ throw new Error(`Unsupported font format: ${ext}. Supported formats: .ttf, .otf`);
165
+ }
166
+ }
167
+
168
+ // Register font family with FontLibrary
169
+ FontLibrary.use(fontFamily, resolvedPaths);
170
+
171
+ // Add to cache
172
+ loadedFonts.set(cacheKey, true);
173
+ console.error(`Loaded custom font family: ${fontFamily} (${resolvedPaths.length} variant${resolvedPaths.length > 1 ? 's' : ''})`);
174
+
175
+ return true;
176
+ } catch (error) {
177
+ throw new Error(`Failed to load custom fonts: ${error.message}`);
178
+ }
179
+ }
180
+
181
+ // Create MCP server
182
+ const server = new Server(
183
+ {
184
+ name: 'image-title-server',
185
+ version: '1.0.0',
186
+ },
187
+ {
188
+ capabilities: {
189
+ tools: {},
190
+ },
191
+ }
192
+ );
193
+
194
+ // Helper function to calculate brightness of an image region
195
+ async function calculateRegionBrightness(imagePath, x, y, width, height, gradientSampling = false) {
196
+ try {
197
+ const image = sharp(imagePath);
198
+ const metadata = await image.metadata();
199
+
200
+ // Ensure coordinates are within image bounds
201
+ x = Math.max(0, Math.min(x, metadata.width - 1));
202
+ y = Math.max(0, Math.min(y, metadata.height - 1));
203
+ width = Math.min(width, metadata.width - x);
204
+ height = Math.min(height, metadata.height - y);
205
+
206
+ if (gradientSampling) {
207
+ // For gradient backgrounds, sample multiple points across the region
208
+ const samplePoints = [];
209
+ const gridSize = 5; // 5x5 grid
210
+
211
+ for (let row = 0; row < gridSize; row++) {
212
+ for (let col = 0; col < gridSize; col++) {
213
+ const sampleX = Math.floor(x + (width / (gridSize - 1)) * col);
214
+ const sampleY = Math.floor(y + (height / (gridSize - 1)) * row);
215
+
216
+ // Extract a small region around each sample point
217
+ const sampleWidth = Math.min(10, width);
218
+ const sampleHeight = Math.min(10, height);
219
+
220
+ try {
221
+ const { data } = await sharp(imagePath)
222
+ .extract({
223
+ left: Math.min(sampleX, metadata.width - sampleWidth),
224
+ top: Math.min(sampleY, metadata.height - sampleHeight),
225
+ width: sampleWidth,
226
+ height: sampleHeight
227
+ })
228
+ .raw()
229
+ .toBuffer({ resolveWithObject: true });
230
+
231
+ let pointBrightness = 0;
232
+ const channels = metadata.channels || 3;
233
+
234
+ for (let i = 0; i < data.length; i += channels) {
235
+ const r = data[i];
236
+ const g = data[i + 1];
237
+ const b = data[i + 2];
238
+ const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
239
+ pointBrightness += brightness;
240
+ }
241
+
242
+ samplePoints.push(pointBrightness / (data.length / channels));
243
+ } catch (err) {
244
+ // Skip this sample point if extraction fails
245
+ continue;
246
+ }
247
+ }
248
+ }
249
+
250
+ // Return average brightness across all sample points
251
+ if (samplePoints.length > 0) {
252
+ return samplePoints.reduce((a, b) => a + b, 0) / samplePoints.length;
253
+ }
254
+ }
255
+
256
+ // Standard single-region sampling
257
+ // Extract the region and convert to raw pixel data
258
+ const { data } = await image
259
+ .extract({ left: x, top: y, width, height })
260
+ .raw()
261
+ .toBuffer({ resolveWithObject: true });
262
+
263
+ let totalBrightness = 0;
264
+ const channels = metadata.channels || 3;
265
+
266
+ for (let i = 0; i < data.length; i += channels) {
267
+ const r = data[i];
268
+ const g = data[i + 1];
269
+ const b = data[i + 2];
270
+
271
+ // Calculate perceived brightness using luminance formula
272
+ const brightness = 0.299 * r + 0.587 * g + 0.114 * b;
273
+ totalBrightness += brightness;
274
+ }
275
+
276
+ return totalBrightness / (data.length / channels);
277
+ } catch (error) {
278
+ console.error('Error calculating brightness:', error);
279
+ return 128; // Default to medium brightness
280
+ }
281
+ }
282
+
283
+ // Function to get optimal text color based on background brightness
284
+ function getOptimalTextColor(brightness, enhanceContrast = true) {
285
+ if (enhanceContrast) {
286
+ // Enhanced contrast mode
287
+ if (brightness > 128) {
288
+ return { color: '#000000', shadow: '#FFFFFF', shadowBlur: 3 };
289
+ } else {
290
+ return { color: '#FFFFFF', shadow: '#000000', shadowBlur: 3 };
291
+ }
292
+ } else {
293
+ // Simple mode
294
+ return brightness > 128
295
+ ? { color: '#000000', shadow: null, shadowBlur: 0 }
296
+ : { color: '#FFFFFF', shadow: null, shadowBlur: 0 };
297
+ }
298
+ }
299
+
300
+ function buildFontDescriptor(fontSize, fontFamily, fontWeight = 'normal', fontStyle = 'normal') {
301
+ return [
302
+ fontStyle !== 'normal' ? fontStyle : '',
303
+ fontWeight !== 'normal' ? fontWeight : '',
304
+ `${fontSize}px`,
305
+ `"${fontFamily}"`
306
+ ].filter(Boolean).join(' ');
307
+ }
308
+
309
+ // Function to calculate optimal font size that fits within image bounds (using binary search)
310
+ function calculateOptimalFontSize(
311
+ ctx,
312
+ text,
313
+ maxWidth,
314
+ maxHeight,
315
+ minFontSize = 12,
316
+ maxFontSize = 200,
317
+ fontOptions = {}
318
+ ) {
319
+ const {
320
+ fontFamily = 'Arial',
321
+ fontWeight = 'normal',
322
+ fontStyle = 'normal',
323
+ } = fontOptions;
324
+
325
+ let low = minFontSize;
326
+ let high = maxFontSize;
327
+ let bestFit = minFontSize;
328
+
329
+ while (low <= high) {
330
+ const mid = Math.floor((low + high) / 2);
331
+ ctx.font = buildFontDescriptor(mid, fontFamily, fontWeight, fontStyle);
332
+ const metrics = ctx.measureText(text);
333
+ const textWidth = metrics.width;
334
+ const textHeight = mid;
335
+
336
+ if (textWidth <= maxWidth && textHeight <= maxHeight) {
337
+ bestFit = mid;
338
+ low = mid + 1; // Try larger size
339
+ } else {
340
+ high = mid - 1; // Try smaller size
341
+ }
342
+ }
343
+
344
+ return bestFit;
345
+ }
346
+
347
+ // Function to wrap text into multiple lines
348
+ function wrapText(ctx, text, maxWidth) {
349
+ // First, split by explicit line breaks (\n)
350
+ const explicitLines = text.split('\n');
351
+ const lines = [];
352
+
353
+ // Process each explicit line
354
+ for (const line of explicitLines) {
355
+ const words = line.split(' ');
356
+ let currentLine = '';
357
+
358
+ for (let i = 0; i < words.length; i++) {
359
+ const word = words[i];
360
+ const testLine = currentLine + (currentLine ? ' ' : '') + word;
361
+ const metrics = ctx.measureText(testLine);
362
+
363
+ if (metrics.width > maxWidth && currentLine) {
364
+ lines.push(currentLine);
365
+ currentLine = word;
366
+ } else {
367
+ currentLine = testLine;
368
+ }
369
+ }
370
+
371
+ if (currentLine) {
372
+ lines.push(currentLine);
373
+ }
374
+ }
375
+
376
+ return lines;
377
+ }
378
+
379
+ // Function to wrap text for vertical writing (by character count)
380
+ function wrapVerticalText(text, maxCharsPerLine) {
381
+ const columns = [];
382
+ const chars = Array.from(text); // Handle multi-byte characters correctly
383
+
384
+ for (let i = 0; i < chars.length; i += maxCharsPerLine) {
385
+ columns.push(chars.slice(i, i + maxCharsPerLine).join(''));
386
+ }
387
+
388
+ return columns;
389
+ }
390
+
391
+ // Function to measure vertical text dimensions
392
+ function measureVerticalText(ctx, text, fontSize, characterSpacing = 0) {
393
+ const chars = Array.from(text);
394
+ const height = chars.length * fontSize + (chars.length - 1) * characterSpacing;
395
+ const width = fontSize; // Approximate width as font size
396
+ return { width, height };
397
+ }
398
+
399
+ // Function to draw vertical text
400
+ function drawVerticalText(ctx, text, x, y, fontSize, characterSpacing = 0, textStyle, textEffect = null, effectOptions = {}) {
401
+ const chars = Array.from(text);
402
+ let currentY = y;
403
+
404
+ const {
405
+ outlineWidth = 3,
406
+ outlineColor = null,
407
+ glowRadius = 10,
408
+ glowIntensity = 0.8
409
+ } = effectOptions;
410
+
411
+ // Apply text effects
412
+ if (textEffect === 'outline') {
413
+ const finalOutlineColor = outlineColor || (textStyle.color === '#FFFFFF' ? '#000000' : '#FFFFFF');
414
+ ctx.strokeStyle = finalOutlineColor;
415
+ ctx.lineWidth = outlineWidth;
416
+ ctx.lineJoin = 'round';
417
+ ctx.miterLimit = 2;
418
+
419
+ // Draw outline for each character
420
+ for (let i = 0; i < chars.length; i++) {
421
+ ctx.strokeText(chars[i], x, currentY);
422
+ currentY += fontSize + characterSpacing;
423
+ }
424
+
425
+ // Reset and draw filled text
426
+ currentY = y;
427
+ ctx.fillStyle = textStyle.color;
428
+ for (let i = 0; i < chars.length; i++) {
429
+ ctx.fillText(chars[i], x, currentY);
430
+ currentY += fontSize + characterSpacing;
431
+ }
432
+ } else if (textEffect === 'glow') {
433
+ const glowColor = outlineColor || textStyle.color;
434
+ ctx.shadowColor = glowColor;
435
+ ctx.shadowBlur = glowRadius;
436
+ ctx.globalAlpha = glowIntensity;
437
+
438
+ // Draw glow multiple times
439
+ for (let j = 0; j < 3; j++) {
440
+ currentY = y;
441
+ ctx.fillStyle = glowColor;
442
+ for (let i = 0; i < chars.length; i++) {
443
+ ctx.fillText(chars[i], x, currentY);
444
+ currentY += fontSize + characterSpacing;
445
+ }
446
+ }
447
+
448
+ // Draw main text
449
+ ctx.shadowBlur = 0;
450
+ ctx.globalAlpha = 1;
451
+ ctx.fillStyle = textStyle.color;
452
+ currentY = y;
453
+ for (let i = 0; i < chars.length; i++) {
454
+ ctx.fillText(chars[i], x, currentY);
455
+ currentY += fontSize + characterSpacing;
456
+ }
457
+ } else if (textEffect === 'emboss') {
458
+ const embossColor = outlineColor || (textStyle.color === '#FFFFFF' ? '#666666' : '#FFFFFF');
459
+
460
+ // Bottom-right shadow
461
+ ctx.fillStyle = '#00000033';
462
+ currentY = y;
463
+ for (let i = 0; i < chars.length; i++) {
464
+ ctx.fillText(chars[i], x + 2, currentY + 2);
465
+ currentY += fontSize + characterSpacing;
466
+ }
467
+
468
+ // Top-left highlight
469
+ ctx.fillStyle = embossColor;
470
+ currentY = y;
471
+ for (let i = 0; i < chars.length; i++) {
472
+ ctx.fillText(chars[i], x - 1, currentY - 1);
473
+ currentY += fontSize + characterSpacing;
474
+ }
475
+
476
+ // Main text
477
+ ctx.fillStyle = textStyle.color;
478
+ currentY = y;
479
+ for (let i = 0; i < chars.length; i++) {
480
+ ctx.fillText(chars[i], x, currentY);
481
+ currentY += fontSize + characterSpacing;
482
+ }
483
+ } else {
484
+ // Default rendering with optional shadow
485
+ if (textStyle.shadow && textStyle.shadowBlur > 0) {
486
+ ctx.shadowColor = textStyle.shadow;
487
+ ctx.shadowBlur = textStyle.shadowBlur;
488
+ ctx.shadowOffsetX = 1;
489
+ ctx.shadowOffsetY = 1;
490
+ }
491
+
492
+ ctx.fillStyle = textStyle.color;
493
+ for (let i = 0; i < chars.length; i++) {
494
+ ctx.fillText(chars[i], x, currentY);
495
+ currentY += fontSize + characterSpacing;
496
+ }
497
+ }
498
+ }
499
+
500
+
501
+ // Function to calculate optimal font size for multi-line text (using binary search)
502
+ function calculateOptimalFontSizeForLines(
503
+ ctx,
504
+ lines,
505
+ maxWidth,
506
+ maxHeight,
507
+ minFontSize = 12,
508
+ maxFontSize = 200,
509
+ fontOptions = {}
510
+ ) {
511
+ const {
512
+ fontFamily = 'Arial',
513
+ fontWeight = 'normal',
514
+ fontStyle = 'normal',
515
+ lineHeight = 1.2,
516
+ } = fontOptions;
517
+
518
+ let low = minFontSize;
519
+ let high = maxFontSize;
520
+ let bestFit = minFontSize;
521
+
522
+ while (low <= high) {
523
+ const mid = Math.floor((low + high) / 2);
524
+ ctx.font = buildFontDescriptor(mid, fontFamily, fontWeight, fontStyle);
525
+
526
+ const lineHeightPixels = mid * lineHeight;
527
+ const totalHeight = lines.length * lineHeightPixels;
528
+
529
+ if (totalHeight <= maxHeight) {
530
+ let allLinesFit = true;
531
+ for (const line of lines) {
532
+ const metrics = ctx.measureText(line);
533
+ if (metrics.width > maxWidth) {
534
+ allLinesFit = false;
535
+ break;
536
+ }
537
+ }
538
+
539
+ if (allLinesFit) {
540
+ bestFit = mid;
541
+ low = mid + 1; // Try larger size
542
+ } else {
543
+ high = mid - 1; // Try smaller size
544
+ }
545
+ } else {
546
+ high = mid - 1; // Try smaller size
547
+ }
548
+ }
549
+
550
+ return bestFit;
551
+ }
552
+
553
+ // Validation helper functions
554
+ function validateParameters(backgroundPath, title, options) {
555
+ const errors = [];
556
+
557
+ // Validate required parameters
558
+ if (!backgroundPath || typeof backgroundPath !== 'string') {
559
+ errors.push('backgroundPath is required and must be a string');
560
+ }
561
+ if (!title || typeof title !== 'string') {
562
+ errors.push('title is required and must be a string');
563
+ }
564
+
565
+ // Validate fontSize
566
+ if (options.fontSize !== undefined) {
567
+ const fontSize = options.fontSize;
568
+ if (typeof fontSize !== 'number' || fontSize < 6 || fontSize > 500) {
569
+ errors.push('fontSize must be a number between 6 and 500');
570
+ }
571
+ }
572
+
573
+ // Validate position
574
+ if (options.position !== undefined) {
575
+ const validPositions = ['top', 'center', 'bottom'];
576
+ if (!validPositions.includes(options.position)) {
577
+ errors.push(`position must be one of: ${validPositions.join(', ')}`);
578
+ }
579
+ }
580
+
581
+ // Validate borderOpacity
582
+ if (options.borderOpacity !== undefined) {
583
+ const opacity = options.borderOpacity;
584
+ if (typeof opacity !== 'number' || opacity < 0 || opacity > 1) {
585
+ errors.push('borderOpacity must be a number between 0 and 1');
586
+ }
587
+ }
588
+
589
+ // Validate border type
590
+ if (options.border !== undefined && options.border !== null) {
591
+ const validBorders = ['rectangle', 'rounded', 'filled', 'outline'];
592
+ if (!validBorders.includes(options.border)) {
593
+ errors.push(`border must be one of: ${validBorders.join(', ')}`);
594
+ }
595
+ }
596
+
597
+ // Validate maxWidthPercent and maxHeightPercent
598
+ if (options.maxWidthPercent !== undefined) {
599
+ const val = options.maxWidthPercent;
600
+ if (typeof val !== 'number' || val < 0.1 || val > 1.0) {
601
+ errors.push('maxWidthPercent must be a number between 0.1 and 1.0');
602
+ }
603
+ }
604
+ if (options.maxHeightPercent !== undefined) {
605
+ const val = options.maxHeightPercent;
606
+ if (typeof val !== 'number' || val < 0.1 || val > 1.0) {
607
+ errors.push('maxHeightPercent must be a number between 0.1 and 1.0');
608
+ }
609
+ }
610
+
611
+ // Validate minFontSize and maxFontSize
612
+ if (options.minFontSize !== undefined) {
613
+ const val = options.minFontSize;
614
+ if (typeof val !== 'number' || val < 8) {
615
+ errors.push('minFontSize must be a number >= 8');
616
+ }
617
+ }
618
+ if (options.maxFontSize !== undefined) {
619
+ const val = options.maxFontSize;
620
+ if (typeof val !== 'number' || val < 12) {
621
+ errors.push('maxFontSize must be a number >= 12');
622
+ }
623
+ }
624
+
625
+ // Validate lineHeight
626
+ if (options.lineHeight !== undefined) {
627
+ const val = options.lineHeight;
628
+ if (typeof val !== 'number' || val < 0.8 || val > 3.0) {
629
+ errors.push('lineHeight must be a number between 0.8 and 3.0');
630
+ }
631
+ }
632
+
633
+ // Validate custom font parameters
634
+ if (options.customFontPath !== undefined && options.customFontPath !== null) {
635
+ if (typeof options.customFontPath !== 'string') {
636
+ errors.push('customFontPath must be a string');
637
+ }
638
+ }
639
+
640
+ if (options.customFontPaths !== undefined && options.customFontPaths !== null) {
641
+ if (!Array.isArray(options.customFontPaths)) {
642
+ errors.push('customFontPaths must be an array');
643
+ } else if (options.customFontPaths.length === 0) {
644
+ errors.push('customFontPaths must not be empty');
645
+ } else if (!options.customFontPaths.every(p => typeof p === 'string')) {
646
+ errors.push('All items in customFontPaths must be strings');
647
+ }
648
+ }
649
+
650
+ if (options.fontWeight !== undefined) {
651
+ const validWeights = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
652
+ if (!validWeights.includes(String(options.fontWeight))) {
653
+ errors.push(`fontWeight must be one of: ${validWeights.join(', ')}`);
654
+ }
655
+ }
656
+
657
+ if (options.fontStyle !== undefined) {
658
+ const validStyles = ['normal', 'italic', 'oblique'];
659
+ if (!validStyles.includes(options.fontStyle)) {
660
+ errors.push(`fontStyle must be one of: ${validStyles.join(', ')}`);
661
+ }
662
+ }
663
+
664
+ if (errors.length > 0) {
665
+ throw new Error(`Validation failed:\n${errors.join('\n')}`);
666
+ }
667
+ }
668
+
669
+ // Function to composite title on image
670
+ async function compositeTitle(backgroundPath, title, options = {}) {
671
+ // Validate parameters first
672
+ validateParameters(backgroundPath, title, options);
673
+
674
+ // Resolve paths with default directories
675
+ const resolvedBackgroundPath = resolveBackgroundPath(backgroundPath);
676
+
677
+ // Merge global config defaults with options
678
+ const defaults = globalConfig.defaults || {};
679
+ const {
680
+ outputPath = 'output.png',
681
+ fontSize = defaults.fontSize || 48,
682
+ fontFamily = defaults.fontFamily || 'Arial',
683
+ fontWeight = defaults.fontWeight || 'normal',
684
+ fontStyle = defaults.fontStyle || 'normal',
685
+ position = defaults.position || 'center',
686
+ offsetX = defaults.offsetX || 0,
687
+ offsetY = defaults.offsetY || 0,
688
+ enhanceContrast = defaults.enhanceContrast !== undefined ? defaults.enhanceContrast : true,
689
+ customColor = defaults.customColor || null,
690
+ customFontPath = defaults.customFontPath || null,
691
+ customFontPaths = defaults.customFontPaths || null,
692
+ border = defaults.border || null,
693
+ borderWidth = defaults.borderWidth || 2,
694
+ borderColor = defaults.borderColor || null,
695
+ borderRadius = defaults.borderRadius || 8,
696
+ borderPadding = defaults.borderPadding || 16,
697
+ borderOpacity = defaults.borderOpacity !== undefined ? defaults.borderOpacity : 0.8,
698
+ autoSize = defaults.autoSize !== undefined ? defaults.autoSize : false,
699
+ autoWrap = defaults.autoWrap !== undefined ? defaults.autoWrap : false,
700
+ maxWidthPercent = defaults.maxWidthPercent || 0.8,
701
+ maxHeightPercent = defaults.maxHeightPercent || 0.8,
702
+ minFontSize = defaults.minFontSize || 12,
703
+ maxFontSize = defaults.maxFontSize || 200,
704
+ lineHeight = defaults.lineHeight || 1.2,
705
+ textEffect = defaults.textEffect || null,
706
+ outlineWidth = defaults.outlineWidth || 3,
707
+ outlineColor = defaults.outlineColor || null,
708
+ glowRadius = defaults.glowRadius || 10,
709
+ glowIntensity = defaults.glowIntensity || 0.8,
710
+ gradientSampling = defaults.gradientSampling !== undefined ? defaults.gradientSampling : false,
711
+ writingMode = defaults.writingMode || 'horizontal',
712
+ characterSpacing = defaults.characterSpacing || 0,
713
+ verticalAlign = defaults.verticalAlign || 'center'
714
+ } = options;
715
+
716
+ // Resolve output path with default directory
717
+ const resolvedOutputPath = resolveOutputPath(outputPath);
718
+
719
+ try {
720
+ // Check if background file exists
721
+ try {
722
+ await fs.access(resolvedBackgroundPath);
723
+ } catch (error) {
724
+ throw new Error(`Background image not found: ${resolvedBackgroundPath}`);
725
+ }
726
+
727
+ // Validate image format
728
+ let metadata;
729
+ try {
730
+ const image = sharp(resolvedBackgroundPath);
731
+ metadata = await image.metadata();
732
+ } catch (error) {
733
+ throw new Error(`Failed to read image: ${error.message}`);
734
+ }
735
+
736
+ const supportedFormats = ['jpeg', 'png', 'webp', 'tiff', 'gif'];
737
+ if (!supportedFormats.includes(metadata.format)) {
738
+ throw new Error(`Unsupported image format: ${metadata.format}. Supported formats: ${supportedFormats.join(', ')}`);
739
+ }
740
+
741
+ // Check if output directory exists and is writable
742
+ const outputDir = path.dirname(path.resolve(resolvedOutputPath));
743
+ try {
744
+ await fs.access(outputDir, fsConstants.W_OK);
745
+ } catch (error) {
746
+ throw new Error(`Output directory is not writable: ${outputDir}`);
747
+ }
748
+
749
+ // Load custom fonts if specified
750
+ if (customFontPaths) {
751
+ // Multiple font variants
752
+ await loadCustomFonts(customFontPaths, fontFamily);
753
+ } else if (customFontPath) {
754
+ // Single font file
755
+ await loadCustomFont(customFontPath, fontFamily);
756
+ } else if (globalConfig.fonts && globalConfig.fonts[fontFamily]) {
757
+ // Auto-load font from config mapping
758
+ const fontPaths = globalConfig.fonts[fontFamily];
759
+ if (Array.isArray(fontPaths)) {
760
+ await loadCustomFonts(fontPaths, fontFamily);
761
+ } else {
762
+ await loadCustomFont(fontPaths, fontFamily);
763
+ }
764
+ }
765
+
766
+ // Load background image
767
+ const background = await loadImage(resolvedBackgroundPath);
768
+ const canvas = new Canvas(background.width, background.height);
769
+ const ctx = canvas.getContext('2d');
770
+
771
+ // Draw background
772
+ ctx.drawImage(background, 0, 0);
773
+
774
+ // Calculate available space for text
775
+ const maxWidth = background.width * maxWidthPercent;
776
+ const maxHeight = background.height * maxHeightPercent;
777
+
778
+ // Process text based on options
779
+ let finalFontSize = fontSize;
780
+ let textLines = title.split('\n');
781
+ let totalTextHeight = fontSize;
782
+ let maxLineWidth = 0;
783
+
784
+ if (autoWrap || autoSize) {
785
+ // Set initial font for measurement
786
+ ctx.font = buildFontDescriptor(fontSize, fontFamily, fontWeight, fontStyle);
787
+
788
+ if (autoWrap) {
789
+ // Wrap text first
790
+ textLines = wrapText(ctx, title, maxWidth);
791
+
792
+ if (autoSize) {
793
+ // Calculate optimal font size for wrapped text
794
+ finalFontSize = calculateOptimalFontSizeForLines(
795
+ ctx,
796
+ textLines,
797
+ maxWidth,
798
+ maxHeight,
799
+ minFontSize,
800
+ autoSize ? maxFontSize : fontSize,
801
+ { fontFamily, fontWeight, fontStyle, lineHeight }
802
+ );
803
+ }
804
+ } else if (autoSize) {
805
+ // Just auto-size without wrapping
806
+ finalFontSize = calculateOptimalFontSize(
807
+ ctx,
808
+ title,
809
+ maxWidth,
810
+ maxHeight,
811
+ minFontSize,
812
+ maxFontSize,
813
+ { fontFamily, fontWeight, fontStyle }
814
+ );
815
+ }
816
+ }
817
+
818
+ // Set final font with weight and style support
819
+ const fontDescriptor = buildFontDescriptor(finalFontSize, fontFamily, fontWeight, fontStyle);
820
+
821
+ ctx.font = fontDescriptor;
822
+ ctx.textAlign = 'center';
823
+ ctx.textBaseline = 'middle';
824
+
825
+ // If we wrapped text, recalculate lines with final font size
826
+ if (autoWrap && textLines.length > 1) {
827
+ textLines = wrapText(ctx, title, maxWidth);
828
+ }
829
+
830
+ // Calculate text dimensions
831
+ totalTextHeight = textLines.length * finalFontSize * lineHeight;
832
+ for (const line of textLines) {
833
+ const lineMetrics = ctx.measureText(line);
834
+ maxLineWidth = Math.max(maxLineWidth, lineMetrics.width);
835
+ }
836
+
837
+ const textWidth = maxLineWidth;
838
+ const textHeight = totalTextHeight;
839
+
840
+ // Calculate position
841
+ let x, y;
842
+ switch (position) {
843
+ case 'top':
844
+ x = background.width / 2 + offsetX;
845
+ y = textHeight + 20 + offsetY;
846
+ break;
847
+ case 'bottom':
848
+ x = background.width / 2 + offsetX;
849
+ y = background.height - textHeight - 20 + offsetY;
850
+ break;
851
+ case 'center':
852
+ default:
853
+ x = background.width / 2 + offsetX;
854
+ y = background.height / 2 + offsetY;
855
+ break;
856
+ }
857
+
858
+ // Calculate brightness of text area if no custom color is specified
859
+ let textStyle;
860
+ if (customColor) {
861
+ textStyle = { color: customColor, shadow: null, shadowBlur: 0 };
862
+ } else {
863
+ const regionX = Math.max(0, x - textWidth / 2 - 10);
864
+ const regionY = Math.max(0, y - textHeight / 2 - 10);
865
+ const regionWidth = Math.min(textWidth + 20, background.width - regionX);
866
+ const regionHeight = Math.min(textHeight + 20, background.height - regionY);
867
+
868
+ const brightness = await calculateRegionBrightness(
869
+ resolvedBackgroundPath,
870
+ Math.floor(regionX),
871
+ Math.floor(regionY),
872
+ Math.floor(regionWidth),
873
+ Math.floor(regionHeight),
874
+ gradientSampling
875
+ );
876
+
877
+ textStyle = getOptimalTextColor(brightness, enhanceContrast);
878
+ }
879
+
880
+ // Draw border/frame if specified
881
+ if (border) {
882
+ const borderX = x - textWidth / 2 - borderPadding;
883
+ const borderY = y - textHeight / 2 - borderPadding;
884
+ const borderWidthTotal = textWidth + (borderPadding * 2);
885
+ const borderHeightTotal = textHeight + (borderPadding * 2);
886
+
887
+ // Set border style
888
+ const finalBorderColor = borderColor || (textStyle.color === '#FFFFFF' ? '#000000' : '#FFFFFF');
889
+
890
+ ctx.save();
891
+ ctx.globalAlpha = borderOpacity;
892
+
893
+ switch (border) {
894
+ case 'rectangle':
895
+ ctx.strokeStyle = finalBorderColor;
896
+ ctx.lineWidth = borderWidth;
897
+ ctx.strokeRect(borderX, borderY, borderWidthTotal, borderHeightTotal);
898
+ break;
899
+
900
+ case 'rounded':
901
+ ctx.strokeStyle = finalBorderColor;
902
+ ctx.lineWidth = borderWidth;
903
+ ctx.beginPath();
904
+ ctx.roundRect(borderX, borderY, borderWidthTotal, borderHeightTotal, borderRadius);
905
+ ctx.stroke();
906
+ break;
907
+
908
+ case 'filled':
909
+ ctx.fillStyle = finalBorderColor;
910
+ ctx.globalAlpha = borderOpacity * 0.7; // More transparent for filled
911
+ if (borderRadius > 0) {
912
+ ctx.beginPath();
913
+ ctx.roundRect(borderX, borderY, borderWidthTotal, borderHeightTotal, borderRadius);
914
+ ctx.fill();
915
+ } else {
916
+ ctx.fillRect(borderX, borderY, borderWidthTotal, borderHeightTotal);
917
+ }
918
+ break;
919
+
920
+ case 'outline':
921
+ // Double border effect
922
+ ctx.strokeStyle = finalBorderColor;
923
+ ctx.lineWidth = borderWidth + 2;
924
+ ctx.globalAlpha = borderOpacity * 0.5;
925
+ ctx.beginPath();
926
+ ctx.roundRect(borderX - 1, borderY - 1, borderWidthTotal + 2, borderHeightTotal + 2, borderRadius + 1);
927
+ ctx.stroke();
928
+
929
+ ctx.strokeStyle = textStyle.color;
930
+ ctx.lineWidth = borderWidth;
931
+ ctx.globalAlpha = borderOpacity;
932
+ ctx.beginPath();
933
+ ctx.roundRect(borderX, borderY, borderWidthTotal, borderHeightTotal, borderRadius);
934
+ ctx.stroke();
935
+ break;
936
+ }
937
+
938
+ ctx.restore();
939
+ }
940
+
941
+ // Draw text with effects
942
+ ctx.save();
943
+
944
+ // Apply text effect based on selected type
945
+ if (textEffect === 'outline') {
946
+ // Outline effect: stroke around text
947
+ const finalOutlineColor = outlineColor || (textStyle.color === '#FFFFFF' ? '#000000' : '#FFFFFF');
948
+ ctx.strokeStyle = finalOutlineColor;
949
+ ctx.lineWidth = outlineWidth;
950
+ ctx.lineJoin = 'round';
951
+ ctx.miterLimit = 2;
952
+
953
+ if (textLines.length === 1) {
954
+ ctx.strokeText(title, x, y);
955
+ ctx.fillStyle = textStyle.color;
956
+ ctx.fillText(title, x, y);
957
+ } else {
958
+ const lineSpacing = finalFontSize * lineHeight;
959
+ const startY = y - ((textLines.length - 1) * lineSpacing) / 2;
960
+
961
+ for (let i = 0; i < textLines.length; i++) {
962
+ const lineY = startY + (i * lineSpacing);
963
+ ctx.strokeText(textLines[i], x, lineY);
964
+ }
965
+
966
+ ctx.fillStyle = textStyle.color;
967
+ for (let i = 0; i < textLines.length; i++) {
968
+ const lineY = startY + (i * lineSpacing);
969
+ ctx.fillText(textLines[i], x, lineY);
970
+ }
971
+ }
972
+ } else if (textEffect === 'glow') {
973
+ // Glow effect: multiple shadows with blur
974
+ const glowColor = outlineColor || textStyle.color;
975
+ ctx.shadowColor = glowColor;
976
+ ctx.shadowBlur = glowRadius;
977
+ ctx.globalAlpha = glowIntensity;
978
+
979
+ // Draw glow multiple times for intensity
980
+ for (let i = 0; i < 3; i++) {
981
+ if (textLines.length === 1) {
982
+ ctx.fillStyle = glowColor;
983
+ ctx.fillText(title, x, y);
984
+ } else {
985
+ const lineSpacing = finalFontSize * lineHeight;
986
+ const startY = y - ((textLines.length - 1) * lineSpacing) / 2;
987
+
988
+ for (let j = 0; j < textLines.length; j++) {
989
+ const lineY = startY + (j * lineSpacing);
990
+ ctx.fillStyle = glowColor;
991
+ ctx.fillText(textLines[j], x, lineY);
992
+ }
993
+ }
994
+ }
995
+
996
+ // Draw main text on top
997
+ ctx.shadowBlur = 0;
998
+ ctx.globalAlpha = 1;
999
+ ctx.fillStyle = textStyle.color;
1000
+
1001
+ if (textLines.length === 1) {
1002
+ ctx.fillText(title, x, y);
1003
+ } else {
1004
+ const lineSpacing = finalFontSize * lineHeight;
1005
+ const startY = y - ((textLines.length - 1) * lineSpacing) / 2;
1006
+
1007
+ for (let i = 0; i < textLines.length; i++) {
1008
+ const lineY = startY + (i * lineSpacing);
1009
+ ctx.fillText(textLines[i], x, lineY);
1010
+ }
1011
+ }
1012
+ } else if (textEffect === 'emboss') {
1013
+ // Emboss effect: shadow offset to create 3D look
1014
+ const embossColor = outlineColor || (textStyle.color === '#FFFFFF' ? '#666666' : '#FFFFFF');
1015
+
1016
+ // Bottom-right shadow (darker)
1017
+ ctx.fillStyle = '#00000033';
1018
+ if (textLines.length === 1) {
1019
+ ctx.fillText(title, x + 2, y + 2);
1020
+ } else {
1021
+ const lineSpacing = finalFontSize * lineHeight;
1022
+ const startY = y - ((textLines.length - 1) * lineSpacing) / 2;
1023
+ for (let i = 0; i < textLines.length; i++) {
1024
+ const lineY = startY + (i * lineSpacing);
1025
+ ctx.fillText(textLines[i], x + 2, lineY + 2);
1026
+ }
1027
+ }
1028
+
1029
+ // Top-left highlight
1030
+ ctx.fillStyle = embossColor;
1031
+ if (textLines.length === 1) {
1032
+ ctx.fillText(title, x - 1, y - 1);
1033
+ } else {
1034
+ const lineSpacing = finalFontSize * lineHeight;
1035
+ const startY = y - ((textLines.length - 1) * lineSpacing) / 2;
1036
+ for (let i = 0; i < textLines.length; i++) {
1037
+ const lineY = startY + (i * lineSpacing);
1038
+ ctx.fillText(textLines[i], x - 1, lineY - 1);
1039
+ }
1040
+ }
1041
+
1042
+ // Main text
1043
+ ctx.fillStyle = textStyle.color;
1044
+ if (textLines.length === 1) {
1045
+ ctx.fillText(title, x, y);
1046
+ } else {
1047
+ const lineSpacing = finalFontSize * lineHeight;
1048
+ const startY = y - ((textLines.length - 1) * lineSpacing) / 2;
1049
+ for (let i = 0; i < textLines.length; i++) {
1050
+ const lineY = startY + (i * lineSpacing);
1051
+ ctx.fillText(textLines[i], x, lineY);
1052
+ }
1053
+ }
1054
+ } else {
1055
+ // Default: shadow if specified
1056
+ if (textStyle.shadow && textStyle.shadowBlur > 0) {
1057
+ ctx.shadowColor = textStyle.shadow;
1058
+ ctx.shadowBlur = textStyle.shadowBlur;
1059
+ ctx.shadowOffsetX = 1;
1060
+ ctx.shadowOffsetY = 1;
1061
+ }
1062
+
1063
+ ctx.fillStyle = textStyle.color;
1064
+
1065
+ // Draw multiple lines if text was wrapped
1066
+ if (textLines.length === 1) {
1067
+ ctx.fillText(title, x, y);
1068
+ } else {
1069
+ const lineSpacing = finalFontSize * lineHeight;
1070
+ const startY = y - ((textLines.length - 1) * lineSpacing) / 2;
1071
+
1072
+ for (let i = 0; i < textLines.length; i++) {
1073
+ const lineY = startY + (i * lineSpacing);
1074
+ ctx.fillText(textLines[i], x, lineY);
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ ctx.restore();
1080
+
1081
+ // Save the result
1082
+ const buffer = await canvas.toBuffer('image/png');
1083
+ await fs.writeFile(resolvedOutputPath, buffer);
1084
+
1085
+ return {
1086
+ success: true,
1087
+ outputPath: resolvedOutputPath,
1088
+ dimensions: { width: background.width, height: background.height },
1089
+ textPosition: { x, y },
1090
+ textStyle,
1091
+ textInfo: {
1092
+ originalText: title,
1093
+ lines: textLines,
1094
+ fontSize: finalFontSize,
1095
+ fontFamily: fontFamily,
1096
+ lineHeight: lineHeight,
1097
+ textWidth: textWidth,
1098
+ textHeight: textHeight,
1099
+ autoSized: autoSize && finalFontSize !== fontSize,
1100
+ autoWrapped: autoWrap && textLines.length > 1
1101
+ },
1102
+ borderInfo: border ? {
1103
+ type: border,
1104
+ color: borderColor || (textStyle.color === '#FFFFFF' ? '#000000' : '#FFFFFF'),
1105
+ width: borderWidth,
1106
+ radius: borderRadius,
1107
+ padding: borderPadding,
1108
+ opacity: borderOpacity
1109
+ } : null,
1110
+ brightness: customColor ? null : await calculateRegionBrightness(
1111
+ resolvedBackgroundPath,
1112
+ Math.floor(x - textWidth / 2),
1113
+ Math.floor(y - textHeight / 2),
1114
+ Math.floor(textWidth),
1115
+ Math.floor(textHeight)
1116
+ )
1117
+ };
1118
+
1119
+ } catch (error) {
1120
+ throw new Error(`Failed to composite title: ${error.message}`);
1121
+ }
1122
+ }
1123
+
1124
+ // Function to composite multiple text layers on image
1125
+ async function compositeTitleMulti(backgroundPath, layers, options = {}) {
1126
+ if (!backgroundPath || typeof backgroundPath !== 'string') {
1127
+ throw new Error('backgroundPath is required and must be a string');
1128
+ }
1129
+ if (!Array.isArray(layers) || layers.length === 0) {
1130
+ throw new Error('layers must be a non-empty array');
1131
+ }
1132
+
1133
+ // Resolve paths with default directories
1134
+ const resolvedBackgroundPath = resolveBackgroundPath(backgroundPath);
1135
+ const defaults = globalConfig.defaults || {};
1136
+ const { outputPath = 'output.png' } = options;
1137
+ const resolvedOutputPath = resolveOutputPath(outputPath);
1138
+
1139
+ try {
1140
+ // Check if background file exists
1141
+ try {
1142
+ await fs.access(resolvedBackgroundPath);
1143
+ } catch (error) {
1144
+ throw new Error(`Background image not found: ${resolvedBackgroundPath}`);
1145
+ }
1146
+
1147
+ // Validate image format
1148
+ let metadata;
1149
+ try {
1150
+ const image = sharp(resolvedBackgroundPath);
1151
+ metadata = await image.metadata();
1152
+ } catch (error) {
1153
+ throw new Error(`Failed to read image: ${error.message}`);
1154
+ }
1155
+
1156
+ const supportedFormats = ['jpeg', 'png', 'webp', 'tiff', 'gif'];
1157
+ if (!supportedFormats.includes(metadata.format)) {
1158
+ throw new Error(`Unsupported image format: ${metadata.format}. Supported formats: ${supportedFormats.join(', ')}`);
1159
+ }
1160
+
1161
+ // Check if output directory exists and is writable
1162
+ const outputDir = path.dirname(path.resolve(resolvedOutputPath));
1163
+ try {
1164
+ await fs.access(outputDir, fsConstants.W_OK);
1165
+ } catch (error) {
1166
+ throw new Error(`Output directory is not writable: ${outputDir}`);
1167
+ }
1168
+
1169
+ // Load background image
1170
+ const background = await loadImage(resolvedBackgroundPath);
1171
+ const canvas = new Canvas(background.width, background.height);
1172
+ const ctx = canvas.getContext('2d');
1173
+
1174
+ // Draw background
1175
+ ctx.drawImage(background, 0, 0);
1176
+
1177
+ // Process each layer
1178
+ const layerResults = [];
1179
+
1180
+ for (let layerIndex = 0; layerIndex < layers.length; layerIndex++) {
1181
+ const layer = layers[layerIndex];
1182
+
1183
+ // Validate layer has text
1184
+ if (!layer.text || typeof layer.text !== 'string') {
1185
+ throw new Error(`Layer ${layerIndex}: text is required and must be a string`);
1186
+ }
1187
+
1188
+ // Merge layer options with defaults
1189
+ const {
1190
+ text,
1191
+ fontSize = defaults.fontSize || 48,
1192
+ fontFamily = defaults.fontFamily || 'Arial',
1193
+ fontWeight = defaults.fontWeight || 'normal',
1194
+ fontStyle = defaults.fontStyle || 'normal',
1195
+ position = defaults.position || 'center',
1196
+ offsetX = defaults.offsetX || 0,
1197
+ offsetY = defaults.offsetY || 0,
1198
+ x = null,
1199
+ y = null,
1200
+ enhanceContrast = defaults.enhanceContrast !== undefined ? defaults.enhanceContrast : true,
1201
+ customColor = defaults.customColor || null,
1202
+ customFontPath = defaults.customFontPath || null,
1203
+ customFontPaths = defaults.customFontPaths || null,
1204
+ border = defaults.border || null,
1205
+ borderWidth = defaults.borderWidth || 2,
1206
+ borderColor = defaults.borderColor || null,
1207
+ borderRadius = defaults.borderRadius || 8,
1208
+ borderPadding = defaults.borderPadding || 16,
1209
+ borderOpacity = defaults.borderOpacity !== undefined ? defaults.borderOpacity : 0.8,
1210
+ autoSize = defaults.autoSize !== undefined ? defaults.autoSize : false,
1211
+ autoWrap = defaults.autoWrap !== undefined ? defaults.autoWrap : false,
1212
+ maxWidthPercent = defaults.maxWidthPercent || 0.8,
1213
+ maxHeightPercent = defaults.maxHeightPercent || 0.8,
1214
+ minFontSize = defaults.minFontSize || 12,
1215
+ maxFontSize = defaults.maxFontSize || 200,
1216
+ lineHeight = defaults.lineHeight || 1.2,
1217
+ textEffect = defaults.textEffect || null,
1218
+ outlineWidth = defaults.outlineWidth || 3,
1219
+ outlineColor = defaults.outlineColor || null,
1220
+ glowRadius = defaults.glowRadius || 10,
1221
+ glowIntensity = defaults.glowIntensity || 0.8,
1222
+ gradientSampling = defaults.gradientSampling !== undefined ? defaults.gradientSampling : false,
1223
+ writingMode = defaults.writingMode || 'horizontal',
1224
+ characterSpacing = defaults.characterSpacing || 0,
1225
+ verticalAlign = defaults.verticalAlign || 'center'
1226
+ } = layer;
1227
+
1228
+ // Load custom fonts if specified
1229
+ if (customFontPaths) {
1230
+ await loadCustomFonts(customFontPaths, fontFamily);
1231
+ } else if (customFontPath) {
1232
+ await loadCustomFont(customFontPath, fontFamily);
1233
+ } else if (globalConfig.fonts && globalConfig.fonts[fontFamily]) {
1234
+ const fontPaths = globalConfig.fonts[fontFamily];
1235
+ if (Array.isArray(fontPaths)) {
1236
+ await loadCustomFonts(fontPaths, fontFamily);
1237
+ } else {
1238
+ await loadCustomFont(fontPaths, fontFamily);
1239
+ }
1240
+ }
1241
+
1242
+ // Calculate available space for text
1243
+ const maxWidth = background.width * maxWidthPercent;
1244
+ const maxHeight = background.height * maxHeightPercent;
1245
+
1246
+ // Process text based on options
1247
+ let finalFontSize = fontSize;
1248
+ let textLines = text.split('\n');
1249
+ let totalTextHeight = fontSize;
1250
+ let maxLineWidth = 0;
1251
+
1252
+ if (autoWrap || autoSize) {
1253
+ ctx.font = buildFontDescriptor(fontSize, fontFamily, fontWeight, fontStyle);
1254
+
1255
+ if (autoWrap) {
1256
+ textLines = wrapText(ctx, text, maxWidth);
1257
+
1258
+ if (autoSize) {
1259
+ finalFontSize = calculateOptimalFontSizeForLines(
1260
+ ctx,
1261
+ textLines,
1262
+ maxWidth,
1263
+ maxHeight,
1264
+ minFontSize,
1265
+ autoSize ? maxFontSize : fontSize,
1266
+ { fontFamily, fontWeight, fontStyle, lineHeight }
1267
+ );
1268
+ }
1269
+ } else if (autoSize) {
1270
+ finalFontSize = calculateOptimalFontSize(
1271
+ ctx,
1272
+ text,
1273
+ maxWidth,
1274
+ maxHeight,
1275
+ minFontSize,
1276
+ maxFontSize,
1277
+ { fontFamily, fontWeight, fontStyle }
1278
+ );
1279
+ }
1280
+ }
1281
+
1282
+ // Set final font
1283
+ const fontDescriptor = buildFontDescriptor(finalFontSize, fontFamily, fontWeight, fontStyle);
1284
+ ctx.font = fontDescriptor;
1285
+ ctx.textAlign = 'center';
1286
+ ctx.textBaseline = 'middle';
1287
+
1288
+ // If we wrapped text, recalculate lines with final font size
1289
+ if (autoWrap && textLines.length > 1) {
1290
+ textLines = wrapText(ctx, text, maxWidth);
1291
+ }
1292
+
1293
+ // Calculate text dimensions
1294
+ totalTextHeight = textLines.length * finalFontSize * lineHeight;
1295
+ for (const line of textLines) {
1296
+ const lineMetrics = ctx.measureText(line);
1297
+ maxLineWidth = Math.max(maxLineWidth, lineMetrics.width);
1298
+ }
1299
+
1300
+ const textWidth = maxLineWidth;
1301
+ const textHeight = totalTextHeight;
1302
+
1303
+ // Calculate position
1304
+ let posX, posY;
1305
+
1306
+ // If absolute coordinates are provided, use them
1307
+ if (x !== null && y !== null) {
1308
+ posX = x;
1309
+ posY = y;
1310
+ } else {
1311
+ // Use position presets
1312
+ switch (position) {
1313
+ case 'top':
1314
+ posX = background.width / 2 + offsetX;
1315
+ posY = textHeight / 2 + 20 + offsetY;
1316
+ break;
1317
+ case 'bottom':
1318
+ posX = background.width / 2 + offsetX;
1319
+ posY = background.height - textHeight / 2 - 20 + offsetY;
1320
+ break;
1321
+ case 'center':
1322
+ default:
1323
+ posX = background.width / 2 + offsetX;
1324
+ posY = background.height / 2 + offsetY;
1325
+ break;
1326
+ }
1327
+ }
1328
+
1329
+ // Calculate brightness of text area if no custom color is specified
1330
+ let textStyle;
1331
+ if (customColor) {
1332
+ textStyle = { color: customColor, shadow: null, shadowBlur: 0 };
1333
+ } else {
1334
+ const regionX = Math.max(0, posX - textWidth / 2 - 10);
1335
+ const regionY = Math.max(0, posY - textHeight / 2 - 10);
1336
+ const regionWidth = Math.min(textWidth + 20, background.width - regionX);
1337
+ const regionHeight = Math.min(textHeight + 20, background.height - regionY);
1338
+
1339
+ const brightness = await calculateRegionBrightness(
1340
+ resolvedBackgroundPath,
1341
+ Math.floor(regionX),
1342
+ Math.floor(regionY),
1343
+ Math.floor(regionWidth),
1344
+ Math.floor(regionHeight),
1345
+ gradientSampling
1346
+ );
1347
+
1348
+ textStyle = getOptimalTextColor(brightness, enhanceContrast);
1349
+ }
1350
+
1351
+ // Draw border/frame if specified
1352
+ if (border) {
1353
+ const borderX = posX - textWidth / 2 - borderPadding;
1354
+ const borderY = posY - textHeight / 2 - borderPadding;
1355
+ const borderWidthTotal = textWidth + (borderPadding * 2);
1356
+ const borderHeightTotal = textHeight + (borderPadding * 2);
1357
+
1358
+ const finalBorderColor = borderColor || (textStyle.color === '#FFFFFF' ? '#000000' : '#FFFFFF');
1359
+
1360
+ ctx.save();
1361
+ ctx.globalAlpha = borderOpacity;
1362
+
1363
+ switch (border) {
1364
+ case 'rectangle':
1365
+ ctx.strokeStyle = finalBorderColor;
1366
+ ctx.lineWidth = borderWidth;
1367
+ ctx.strokeRect(borderX, borderY, borderWidthTotal, borderHeightTotal);
1368
+ break;
1369
+
1370
+ case 'rounded':
1371
+ ctx.strokeStyle = finalBorderColor;
1372
+ ctx.lineWidth = borderWidth;
1373
+ ctx.beginPath();
1374
+ ctx.roundRect(borderX, borderY, borderWidthTotal, borderHeightTotal, borderRadius);
1375
+ ctx.stroke();
1376
+ break;
1377
+
1378
+ case 'filled':
1379
+ ctx.fillStyle = finalBorderColor;
1380
+ ctx.globalAlpha = borderOpacity * 0.7;
1381
+ if (borderRadius > 0) {
1382
+ ctx.beginPath();
1383
+ ctx.roundRect(borderX, borderY, borderWidthTotal, borderHeightTotal, borderRadius);
1384
+ ctx.fill();
1385
+ } else {
1386
+ ctx.fillRect(borderX, borderY, borderWidthTotal, borderHeightTotal);
1387
+ }
1388
+ break;
1389
+
1390
+ case 'outline':
1391
+ ctx.strokeStyle = finalBorderColor;
1392
+ ctx.lineWidth = borderWidth + 2;
1393
+ ctx.globalAlpha = borderOpacity * 0.5;
1394
+ ctx.beginPath();
1395
+ ctx.roundRect(borderX - 1, borderY - 1, borderWidthTotal + 2, borderHeightTotal + 2, borderRadius + 1);
1396
+ ctx.stroke();
1397
+
1398
+ ctx.strokeStyle = textStyle.color;
1399
+ ctx.lineWidth = borderWidth;
1400
+ ctx.globalAlpha = borderOpacity;
1401
+ ctx.beginPath();
1402
+ ctx.roundRect(borderX, borderY, borderWidthTotal, borderHeightTotal, borderRadius);
1403
+ ctx.stroke();
1404
+ break;
1405
+ }
1406
+
1407
+ ctx.restore();
1408
+ }
1409
+
1410
+ // Draw text with effects (same logic as compositeTitle)
1411
+ ctx.save();
1412
+
1413
+ if (textEffect === 'outline') {
1414
+ const finalOutlineColor = outlineColor || (textStyle.color === '#FFFFFF' ? '#000000' : '#FFFFFF');
1415
+ ctx.strokeStyle = finalOutlineColor;
1416
+ ctx.lineWidth = outlineWidth;
1417
+ ctx.lineJoin = 'round';
1418
+ ctx.miterLimit = 2;
1419
+
1420
+ if (textLines.length === 1) {
1421
+ ctx.strokeText(text, posX, posY);
1422
+ ctx.fillStyle = textStyle.color;
1423
+ ctx.fillText(text, posX, posY);
1424
+ } else {
1425
+ const lineSpacing = finalFontSize * lineHeight;
1426
+ const startY = posY - ((textLines.length - 1) * lineSpacing) / 2;
1427
+
1428
+ for (let i = 0; i < textLines.length; i++) {
1429
+ const lineY = startY + (i * lineSpacing);
1430
+ ctx.strokeText(textLines[i], posX, lineY);
1431
+ }
1432
+
1433
+ ctx.fillStyle = textStyle.color;
1434
+ for (let i = 0; i < textLines.length; i++) {
1435
+ const lineY = startY + (i * lineSpacing);
1436
+ ctx.fillText(textLines[i], posX, lineY);
1437
+ }
1438
+ }
1439
+ } else if (textEffect === 'glow') {
1440
+ const glowColor = outlineColor || textStyle.color;
1441
+ ctx.shadowColor = glowColor;
1442
+ ctx.shadowBlur = glowRadius;
1443
+ ctx.globalAlpha = glowIntensity;
1444
+
1445
+ for (let i = 0; i < 3; i++) {
1446
+ if (textLines.length === 1) {
1447
+ ctx.fillStyle = glowColor;
1448
+ ctx.fillText(text, posX, posY);
1449
+ } else {
1450
+ const lineSpacing = finalFontSize * lineHeight;
1451
+ const startY = posY - ((textLines.length - 1) * lineSpacing) / 2;
1452
+
1453
+ for (let j = 0; j < textLines.length; j++) {
1454
+ const lineY = startY + (j * lineSpacing);
1455
+ ctx.fillStyle = glowColor;
1456
+ ctx.fillText(textLines[j], posX, lineY);
1457
+ }
1458
+ }
1459
+ }
1460
+
1461
+ ctx.shadowBlur = 0;
1462
+ ctx.globalAlpha = 1;
1463
+ ctx.fillStyle = textStyle.color;
1464
+
1465
+ if (textLines.length === 1) {
1466
+ ctx.fillText(text, posX, posY);
1467
+ } else {
1468
+ const lineSpacing = finalFontSize * lineHeight;
1469
+ const startY = posY - ((textLines.length - 1) * lineSpacing) / 2;
1470
+
1471
+ for (let i = 0; i < textLines.length; i++) {
1472
+ const lineY = startY + (i * lineSpacing);
1473
+ ctx.fillText(textLines[i], posX, lineY);
1474
+ }
1475
+ }
1476
+ } else if (textEffect === 'emboss') {
1477
+ const embossColor = outlineColor || (textStyle.color === '#FFFFFF' ? '#666666' : '#FFFFFF');
1478
+
1479
+ ctx.fillStyle = '#00000033';
1480
+ if (textLines.length === 1) {
1481
+ ctx.fillText(text, posX + 2, posY + 2);
1482
+ } else {
1483
+ const lineSpacing = finalFontSize * lineHeight;
1484
+ const startY = posY - ((textLines.length - 1) * lineSpacing) / 2;
1485
+ for (let i = 0; i < textLines.length; i++) {
1486
+ const lineY = startY + (i * lineSpacing);
1487
+ ctx.fillText(textLines[i], posX + 2, lineY + 2);
1488
+ }
1489
+ }
1490
+
1491
+ ctx.fillStyle = embossColor;
1492
+ if (textLines.length === 1) {
1493
+ ctx.fillText(text, posX - 1, posY - 1);
1494
+ } else {
1495
+ const lineSpacing = finalFontSize * lineHeight;
1496
+ const startY = posY - ((textLines.length - 1) * lineSpacing) / 2;
1497
+ for (let i = 0; i < textLines.length; i++) {
1498
+ const lineY = startY + (i * lineSpacing);
1499
+ ctx.fillText(textLines[i], posX - 1, lineY - 1);
1500
+ }
1501
+ }
1502
+
1503
+ ctx.fillStyle = textStyle.color;
1504
+ if (textLines.length === 1) {
1505
+ ctx.fillText(text, posX, posY);
1506
+ } else {
1507
+ const lineSpacing = finalFontSize * lineHeight;
1508
+ const startY = posY - ((textLines.length - 1) * lineSpacing) / 2;
1509
+ for (let i = 0; i < textLines.length; i++) {
1510
+ const lineY = startY + (i * lineSpacing);
1511
+ ctx.fillText(textLines[i], posX, lineY);
1512
+ }
1513
+ }
1514
+ } else {
1515
+ if (textStyle.shadow && textStyle.shadowBlur > 0) {
1516
+ ctx.shadowColor = textStyle.shadow;
1517
+ ctx.shadowBlur = textStyle.shadowBlur;
1518
+ ctx.shadowOffsetX = 1;
1519
+ ctx.shadowOffsetY = 1;
1520
+ }
1521
+
1522
+ ctx.fillStyle = textStyle.color;
1523
+
1524
+ if (textLines.length === 1) {
1525
+ ctx.fillText(text, posX, posY);
1526
+ } else {
1527
+ const lineSpacing = finalFontSize * lineHeight;
1528
+ const startY = posY - ((textLines.length - 1) * lineSpacing) / 2;
1529
+
1530
+ for (let i = 0; i < textLines.length; i++) {
1531
+ const lineY = startY + (i * lineSpacing);
1532
+ ctx.fillText(textLines[i], posX, lineY);
1533
+ }
1534
+ }
1535
+ }
1536
+
1537
+ ctx.restore();
1538
+
1539
+ // Store layer result
1540
+ layerResults.push({
1541
+ text,
1542
+ lines: textLines,
1543
+ position: { x: posX, y: posY },
1544
+ fontSize: finalFontSize,
1545
+ fontFamily,
1546
+ textWidth,
1547
+ textHeight,
1548
+ textStyle,
1549
+ autoSized: autoSize && finalFontSize !== fontSize,
1550
+ autoWrapped: autoWrap && textLines.length > 1
1551
+ });
1552
+ }
1553
+
1554
+ // Save the result
1555
+ const buffer = await canvas.toBuffer('image/png');
1556
+ await fs.writeFile(resolvedOutputPath, buffer);
1557
+
1558
+ return {
1559
+ success: true,
1560
+ outputPath: resolvedOutputPath,
1561
+ dimensions: { width: background.width, height: background.height },
1562
+ layers: layerResults,
1563
+ layerCount: layers.length
1564
+ };
1565
+
1566
+ } catch (error) {
1567
+ throw new Error(`Failed to composite multi-layer title: ${error.message}`);
1568
+ }
1569
+ }
1570
+
1571
+ // List available tools
1572
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1573
+ return {
1574
+ tools: [
1575
+ {
1576
+ name: 'composite_title',
1577
+ description: 'Composite title text on a background image with automatic brightness adjustment',
1578
+ inputSchema: {
1579
+ type: 'object',
1580
+ properties: {
1581
+ backgroundPath: {
1582
+ type: 'string',
1583
+ description: 'Path to the background image file'
1584
+ },
1585
+ title: {
1586
+ type: 'string',
1587
+ description: 'Title text to composite on the image'
1588
+ },
1589
+ outputPath: {
1590
+ type: 'string',
1591
+ description: 'Output file path (default: output.png)',
1592
+ default: 'output.png'
1593
+ },
1594
+ fontSize: {
1595
+ type: 'number',
1596
+ description: 'Font size in pixels (default: 48)',
1597
+ default: 48
1598
+ },
1599
+ fontFamily: {
1600
+ type: 'string',
1601
+ description: 'Font family (default: Arial)',
1602
+ default: 'Arial'
1603
+ },
1604
+ position: {
1605
+ type: 'string',
1606
+ enum: ['top', 'center', 'bottom'],
1607
+ description: 'Text position (default: center)',
1608
+ default: 'center'
1609
+ },
1610
+ offsetX: {
1611
+ type: 'number',
1612
+ description: 'Horizontal offset in pixels (default: 0)',
1613
+ default: 0
1614
+ },
1615
+ offsetY: {
1616
+ type: 'number',
1617
+ description: 'Vertical offset in pixels (default: 0)',
1618
+ default: 0
1619
+ },
1620
+ enhanceContrast: {
1621
+ type: 'boolean',
1622
+ description: 'Add shadow for better contrast (default: true)',
1623
+ default: true
1624
+ },
1625
+ customColor: {
1626
+ type: 'string',
1627
+ description: 'Custom text color (hex format, overrides auto-detection)',
1628
+ default: null
1629
+ },
1630
+ border: {
1631
+ type: 'string',
1632
+ enum: ['rectangle', 'rounded', 'filled', 'outline'],
1633
+ description: 'Border type around text (default: none)',
1634
+ default: null
1635
+ },
1636
+ borderWidth: {
1637
+ type: 'number',
1638
+ description: 'Border width in pixels (default: 2)',
1639
+ default: 2
1640
+ },
1641
+ borderColor: {
1642
+ type: 'string',
1643
+ description: 'Border color (hex format, auto-detected if not specified)',
1644
+ default: null
1645
+ },
1646
+ borderRadius: {
1647
+ type: 'number',
1648
+ description: 'Border radius for rounded borders (default: 8)',
1649
+ default: 8
1650
+ },
1651
+ borderPadding: {
1652
+ type: 'number',
1653
+ description: 'Padding around text inside border (default: 16)',
1654
+ default: 16
1655
+ },
1656
+ borderOpacity: {
1657
+ type: 'number',
1658
+ description: 'Border opacity (0-1, default: 0.8)',
1659
+ default: 0.8,
1660
+ minimum: 0,
1661
+ maximum: 1
1662
+ },
1663
+ autoSize: {
1664
+ type: 'boolean',
1665
+ description: 'Automatically calculate font size to fit within image bounds (default: false)',
1666
+ default: false
1667
+ },
1668
+ autoWrap: {
1669
+ type: 'boolean',
1670
+ description: 'Automatically wrap long text into multiple lines (default: false)',
1671
+ default: false
1672
+ },
1673
+ maxWidthPercent: {
1674
+ type: 'number',
1675
+ description: 'Maximum width as percentage of image width for auto-sizing/wrapping (default: 0.8)',
1676
+ default: 0.8,
1677
+ minimum: 0.1,
1678
+ maximum: 1.0
1679
+ },
1680
+ maxHeightPercent: {
1681
+ type: 'number',
1682
+ description: 'Maximum height as percentage of image height for auto-sizing/wrapping (default: 0.8)',
1683
+ default: 0.8,
1684
+ minimum: 0.1,
1685
+ maximum: 1.0
1686
+ },
1687
+ minFontSize: {
1688
+ type: 'number',
1689
+ description: 'Minimum font size when auto-sizing (default: 12)',
1690
+ default: 12,
1691
+ minimum: 8
1692
+ },
1693
+ maxFontSize: {
1694
+ type: 'number',
1695
+ description: 'Maximum font size when auto-sizing (default: 200)',
1696
+ default: 200,
1697
+ minimum: 12
1698
+ },
1699
+ lineHeight: {
1700
+ type: 'number',
1701
+ description: 'Line height multiplier for multi-line text (default: 1.2)',
1702
+ default: 1.2,
1703
+ minimum: 0.8,
1704
+ maximum: 3.0
1705
+ },
1706
+ textEffect: {
1707
+ type: 'string',
1708
+ enum: ['outline', 'glow', 'emboss'],
1709
+ description: 'Text effect type (default: none)',
1710
+ default: null
1711
+ },
1712
+ outlineWidth: {
1713
+ type: 'number',
1714
+ description: 'Outline width in pixels for outline effect (default: 3)',
1715
+ default: 3
1716
+ },
1717
+ outlineColor: {
1718
+ type: 'string',
1719
+ description: 'Outline/glow color (hex format, auto-detected if not specified)',
1720
+ default: null
1721
+ },
1722
+ glowRadius: {
1723
+ type: 'number',
1724
+ description: 'Glow radius for glow effect (default: 10)',
1725
+ default: 10
1726
+ },
1727
+ glowIntensity: {
1728
+ type: 'number',
1729
+ description: 'Glow intensity (0-1, default: 0.8)',
1730
+ default: 0.8,
1731
+ minimum: 0,
1732
+ maximum: 1
1733
+ },
1734
+ gradientSampling: {
1735
+ type: 'boolean',
1736
+ description: 'Use multi-point sampling for gradient backgrounds (default: false)',
1737
+ default: false
1738
+ },
1739
+ customFontPath: {
1740
+ type: 'string',
1741
+ description: 'Path to custom font file (.ttf or .otf)',
1742
+ default: null
1743
+ },
1744
+ customFontPaths: {
1745
+ type: 'array',
1746
+ items: { type: 'string' },
1747
+ description: 'Array of paths to font variant files (regular, bold, italic, etc.)',
1748
+ default: null
1749
+ },
1750
+ fontWeight: {
1751
+ type: 'string',
1752
+ enum: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
1753
+ description: 'Font weight (default: normal)',
1754
+ default: 'normal'
1755
+ },
1756
+ fontStyle: {
1757
+ type: 'string',
1758
+ enum: ['normal', 'italic', 'oblique'],
1759
+ description: 'Font style (default: normal)',
1760
+ default: 'normal'
1761
+ }
1762
+ },
1763
+ required: ['backgroundPath', 'title']
1764
+ }
1765
+ },
1766
+ {
1767
+ name: 'analyze_brightness',
1768
+ description: 'Analyze brightness of an image region',
1769
+ inputSchema: {
1770
+ type: 'object',
1771
+ properties: {
1772
+ imagePath: {
1773
+ type: 'string',
1774
+ description: 'Path to the image file'
1775
+ },
1776
+ x: {
1777
+ type: 'number',
1778
+ description: 'X coordinate of the region (default: 0)',
1779
+ default: 0
1780
+ },
1781
+ y: {
1782
+ type: 'number',
1783
+ description: 'Y coordinate of the region (default: 0)',
1784
+ default: 0
1785
+ },
1786
+ width: {
1787
+ type: 'number',
1788
+ description: 'Width of the region (default: entire image width)',
1789
+ default: null
1790
+ },
1791
+ height: {
1792
+ type: 'number',
1793
+ description: 'Height of the region (default: entire image height)',
1794
+ default: null
1795
+ }
1796
+ },
1797
+ required: ['imagePath']
1798
+ }
1799
+ },
1800
+ {
1801
+ name: 'get_config',
1802
+ description: 'Get current configuration and path resolution information for debugging',
1803
+ inputSchema: {
1804
+ type: 'object',
1805
+ properties: {
1806
+ testPath: {
1807
+ type: 'string',
1808
+ description: 'Optional test path to see how it would be resolved',
1809
+ default: null
1810
+ }
1811
+ }
1812
+ }
1813
+ },
1814
+ {
1815
+ name: 'composite_title_multi',
1816
+ description: 'Composite multiple text layers on a background image with individual font/size/position control for each layer. Perfect for creating complex layouts with main title, subtitle, date, etc. Each layer can have different fonts, sizes, positions, and effects.',
1817
+ inputSchema: {
1818
+ type: 'object',
1819
+ properties: {
1820
+ backgroundPath: {
1821
+ type: 'string',
1822
+ description: 'Path to the background image file'
1823
+ },
1824
+ outputPath: {
1825
+ type: 'string',
1826
+ description: 'Output file path (default: output.png)',
1827
+ default: 'output.png'
1828
+ },
1829
+ layers: {
1830
+ type: 'array',
1831
+ description: 'Array of text layers to composite. Each layer is rendered in order (later layers appear on top)',
1832
+ items: {
1833
+ type: 'object',
1834
+ properties: {
1835
+ text: {
1836
+ type: 'string',
1837
+ description: 'Text content for this layer (required)'
1838
+ },
1839
+ fontSize: {
1840
+ type: 'number',
1841
+ description: 'Font size in pixels (inherits from defaults if not specified)'
1842
+ },
1843
+ fontFamily: {
1844
+ type: 'string',
1845
+ description: 'Font family (inherits from defaults if not specified)'
1846
+ },
1847
+ fontWeight: {
1848
+ type: 'string',
1849
+ enum: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
1850
+ description: 'Font weight'
1851
+ },
1852
+ fontStyle: {
1853
+ type: 'string',
1854
+ enum: ['normal', 'italic', 'oblique'],
1855
+ description: 'Font style'
1856
+ },
1857
+ position: {
1858
+ type: 'string',
1859
+ enum: ['top', 'center', 'bottom'],
1860
+ description: 'Text position preset (can be combined with offsetX/offsetY)'
1861
+ },
1862
+ x: {
1863
+ type: 'number',
1864
+ description: 'Absolute X coordinate (overrides position preset)'
1865
+ },
1866
+ y: {
1867
+ type: 'number',
1868
+ description: 'Absolute Y coordinate (overrides position preset)'
1869
+ },
1870
+ offsetX: {
1871
+ type: 'number',
1872
+ description: 'Horizontal offset in pixels from position preset'
1873
+ },
1874
+ offsetY: {
1875
+ type: 'number',
1876
+ description: 'Vertical offset in pixels from position preset'
1877
+ },
1878
+ enhanceContrast: {
1879
+ type: 'boolean',
1880
+ description: 'Add shadow for better contrast'
1881
+ },
1882
+ customColor: {
1883
+ type: 'string',
1884
+ description: 'Custom text color (hex format, overrides auto-detection)'
1885
+ },
1886
+ border: {
1887
+ type: 'string',
1888
+ enum: ['rectangle', 'rounded', 'filled', 'outline'],
1889
+ description: 'Border type around text'
1890
+ },
1891
+ borderWidth: {
1892
+ type: 'number',
1893
+ description: 'Border width in pixels'
1894
+ },
1895
+ borderColor: {
1896
+ type: 'string',
1897
+ description: 'Border color (hex format)'
1898
+ },
1899
+ borderRadius: {
1900
+ type: 'number',
1901
+ description: 'Border radius for rounded borders'
1902
+ },
1903
+ borderPadding: {
1904
+ type: 'number',
1905
+ description: 'Padding around text inside border'
1906
+ },
1907
+ borderOpacity: {
1908
+ type: 'number',
1909
+ description: 'Border opacity (0-1)',
1910
+ minimum: 0,
1911
+ maximum: 1
1912
+ },
1913
+ autoSize: {
1914
+ type: 'boolean',
1915
+ description: 'Automatically calculate font size to fit within bounds'
1916
+ },
1917
+ autoWrap: {
1918
+ type: 'boolean',
1919
+ description: 'Automatically wrap long text into multiple lines'
1920
+ },
1921
+ maxWidthPercent: {
1922
+ type: 'number',
1923
+ description: 'Maximum width as percentage of image width',
1924
+ minimum: 0.1,
1925
+ maximum: 1.0
1926
+ },
1927
+ maxHeightPercent: {
1928
+ type: 'number',
1929
+ description: 'Maximum height as percentage of image height',
1930
+ minimum: 0.1,
1931
+ maximum: 1.0
1932
+ },
1933
+ textEffect: {
1934
+ type: 'string',
1935
+ enum: ['outline', 'glow', 'emboss'],
1936
+ description: 'Text effect type'
1937
+ },
1938
+ outlineWidth: {
1939
+ type: 'number',
1940
+ description: 'Outline width in pixels for outline effect'
1941
+ },
1942
+ outlineColor: {
1943
+ type: 'string',
1944
+ description: 'Outline/glow color (hex format)'
1945
+ },
1946
+ customFontPath: {
1947
+ type: 'string',
1948
+ description: 'Path to custom font file (.ttf or .otf)'
1949
+ },
1950
+ customFontPaths: {
1951
+ type: 'array',
1952
+ items: { type: 'string' },
1953
+ description: 'Array of paths to font variant files'
1954
+ }
1955
+ },
1956
+ required: ['text']
1957
+ }
1958
+ }
1959
+ },
1960
+ required: ['backgroundPath', 'layers']
1961
+ }
1962
+ }
1963
+ ]
1964
+ };
1965
+ });
1966
+
1967
+ // Handle tool calls
1968
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1969
+ const { name, arguments: args } = request.params;
1970
+
1971
+ try {
1972
+ switch (name) {
1973
+ case 'composite_title':
1974
+ const result = await compositeTitle(args.backgroundPath, args.title, args);
1975
+ return {
1976
+ content: [
1977
+ {
1978
+ type: 'text',
1979
+ text: JSON.stringify(result, null, 2)
1980
+ }
1981
+ ]
1982
+ };
1983
+
1984
+ case 'analyze_brightness':
1985
+ const image = sharp(args.imagePath);
1986
+ const metadata = await image.metadata();
1987
+
1988
+ const width = args.width || metadata.width;
1989
+ const height = args.height || metadata.height;
1990
+
1991
+ const brightness = await calculateRegionBrightness(
1992
+ args.imagePath,
1993
+ args.x || 0,
1994
+ args.y || 0,
1995
+ width,
1996
+ height
1997
+ );
1998
+
1999
+ const recommendedColor = getOptimalTextColor(brightness, true);
2000
+
2001
+ return {
2002
+ content: [
2003
+ {
2004
+ type: 'text',
2005
+ text: JSON.stringify({
2006
+ brightness,
2007
+ recommendedTextColor: recommendedColor.color,
2008
+ recommendedShadow: recommendedColor.shadow,
2009
+ isLightBackground: brightness > 128,
2010
+ region: {
2011
+ x: args.x || 0,
2012
+ y: args.y || 0,
2013
+ width,
2014
+ height
2015
+ }
2016
+ }, null, 2)
2017
+ }
2018
+ ]
2019
+ };
2020
+
2021
+ case 'get_config':
2022
+ const configInfo = {
2023
+ configLoaded: Object.keys(globalConfig).length > 0,
2024
+ globalConfig: globalConfig,
2025
+ environment: {
2026
+ platform: process.platform,
2027
+ cwd: process.cwd(),
2028
+ homeDir: homedir(),
2029
+ nodeVersion: process.version
2030
+ },
2031
+ configSearchPaths: [
2032
+ process.env.MCP_IMAGE_TITLE_CONFIG || '(not set)',
2033
+ path.join(process.cwd(), '.mcp-image-title.json'),
2034
+ path.join(homedir(), '.mcp-image-title.json')
2035
+ ]
2036
+ };
2037
+
2038
+ // Test path resolution if provided
2039
+ if (args.testPath) {
2040
+ configInfo.pathResolution = {
2041
+ input: args.testPath,
2042
+ resolvedAsBackground: resolveBackgroundPath(args.testPath),
2043
+ resolvedAsOutput: resolveOutputPath(args.testPath),
2044
+ resolvedAsFont: resolveFontPath(args.testPath),
2045
+ isAbsolute: path.isAbsolute(args.testPath),
2046
+ startsWithDotSlash: args.testPath.startsWith('./') || args.testPath.startsWith('../')
2047
+ };
2048
+
2049
+ // Check if resolved path exists
2050
+ try {
2051
+ await fs.access(resolveBackgroundPath(args.testPath));
2052
+ configInfo.pathResolution.backgroundPathExists = true;
2053
+ } catch {
2054
+ configInfo.pathResolution.backgroundPathExists = false;
2055
+ }
2056
+ }
2057
+
2058
+ return {
2059
+ content: [
2060
+ {
2061
+ type: 'text',
2062
+ text: JSON.stringify(configInfo, null, 2)
2063
+ }
2064
+ ]
2065
+ };
2066
+
2067
+ case 'composite_title_multi':
2068
+ const multiResult = await compositeTitleMulti(args.backgroundPath, args.layers, args);
2069
+ return {
2070
+ content: [
2071
+ {
2072
+ type: 'text',
2073
+ text: JSON.stringify(multiResult, null, 2)
2074
+ }
2075
+ ]
2076
+ };
2077
+
2078
+
2079
+ default:
2080
+ throw new Error(`Unknown tool: ${name}`);
2081
+ }
2082
+ } catch (error) {
2083
+ return {
2084
+ content: [
2085
+ {
2086
+ type: 'text',
2087
+ text: `Error: ${error.message}`
2088
+ }
2089
+ ],
2090
+ isError: true
2091
+ };
2092
+ }
2093
+ });
2094
+
2095
+ // Start the server
2096
+ async function main() {
2097
+ const transport = new StdioServerTransport();
2098
+ await server.connect(transport);
2099
+ console.error('MCP Image Title Server running on stdio');
2100
+ }
2101
+
2102
+ main().catch((error) => {
2103
+ console.error('Server error:', error);
2104
+ process.exit(1);
2105
+ });