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:
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))
]);