appshot-cli 0.5.0 → 0.7.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 +185 -14
- package/dist/cli.js +34 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +145 -62
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/caption.d.ts.map +1 -1
- package/dist/commands/caption.js +28 -1
- package/dist/commands/caption.js.map +1 -1
- package/dist/commands/fonts.d.ts.map +1 -1
- package/dist/commands/fonts.js +89 -6
- package/dist/commands/fonts.js.map +1 -1
- package/dist/commands/gradients.d.ts.map +1 -1
- package/dist/commands/gradients.js +42 -2
- package/dist/commands/gradients.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +20 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/localize.d.ts.map +1 -1
- package/dist/commands/localize.js +33 -1
- package/dist/commands/localize.js.map +1 -1
- package/dist/commands/style.d.ts.map +1 -1
- package/dist/commands/style.js +156 -2
- package/dist/commands/style.js.map +1 -1
- package/dist/core/compose.d.ts +6 -0
- package/dist/core/compose.d.ts.map +1 -1
- package/dist/core/compose.js +433 -47
- package/dist/core/compose.js.map +1 -1
- package/dist/core/devices.d.ts +1 -1
- package/dist/core/devices.d.ts.map +1 -1
- package/dist/core/devices.js +5 -1
- package/dist/core/devices.js.map +1 -1
- package/dist/core/files.d.ts +1 -1
- package/dist/core/files.d.ts.map +1 -1
- package/dist/core/files.js +15 -3
- package/dist/core/files.js.map +1 -1
- package/dist/services/doctor.js +1 -1
- package/dist/services/fonts.d.ts +37 -2
- package/dist/services/fonts.d.ts.map +1 -1
- package/dist/services/fonts.js +225 -0
- package/dist/services/fonts.js.map +1 -1
- package/dist/types.d.ts +15 -2
- package/dist/types.d.ts.map +1 -1
- package/fonts/DMSans/DMSans-LICENSE.txt +93 -0
- package/fonts/DMSans/DMSans-Regular.ttf +0 -0
- package/fonts/FiraCode/FiraCode-Bold.ttf +0 -0
- package/fonts/FiraCode/FiraCode-LICENSE.txt +93 -0
- package/fonts/FiraCode/FiraCode-Light.ttf +0 -0
- package/fonts/FiraCode/FiraCode-Regular.ttf +0 -0
- package/fonts/FiraCode/FiraCode-SemiBold.ttf +0 -0
- package/fonts/Inter/InterVariable-Italic.ttf +0 -0
- package/fonts/Inter/InterVariable.ttf +0 -0
- package/fonts/Inter/LICENSE.txt +92 -0
- package/fonts/JetBrainsMono/JetBrainsMono-Bold.ttf +0 -0
- package/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.ttf +0 -0
- package/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf +0 -0
- package/fonts/JetBrainsMono/JetBrainsMono-LICENSE.txt +93 -0
- package/fonts/JetBrainsMono/JetBrainsMono-Regular.ttf +0 -0
- package/fonts/Lato/Lato-Bold.ttf +0 -0
- package/fonts/Lato/Lato-BoldItalic.ttf +0 -0
- package/fonts/Lato/Lato-Italic.ttf +0 -0
- package/fonts/Lato/Lato-LICENSE.txt +93 -0
- package/fonts/Lato/Lato-Regular.ttf +0 -0
- package/fonts/Montserrat/Montserrat-Bold.ttf +2070 -0
- package/fonts/Montserrat/Montserrat-BoldItalic.ttf +2070 -0
- package/fonts/Montserrat/Montserrat-Italic.ttf +2070 -0
- package/fonts/Montserrat/Montserrat-LICENSE.txt +93 -0
- package/fonts/Montserrat/Montserrat-Regular.ttf +2070 -0
- package/fonts/OpenSans/OpenSans-LICENSE.txt +92 -0
- package/fonts/OpenSans/OpenSans-Regular.ttf +0 -0
- package/fonts/Poppins/Poppins-Bold.ttf +0 -0
- package/fonts/Poppins/Poppins-BoldItalic.ttf +0 -0
- package/fonts/Poppins/Poppins-Italic.ttf +0 -0
- package/fonts/Poppins/Poppins-LICENSE.txt +93 -0
- package/fonts/Poppins/Poppins-Regular.ttf +0 -0
- package/fonts/Roboto/Roboto-Bold.ttf +2070 -0
- package/fonts/Roboto/Roboto-BoldItalic.ttf +2070 -0
- package/fonts/Roboto/Roboto-Italic.ttf +2070 -0
- package/fonts/Roboto/Roboto-LICENSE.txt +2070 -0
- package/fonts/Roboto/Roboto-Regular.ttf +2070 -0
- package/fonts/WorkSans/WorkSans-LICENSE.txt +93 -0
- package/fonts/WorkSans/WorkSans-Regular.ttf +0 -0
- package/package.json +7 -3
package/dist/core/compose.js
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
import sharp from 'sharp';
|
|
2
2
|
import { promises as fs } from 'fs';
|
|
3
|
+
import pc from 'picocolors';
|
|
3
4
|
import { renderGradient } from './render.js';
|
|
4
5
|
import { applyRoundedCorners } from './mask-generator.js';
|
|
5
6
|
import { calculateAdaptiveCaptionHeight, wrapText } from './text-utils.js';
|
|
7
|
+
import { FontService } from '../services/fonts.js';
|
|
8
|
+
/**
|
|
9
|
+
* Parse font name to extract style and weight
|
|
10
|
+
*/
|
|
11
|
+
function parseFontName(fontName) {
|
|
12
|
+
const parts = fontName.trim().split(/\s+/);
|
|
13
|
+
let family = fontName;
|
|
14
|
+
let style;
|
|
15
|
+
let weight;
|
|
16
|
+
// Check for italic
|
|
17
|
+
if (parts[parts.length - 1]?.toLowerCase() === 'italic') {
|
|
18
|
+
style = 'italic';
|
|
19
|
+
parts.pop();
|
|
20
|
+
// Check for bold italic
|
|
21
|
+
if (parts[parts.length - 1]?.toLowerCase() === 'bold') {
|
|
22
|
+
weight = 'bold';
|
|
23
|
+
parts.pop();
|
|
24
|
+
}
|
|
25
|
+
family = parts.join(' ');
|
|
26
|
+
}
|
|
27
|
+
else if (parts[parts.length - 1]?.toLowerCase() === 'bold') {
|
|
28
|
+
// Just bold
|
|
29
|
+
weight = 'bold';
|
|
30
|
+
parts.pop();
|
|
31
|
+
family = parts.join(' ');
|
|
32
|
+
}
|
|
33
|
+
return { family, style, weight };
|
|
34
|
+
}
|
|
6
35
|
/**
|
|
7
36
|
* Escape special XML/HTML characters in text
|
|
8
37
|
*/
|
|
@@ -14,11 +43,66 @@ function escapeXml(text) {
|
|
|
14
43
|
.replace(/"/g, '"')
|
|
15
44
|
.replace(/'/g, ''');
|
|
16
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Generate SVG with text, background, and border support
|
|
48
|
+
*/
|
|
49
|
+
function generateCaptionSVG(lines, width, height, fontSize, fontFamily, fontStyle, fontWeight, textColor, lineHeight, captionConfig) {
|
|
50
|
+
const backgroundConfig = captionConfig.background;
|
|
51
|
+
const borderConfig = captionConfig.border;
|
|
52
|
+
// Calculate text positioning
|
|
53
|
+
const totalTextHeight = lines.length * fontSize * lineHeight;
|
|
54
|
+
const startY = (height - totalTextHeight) / 2 + fontSize;
|
|
55
|
+
// SVG elements array
|
|
56
|
+
const svgElements = [];
|
|
57
|
+
// Add background rectangle if configured
|
|
58
|
+
if (backgroundConfig?.color) {
|
|
59
|
+
const bgPadding = backgroundConfig.padding || 20;
|
|
60
|
+
const bgOpacity = backgroundConfig.opacity || 0.8;
|
|
61
|
+
// Use full width minus margins for uniform appearance
|
|
62
|
+
const sideMargin = 30; // Margin from edges
|
|
63
|
+
const bgWidth = width - (sideMargin * 2);
|
|
64
|
+
const bgHeight = totalTextHeight + bgPadding * 2;
|
|
65
|
+
const bgX = sideMargin;
|
|
66
|
+
const bgY = startY - fontSize - bgPadding;
|
|
67
|
+
const bgRadius = borderConfig?.radius || 12; // Default to 12px for better visibility
|
|
68
|
+
svgElements.push(`<rect x="${bgX}" y="${bgY}" width="${bgWidth}" height="${bgHeight}" ` +
|
|
69
|
+
`fill="${backgroundConfig.color}" opacity="${bgOpacity}" rx="${bgRadius}"/>`);
|
|
70
|
+
}
|
|
71
|
+
// Add border rectangle if configured
|
|
72
|
+
if (borderConfig?.color && borderConfig?.width) {
|
|
73
|
+
const bgPadding = backgroundConfig?.padding || 20;
|
|
74
|
+
const borderWidth = borderConfig.width;
|
|
75
|
+
const borderRadius = borderConfig.radius || 12;
|
|
76
|
+
// Use full width minus margins for uniform appearance
|
|
77
|
+
const sideMargin = 30; // Margin from edges
|
|
78
|
+
const rectWidth = width - (sideMargin * 2);
|
|
79
|
+
const rectHeight = totalTextHeight + bgPadding * 2;
|
|
80
|
+
const rectX = sideMargin;
|
|
81
|
+
const rectY = startY - fontSize - bgPadding;
|
|
82
|
+
svgElements.push(`<rect x="${rectX}" y="${rectY}" width="${rectWidth}" height="${rectHeight}" ` +
|
|
83
|
+
`fill="none" stroke="${borderConfig.color}" stroke-width="${borderWidth}" rx="${borderRadius}"/>`);
|
|
84
|
+
}
|
|
85
|
+
// Add text elements
|
|
86
|
+
const textElements = lines.map((line, index) => {
|
|
87
|
+
const y = startY + (index * fontSize * lineHeight);
|
|
88
|
+
return `<text x="${width / 2}" y="${y}" ` +
|
|
89
|
+
`font-family="${fontFamily}" ` +
|
|
90
|
+
`font-size="${fontSize}" ` +
|
|
91
|
+
`font-style="${fontStyle}" ` +
|
|
92
|
+
`font-weight="${fontWeight}" ` +
|
|
93
|
+
`fill="${textColor}" ` +
|
|
94
|
+
`text-anchor="middle">${escapeXml(line)}</text>`;
|
|
95
|
+
});
|
|
96
|
+
svgElements.push(...textElements);
|
|
97
|
+
return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
98
|
+
${svgElements.join('\n ')}
|
|
99
|
+
</svg>`;
|
|
100
|
+
}
|
|
17
101
|
/**
|
|
18
102
|
* Compose a complete App Store screenshot with gradient, caption, and framed device
|
|
19
103
|
*/
|
|
20
104
|
export async function composeAppStoreScreenshot(options) {
|
|
21
|
-
const { screenshot, frame, frameMetadata, caption, captionConfig, gradientConfig, deviceConfig, outputWidth, outputHeight } = options;
|
|
105
|
+
const { screenshot, frame, frameMetadata, caption, captionConfig, gradientConfig, deviceConfig, outputWidth, outputHeight, verbose = false } = options;
|
|
22
106
|
// Determine caption position (default to 'above' for better App Store style)
|
|
23
107
|
// Check for device-specific override first
|
|
24
108
|
const captionPosition = deviceConfig.captionPosition || captionConfig.position || 'above';
|
|
@@ -51,15 +135,22 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
51
135
|
deviceTop = Math.floor((outputHeight - targetDeviceHeight) / 2);
|
|
52
136
|
}
|
|
53
137
|
}
|
|
54
|
-
// Calculate caption height
|
|
138
|
+
// Calculate caption height based on position
|
|
55
139
|
let captionHeight = 0;
|
|
56
140
|
let captionLines = [];
|
|
57
|
-
if (captionPosition === 'above' && caption) {
|
|
141
|
+
if ((captionPosition === 'above' || captionPosition === 'below') && caption) {
|
|
58
142
|
const isWatch = outputWidth < 500;
|
|
59
143
|
const captionFontSize = deviceConfig.captionSize || captionConfig.fontsize;
|
|
60
144
|
// Get caption box config (device-specific or global)
|
|
61
145
|
const captionBoxConfig = deviceConfig.captionBox || captionConfig.box || {};
|
|
62
146
|
const autoSize = captionBoxConfig.autoSize !== false; // Default true
|
|
147
|
+
if (verbose) {
|
|
148
|
+
console.log(pc.dim(' Caption metrics:'));
|
|
149
|
+
console.log(pc.dim(` Text: "${caption.substring(0, 50)}${caption.length > 50 ? '...' : ''}"`));
|
|
150
|
+
console.log(pc.dim(` Base font size: ${captionFontSize}px`));
|
|
151
|
+
console.log(pc.dim(` Canvas width: ${outputWidth}px`));
|
|
152
|
+
console.log(pc.dim(` Auto-size: ${autoSize}`));
|
|
153
|
+
}
|
|
63
154
|
if (isWatch) {
|
|
64
155
|
// Use proper text wrapping for watch with padding
|
|
65
156
|
captionHeight = Math.floor(outputHeight / 3);
|
|
@@ -67,12 +158,19 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
67
158
|
const watchFontSize = Math.min(36, captionFontSize);
|
|
68
159
|
// Use wrapText which now accounts for watch padding - allow 3 lines for watch
|
|
69
160
|
captionLines = wrapText(caption, outputWidth, watchFontSize, 3);
|
|
161
|
+
if (verbose) {
|
|
162
|
+
console.log(pc.dim(` Watch mode: font reduced to ${watchFontSize}px`));
|
|
163
|
+
console.log(pc.dim(` Wrap width: ${outputWidth}px (with padding)`));
|
|
164
|
+
}
|
|
70
165
|
}
|
|
71
166
|
else if (autoSize) {
|
|
72
167
|
// Use adaptive caption height
|
|
73
168
|
const result = calculateAdaptiveCaptionHeight(caption, captionFontSize, outputWidth, outputHeight, deviceTop, targetDeviceHeight, framePosition);
|
|
74
169
|
captionHeight = result.height;
|
|
75
170
|
captionLines = result.lines;
|
|
171
|
+
if (verbose) {
|
|
172
|
+
console.log(pc.dim(` Adaptive height: ${captionHeight}px`));
|
|
173
|
+
}
|
|
76
174
|
}
|
|
77
175
|
else {
|
|
78
176
|
// Use fixed height with text wrapping
|
|
@@ -88,6 +186,15 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
88
186
|
if (captionBoxConfig.maxHeight) {
|
|
89
187
|
captionHeight = Math.min(captionBoxConfig.maxHeight, captionHeight);
|
|
90
188
|
}
|
|
189
|
+
if (verbose) {
|
|
190
|
+
console.log(pc.dim(` Max lines: ${maxLines}`));
|
|
191
|
+
console.log(pc.dim(` Line height: ${lineHeight}`));
|
|
192
|
+
console.log(pc.dim(` Fixed height: ${captionHeight}px`));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (verbose && captionLines.length > 0) {
|
|
196
|
+
console.log(pc.dim(` Lines wrapped: ${captionLines.length}`));
|
|
197
|
+
console.log(pc.dim(` Wrap width: ${outputWidth - 80}px`)); // Assuming 40px padding each side
|
|
91
198
|
}
|
|
92
199
|
}
|
|
93
200
|
// Calculate total canvas dimensions (should be output dimensions)
|
|
@@ -113,24 +220,22 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
113
220
|
// Fallback if no lines were calculated
|
|
114
221
|
captionLines = [caption];
|
|
115
222
|
}
|
|
116
|
-
// Calculate vertical positioning for centered text block
|
|
117
|
-
const totalTextHeight = captionLines.length * fontSize * lineHeight;
|
|
118
|
-
const startY = (captionHeight - totalTextHeight) / 2 + fontSize;
|
|
119
|
-
// Create SVG with multiple text lines
|
|
120
223
|
// Use device-specific font if available, otherwise use global caption font
|
|
121
224
|
const fontToUse = deviceConfig.captionFont || captionConfig.font;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
225
|
+
// Parse font name for style and weight
|
|
226
|
+
const parsedFont = parseFontName(fontToUse);
|
|
227
|
+
const fontFamily = getFontStack(parsedFont.family);
|
|
228
|
+
const fontStyle = parsedFont.style || 'normal';
|
|
229
|
+
const fontWeight = parsedFont.weight === 'bold' ? '700' : '400';
|
|
230
|
+
if (verbose) {
|
|
231
|
+
console.log(pc.dim(' Font information:'));
|
|
232
|
+
console.log(pc.dim(` Requested: ${fontToUse}`));
|
|
233
|
+
console.log(pc.dim(` Font stack: ${fontFamily}`));
|
|
234
|
+
if (parsedFont.style || parsedFont.weight) {
|
|
235
|
+
console.log(pc.dim(` Style: ${fontStyle}, Weight: ${fontWeight}`));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
svgText = generateCaptionSVG(captionLines, canvasWidth, captionHeight, fontSize, fontFamily, fontStyle, fontWeight, captionConfig.color, lineHeight, captionConfig);
|
|
134
239
|
const captionImage = await sharp(Buffer.from(svgText))
|
|
135
240
|
.png()
|
|
136
241
|
.toBuffer();
|
|
@@ -170,7 +275,19 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
170
275
|
const originalFrameHeight = frameMetadata.frameHeight;
|
|
171
276
|
// Calculate available space for the device
|
|
172
277
|
const availableWidth = outputWidth;
|
|
173
|
-
let availableHeight
|
|
278
|
+
let availableHeight;
|
|
279
|
+
// For 'above' positioning, reduce available height by caption
|
|
280
|
+
// For 'below' positioning, we'll adjust positioning later but calculate scale normally
|
|
281
|
+
if (captionPosition === 'above') {
|
|
282
|
+
availableHeight = Math.max(100, outputHeight - captionHeight);
|
|
283
|
+
}
|
|
284
|
+
else if (captionPosition === 'below') {
|
|
285
|
+
availableHeight = Math.max(100, outputHeight - captionHeight);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// 'overlay' positioning doesn't reduce available space
|
|
289
|
+
availableHeight = outputHeight;
|
|
290
|
+
}
|
|
174
291
|
// If frameScale is explicitly set, use total output height for consistent sizing
|
|
175
292
|
if (deviceFrameScale !== undefined) {
|
|
176
293
|
availableHeight = outputHeight; // Use full height for consistent scale
|
|
@@ -197,6 +314,13 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
197
314
|
// Apply scaling to optimize canvas usage
|
|
198
315
|
let targetDeviceWidth = Math.min(Math.floor(originalFrameWidth * scale), outputWidth);
|
|
199
316
|
let targetDeviceHeight = Math.min(Math.floor(originalFrameHeight * scale), outputHeight);
|
|
317
|
+
if (verbose) {
|
|
318
|
+
console.log(pc.dim(' Device composition:'));
|
|
319
|
+
console.log(pc.dim(` Frame: ${frameMetadata.displayName || frameMetadata.name}`));
|
|
320
|
+
console.log(pc.dim(` Original size: ${originalFrameWidth}x${originalFrameHeight}`));
|
|
321
|
+
console.log(pc.dim(` Scale factor: ${scale.toFixed(2)}`));
|
|
322
|
+
console.log(pc.dim(` Target size: ${targetDeviceWidth}x${targetDeviceHeight}`));
|
|
323
|
+
}
|
|
200
324
|
// Scale screenshot to fit in frame's screen area
|
|
201
325
|
let resizedScreenshot;
|
|
202
326
|
try {
|
|
@@ -301,6 +425,10 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
301
425
|
// If partial frame, crop the bottom
|
|
302
426
|
if (partialFrame) {
|
|
303
427
|
const cropHeight = Math.floor(originalFrameHeight * (1 - frameOffset / 100));
|
|
428
|
+
if (verbose) {
|
|
429
|
+
console.log(pc.dim(` Partial frame: cropping ${frameOffset}% from bottom`));
|
|
430
|
+
console.log(pc.dim(` Crop height: ${cropHeight}px`));
|
|
431
|
+
}
|
|
304
432
|
try {
|
|
305
433
|
deviceComposite = await sharp(deviceComposite)
|
|
306
434
|
.extract({
|
|
@@ -335,35 +463,98 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
335
463
|
}
|
|
336
464
|
}
|
|
337
465
|
// Recalculate position with actual caption height
|
|
338
|
-
if (
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
466
|
+
if (captionPosition === 'above') {
|
|
467
|
+
// Caption above: position device below caption area
|
|
468
|
+
if (typeof framePosition === 'number') {
|
|
469
|
+
// Custom position as percentage from top (0-100)
|
|
470
|
+
const availableSpace = canvasHeight - captionHeight - targetDeviceHeight;
|
|
471
|
+
deviceTop = captionHeight + Math.floor(availableSpace * (framePosition / 100));
|
|
472
|
+
}
|
|
473
|
+
else if (framePosition === 'top') {
|
|
474
|
+
deviceTop = captionHeight;
|
|
475
|
+
}
|
|
476
|
+
else if (framePosition === 'bottom') {
|
|
477
|
+
deviceTop = canvasHeight - targetDeviceHeight;
|
|
478
|
+
}
|
|
479
|
+
else if (framePosition === 'center') {
|
|
480
|
+
// Default centered positioning
|
|
481
|
+
if (frameMetadata.deviceType === 'watch' && !deviceConfig.framePosition) {
|
|
482
|
+
// Special watch positioning (unless explicitly overridden)
|
|
483
|
+
deviceTop = canvasHeight - Math.floor(targetDeviceHeight * 0.75) - 25;
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
const availableSpace = canvasHeight - captionHeight;
|
|
487
|
+
deviceTop = captionHeight + Math.floor((availableSpace - targetDeviceHeight) / 2);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
// Default to centered
|
|
492
|
+
deviceTop = captionHeight;
|
|
493
|
+
}
|
|
494
|
+
// Ensure device doesn't go off canvas
|
|
495
|
+
deviceTop = Math.floor(Math.max(captionHeight, Math.min(deviceTop, canvasHeight - targetDeviceHeight)));
|
|
348
496
|
}
|
|
349
|
-
else if (
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
497
|
+
else if (captionPosition === 'below') {
|
|
498
|
+
// Caption below: position device in upper area, leaving space for caption at bottom
|
|
499
|
+
const deviceAreaHeight = canvasHeight - captionHeight;
|
|
500
|
+
if (typeof framePosition === 'number') {
|
|
501
|
+
// Custom position as percentage within device area
|
|
502
|
+
const availableSpace = deviceAreaHeight - targetDeviceHeight;
|
|
503
|
+
deviceTop = Math.floor(availableSpace * (framePosition / 100));
|
|
504
|
+
}
|
|
505
|
+
else if (framePosition === 'top') {
|
|
506
|
+
deviceTop = 0;
|
|
507
|
+
}
|
|
508
|
+
else if (framePosition === 'bottom') {
|
|
509
|
+
deviceTop = deviceAreaHeight - targetDeviceHeight;
|
|
510
|
+
}
|
|
511
|
+
else if (framePosition === 'center') {
|
|
512
|
+
// Default centered positioning in device area
|
|
513
|
+
if (frameMetadata.deviceType === 'watch' && !deviceConfig.framePosition) {
|
|
514
|
+
// Special watch positioning (unless explicitly overridden)
|
|
515
|
+
deviceTop = Math.max(0, deviceAreaHeight - Math.floor(targetDeviceHeight * 0.75) - 25);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
deviceTop = Math.floor((deviceAreaHeight - targetDeviceHeight) / 2);
|
|
519
|
+
}
|
|
354
520
|
}
|
|
355
521
|
else {
|
|
356
|
-
|
|
357
|
-
deviceTop =
|
|
522
|
+
// Default to centered in device area
|
|
523
|
+
deviceTop = Math.floor((deviceAreaHeight - targetDeviceHeight) / 2);
|
|
358
524
|
}
|
|
525
|
+
// Ensure device doesn't go off canvas or into caption area
|
|
526
|
+
deviceTop = Math.floor(Math.max(0, Math.min(deviceTop, deviceAreaHeight - targetDeviceHeight)));
|
|
359
527
|
}
|
|
360
528
|
else {
|
|
361
|
-
//
|
|
362
|
-
|
|
529
|
+
// 'overlay' positioning: position device normally without caption area considerations
|
|
530
|
+
if (typeof framePosition === 'number') {
|
|
531
|
+
const availableSpace = canvasHeight - targetDeviceHeight;
|
|
532
|
+
deviceTop = Math.floor(availableSpace * (framePosition / 100));
|
|
533
|
+
}
|
|
534
|
+
else if (framePosition === 'top') {
|
|
535
|
+
deviceTop = 0;
|
|
536
|
+
}
|
|
537
|
+
else if (framePosition === 'bottom') {
|
|
538
|
+
deviceTop = canvasHeight - targetDeviceHeight;
|
|
539
|
+
}
|
|
540
|
+
else if (framePosition === 'center') {
|
|
541
|
+
if (frameMetadata.deviceType === 'watch' && !deviceConfig.framePosition) {
|
|
542
|
+
deviceTop = canvasHeight - Math.floor(targetDeviceHeight * 0.75) - 25;
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
deviceTop = Math.floor((canvasHeight - targetDeviceHeight) / 2);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
deviceTop = Math.floor((canvasHeight - targetDeviceHeight) / 2);
|
|
550
|
+
}
|
|
551
|
+
// Ensure device doesn't go off canvas
|
|
552
|
+
deviceTop = Math.floor(Math.max(0, Math.min(deviceTop, canvasHeight - targetDeviceHeight)));
|
|
363
553
|
}
|
|
364
|
-
// Ensure device doesn't go off canvas
|
|
365
|
-
deviceTop = Math.floor(Math.max(captionHeight, Math.min(deviceTop, canvasHeight - targetDeviceHeight)));
|
|
366
554
|
const deviceLeft = Math.floor((canvasWidth - targetDeviceWidth) / 2);
|
|
555
|
+
if (verbose) {
|
|
556
|
+
console.log(pc.dim(` Position: ${framePosition} → top: ${deviceTop}px, left: ${deviceLeft}px`));
|
|
557
|
+
}
|
|
367
558
|
// Add the complete device to composites
|
|
368
559
|
composites.push({
|
|
369
560
|
input: deviceComposite,
|
|
@@ -378,7 +569,21 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
378
569
|
const screenshotHeight = screenshotMeta.height || outputHeight;
|
|
379
570
|
// Calculate available space for the screenshot
|
|
380
571
|
const availableWidth = outputWidth;
|
|
381
|
-
|
|
572
|
+
let availableHeight;
|
|
573
|
+
let deviceTop;
|
|
574
|
+
if (captionPosition === 'above') {
|
|
575
|
+
availableHeight = outputHeight - captionHeight;
|
|
576
|
+
deviceTop = Math.floor(captionHeight);
|
|
577
|
+
}
|
|
578
|
+
else if (captionPosition === 'below') {
|
|
579
|
+
availableHeight = outputHeight - captionHeight;
|
|
580
|
+
deviceTop = 0; // Start from top when caption is below
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
// 'overlay' positioning
|
|
584
|
+
availableHeight = outputHeight;
|
|
585
|
+
deviceTop = 0;
|
|
586
|
+
}
|
|
382
587
|
// Calculate scale to fit within available space while maintaining aspect ratio
|
|
383
588
|
const scaleX = availableWidth / screenshotWidth;
|
|
384
589
|
const scaleY = availableHeight / screenshotHeight;
|
|
@@ -395,8 +600,11 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
395
600
|
})
|
|
396
601
|
.toBuffer();
|
|
397
602
|
}
|
|
603
|
+
// For 'below' positioning, center the screenshot in the available device area
|
|
604
|
+
if (captionPosition === 'below') {
|
|
605
|
+
deviceTop = Math.floor((availableHeight - targetHeight) / 2);
|
|
606
|
+
}
|
|
398
607
|
// Center the screenshot horizontally
|
|
399
|
-
const deviceTop = Math.floor(captionHeight);
|
|
400
608
|
const deviceLeft = Math.floor((canvasWidth - targetWidth) / 2);
|
|
401
609
|
composites.push({
|
|
402
610
|
input: resizedScreenshot,
|
|
@@ -404,11 +612,169 @@ export async function composeAppStoreScreenshot(options) {
|
|
|
404
612
|
left: Math.max(0, deviceLeft)
|
|
405
613
|
});
|
|
406
614
|
}
|
|
407
|
-
// Add
|
|
408
|
-
|
|
615
|
+
// Add caption if positioned below the device
|
|
616
|
+
if (caption && captionPosition === 'below') {
|
|
617
|
+
try {
|
|
618
|
+
// Create simple SVG text
|
|
619
|
+
const isWatch = outputWidth < 500;
|
|
620
|
+
// Use device-specific caption size if provided
|
|
621
|
+
const baseFontSize = deviceConfig.captionSize || captionConfig.fontsize;
|
|
622
|
+
const fontSize = isWatch ? Math.min(36, baseFontSize) : baseFontSize; // Smaller font for watch
|
|
623
|
+
let svgText;
|
|
624
|
+
// Get caption box config
|
|
625
|
+
const captionBoxConfig = deviceConfig.captionBox || captionConfig.box || {};
|
|
626
|
+
const lineHeight = captionBoxConfig.lineHeight || 1.4;
|
|
627
|
+
if (captionLines.length === 0) {
|
|
628
|
+
// Fallback if no lines were calculated
|
|
629
|
+
captionLines = [caption];
|
|
630
|
+
}
|
|
631
|
+
// Use device-specific font if available, otherwise use global caption font
|
|
632
|
+
const fontToUse = deviceConfig.captionFont || captionConfig.font;
|
|
633
|
+
// Parse font name for style and weight
|
|
634
|
+
const parsedFont = parseFontName(fontToUse);
|
|
635
|
+
const fontFamily = getFontStack(parsedFont.family);
|
|
636
|
+
const fontStyle = parsedFont.style || 'normal';
|
|
637
|
+
const fontWeight = parsedFont.weight === 'bold' ? '700' : '400';
|
|
638
|
+
if (verbose) {
|
|
639
|
+
console.log(pc.dim(' Caption below device:'));
|
|
640
|
+
console.log(pc.dim(` Font: ${fontToUse} → ${fontFamily}`));
|
|
641
|
+
if (parsedFont.style || parsedFont.weight) {
|
|
642
|
+
console.log(pc.dim(` Style: ${fontStyle}, Weight: ${fontWeight}`));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
svgText = generateCaptionSVG(captionLines, canvasWidth, captionHeight, fontSize, fontFamily, fontStyle, fontWeight, captionConfig.color, lineHeight, captionConfig);
|
|
646
|
+
const captionImage = await sharp(Buffer.from(svgText))
|
|
647
|
+
.png()
|
|
648
|
+
.toBuffer();
|
|
649
|
+
// Position caption at bottom of canvas
|
|
650
|
+
const captionTop = canvasHeight - captionHeight;
|
|
651
|
+
composites.push({
|
|
652
|
+
input: captionImage,
|
|
653
|
+
top: captionTop,
|
|
654
|
+
left: 0
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
// If text rendering fails, just add transparent area at bottom
|
|
659
|
+
console.log('[INFO] Bottom caption rendering failed, reserving space');
|
|
660
|
+
const captionArea = await sharp({
|
|
661
|
+
create: {
|
|
662
|
+
width: canvasWidth,
|
|
663
|
+
height: captionHeight,
|
|
664
|
+
channels: 4,
|
|
665
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
666
|
+
}
|
|
667
|
+
})
|
|
668
|
+
.png()
|
|
669
|
+
.toBuffer();
|
|
670
|
+
composites.push({
|
|
671
|
+
input: captionArea,
|
|
672
|
+
top: canvasHeight - captionHeight,
|
|
673
|
+
left: 0
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Add overlay caption if specified
|
|
409
678
|
if (caption && captionPosition === 'overlay') {
|
|
410
|
-
|
|
411
|
-
|
|
679
|
+
try {
|
|
680
|
+
// Create simple SVG text for overlay
|
|
681
|
+
const isWatch = outputWidth < 500;
|
|
682
|
+
const baseFontSize = deviceConfig.captionSize || captionConfig.fontsize;
|
|
683
|
+
const fontSize = isWatch ? Math.min(36, baseFontSize) : baseFontSize;
|
|
684
|
+
// Get caption box config
|
|
685
|
+
const captionBoxConfig = deviceConfig.captionBox || captionConfig.box || {};
|
|
686
|
+
const lineHeight = captionBoxConfig.lineHeight || 1.4;
|
|
687
|
+
// For overlay, use simple single line or basic wrapping
|
|
688
|
+
let overlayLines = [];
|
|
689
|
+
if (captionLines.length > 0) {
|
|
690
|
+
overlayLines = captionLines;
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
// Simple fallback wrapping for overlay
|
|
694
|
+
const maxCharsPerLine = Math.floor(outputWidth / (fontSize * 0.6));
|
|
695
|
+
if (caption.length > maxCharsPerLine) {
|
|
696
|
+
overlayLines = wrapText(caption, outputWidth, fontSize, 2); // Max 2 lines for overlay
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
overlayLines = [caption];
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// Position overlay text based on padding settings
|
|
703
|
+
const textY = captionConfig.paddingTop + fontSize;
|
|
704
|
+
// Use device-specific font if available, otherwise use global caption font
|
|
705
|
+
const fontToUse = deviceConfig.captionFont || captionConfig.font;
|
|
706
|
+
// Parse font name for style and weight
|
|
707
|
+
const parsedFont = parseFontName(fontToUse);
|
|
708
|
+
const fontFamily = getFontStack(parsedFont.family);
|
|
709
|
+
const fontStyle = parsedFont.style || 'normal';
|
|
710
|
+
const fontWeight = parsedFont.weight === 'bold' ? '700' : '400';
|
|
711
|
+
if (verbose) {
|
|
712
|
+
console.log(pc.dim(' Overlay caption:'));
|
|
713
|
+
console.log(pc.dim(` Font: ${fontToUse} → ${fontFamily}`));
|
|
714
|
+
console.log(pc.dim(` Position: ${textY}px from top`));
|
|
715
|
+
}
|
|
716
|
+
// For overlay, we need custom SVG generation with background/border support
|
|
717
|
+
const svgElements = [];
|
|
718
|
+
// Add background rectangle if configured
|
|
719
|
+
const backgroundConfig = captionConfig.background;
|
|
720
|
+
const borderConfig = captionConfig.border;
|
|
721
|
+
if (backgroundConfig?.color) {
|
|
722
|
+
const bgPadding = backgroundConfig.padding || 20;
|
|
723
|
+
const bgOpacity = backgroundConfig.opacity || 0.8;
|
|
724
|
+
// Use full width minus margins for uniform appearance
|
|
725
|
+
const totalTextHeight = overlayLines.length * fontSize * lineHeight;
|
|
726
|
+
const sideMargin = 30; // Margin from edges
|
|
727
|
+
const bgWidth = canvasWidth - (sideMargin * 2);
|
|
728
|
+
const bgHeight = totalTextHeight + bgPadding * 2;
|
|
729
|
+
const bgX = sideMargin;
|
|
730
|
+
const bgY = textY - fontSize - bgPadding;
|
|
731
|
+
const bgRadius = borderConfig?.radius || 12; // Default to 12px for better visibility
|
|
732
|
+
svgElements.push(`<rect x="${bgX}" y="${bgY}" width="${bgWidth}" height="${bgHeight}" ` +
|
|
733
|
+
`fill="${backgroundConfig.color}" opacity="${bgOpacity}" rx="${bgRadius}"/>`);
|
|
734
|
+
}
|
|
735
|
+
// Add border rectangle if configured
|
|
736
|
+
if (borderConfig?.color && borderConfig?.width) {
|
|
737
|
+
const bgPadding = backgroundConfig?.padding || 20;
|
|
738
|
+
const borderWidth = borderConfig.width;
|
|
739
|
+
const borderRadius = borderConfig.radius || 12;
|
|
740
|
+
const totalTextHeight = overlayLines.length * fontSize * lineHeight;
|
|
741
|
+
const sideMargin = 30; // Margin from edges
|
|
742
|
+
const rectWidth = canvasWidth - (sideMargin * 2);
|
|
743
|
+
const rectHeight = totalTextHeight + bgPadding * 2;
|
|
744
|
+
const rectX = sideMargin;
|
|
745
|
+
const rectY = textY - fontSize - bgPadding;
|
|
746
|
+
svgElements.push(`<rect x="${rectX}" y="${rectY}" width="${rectWidth}" height="${rectHeight}" ` +
|
|
747
|
+
`fill="none" stroke="${borderConfig.color}" stroke-width="${borderWidth}" rx="${borderRadius}"/>`);
|
|
748
|
+
}
|
|
749
|
+
// Add text elements
|
|
750
|
+
const textElements = overlayLines.map((line, index) => {
|
|
751
|
+
const y = textY + (index * fontSize * lineHeight);
|
|
752
|
+
return `<text x="${canvasWidth / 2}" y="${y}" ` +
|
|
753
|
+
`font-family="${fontFamily}" ` +
|
|
754
|
+
`font-size="${fontSize}" ` +
|
|
755
|
+
`font-style="${fontStyle}" ` +
|
|
756
|
+
`font-weight="${fontWeight}" ` +
|
|
757
|
+
`fill="${captionConfig.color}" ` +
|
|
758
|
+
`text-anchor="middle">${escapeXml(line)}</text>`;
|
|
759
|
+
});
|
|
760
|
+
svgElements.push(...textElements);
|
|
761
|
+
const svgText = `<svg width="${canvasWidth}" height="${canvasHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
762
|
+
${svgElements.join('\n ')}
|
|
763
|
+
</svg>`;
|
|
764
|
+
const overlayImage = await sharp(Buffer.from(svgText))
|
|
765
|
+
.png()
|
|
766
|
+
.toBuffer();
|
|
767
|
+
// Add overlay caption on top of everything
|
|
768
|
+
composites.push({
|
|
769
|
+
input: overlayImage,
|
|
770
|
+
top: 0,
|
|
771
|
+
left: 0,
|
|
772
|
+
blend: 'over'
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
console.log('[INFO] Overlay caption rendering failed, skipping caption');
|
|
777
|
+
}
|
|
412
778
|
}
|
|
413
779
|
// Composite everything onto the gradient
|
|
414
780
|
const result = await sharp(gradient)
|
|
@@ -505,6 +871,24 @@ function _createCaptionSvg(text, config, width, height) {
|
|
|
505
871
|
/**
|
|
506
872
|
* Get a safe font stack that Sharp's SVG renderer can use
|
|
507
873
|
*/
|
|
874
|
+
export async function getFontStackAsync(requestedFont) {
|
|
875
|
+
// Check if font is embedded
|
|
876
|
+
const fontService = FontService.getInstance();
|
|
877
|
+
const fontStatus = await fontService.getFontStatusWithEmbedded(requestedFont);
|
|
878
|
+
if (fontStatus.embedded && fontStatus.path) {
|
|
879
|
+
// Return embedded font info
|
|
880
|
+
return {
|
|
881
|
+
stack: getFontStack(requestedFont),
|
|
882
|
+
isEmbedded: true,
|
|
883
|
+
path: fontStatus.path
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
// Return normal font stack
|
|
887
|
+
return {
|
|
888
|
+
stack: getFontStack(requestedFont),
|
|
889
|
+
isEmbedded: false
|
|
890
|
+
};
|
|
891
|
+
}
|
|
508
892
|
export function getFontStack(requestedFont) {
|
|
509
893
|
// Map common fonts to web-safe alternatives with appropriate fallbacks
|
|
510
894
|
// Note: Using single quotes inside to avoid XML attribute quote conflicts
|
|
@@ -525,6 +909,8 @@ export function getFontStack(requestedFont) {
|
|
|
525
909
|
'Lato': "Lato, 'Helvetica Neue', Arial, sans-serif",
|
|
526
910
|
'Poppins': "Poppins, 'Helvetica Neue', Arial, sans-serif",
|
|
527
911
|
'Inter': "Inter, system-ui, 'Helvetica Neue', Arial, sans-serif",
|
|
912
|
+
'DM Sans': "'DM Sans', system-ui, 'Helvetica Neue', Arial, sans-serif",
|
|
913
|
+
'Work Sans': "'Work Sans', 'Helvetica Neue', Arial, sans-serif",
|
|
528
914
|
'Segoe UI': "'Segoe UI', system-ui, Tahoma, Geneva, sans-serif",
|
|
529
915
|
'Ubuntu': "Ubuntu, system-ui, 'Helvetica Neue', Arial, sans-serif",
|
|
530
916
|
'Fira Sans': "'Fira Sans', 'Helvetica Neue', Arial, sans-serif",
|