crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 9623a00bbb5df4758d382877a3ef9cea0ce5daf2
parent ee49963daf1497dc7596f3578646b85c18ebda7e
Author: Michael Camilleri <[email protected]>
Date:   Wed, 17 Jun 2026 06:01:26 +0900

Composite app icon onto share link preview image

Diffstat:
MWorkers/link-worker.js | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 149 insertions(+), 15 deletions(-)

diff --git a/Workers/link-worker.js b/Workers/link-worker.js @@ -101,12 +101,13 @@ export default { // The per-puzzle preview image. The grid silhouette rides in the path, so // the image is a pure function of it — deterministic and immutably - // cacheable. A malformed segment falls back to the generic card image - // rather than erroring, so a link preview never breaks on a bad shape. + // cacheable. A malformed or too-small segment falls back to the generic + // card image rather than erroring, so a link preview never breaks on a bad + // shape. const imageMatch = url.pathname.match(/^\/g\/([A-Za-z0-9_-]+)\.png$/); if (imageMatch) { const grid = decodeSilhouette(imageMatch[1]); - if (!grid) { + if (!grid || grid.side < DYNAMIC_IMAGE_MIN_SIDE) { return new Response(ogImage, { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400" } }); @@ -162,11 +163,13 @@ function previewPage(origin, token, target, segments) { "You’re invited to a collaborative crossword. " + "Open the link to join the puzzle in Crossmate."; - // A valid shape gets its own rendered silhouette; otherwise the generic - // card. The dimension hint lets clients lay the card out before the fetch. + // A valid 10x10-or-larger shape gets its own rendered silhouette; otherwise + // the generic card. The dimension hint lets clients lay the card out before + // the fetch. const grid = shape ? decodeSilhouette(shape) : null; - const imageURL = grid ? `${origin}/g/${shape}.png` : `${origin}/og.png`; - const imageSize = grid ? silhouettePixelSize(grid.side) : null; + const usesDynamicImage = grid && grid.side >= DYNAMIC_IMAGE_MIN_SIDE; + const imageURL = usesDynamicImage ? `${origin}/g/${shape}.png` : `${origin}/og.png`; + const imageSize = usesDynamicImage ? silhouettePixelSize(grid.side) : null; // og:url stays the token-only canonical form so scrapers collapse links // that differ only in their title segment onto the same card. The meta @@ -316,15 +319,54 @@ function base64urlToBytes(s) { // image side is a pure function of the grid side (35 → 798px is the largest). const CELL = 22; const MARGIN = 14; -const LINE = 205; // grid line gray -const BLOCK = 0; // block square -const BG = 255; // empty square / background +const BG = 0; // empty square / background +const LINE = 1; // grid line gray +const BLOCK = 2; // block square / black logo line +const DYNAMIC_IMAGE_MIN_SIDE = 10; + +const PALETTE = new Uint8Array([ + 255, 255, 255, // background / white logo square + 205, 205, 205, // grid line gray + 0, 0, 0, // block square / black logo line + 42, 42, 42, // logo dark + 255, 135, 132, // logo coral + 109, 173, 240, // logo blue + 207, 226, 246, // logo pale blue + 142, 142, 142, // shadow ramp + 154, 154, 154, + 166, 166, 166, + 178, 178, 178, + 190, 190, 190, + 202, 202, 202, + 214, 214, 214, + 226, 226, 226, + 238, 238, 238, + 246, 246, 246 +]); + +const LOGO_SOURCE_SIZE = 512; +const LOGO_MIN_SIZE = 64; +const LOGO_MAX_SIZE = 152; +const LOGO_SCALE = 0.22; +const LOGO_PADDING_SCALE = 0.035; +const LOGO_MIN_PADDING = 10; +const LOGO_DARK = 3; +const LOGO_CORAL = 4; +const LOGO_BLUE = 5; +const LOGO_PALE_BLUE = 6; +const LOGO_WHITE = BG; +const LOGO_BLACK = BLOCK; +const SHADOW_START = 7; +const SHADOW_LEVELS = 10; +const SHADOW_OFFSET_X = 0.035; +const SHADOW_OFFSET_Y = 0.055; +const SHADOW_BLUR = 0.150; function silhouettePixelSize(side) { return MARGIN * 2 + side * CELL; } -// Paints the grid into an 8-bit grayscale buffer and wraps it in a PNG. +// Paints the grid and a scaled Crossmate icon into an indexed-color PNG. function renderSilhouettePNG(side, blocks) { const dim = silhouettePixelSize(side); const pixels = new Uint8Array(dim * dim).fill(BG); @@ -351,12 +393,103 @@ function renderSilhouettePNG(side, blocks) { } } - return encodeGrayscalePNG(dim, dim, pixels); + drawCrossmateIcon(pixels, dim, side); + return encodeIndexedPNG(dim, dim, pixels); } -// MARK: - Minimal PNG encoder (8-bit grayscale, stored DEFLATE) +function drawCrossmateIcon(pixels, dim, side) { + const size = clamp(Math.round(dim * LOGO_SCALE), LOGO_MIN_SIZE, LOGO_MAX_SIZE); + const padding = Math.max(LOGO_MIN_PADDING, Math.round(dim * LOGO_PADDING_SCALE)); + const gridMax = MARGIN + side * CELL; + const origin = gridMax - padding - size; + const radius = Math.round(size * 0.135); + drawRoundedShadow(pixels, dim, origin, size, radius); + + for (let y = 0; y < size; y++) { + const sourceY = Math.floor((y * LOGO_SOURCE_SIZE) / size); + for (let x = 0; x < size; x++) { + if (!isInsideRoundedRect(x, y, size, radius)) continue; + const sourceX = Math.floor((x * LOGO_SOURCE_SIZE) / size); + pixels[(origin + y) * dim + origin + x] = crossmateIconColor(sourceX, sourceY); + } + } +} + +function drawRoundedShadow(pixels, dim, iconOrigin, size, radius) { + const blur = Math.max(10, Math.round(size * SHADOW_BLUR)); + const shadowX = iconOrigin + Math.round(size * SHADOW_OFFSET_X); + const shadowY = iconOrigin + Math.round(size * SHADOW_OFFSET_Y); + const minX = shadowX - blur; + const minY = shadowY - blur; + const maxX = shadowX + size + blur; + const maxY = shadowY + size + blur; + + for (let py = minY; py < maxY; py++) { + if (py < 0 || py >= dim) continue; + for (let px = minX; px < maxX; px++) { + if (px < 0 || px >= dim) continue; + + const distance = distanceToRoundedRect(px - shadowX, py - shadowY, size, radius); + if (distance > blur) continue; + + const strength = Math.pow(1 - Math.max(distance, 0) / blur, 1.85); + const level = SHADOW_LEVELS - 1 - Math.round(strength * (SHADOW_LEVELS - 1)); + blendShadowPixel(pixels, py * dim + px, SHADOW_START + level); + } + } +} + +function blendShadowPixel(pixels, offset, color) { + const current = pixels[offset]; + if (current === BLOCK) return; + if (current >= SHADOW_START) { + pixels[offset] = Math.min(current, color); + return; + } + if (current === BG || color <= SHADOW_START + 5) { + pixels[offset] = color; + } +} + +function distanceToRoundedRect(x, y, size, radius) { + const half = size / 2; + const qx = Math.abs(x - half + 0.5) - (half - radius); + const qy = Math.abs(y - half + 0.5) - (half - radius); + const outsideX = Math.max(qx, 0); + const outsideY = Math.max(qy, 0); + return Math.hypot(outsideX, outsideY) + Math.min(Math.max(qx, qy), 0) - radius; +} + +function isInsideRoundedRect(x, y, size, radius) { + const left = radius; + const right = size - 1 - radius; + const top = radius; + const bottom = size - 1 - radius; + const cx = x < left ? left : x > right ? right : x; + const cy = y < top ? top : y > bottom ? bottom : y; + const dx = x - cx; + const dy = y - cy; + return dx * dx + dy * dy <= radius * radius; +} + +function crossmateIconColor(x, y) { + if (x >= 252 && x <= 263) return LOGO_BLACK; + if (y >= 124 && y <= 134) return LOGO_BLACK; + if (y >= 390 && y <= 400) return LOGO_BLACK; + + const right = x >= 264; + if (y < 124) return right ? LOGO_CORAL : LOGO_DARK; + if (y < 390) return right ? LOGO_WHITE : LOGO_BLUE; + return right ? LOGO_DARK : LOGO_PALE_BLUE; +} + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} + +// MARK: - Minimal PNG encoder (8-bit indexed color, stored DEFLATE) -function encodeGrayscalePNG(width, height, pixels) { +function encodeIndexedPNG(width, height, pixels) { // Each scanline is prefixed with a filter-type byte (0 = None). const raw = new Uint8Array(height * (width + 1)); for (let y = 0; y < height; y++) { @@ -369,13 +502,14 @@ function encodeGrayscalePNG(width, height, pixels) { dv.setUint32(0, width); dv.setUint32(4, height); ihdr[8] = 8; // bit depth - ihdr[9] = 0; // color type: grayscale + ihdr[9] = 3; // color type: indexed color // [10..12] compression/filter/interlace = 0 const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); return concatBytes([ signature, pngChunk("IHDR", ihdr), + pngChunk("PLTE", PALETTE), pngChunk("IDAT", zlibStore(raw)), pngChunk("IEND", new Uint8Array(0)) ]);