bluesky quote bot

Replaced skia canvas with ImageScript

+325 -294
+106 -72
deno.lock
··· 1 1 { 2 2 "version": "4", 3 3 "specifiers": { 4 - "jsr:@denosaurs/plug@1.0.5": "1.0.5", 5 - "jsr:@gfx/canvas@0.5.7": "0.5.7", 6 - "jsr:@std/assert@0.214": "0.214.0", 7 - "jsr:@std/assert@0.217": "0.217.0", 8 4 "jsr:@std/dotenv@*": "0.225.2", 9 - "jsr:@std/encoding@0.214": "0.214.0", 10 - "jsr:@std/encoding@0.217.0": "0.217.0", 11 - "jsr:@std/fmt@0.214": "0.214.0", 12 - "jsr:@std/fs@0.214": "0.214.0", 13 - "jsr:@std/fs@0.217.0": "0.217.0", 14 - "jsr:@std/path@0.214": "0.214.0", 15 - "jsr:@std/path@0.217": "0.217.0", 16 - "jsr:@std/path@0.217.0": "0.217.0", 17 - "npm:@atcute/bluesky-richtext-builder@*": "1.0.1_@atcute+bluesky@1.0.7__@atcute+client@2.0.3_@atcute+client@2.0.3", 18 - "npm:@atcute/client@^2.0.3": "2.0.3", 5 + "npm:@atcute/bluesky-richtext-builder@*": "1.0.2_@atcute+bluesky@1.0.8__@atcute+client@2.0.4_@atcute+client@2.0.4", 6 + "npm:@atcute/client@^2.0.3": "2.0.4", 7 + "npm:fontkit@*": "2.0.4", 8 + "npm:hyphenation.en-us@*": "0.2.1", 19 9 "npm:tex-linebreak@*": "0.7.1" 20 10 }, 21 11 "jsr": { 22 - "@denosaurs/plug@1.0.5": { 23 - "integrity": "04cd988da558adc226202d88c3a434d5fcc08146eaf4baf0cea0c2284b16d2bf", 12 + "@std/dotenv@0.225.2": { 13 + "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" 14 + } 15 + }, 16 + "npm": { 17 + "@atcute/bluesky-richtext-builder@1.0.2_@atcute+bluesky@1.0.8__@atcute+client@2.0.4_@atcute+client@2.0.4": { 18 + "integrity": "sha512-sa+9B5Ygb1GcWeMpav9RVBRdFLL5snZEoFFF2RkTaNr61m/cLd5lk97QJs+t9LXUEl5cfHS3jXujywFrGXZj9w==", 24 19 "dependencies": [ 25 - "jsr:@std/encoding@0.214", 26 - "jsr:@std/fmt", 27 - "jsr:@std/fs@0.214", 28 - "jsr:@std/path@0.214" 20 + "@atcute/bluesky", 21 + "@atcute/client" 29 22 ] 30 23 }, 31 - "@gfx/canvas@0.5.7": { 32 - "integrity": "cd054e34f64763c7e6958bedc5fef00def929c20ebb89da5687112b5d402dbe3", 24 + "@atcute/bluesky@1.0.8_@atcute+client@2.0.4": { 25 + "integrity": "sha512-XqAZHYh65ZyBBT5rRkEhP656THYaY6CE+EY0AZXkIDaXJNdIl9KINZW5dnSrVWifspJbM+gUw1QFlCv8Yrx01g==", 33 26 "dependencies": [ 34 - "jsr:@denosaurs/plug", 35 - "jsr:@std/encoding@0.217.0", 36 - "jsr:@std/fs@0.217.0", 37 - "jsr:@std/path@0.217.0" 27 + "@atcute/client" 38 28 ] 39 29 }, 40 - "@std/assert@0.214.0": { 41 - "integrity": "55d398de76a9828fd3b1aa653f4dba3eee4c6985d90c514865d2be9bd082b140" 30 + "@atcute/client@2.0.4": { 31 + "integrity": "sha512-bKA6KEOmrdhU2CDRNp13M4WyKN0EdrVLKJffzPo62ANSTMacz5hRJhmvQYwuo7BZSGIoDql4sH+QR6Xbk3DERg==" 42 32 }, 43 - "@std/assert@0.217.0": { 44 - "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" 33 + "@swc/helpers@0.5.13": { 34 + "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", 35 + "dependencies": [ 36 + "tslib" 37 + ] 45 38 }, 46 - "@std/dotenv@0.225.2": { 47 - "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" 39 + "base64-js@1.5.1": { 40 + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" 48 41 }, 49 - "@std/encoding@0.214.0": { 50 - "integrity": "30a8713e1db22986c7e780555ffd2fefd1d4f9374d734bb41f5970f6c3352af5" 42 + "brotli@1.3.3": { 43 + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", 44 + "dependencies": [ 45 + "base64-js" 46 + ] 51 47 }, 52 - "@std/encoding@0.217.0": { 53 - "integrity": "b03e8ff94c98d6b6a02c02c5cf8e5d203400155516248964fc4559abc04669dc" 48 + "clone@2.1.2": { 49 + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" 54 50 }, 55 - "@std/fmt@0.214.0": { 56 - "integrity": "40382cff88a0783b347b4d69b94cf931ab8e549a733916718cb866c08efac4d4" 51 + "dfa@1.2.0": { 52 + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" 57 53 }, 58 - "@std/fs@0.214.0": { 59 - "integrity": "bc880fea0be120cb1550b1ed7faf92fe071003d83f2456a1e129b39193d85bea", 60 - "dependencies": [ 61 - "jsr:@std/assert@0.214", 62 - "jsr:@std/path@0.214" 63 - ] 54 + "fast-deep-equal@3.1.3": { 55 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 64 56 }, 65 - "@std/fs@0.217.0": { 66 - "integrity": "0bfff5f3618d68c385b28b4ffbf3a15c98293a0f1186444458b62e0111ce77b2", 57 + "fontkit@2.0.4": { 58 + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", 67 59 "dependencies": [ 68 - "jsr:@std/assert@0.217", 69 - "jsr:@std/path@0.217" 60 + "@swc/helpers", 61 + "brotli", 62 + "clone", 63 + "dfa", 64 + "fast-deep-equal", 65 + "restructure", 66 + "tiny-inflate", 67 + "unicode-properties", 68 + "unicode-trie" 70 69 ] 71 70 }, 72 - "@std/path@0.214.0": { 73 - "integrity": "d5577c0b8d66f7e8e3586d864ebdf178bb326145a3611da5a51c961740300285", 71 + "hyphenation.en-us@0.2.1": { 72 + "integrity": "sha512-ItXYgvIpfN8rfXl/GTBQC7DsSb5PPsKh9gGzViK/iWzCS5mvjDebFJ6xCcIYo8dal+nSp2rUzvTT7BosrKlL8A==", 74 73 "dependencies": [ 75 - "jsr:@std/assert@0.214" 74 + "hypher" 76 75 ] 77 76 }, 78 - "@std/path@0.217.0": { 79 - "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", 77 + "hypher@0.2.5": { 78 + "integrity": "sha512-kUTpuyzBWWDO2VakmjHC/cxesg4lKQP+Fdc+7lrK4yvjNjkV9vm5UTZMDAwOyyHTOpbkYrAMlNZHG61NnE9vYQ==" 79 + }, 80 + "pako@0.2.9": { 81 + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" 82 + }, 83 + "restructure@3.0.2": { 84 + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==" 85 + }, 86 + "tex-linebreak@0.7.1": { 87 + "integrity": "sha512-DioAcgbUGvacQSNJBcHoIUl2MnjWpNGz3R9tDX6gjtn/c+59ySW1dyFGMhIpl+u7sT+ywRwYAoAtk+wA5xUIsg==" 88 + }, 89 + "tiny-inflate@1.0.3": { 90 + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" 91 + }, 92 + "tslib@2.7.0": { 93 + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" 94 + }, 95 + "unicode-properties@1.4.1": { 96 + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", 80 97 "dependencies": [ 81 - "jsr:@std/assert@0.217" 82 - ] 83 - } 84 - }, 85 - "npm": { 86 - "@atcute/bluesky-richtext-builder@1.0.1_@atcute+bluesky@1.0.7__@atcute+client@2.0.3_@atcute+client@2.0.3": { 87 - "integrity": "sha512-msyKHZa47id7Oe2zHpgPSuL+l9vLQXmKfi6uyMOS5yw6nLpNd5Tdb96wcgwscMmyH8UR4EoM+2oYLvyH89DcEQ==", 88 - "dependencies": [ 89 - "@atcute/bluesky", 90 - "@atcute/client" 98 + "base64-js", 99 + "unicode-trie" 91 100 ] 92 101 }, 93 - "@atcute/bluesky@1.0.7_@atcute+client@2.0.3": { 94 - "integrity": "sha512-2jPHzl7WbcqRtcAXanJy4Lp638ujqnoGmPCPmBlmpEDP34D7EVKQqjN/mlvglb5n539dThA9xlSgIS8yOxwzDA==", 102 + "unicode-trie@2.0.0": { 103 + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", 95 104 "dependencies": [ 96 - "@atcute/client" 105 + "pako", 106 + "tiny-inflate" 97 107 ] 98 - }, 99 - "@atcute/client@2.0.3": { 100 - "integrity": "sha512-j9GryA5l+4F0BTQWa6/1XmsuSPSq+bqNCY3mrHUGD592hMqUZxgpYDLgRWL+719V287AW/56AwvFYlbjlENp7A==" 101 - }, 102 - "tex-linebreak@0.7.1": { 103 - "integrity": "sha512-DioAcgbUGvacQSNJBcHoIUl2MnjWpNGz3R9tDX6gjtn/c+59ySW1dyFGMhIpl+u7sT+ywRwYAoAtk+wA5xUIsg==" 104 108 } 109 + }, 110 + "remote": { 111 + "https://deno.land/x/imagescript@1.3.0/ImageScript.js": "cf90773c966031edd781ed176c598f7ed495e7694cd9b86c986d2d97f783cca0", 112 + "https://deno.land/x/imagescript@1.3.0/mod.ts": "18a6cb83c55e690c873505f6fe867364c678afb64934fe7aef593a6b92f79995", 113 + "https://deno.land/x/imagescript@1.3.0/png/src/crc.mjs": "5cf50de181d61dd00e66a240d811018ba5070afa8bba302f393604404604de84", 114 + "https://deno.land/x/imagescript@1.3.0/png/src/mem.mjs": "4968d400dae069b4bf0ef4767c1802fd2cc7d15d90eda4cfadf5b4cd19b96c6d", 115 + "https://deno.land/x/imagescript@1.3.0/png/src/png.mjs": "96ef0ceff1b5a6cd9304749e5f187b4ab238509fb5f9a8be8ee934240271ed8d", 116 + "https://deno.land/x/imagescript@1.3.0/png/src/zlib.mjs": "9867dc3fab1d31b664f9344b0d7e977f493d9c912a76c760d012ed2b89f7061c", 117 + "https://deno.land/x/imagescript@1.3.0/utils/buffer.js": "952cb1beb8827e50a493a5d1f29a4845e8c648789406d389dd51f51205ba02d8", 118 + "https://deno.land/x/imagescript@1.3.0/utils/crc32.js": "573d6222b3605890714ebc374e687ec2aa3e9a949223ea199483e47ca4864f7d", 119 + "https://deno.land/x/imagescript@1.3.0/utils/png.js": "fbed9117e0a70602645d70df9c103ff6e79c03e987bd5c1685dcb4200729b6de", 120 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/font.js": "9e75d842608c057045698d6a7cdf5ffd27241b5cdea0391c89a1917b31294524", 121 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/gif.js": "8b86f7b96486bb8ff50fbc7c7487f86cb5cef85e6acd71e1def78a1aa2f12e4f", 122 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/jpeg.js": "75295e2fcf96b4f7bb894b3844fdaa8140d63169d28b466b5d5be89d59a7b6e6", 123 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/png.js": "0659536a8dd8f892c8346e268b2754b4414fad0ec1e9794dfcde1ba1c804ee02", 124 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/svg.js": "f5c8a9d1977b51a7c07549ceb6bbbaca9497321a193f28b3dc229a42d91bcf14", 125 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/tiff.js": "c2d7bdaef094df25aae1752e75167f485e89275d76a1379e39d8949580b7af4f", 126 + "https://deno.land/x/imagescript@1.3.0/utils/wasm/zlib.js": "749875f83abffe24d3b977475a0cbd5f9b52bee1fbdbef61ec183cbfc17805f6", 127 + "https://deno.land/x/imagescript@1.3.0/v2/framebuffer.mjs": "add44ff184636659714b3c6d4b896f628545451abffbc30b5bcc2e8d9a73d012", 128 + "https://deno.land/x/imagescript@1.3.0/v2/ops/blur.mjs": "80716f1ffab8a2aeb54a036f583bf51a2b9dd37e005adc000add803df8e8a12f", 129 + "https://deno.land/x/imagescript@1.3.0/v2/ops/color.mjs": "5e72cdcbf97dc939a2795223f01e3cb0544c0c56b03ea2aa026050df58348814", 130 + "https://deno.land/x/imagescript@1.3.0/v2/ops/crop.mjs": "69431fa6f687fd9f0c31eff0ec27d7ac925275005e53a37f0c3fab4cc4d9a9ea", 131 + "https://deno.land/x/imagescript@1.3.0/v2/ops/fill.mjs": "cf1b9488314753fbc9ebf03410ac74c2a34ea5a69fb6892cd6e8366cd1930d93", 132 + "https://deno.land/x/imagescript@1.3.0/v2/ops/flip.mjs": "825a34a66567dcf15e76a719f1bf2f66fb106503cd69942292b1b0ae05c5718e", 133 + "https://deno.land/x/imagescript@1.3.0/v2/ops/index.mjs": "423ba687119be2bba8cec72890577d3afa3621b6b8108912242fe937a183f2aa", 134 + "https://deno.land/x/imagescript@1.3.0/v2/ops/iterator.mjs": "c2adf3d90ce00719a02c48c97634574176a3501ff026676259bd71aa8f5d69b9", 135 + "https://deno.land/x/imagescript@1.3.0/v2/ops/overlay.mjs": "7e6e2c2ffd25006d52597ab8babc5f8f503d388a3fdf2fbc0eaea02799a020c9", 136 + "https://deno.land/x/imagescript@1.3.0/v2/ops/resize.mjs": "814e78ebce8eaf8f1f918688db7b52a141405e06a36ed4b25d04413d69e7d17b", 137 + "https://deno.land/x/imagescript@1.3.0/v2/ops/rotate.mjs": "a1b65616717bd2eed8db406affea3263b4674dada46b56441ef38167a187455d", 138 + "https://deno.land/x/imagescript@1.3.0/v2/util/mem.mjs": "4968d400dae069b4bf0ef4767c1802fd2cc7d15d90eda4cfadf5b4cd19b96c6d" 105 139 } 106 140 }
+3 -3
main.ts
··· 1 - import { generatePng } from "./utils/canvasUtils.ts"; 1 + import { generateImage } from "./utils/imageUtils.ts"; 2 2 import { createBskyPost } from "./utils/blueskyUtils.ts"; 3 3 import { load } from "jsr:@std/dotenv"; 4 4 ··· 64 64 let index = result.value ?? 0; 65 65 66 66 // Generate screenshot and upload it to Bluesky 67 - const { image, aspectRatio } = generatePng( 68 - data[index].quote, // "image.png" 67 + const { image, aspectRatio } = await generateImage( 68 + data[index].quote, 69 69 ); 70 70 await createBskyPost(data[0], image, aspectRatio); 71 71 console.log(`Cron: posted quote of index ${index} to Bluesky.`);
-219
utils/canvasUtils.ts
··· 1 - import tex from "npm:tex-linebreak"; 2 - import { 3 - Canvas, 4 - CanvasRenderingContext2D, 5 - createCanvas, 6 - Fonts, 7 - SvgCanvas, 8 - SvgRenderingContext2D, 9 - } from "jsr:@gfx/canvas@0.5.7"; 10 - 11 - // Font registering 12 - // Fetch both regular and italic Merriweather font URLs from Google Fonts 13 - const fontUrls = [ 14 - "https://fonts.googleapis.com/css2?family=Merriweather:wght@400&display=swap", // Regular 15 - "https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@1,400&display=swap", // Italic 16 - ]; 17 - 18 - const fontDataArray = await Promise.all( 19 - fontUrls.map(async (url) => { 20 - const fontResponse = await fetch(url); 21 - const cssText = await fontResponse.text(); 22 - const fontMatch = cssText.match(/url\(([^)]+)\)/); 23 - if (!fontMatch) throw new Error("Failed to load font URL"); 24 - const fontFileUrl = fontMatch[1]; 25 - return fetch(fontFileUrl).then((res) => res.arrayBuffer()); 26 - }), 27 - ); 28 - 29 - const lineWidth = 620; 30 - const fontSize = 20; 31 - const fontFamily = "Merriweather"; 32 - const indentSize = 30; 33 - const padding = 40; 34 - 35 - const fontRegularBuffer = new Uint8Array(fontDataArray[0]); 36 - const fontItalicBuffer = new Uint8Array(fontDataArray[0]); 37 - 38 - // Register both regular and italic fonts, set common family name 39 - Fonts.register(fontRegularBuffer, "Merriweather-Regular"); 40 - Fonts.register(fontItalicBuffer, "Merriweather-Italic"); 41 - Fonts.setAlias("Merriweather", "Merriweather-Regular"); 42 - Fonts.setAlias("Merriweather", "Merriweather-Italic"); 43 - 44 - function generateCanvas( 45 - createLocalCanvas: Function, 46 - quote: string, 47 - ) { 48 - const paragraphs = quote.split("\\n"); 49 - const rawTotalItems: tex.TextInputItem[][] = []; 50 - const rawTotalPositionedItems: tex.PositionedItem[][] = []; 51 - 52 - let lineOffset = 0; 53 - let itemOffset = 0; 54 - 55 - for (const paragraph of paragraphs) { 56 - const items = tex.layoutItemsFromString( 57 - paragraph.trim(), 58 - measureText, 59 - ); 60 - rawTotalItems.push(items); 61 - 62 - // Create an array of line widths where the first line is indented 63 - const lineWidths = items.map((_, index) => 64 - index === 0 ? lineWidth - indentSize : lineWidth 65 - ); 66 - 67 - // Find where to insert line-breaks using the varying line widths 68 - const breakpoints = tex.breakLines(items, lineWidths); 69 - 70 - // Compute positions with indentation for the first line 71 - const positionedItems = tex.positionItems( 72 - items, 73 - lineWidths, 74 - breakpoints, 75 - ); 76 - 77 - // Add the indent to the first line's xOffset 78 - const adjustedPositionedItems = positionedItems.map(( 79 - element, 80 - _idx, 81 - ) => ({ 82 - ...element, 83 - item: element.item + itemOffset, 84 - line: element.line + lineOffset, 85 - xOffset: element.line === 0 86 - ? element.xOffset + indentSize 87 - : element.xOffset, 88 - })); 89 - 90 - rawTotalPositionedItems.push(adjustedPositionedItems); 91 - 92 - lineOffset += positionedItems[positionedItems.length - 1].line + 1; 93 - itemOffset += items.length; 94 - } 95 - 96 - const totalItems = rawTotalItems.flat(); 97 - const totalPositionedItems = rawTotalPositionedItems.flat(); 98 - 99 - const nLines = totalPositionedItems[totalPositionedItems.length - 1].line; 100 - const width = lineWidth + padding * 2; 101 - const height = nLines * fontSize * 1.5 + padding * 2.5; 102 - 103 - const { canvas, ctx } = createLocalCanvas(width, height); 104 - 105 - ctx.fillStyle = "ivory"; 106 - ctx.fillRect(0, 0, width, height); 107 - 108 - let isItalic = false; 109 - for (const positionedItem of totalPositionedItems) { 110 - const { item, line, xOffset } = positionedItem; 111 - const currentItem = totalItems[item]; 112 - 113 - if (!("text" in currentItem)) { 114 - console.error( 115 - `Error: totalItems[${item}] does not have a 'text' property.`, 116 - ); 117 - continue; // Skip this item if it doesn't have 'text' 118 - } 119 - renderText( 120 - ctx, 121 - currentItem.text, 122 - xOffset + 40, 123 - fontSize * 1.5 * line + 40, 124 - fontSize, 125 - fontFamily, 126 - ); 127 - } 128 - 129 - return canvas; 130 - 131 - function measureText( 132 - textToMeasure: string, 133 - ): number { 134 - const tempCanvas = new SvgCanvas(1, 1); 135 - const ctx = tempCanvas.getContext(); 136 - ctx.font = `${fontSize}px ${fontFamily}`; 137 - return ctx.measureText(textToMeasure.replace(/\*/g, "")).width; 138 - } 139 - 140 - function drawText( 141 - ctx: CanvasRenderingContext2D | SvgRenderingContext2D, 142 - text: string, 143 - x: number, 144 - y: number, 145 - fontSize: number, 146 - fontFamily: string, 147 - ) { 148 - const fontStyle = isItalic ? "italic" : "normal"; 149 - ctx.font = `${fontStyle} ${fontSize}px ${fontFamily}`; 150 - ctx.fillStyle = "black"; 151 - ctx.textAlign = "left"; 152 - ctx.textBaseline = "top"; 153 - ctx.fillText(text, x, y); 154 - } 155 - 156 - // Helper function to handle italicization 157 - function renderText( 158 - ctx: CanvasRenderingContext2D | SvgRenderingContext2D, 159 - substring: string, 160 - x: number, 161 - y: number, 162 - fontSize: number, 163 - fontFamily: string, 164 - ) { 165 - // Split the substring by asterisks 166 - const parts = substring.split("*"); 167 - let currentX = x; 168 - 169 - // Loop through parts and render each separately 170 - parts.forEach((part, index) => { 171 - if (part.length > 0) { 172 - // Draw the current part 173 - drawText(ctx, part, currentX, y, fontSize, fontFamily); 174 - // Increment x-coordinate for the next segment 175 - const partWidth = ctx.measureText(part).width; 176 - currentX += partWidth; 177 - } 178 - 179 - // Toggle italics if we're at an asterisk boundary 180 - if (index < parts.length - 1) { 181 - isItalic = !isItalic; 182 - } 183 - }); 184 - } 185 - } 186 - 187 - // SVG wrapper 188 - export function generateSvg(quote: string, path: string | null = null) { 189 - const createLocalCanvas = (width: number, height: number) => { 190 - const canvas = new SvgCanvas(width, height); 191 - const ctx = canvas.getContext(); 192 - 193 - return { canvas, ctx }; 194 - }; 195 - 196 - const svgCanvas: SvgCanvas = generateCanvas(createLocalCanvas, quote); 197 - svgCanvas.complete(); 198 - if (path) { 199 - svgCanvas.save(path); 200 - } 201 - return svgCanvas.encode(); 202 - } 203 - 204 - // PNG wrapper 205 - export function generatePng(quote: string, path: string | null = null) { 206 - const createLocalCanvas = (width: number, height: number) => { 207 - const canvas = createCanvas(width, height); 208 - const ctx = canvas.getContext("2d"); 209 - 210 - return { canvas, ctx }; 211 - }; 212 - 213 - const pngCanvas: Canvas = generateCanvas(createLocalCanvas, quote); 214 - const aspectRatio = { width: pngCanvas.width, height: pngCanvas.height }; 215 - if (path) { 216 - pngCanvas.save(path); 217 - } 218 - return { image: pngCanvas.encode("png"), aspectRatio}; 219 - }
+216
utils/imageUtils.ts
··· 1 + import tex from "npm:tex-linebreak"; 2 + import enUsPatterns from "npm:hyphenation.en-us"; 3 + import * as fontkit from "npm:fontkit"; 4 + import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts"; 5 + import { Buffer } from "node:buffer"; 6 + 7 + const lineWidth = 620; 8 + const defaultFontSize = 20; 9 + const indentSize = 30; 10 + const padding = 40; 11 + 12 + interface Item { 13 + text: string; 14 + posX: number; 15 + line: number; 16 + isItalic?: boolean; 17 + } 18 + 19 + type Font = { 20 + url: string; 21 + family: string; 22 + weight: string; 23 + data?: ArrayBuffer; 24 + }; 25 + 26 + const fonts: Record<string, Font> = { 27 + normal: { 28 + url: "https://github.com/google/fonts/raw/refs/heads/main/ofl/merriweather/Merriweather-Regular.ttf", 29 + family: "Merriweather", 30 + weight: "400", 31 + }, 32 + italic: { 33 + url: "https://github.com/google/fonts/raw/refs/heads/main/ofl/merriweather/Merriweather-Italic.ttf", 34 + family: "Merriweather", 35 + weight: "400", 36 + }, 37 + }; 38 + 39 + await Promise.all( 40 + Object.entries(fonts).map(async ([_key, font]) => { 41 + const response = await fetch(font.url); 42 + const fontData = await response.arrayBuffer(); 43 + font.data = fontData; 44 + }), 45 + ); 46 + 47 + function formatText(quote: string) { 48 + const paragraphs = quote.split("\\n"); 49 + const totalItems: tex.TextInputItem[][] = []; 50 + const totalPositionedItems: tex.PositionedItem[][] = []; 51 + 52 + const _hyphenate = tex.createHyphenator(enUsPatterns); 53 + 54 + let lineOffset = 0; 55 + let itemOffset = 0; 56 + 57 + for (const paragraph of paragraphs) { 58 + const items = tex.layoutItemsFromString( 59 + paragraph.trim(), 60 + measureText, 61 + // hyphenate, // disabled for now 62 + ); 63 + totalItems.push(items); 64 + 65 + // Create an array of line widths where the first line is indented 66 + const lineWidths = items.map((_, index) => 67 + index === 0 ? lineWidth - indentSize : lineWidth 68 + ); 69 + 70 + // Find where to insert line-breaks using the varying line widths 71 + const breakpoints = tex.breakLines(items, lineWidths); 72 + 73 + // Compute positions with indentation for the first line 74 + const positionedItems = tex.positionItems( 75 + items, 76 + lineWidths, 77 + breakpoints, 78 + ); 79 + 80 + // Add the indent to the first line's xOffset 81 + const adjustedPositionedItems = positionedItems.map(( 82 + element, 83 + _idx, 84 + ) => ({ 85 + ...element, 86 + item: element.item + itemOffset, 87 + line: element.line + lineOffset, 88 + xOffset: element.line === 0 89 + ? element.xOffset + indentSize 90 + : element.xOffset, 91 + })); 92 + 93 + totalPositionedItems.push(adjustedPositionedItems); 94 + 95 + lineOffset += positionedItems[positionedItems.length - 1].line + 1; 96 + itemOffset += items.length; 97 + } 98 + 99 + const items: Item[] = []; 100 + 101 + let noPenalty = false; 102 + for (const positionedItem of totalPositionedItems.flat()) { 103 + const { xOffset, line, item } = positionedItem; 104 + const box = totalItems.flat()[item]; 105 + let text: string; 106 + let penalty = 0; 107 + 108 + // Penalty calculation, needs some work, unused for now 109 + if (!("text" in box)) { 110 + text = "-"; 111 + penalty += 3; 112 + noPenalty = true; 113 + } else { 114 + text = box.text; 115 + } 116 + if ( 117 + item > 0 && totalItems.flat()[item - 1].type === "penalty" && 118 + noPenalty === false 119 + ) { 120 + penalty += 3; 121 + noPenalty = false; 122 + } 123 + 124 + items.push({ 125 + text: text, 126 + posX: xOffset, // + penalty, 127 + line: line, 128 + }); 129 + } 130 + const styledItems = getItalics(items); 131 + 132 + return styledItems; 133 + } 134 + 135 + function measureText( 136 + text: string, 137 + fontSize: number = defaultFontSize, 138 + fontData: ArrayBuffer = fonts.normal.data!, 139 + ) { 140 + // Parse font 141 + const font = fontkit.create(Buffer.from(fontData)); 142 + 143 + // Get width (scale based on fontSize) 144 + const scale = fontSize / font.unitsPerEm; 145 + const width = font.layout( 146 + text.replace(/\*/g, "") 147 + .replace(" ", " "), // hack, because single space made it look smushed 148 + ).advanceWidth * scale; 149 + 150 + return width; 151 + } 152 + 153 + function getItalics(items: Item[]) { 154 + let isItalic = false; 155 + const styledItems: Item[] = []; 156 + 157 + for (const item of items) { 158 + const parts = item.text.split("*"); 159 + let currentX = item.posX; 160 + parts.forEach((part, index) => { 161 + if (part.length > 0) { 162 + styledItems.push({ 163 + text: part, 164 + posX: currentX, 165 + line: item.line, 166 + isItalic: isItalic, 167 + }); 168 + currentX += measureText(part); 169 + } 170 + 171 + // Toggle italics if we're at an asterisk boundary 172 + if (index < parts.length - 1) { 173 + isItalic = !isItalic; 174 + } 175 + }); 176 + } 177 + 178 + return styledItems; 179 + } 180 + 181 + export async function generateImage(quote: string) { 182 + const items = formatText(quote); 183 + 184 + const nLines = items[items.length - 1].line; 185 + const width = lineWidth + padding * 2; 186 + const height = nLines * defaultFontSize * 1.5 + padding * 2.5; 187 + 188 + const image = new Image(width, height); 189 + image.fill(0xFFFFF0FF); 190 + 191 + for (const item of items) { 192 + const { text, posX, line } = item; 193 + 194 + const font = item.isItalic 195 + ? new Uint8Array(fonts.italic.data!) 196 + : new Uint8Array(fonts.normal.data!); 197 + 198 + const textImage = Image.renderText( 199 + font, 200 + defaultFontSize, 201 + text, 202 + 0x000000FF, 203 + ); 204 + 205 + image.composite( 206 + textImage, 207 + posX + 40, 208 + defaultFontSize * 1.5 * line + 40, 209 + ); 210 + } 211 + 212 + return { 213 + image: await image.encode(), 214 + aspectRatio: { width: width, height: height }, 215 + }; 216 + }