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.
Files changed (83) hide show
  1. package/README.md +185 -14
  2. package/dist/cli.js +34 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/build.d.ts.map +1 -1
  5. package/dist/commands/build.js +145 -62
  6. package/dist/commands/build.js.map +1 -1
  7. package/dist/commands/caption.d.ts.map +1 -1
  8. package/dist/commands/caption.js +28 -1
  9. package/dist/commands/caption.js.map +1 -1
  10. package/dist/commands/fonts.d.ts.map +1 -1
  11. package/dist/commands/fonts.js +89 -6
  12. package/dist/commands/fonts.js.map +1 -1
  13. package/dist/commands/gradients.d.ts.map +1 -1
  14. package/dist/commands/gradients.js +42 -2
  15. package/dist/commands/gradients.js.map +1 -1
  16. package/dist/commands/init.d.ts.map +1 -1
  17. package/dist/commands/init.js +20 -1
  18. package/dist/commands/init.js.map +1 -1
  19. package/dist/commands/localize.d.ts.map +1 -1
  20. package/dist/commands/localize.js +33 -1
  21. package/dist/commands/localize.js.map +1 -1
  22. package/dist/commands/style.d.ts.map +1 -1
  23. package/dist/commands/style.js +156 -2
  24. package/dist/commands/style.js.map +1 -1
  25. package/dist/core/compose.d.ts +6 -0
  26. package/dist/core/compose.d.ts.map +1 -1
  27. package/dist/core/compose.js +433 -47
  28. package/dist/core/compose.js.map +1 -1
  29. package/dist/core/devices.d.ts +1 -1
  30. package/dist/core/devices.d.ts.map +1 -1
  31. package/dist/core/devices.js +5 -1
  32. package/dist/core/devices.js.map +1 -1
  33. package/dist/core/files.d.ts +1 -1
  34. package/dist/core/files.d.ts.map +1 -1
  35. package/dist/core/files.js +15 -3
  36. package/dist/core/files.js.map +1 -1
  37. package/dist/services/doctor.js +1 -1
  38. package/dist/services/fonts.d.ts +37 -2
  39. package/dist/services/fonts.d.ts.map +1 -1
  40. package/dist/services/fonts.js +225 -0
  41. package/dist/services/fonts.js.map +1 -1
  42. package/dist/types.d.ts +15 -2
  43. package/dist/types.d.ts.map +1 -1
  44. package/fonts/DMSans/DMSans-LICENSE.txt +93 -0
  45. package/fonts/DMSans/DMSans-Regular.ttf +0 -0
  46. package/fonts/FiraCode/FiraCode-Bold.ttf +0 -0
  47. package/fonts/FiraCode/FiraCode-LICENSE.txt +93 -0
  48. package/fonts/FiraCode/FiraCode-Light.ttf +0 -0
  49. package/fonts/FiraCode/FiraCode-Regular.ttf +0 -0
  50. package/fonts/FiraCode/FiraCode-SemiBold.ttf +0 -0
  51. package/fonts/Inter/InterVariable-Italic.ttf +0 -0
  52. package/fonts/Inter/InterVariable.ttf +0 -0
  53. package/fonts/Inter/LICENSE.txt +92 -0
  54. package/fonts/JetBrainsMono/JetBrainsMono-Bold.ttf +0 -0
  55. package/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.ttf +0 -0
  56. package/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf +0 -0
  57. package/fonts/JetBrainsMono/JetBrainsMono-LICENSE.txt +93 -0
  58. package/fonts/JetBrainsMono/JetBrainsMono-Regular.ttf +0 -0
  59. package/fonts/Lato/Lato-Bold.ttf +0 -0
  60. package/fonts/Lato/Lato-BoldItalic.ttf +0 -0
  61. package/fonts/Lato/Lato-Italic.ttf +0 -0
  62. package/fonts/Lato/Lato-LICENSE.txt +93 -0
  63. package/fonts/Lato/Lato-Regular.ttf +0 -0
  64. package/fonts/Montserrat/Montserrat-Bold.ttf +2070 -0
  65. package/fonts/Montserrat/Montserrat-BoldItalic.ttf +2070 -0
  66. package/fonts/Montserrat/Montserrat-Italic.ttf +2070 -0
  67. package/fonts/Montserrat/Montserrat-LICENSE.txt +93 -0
  68. package/fonts/Montserrat/Montserrat-Regular.ttf +2070 -0
  69. package/fonts/OpenSans/OpenSans-LICENSE.txt +92 -0
  70. package/fonts/OpenSans/OpenSans-Regular.ttf +0 -0
  71. package/fonts/Poppins/Poppins-Bold.ttf +0 -0
  72. package/fonts/Poppins/Poppins-BoldItalic.ttf +0 -0
  73. package/fonts/Poppins/Poppins-Italic.ttf +0 -0
  74. package/fonts/Poppins/Poppins-LICENSE.txt +93 -0
  75. package/fonts/Poppins/Poppins-Regular.ttf +0 -0
  76. package/fonts/Roboto/Roboto-Bold.ttf +2070 -0
  77. package/fonts/Roboto/Roboto-BoldItalic.ttf +2070 -0
  78. package/fonts/Roboto/Roboto-Italic.ttf +2070 -0
  79. package/fonts/Roboto/Roboto-LICENSE.txt +2070 -0
  80. package/fonts/Roboto/Roboto-Regular.ttf +2070 -0
  81. package/fonts/WorkSans/WorkSans-LICENSE.txt +93 -0
  82. package/fonts/WorkSans/WorkSans-Regular.ttf +0 -0
  83. package/package.json +7 -3
@@ -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 if positioned above
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
- const textElements = captionLines.map((line, index) => {
123
- const y = startY + (index * fontSize * lineHeight);
124
- return `<text x="${canvasWidth / 2}" y="${y}"
125
- font-family="${getFontStack(fontToUse)}"
126
- font-size="${fontSize}"
127
- fill="${captionConfig.color}"
128
- text-anchor="middle"
129
- font-weight="bold">${escapeXml(line)}</text>`;
130
- }).join('\n');
131
- svgText = `<svg width="${canvasWidth}" height="${captionHeight}" xmlns="http://www.w3.org/2000/svg">
132
- ${textElements}
133
- </svg>`;
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 = Math.max(100, outputHeight - captionHeight); // Default: account for caption
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 (typeof framePosition === 'number') {
339
- // Custom position as percentage from top (0-100)
340
- const availableSpace = canvasHeight - captionHeight - targetDeviceHeight;
341
- deviceTop = captionHeight + Math.floor(availableSpace * (framePosition / 100));
342
- }
343
- else if (framePosition === 'top') {
344
- deviceTop = captionHeight;
345
- }
346
- else if (framePosition === 'bottom') {
347
- deviceTop = canvasHeight - targetDeviceHeight;
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 (framePosition === 'center') {
350
- // Default centered positioning
351
- if (frameMetadata.deviceType === 'watch' && !deviceConfig.framePosition) {
352
- // Special watch positioning (unless explicitly overridden)
353
- deviceTop = canvasHeight - Math.floor(targetDeviceHeight * 0.75) - 25;
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
- const availableSpace = canvasHeight - captionHeight;
357
- deviceTop = captionHeight + Math.floor((availableSpace - targetDeviceHeight) / 2);
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
- // Default to centered
362
- deviceTop = captionHeight;
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
- const availableHeight = outputHeight - captionHeight;
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 overlay caption if specified (legacy support)
408
- // Note: Caption rendering requires librsvg to be installed
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
- // Skip caption rendering for now - would require librsvg
411
- // TODO: Implement pure bitmap text rendering in future version
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",