bluesky quote bot

New canvasUtils.ts file that does Knuth-Plass word wrapping on a canvas with optional png or svg output.

+218
+218
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 + if (path) { 215 + pngCanvas.save(path); 216 + } 217 + return pngCanvas.encode("png"); 218 + }