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.
- package/README.md +532 -0
- package/index.js +2105 -0
- 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
|
+
});
|