apply-template.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. /**
  2. * apply-template.ts
  3. * Apply the academic-paper figure container template to an extracted figure.
  4. *
  5. * Usage:
  6. * npx -y bun apply-template.ts --figure figure.png --title "Title" --caption "Figure 1: Caption" --output slide.png
  7. *
  8. * Options:
  9. * --figure Path to extracted figure image (required)
  10. * --title Slide title/headline (required)
  11. * --caption Figure caption, e.g., "Figure 1: Description" (required)
  12. * --output Output slide PNG file path (required)
  13. * --width Output width, default 1920 (optional)
  14. * --height Output height, default 1080 (optional)
  15. */
  16. import { existsSync, writeFileSync, mkdirSync } from "fs";
  17. import { dirname, resolve } from "path";
  18. import { createCanvas, loadImage, CanvasRenderingContext2D } from "canvas";
  19. // Color palette from academic-paper style
  20. const COLORS = {
  21. background: "#FFFFFF",
  22. titleText: "#1E3A5F",
  23. captionText: "#6B7280",
  24. border: "#E5E7EB",
  25. shadow: "rgba(0, 0, 0, 0.1)",
  26. };
  27. // Layout constants
  28. const LAYOUT = {
  29. titleY: 80,
  30. titleFontSize: 48,
  31. captionFontSize: 24,
  32. figureMaxWidthRatio: 0.85,
  33. figureMaxHeightRatio: 0.65,
  34. padding: 20,
  35. borderRadius: 8,
  36. marginBottom: 80,
  37. };
  38. interface Args {
  39. figure: string;
  40. title: string;
  41. caption: string;
  42. output: string;
  43. width: number;
  44. height: number;
  45. }
  46. function parseArgs(): Args {
  47. const args = process.argv.slice(2);
  48. let figure = "";
  49. let title = "";
  50. let caption = "";
  51. let output = "";
  52. let width = 1920;
  53. let height = 1080;
  54. for (let i = 0; i < args.length; i++) {
  55. switch (args[i]) {
  56. case "--figure":
  57. figure = args[++i];
  58. break;
  59. case "--title":
  60. title = args[++i];
  61. break;
  62. case "--caption":
  63. caption = args[++i];
  64. break;
  65. case "--output":
  66. output = args[++i];
  67. break;
  68. case "--width":
  69. width = parseInt(args[++i], 10);
  70. break;
  71. case "--height":
  72. height = parseInt(args[++i], 10);
  73. break;
  74. }
  75. }
  76. if (!figure || !title || !caption || !output) {
  77. console.error("Usage: bun apply-template.ts --figure <image> --title <title> --caption <caption> --output <output.png>");
  78. console.error("\nOptions:");
  79. console.error(" --figure Path to extracted figure image (required)");
  80. console.error(" --title Slide title/headline (required)");
  81. console.error(" --caption Figure caption (required)");
  82. console.error(" --output Output slide PNG file path (required)");
  83. console.error(" --width Output width, default 1920 (optional)");
  84. console.error(" --height Output height, default 1080 (optional)");
  85. process.exit(1);
  86. }
  87. return { figure, title, caption, output, width, height };
  88. }
  89. function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
  90. const words = text.split(" ");
  91. const lines: string[] = [];
  92. let currentLine = "";
  93. for (const word of words) {
  94. const testLine = currentLine ? `${currentLine} ${word}` : word;
  95. const metrics = ctx.measureText(testLine);
  96. if (metrics.width > maxWidth && currentLine) {
  97. lines.push(currentLine);
  98. currentLine = word;
  99. } else {
  100. currentLine = testLine;
  101. }
  102. }
  103. if (currentLine) {
  104. lines.push(currentLine);
  105. }
  106. return lines;
  107. }
  108. function drawRoundedRect(
  109. ctx: CanvasRenderingContext2D,
  110. x: number,
  111. y: number,
  112. width: number,
  113. height: number,
  114. radius: number
  115. ) {
  116. ctx.beginPath();
  117. ctx.moveTo(x + radius, y);
  118. ctx.lineTo(x + width - radius, y);
  119. ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
  120. ctx.lineTo(x + width, y + height - radius);
  121. ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
  122. ctx.lineTo(x + radius, y + height);
  123. ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
  124. ctx.lineTo(x, y + radius);
  125. ctx.quadraticCurveTo(x, y, x + radius, y);
  126. ctx.closePath();
  127. }
  128. async function applyTemplate(args: Args) {
  129. const { figure, title, caption, output, width, height } = args;
  130. // Validate figure exists
  131. const absoluteFigurePath = resolve(figure);
  132. if (!existsSync(absoluteFigurePath)) {
  133. throw new Error(`Figure image not found: ${absoluteFigurePath}`);
  134. }
  135. console.log(`Loading figure: ${absoluteFigurePath}`);
  136. // Load the figure image
  137. const figureImage = await loadImage(absoluteFigurePath);
  138. console.log(`Figure size: ${figureImage.width}x${figureImage.height}`);
  139. // Create output canvas
  140. const canvas = createCanvas(width, height);
  141. const ctx = canvas.getContext("2d");
  142. // Scale factors for different resolutions
  143. const scaleFactor = width / 1920;
  144. const titleFontSize = Math.round(LAYOUT.titleFontSize * scaleFactor);
  145. const captionFontSize = Math.round(LAYOUT.captionFontSize * scaleFactor);
  146. const padding = Math.round(LAYOUT.padding * scaleFactor);
  147. const borderRadius = Math.round(LAYOUT.borderRadius * scaleFactor);
  148. // Fill background
  149. ctx.fillStyle = COLORS.background;
  150. ctx.fillRect(0, 0, width, height);
  151. // Draw title
  152. ctx.fillStyle = COLORS.titleText;
  153. ctx.font = `bold ${titleFontSize}px Arial, Helvetica, sans-serif`;
  154. ctx.textAlign = "center";
  155. ctx.textBaseline = "middle";
  156. const titleMaxWidth = width * 0.9;
  157. const titleLines = wrapText(ctx, title, titleMaxWidth);
  158. const titleLineHeight = titleFontSize * 1.3;
  159. const titleStartY = Math.round(LAYOUT.titleY * scaleFactor);
  160. titleLines.forEach((line, index) => {
  161. ctx.fillText(line, width / 2, titleStartY + index * titleLineHeight);
  162. });
  163. // Calculate figure area
  164. const titleBottomY = titleStartY + titleLines.length * titleLineHeight + padding * 2;
  165. const captionY = height - Math.round(LAYOUT.marginBottom * scaleFactor);
  166. const figureAreaHeight = captionY - titleBottomY - padding * 2;
  167. const maxFigureWidth = width * LAYOUT.figureMaxWidthRatio;
  168. const maxFigureHeight = Math.min(height * LAYOUT.figureMaxHeightRatio, figureAreaHeight);
  169. // Calculate scaled figure dimensions
  170. const figureAspect = figureImage.width / figureImage.height;
  171. let scaledWidth: number;
  172. let scaledHeight: number;
  173. if (figureAspect > maxFigureWidth / maxFigureHeight) {
  174. // Width constrained
  175. scaledWidth = maxFigureWidth;
  176. scaledHeight = scaledWidth / figureAspect;
  177. } else {
  178. // Height constrained
  179. scaledHeight = maxFigureHeight;
  180. scaledWidth = scaledHeight * figureAspect;
  181. }
  182. // Center figure horizontally and vertically in available space
  183. const figureX = (width - scaledWidth) / 2;
  184. const figureY = titleBottomY + (figureAreaHeight - scaledHeight) / 2;
  185. // Draw figure container with shadow and border
  186. const containerX = figureX - padding;
  187. const containerY = figureY - padding;
  188. const containerWidth = scaledWidth + padding * 2;
  189. const containerHeight = scaledHeight + padding * 2;
  190. // Shadow
  191. ctx.save();
  192. ctx.shadowColor = COLORS.shadow;
  193. ctx.shadowBlur = 12 * scaleFactor;
  194. ctx.shadowOffsetX = 0;
  195. ctx.shadowOffsetY = 4 * scaleFactor;
  196. ctx.fillStyle = COLORS.background;
  197. drawRoundedRect(ctx, containerX, containerY, containerWidth, containerHeight, borderRadius);
  198. ctx.fill();
  199. ctx.restore();
  200. // Border
  201. ctx.strokeStyle = COLORS.border;
  202. ctx.lineWidth = 1;
  203. drawRoundedRect(ctx, containerX, containerY, containerWidth, containerHeight, borderRadius);
  204. ctx.stroke();
  205. // Draw figure
  206. ctx.drawImage(figureImage, figureX, figureY, scaledWidth, scaledHeight);
  207. // Draw caption
  208. ctx.fillStyle = COLORS.captionText;
  209. ctx.font = `italic ${captionFontSize}px Arial, Helvetica, sans-serif`;
  210. ctx.textAlign = "center";
  211. ctx.textBaseline = "middle";
  212. const captionMaxWidth = width * 0.85;
  213. const captionLines = wrapText(ctx, caption, captionMaxWidth);
  214. const captionLineHeight = captionFontSize * 1.4;
  215. captionLines.forEach((line, index) => {
  216. ctx.fillText(line, width / 2, captionY + index * captionLineHeight);
  217. });
  218. // Ensure output directory exists
  219. const outputDir = dirname(output);
  220. if (outputDir && !existsSync(outputDir)) {
  221. mkdirSync(outputDir, { recursive: true });
  222. }
  223. // Save as PNG
  224. const buffer = canvas.toBuffer("image/png");
  225. writeFileSync(output, buffer);
  226. console.log(`\nSaved: ${output}`);
  227. console.log(`Size: ${width}x${height}`);
  228. console.log(`File size: ${Math.round(buffer.length / 1024)} KB`);
  229. }
  230. async function main() {
  231. const args = parseArgs();
  232. try {
  233. await applyTemplate(args);
  234. console.log("\nTemplate applied successfully!");
  235. } catch (error) {
  236. console.error("\nError:", error instanceof Error ? error.message : error);
  237. process.exit(1);
  238. }
  239. }
  240. main();