crossmate

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

commit f7a5610d8d89dcc1c1a459053b4e4ed31aa8214c
parent 9623a00bbb5df4758d382877a3ef9cea0ce5daf2
Author: Michael Camilleri <[email protected]>
Date:   Wed, 17 Jun 2026 06:14:43 +0900

Reposition share link composite image

Diffstat:
MWorkers/link-worker.js | 127+++++++++++++++++++++++++++++++++++--------------------------------------------
1 file changed, 56 insertions(+), 71 deletions(-)

diff --git a/Workers/link-worker.js b/Workers/link-worker.js @@ -112,7 +112,7 @@ export default { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400" } }); } - return new Response(renderSilhouettePNG(grid.side, grid.blocks), { + return new Response(await renderSilhouettePNG(grid.side, grid.blocks), { headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400, immutable" @@ -316,61 +316,40 @@ function base64urlToBytes(s) { } // Render constants: each cell is CELL px with a MARGIN-px quiet border, so the -// image side is a pure function of the grid side (35 → 798px is the largest). +// image side is a pure function of the grid side (35 → 842px is the largest). const CELL = 22; -const MARGIN = 14; -const BG = 0; // empty square / background -const LINE = 1; // grid line gray -const BLOCK = 2; // block square / black logo line +const MARGIN = 36; +const BG = [255, 255, 255]; // empty square / background +const LINE = [205, 205, 205]; // grid line gray +const BLOCK = [0, 0, 0]; // 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_DARK = [42, 42, 42]; +const LOGO_CORAL = [255, 135, 132]; +const LOGO_BLUE = [109, 173, 240]; +const LOGO_PALE_BLUE = [207, 226, 246]; 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; +const SHADOW_ALPHA = 0.26; function silhouettePixelSize(side) { return MARGIN * 2 + side * CELL; } -// Paints the grid and a scaled Crossmate icon into an indexed-color PNG. -function renderSilhouettePNG(side, blocks) { +// Paints the grid and a scaled Crossmate icon into a truecolor PNG. +async function renderSilhouettePNG(side, blocks) { const dim = silhouettePixelSize(side); - const pixels = new Uint8Array(dim * dim).fill(BG); - const set = (x, y, v) => { pixels[y * dim + x] = v; }; + const pixels = new Uint8Array(dim * dim * 3).fill(255); + const set = (x, y, color) => setRGB(pixels, dim, x, y, color); // Grid lines bounding every cell. const extent = side * CELL; @@ -394,23 +373,30 @@ function renderSilhouettePNG(side, blocks) { } drawCrossmateIcon(pixels, dim, side); - return encodeIndexedPNG(dim, dim, pixels); + return await encodeTruecolorPNG(dim, dim, pixels); +} + +function setRGB(pixels, width, x, y, color) { + const offset = (y * width + x) * 3; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; } 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 origin = dim - 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 coverage = roundedRectCoverage(x, y, size, radius); + if (coverage <= 0) continue; const sourceX = Math.floor((x * LOGO_SOURCE_SIZE) / size); - pixels[(origin + y) * dim + origin + x] = crossmateIconColor(sourceX, sourceY); + blendPixel(pixels, dim, origin + x, origin + y, crossmateIconColor(sourceX, sourceY), coverage); } } } @@ -433,22 +419,17 @@ function drawRoundedShadow(pixels, dim, iconOrigin, 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); + blendPixel(pixels, dim, px, py, BLOCK, strength * SHADOW_ALPHA); } } } -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 blendPixel(pixels, width, x, y, color, alpha) { + const offset = (y * width + x) * 3; + const keep = 1 - alpha; + pixels[offset] = Math.round(pixels[offset] * keep + color[0] * alpha); + pixels[offset + 1] = Math.round(pixels[offset + 1] * keep + color[1] * alpha); + pixels[offset + 2] = Math.round(pixels[offset + 2] * keep + color[2] * alpha); } function distanceToRoundedRect(x, y, size, radius) { @@ -460,16 +441,8 @@ function distanceToRoundedRect(x, y, size, radius) { 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 roundedRectCoverage(x, y, size, radius) { + return clamp(0.5 - distanceToRoundedRect(x, y, size, radius), 0, 1); } function crossmateIconColor(x, y) { @@ -487,14 +460,15 @@ function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } -// MARK: - Minimal PNG encoder (8-bit indexed color, stored DEFLATE) +// MARK: - Minimal PNG encoder (8-bit truecolor, stored DEFLATE) -function encodeIndexedPNG(width, height, pixels) { +async function encodeTruecolorPNG(width, height, pixels) { // Each scanline is prefixed with a filter-type byte (0 = None). - const raw = new Uint8Array(height * (width + 1)); + const stride = width * 3; + const raw = new Uint8Array(height * (stride + 1)); for (let y = 0; y < height; y++) { - raw[y * (width + 1)] = 0; - raw.set(pixels.subarray(y * width, (y + 1) * width), y * (width + 1) + 1); + raw[y * (stride + 1)] = 0; + raw.set(pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1); } const ihdr = new Uint8Array(13); @@ -502,19 +476,30 @@ function encodeIndexedPNG(width, height, pixels) { dv.setUint32(0, width); dv.setUint32(4, height); ihdr[8] = 8; // bit depth - ihdr[9] = 3; // color type: indexed color + ihdr[9] = 2; // color type: truecolor // [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("IDAT", await zlibDeflate(raw)), pngChunk("IEND", new Uint8Array(0)) ]); } +async function zlibDeflate(raw) { + if (typeof CompressionStream === "undefined") { + return zlibStore(raw); + } + try { + const stream = new Blob([raw]).stream().pipeThrough(new CompressionStream("deflate")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } catch { + return zlibStore(raw); + } +} + function pngChunk(type, data) { const out = new Uint8Array(12 + data.length); const dv = new DataView(out.buffer);