commit da2fe7963f6a01699b51b199668643cbdad28eb6
parent c0d703108be78ad311939912ba195c9d37ee6cd6
Author: Michael Camilleri <[email protected]>
Date: Thu, 18 Jun 2026 11:56:46 +0900
Render share-link previews for non-square grids
This commit extends the grid silhouette so a rectangular puzzle gets its
own rendered share-link and 'Invited'-row preview instead of falling
back to the generic Crossmate card. Previously the silhouette codec
accepted only square grids, so any grid whose width and height differed
carried no shape segment and lost its rich preview.
The wire format gains a rectangular variant alongside the existing
square one. A square grid still encodes as the lowercase 's'/'f' tag
with a single base-36 side digit; a rectangular grid uses an uppercase
'S'/'F' tag carrying a width digit then a height digit. The two forms
are distinguished by tag case, so a link minted by an older build still
decodes and a decoder that predates the rectangular form simply ignores
the uppercase tag and shows the generic card. The 180° mirror partner of
a cell is 'n - 1 - k' regardless of the dimensions, so the symmetric
half-grid packing carries over to rectangles unchanged and most variety
grids still encode in the compact symmetric form.
GridSilhouette.Grid now holds a separate width and height, and the app,
the share-link shortener, the invite coordinator, and the link worker
all thread both dimensions through. The worker paints a non-square
canvas, gates the dynamic image on the smaller side clearing the
minimum, anchors the Crossmate badge off both dimensions, and emits
distinct `og:image:width` and `og:image:height` hints.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
10 files changed, 267 insertions(+), 116 deletions(-)
diff --git a/Crossmate/Services/GridSilhouette.swift b/Crossmate/Services/GridSilhouette.swift
@@ -5,57 +5,105 @@ import Foundation
/// worker render a preview of the actual grid (and, later, lets the app paint a
/// placeholder the instant a link is tapped) without any CloudKit round-trip.
///
-/// Only square grids are supported. An irregular shape returns `nil` and simply
-/// gets no segment — and therefore no rich preview — which keeps the format free
-/// of width/height bookkeeping (per the "no preview for weird shapes" rule).
-///
-/// Wire format: `<tag><size><payload>`
-/// - `tag` `s` when the grid is 180°-rotationally symmetric (only the first
-/// ⌈N/2⌉ cells are stored, since the rest mirror them); `f` for a
-/// full dump of every cell.
-/// - `size` the grid's side length as a single base-36 digit (`f` = 15,
-/// `l` = 21), so sides are bounded to 2…35.
+/// Wire format: `<tag><size…><payload>`
+/// - `tag` case selects geometry and symmetry. Lowercase `s`/`f` are square
+/// grids carrying a single side digit; uppercase `S`/`F` are
+/// rectangular grids carrying a width digit then a height digit.
+/// `s`/`S` mean the grid is 180°-rotationally symmetric (only the
+/// first ⌈N/2⌉ cells are stored, since the rest mirror them); `f`/`F`
+/// are a full dump of every cell.
+/// - `size…` each dimension as a single base-36 digit (`f` = 15, `l` = 21), so
+/// sides are bounded to 2…35.
/// - `payload` the cell bits, row-major, MSB-first, `1` = block, as base64url
/// without padding. Each character therefore carries six cells.
///
/// A symmetric 15×15 lands in ~22 characters (`s` `f` + 20); the full fallback
-/// for an asymmetric 15×15 is ~40. See `GridSilhouetteTests`.
+/// for an asymmetric 15×15 is ~40. The 180° mirror partner of cell `k` in a
+/// `w×h` grid is `n-1-k` regardless of the dimensions, so the symmetric packing
+/// and the rectangular case share the same reconstruction. See
+/// `GridSilhouetteTests`.
enum GridSilhouette {
static let minSide = 2
/// A single base-36 size digit tops out at 35.
static let maxSide = 35
struct Grid: Equatable {
- let side: Int
+ let width: Int
+ let height: Int
/// Row-major, `true` = block.
let blocks: [Bool]
+
+ init(width: Int, height: Int, blocks: [Bool]) {
+ self.width = width
+ self.height = height
+ self.blocks = blocks
+ }
+
+ /// Convenience for the common square case.
+ init(side: Int, blocks: [Bool]) {
+ self.init(width: side, height: side, blocks: blocks)
+ }
}
- /// Encodes a square grid, or `nil` when the grid is non-square or its side
- /// falls outside `minSide...maxSide`.
+ /// Encodes a square grid, or `nil` when the side falls outside
+ /// `minSide...maxSide` or the block count doesn't match. A thin shim over
+ /// `encode(width:height:blocks:)` for the square call sites and tests.
static func encode(side: Int, blocks: [Bool]) -> String? {
- guard (minSide...maxSide).contains(side), blocks.count == side * side else {
+ encode(width: side, height: side, blocks: blocks)
+ }
+
+ /// Encodes a grid, or `nil` when either dimension falls outside
+ /// `minSide...maxSide` or `blocks` doesn't hold exactly `width * height`
+ /// cells. A square grid uses the compact lowercase single-digit form; a
+ /// rectangular grid uses the uppercase two-digit form.
+ static func encode(width: Int, height: Int, blocks: [Bool]) -> String? {
+ guard (minSide...maxSide).contains(width), (minSide...maxSide).contains(height),
+ blocks.count == width * height else {
return nil
}
let n = blocks.count
let symmetric = (0..<(n / 2)).allSatisfy { blocks[$0] == blocks[n - 1 - $0] }
let stored = symmetric ? Array(blocks.prefix((n + 1) / 2)) : blocks
- let tag = symmetric ? "s" : "f"
- return tag + String(side, radix: 36) + Self.base64URLEncode(Self.packBits(stored))
+ let payload = Self.base64URLEncode(Self.packBits(stored))
+ if width == height {
+ let tag = symmetric ? "s" : "f"
+ return tag + String(width, radix: 36) + payload
+ }
+ let tag = symmetric ? "S" : "F"
+ return tag + String(width, radix: 36) + String(height, radix: 36) + payload
}
/// Reverses `encode`, or `nil` when the segment is malformed.
static func decode(_ segment: String) -> Grid? {
let chars = Array(segment)
- guard chars.count >= 2 else { return nil }
- let tag = chars[0]
- guard tag == "s" || tag == "f" else { return nil }
- guard let side = Int(String(chars[1]), radix: 36),
- (minSide...maxSide).contains(side) else { return nil }
-
- let n = side * side
- let storedCount = tag == "s" ? (n + 1) / 2 : n
- guard let bytes = Self.base64URLDecode(String(chars[2...])) else { return nil }
+ guard let tag = chars.first else { return nil }
+
+ let width: Int
+ let height: Int
+ let payload: ArraySlice<Character>
+ switch tag {
+ case "s", "f":
+ guard chars.count >= 2,
+ let side = Int(String(chars[1]), radix: 36),
+ (minSide...maxSide).contains(side) else { return nil }
+ width = side
+ height = side
+ payload = chars[2...]
+ case "S", "F":
+ guard chars.count >= 3,
+ let w = Int(String(chars[1]), radix: 36), (minSide...maxSide).contains(w),
+ let h = Int(String(chars[2]), radix: 36), (minSide...maxSide).contains(h) else { return nil }
+ width = w
+ height = h
+ payload = chars[3...]
+ default:
+ return nil
+ }
+
+ let n = width * height
+ let symmetric = tag == "s" || tag == "S"
+ let storedCount = symmetric ? (n + 1) / 2 : n
+ guard let bytes = Self.base64URLDecode(String(payload)) else { return nil }
let bits = Self.unpackBits(bytes, count: storedCount)
guard bits.count == storedCount else { return nil }
@@ -65,7 +113,7 @@ enum GridSilhouette {
// its 180°-rotation partner, which lands inside the stored half.
blocks[k] = k < storedCount ? bits[k] : bits[n - 1 - k]
}
- return Grid(side: side, blocks: blocks)
+ return Grid(width: width, height: height, blocks: blocks)
}
// MARK: - Bit packing
diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift
@@ -81,11 +81,11 @@ final class InviteCoordinator {
let title = (try? ctx.fetch(req).first)?.title ?? ""
// Encode the grid silhouette the same way share links do, so the
- // recipient can preview the puzzle in their "Invited" row. `nil` for
- // non-square grids, which simply get no preview.
+ // recipient can preview the puzzle in their "Invited" row. `nil` when
+ // the layout cache is unpopulated, which simply gets no preview.
let shape = shareController.gridSilhouette(for: gameID)
let silhouette = shape.flatMap {
- GridSilhouette.encode(side: $0.side, blocks: $0.blocks)
+ GridSilhouette.encode(width: $0.width, height: $0.height, blocks: $0.blocks)
}
let url = try await shareController.addFriendParticipant(
diff --git a/Crossmate/Services/ShareLinkShortener.swift b/Crossmate/Services/ShareLinkShortener.swift
@@ -61,7 +61,8 @@ enum ShareLinkShortener {
if let titleSegment = encodedTitle(title) {
path += "/\(titleSegment)"
}
- if let shape, let shapeSegment = GridSilhouette.encode(side: shape.side, blocks: shape.blocks) {
+ if let shape,
+ let shapeSegment = GridSilhouette.encode(width: shape.width, height: shape.height, blocks: shape.blocks) {
path += "/\(shapeSegment)"
}
return baseURL.appending(path: path)
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -344,20 +344,21 @@ final class ShareController {
/// The game's grid silhouette for share-link previews, read from the
/// cached block layout so it costs nothing at link-creation time. Returns
- /// `nil` when the grid isn't square or the cache hasn't been populated, in
- /// which case the link simply carries no shape segment.
+ /// `nil` when the cache hasn't been populated, in which case the link simply
+ /// carries no shape segment.
func gridSilhouette(for gameID: UUID) -> GridSilhouette.Grid? {
let ctx = persistence.viewContext
let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
request.fetchLimit = 1
guard let entity = try? ctx.fetch(request).first else { return nil }
- let side = Int(entity.gridWidth)
- guard side > 0, Int(entity.gridHeight) == side,
- let mask = entity.blockMask, mask.count == side * side else {
+ let width = Int(entity.gridWidth)
+ let height = Int(entity.gridHeight)
+ guard width > 0, height > 0,
+ let mask = entity.blockMask, mask.count == width * height else {
return nil
}
- return GridSilhouette.Grid(side: side, blocks: mask.map { $0 != 0 })
+ return GridSilhouette.Grid(width: width, height: height, blocks: mask.map { $0 != 0 })
}
private func prepareShareRecord(
diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift
@@ -492,14 +492,14 @@ struct GameListView: View {
/// The puzzle-shape preview for an invite, decoded from the silhouette
/// segment the inviter sent. Open cells render grey (`.filled`) to read as
/// "not yet playable", matching the link-tap placeholder in
- /// `JoiningPuzzleView`. Absent or non-square grids get no thumbnail.
+ /// `JoiningPuzzleView`. An absent or undecodable grid gets no thumbnail.
@ViewBuilder
private func inviteThumbnail(for invite: InviteEntity) -> some View {
if let segment = invite.gridSilhouette,
let shape = GridSilhouette.decode(segment) {
GridThumbnailView(
- width: shape.side,
- height: shape.side,
+ width: shape.width,
+ height: shape.height,
cells: shape.blocks.map { $0 ? .block : .filled }
)
}
diff --git a/Crossmate/Views/Puzzle/JoiningPuzzleView.swift b/Crossmate/Views/Puzzle/JoiningPuzzleView.swift
@@ -38,8 +38,8 @@ struct JoiningPuzzleView: View {
VStack(spacing: 28) {
if let shape {
GridThumbnailView(
- width: shape.side,
- height: shape.side,
+ width: shape.width,
+ height: shape.height,
// Open cells render grey (not white) to read as
// "not editable yet" rather than a playable grid.
cells: shape.blocks.map { $0 ? .block : .filled },
diff --git a/Tests/Unit/GridSilhouetteTests.swift b/Tests/Unit/GridSilhouetteTests.swift
@@ -30,7 +30,7 @@ struct GridSilhouetteTests {
var blocks = [Bool](repeating: false, count: 9)
blocks[4] = true
let grid = GridSilhouette.Grid(side: 3, blocks: blocks)
- let segment = GridSilhouette.encode(side: grid.side, blocks: grid.blocks)
+ let segment = GridSilhouette.encode(side: grid.width, blocks: grid.blocks)
#expect(GridSilhouette.decode(segment ?? "") == grid)
}
@@ -52,6 +52,48 @@ struct GridSilhouetteTests {
#expect(GridSilhouette.decode(segment ?? "") == grid)
}
+ @Test("encodes a rectangular grid under the uppercase two-digit form")
+ func encodesRectangularSegment() {
+ // 2×3, all empty. Width≠height → uppercase `S`, width "2", height "3";
+ // symmetric (all false), so ⌈6/2⌉ = 3 cells pack into 0x00 → "AA".
+ let grid = GridSilhouette.Grid(width: 2, height: 3, blocks: [Bool](repeating: false, count: 6))
+ let segment = GridSilhouette.encode(width: 2, height: 3, blocks: grid.blocks)
+ #expect(segment == "S23AA")
+ #expect(GridSilhouette.decode(segment ?? "") == grid)
+ }
+
+ @Test("tags an asymmetric rectangular grid with uppercase F")
+ func tagsAsymmetricRectangle() {
+ // A single corner block has no 180° partner → full dump → "F".
+ var blocks = [Bool](repeating: false, count: 6)
+ blocks[0] = true
+ let segment = GridSilhouette.encode(width: 2, height: 3, blocks: blocks)
+ #expect(segment?.first == "F")
+ #expect(
+ GridSilhouette.decode(segment ?? "")
+ == GridSilhouette.Grid(width: 2, height: 3, blocks: blocks)
+ )
+ }
+
+ @Test("round-trips a realistic 21×22 Sunday grid")
+ func roundTrips21x22() {
+ // The shape that motivated rectangular support: a non-square Sunday.
+ let width = 21
+ let height = 22
+ let n = width * height
+ var blocks = [Bool](repeating: false, count: n)
+ for k in [0, 5, 21, 64, 200, 230, 300, 410] {
+ blocks[k] = true
+ blocks[n - 1 - k] = true // keep it 180°-symmetric
+ }
+ let grid = GridSilhouette.Grid(width: width, height: height, blocks: blocks)
+ let segment = GridSilhouette.encode(width: width, height: height, blocks: blocks)
+ #expect(segment?.first == "S")
+ // width 21 → "l", height 22 → "m".
+ #expect(segment?.dropFirst().prefix(2) == "lm")
+ #expect(GridSilhouette.decode(segment ?? "") == grid)
+ }
+
@Test("encodes the largest supported side via base-36")
func encodesLargeSide() {
let side = GridSilhouette.maxSide // 35 → base-36 'z'
@@ -61,11 +103,13 @@ struct GridSilhouetteTests {
#expect(GridSilhouette.decode(segment ?? "") == GridSilhouette.Grid(side: side, blocks: blocks))
}
- @Test("refuses non-square or out-of-range grids")
+ @Test("refuses a mismatched block count or out-of-range dimension")
func refusesUnsupportedGrids() {
#expect(GridSilhouette.encode(side: 3, blocks: [Bool](repeating: false, count: 6)) == nil)
#expect(GridSilhouette.encode(side: 1, blocks: [false]) == nil)
#expect(GridSilhouette.encode(side: 36, blocks: [Bool](repeating: false, count: 36 * 36)) == nil)
+ #expect(GridSilhouette.encode(width: 2, height: 36, blocks: [Bool](repeating: false, count: 72)) == nil)
+ #expect(GridSilhouette.encode(width: 2, height: 3, blocks: [Bool](repeating: false, count: 5)) == nil)
}
@Test("rejects malformed segments")
@@ -75,5 +119,7 @@ struct GridSilhouetteTests {
#expect(GridSilhouette.decode("s") == nil) // no size/payload
#expect(GridSilhouette.decode("s3") == nil) // empty payload, no bits
#expect(GridSilhouette.decode("s1CA") == nil) // side 1 is below minSide
+ #expect(GridSilhouette.decode("S2") == nil) // rectangular tag, missing height
+ #expect(GridSilhouette.decode("S21AA") == nil) // height 1 is below minSide
}
}
diff --git a/Tests/Unit/ShareLinkRouteTests.swift b/Tests/Unit/ShareLinkRouteTests.swift
@@ -15,15 +15,24 @@ struct ShareLinkRouteTests {
func parsesTokenAndShape() {
let route = ShareLinkRoute(shortLink: url("/s/\(token)/1/s3CA"))
#expect(route?.token == token)
- #expect(route?.shape?.side == 3)
+ #expect(route?.shape?.width == 3)
+ #expect(route?.shape?.height == 3)
#expect(route?.shape?.blocks[4] == true)
}
+ @Test("parses a rectangular silhouette segment")
+ func parsesRectangularShape() {
+ let route = ShareLinkRoute(shortLink: url("/s/\(token)/S23AA"))
+ #expect(route?.token == token)
+ #expect(route?.shape?.width == 2)
+ #expect(route?.shape?.height == 3)
+ }
+
@Test("parses a shape-only link (no title segment)")
func parsesShapeOnly() {
let route = ShareLinkRoute(shortLink: url("/s/\(token)/s3CA"))
#expect(route?.token == token)
- #expect(route?.shape?.side == 3)
+ #expect(route?.shape?.width == 3)
}
@Test("a title-only link yields no shape")
diff --git a/Tests/Unit/ShareLinkShortenerTests.swift b/Tests/Unit/ShareLinkShortenerTests.swift
@@ -99,8 +99,16 @@ struct ShareLinkShortenerTests {
#expect(short.absoluteString == "https://crossmate.example.net/s/\(token)/s3CA")
}
- @Test("omits a non-square silhouette rather than emitting a bad segment")
- func omitsNonSquareShape() {
+ @Test("appends a rectangular silhouette under the uppercase form")
+ func appendsRectangularShape() {
+ // 2×3, all empty → symmetric → "S23AA".
+ let grid = GridSilhouette.Grid(width: 2, height: 3, blocks: [Bool](repeating: false, count: 6))
+ let short = ShareLinkShortener.shortURL(for: share, title: nil, shape: grid, baseURL: base)
+ #expect(short.absoluteString == "https://crossmate.example.net/s/\(token)/S23AA")
+ }
+
+ @Test("omits a silhouette whose block count doesn't match its dimensions")
+ func omitsMismatchedShape() {
let grid = GridSilhouette.Grid(side: 3, blocks: [Bool](repeating: false, count: 6))
let short = ShareLinkShortener.shortURL(for: share, title: nil, shape: grid, baseURL: base)
#expect(short.absoluteString == "https://crossmate.example.net/s/\(token)")
diff --git a/Workers/link-worker.js b/Workers/link-worker.js
@@ -107,12 +107,12 @@ export default {
const imageMatch = url.pathname.match(/^\/g\/([A-Za-z0-9_-]+)\.png$/);
if (imageMatch) {
const grid = decodeSilhouette(imageMatch[1]);
- if (!grid || grid.side < DYNAMIC_IMAGE_MIN_SIDE) {
+ if (!grid || !meetsDynamicMinimum(grid)) {
return new Response(ogImage, {
headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=86400" }
});
}
- return new Response(await renderSilhouettePNG(grid.side, grid.blocks), {
+ return new Response(await renderSilhouettePNG(grid.width, grid.height, grid.blocks), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400, immutable"
@@ -163,13 +163,14 @@ function previewPage(origin, token, target, segments) {
"You’re invited to a collaborative crossword. " +
"Open the link to join the puzzle in Crossmate.";
- // 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.
+ // A valid shape with both sides 10-or-larger gets its own rendered
+ // silhouette; otherwise the generic card. The dimension hints let clients lay
+ // the card out before the fetch.
const grid = shape ? decodeSilhouette(shape) : null;
- const usesDynamicImage = grid && grid.side >= DYNAMIC_IMAGE_MIN_SIDE;
+ const usesDynamicImage = grid && meetsDynamicMinimum(grid);
const imageURL = usesDynamicImage ? `${origin}/g/${shape}.png` : `${origin}/og.png`;
- const imageSize = usesDynamicImage ? silhouettePixelSize(grid.side) : null;
+ const imageWidth = usesDynamicImage ? silhouettePixelSize(grid.width) : null;
+ const imageHeight = usesDynamicImage ? silhouettePixelSize(grid.height) : 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
@@ -186,9 +187,9 @@ function previewPage(origin, token, target, segments) {
<meta property="og:site_name" content="Crossmate">
<meta property="og:title" content="${escapeHTML(title)}">
<meta property="og:description" content="${description}">
-<meta property="og:image" content="${imageURL}">${imageSize ? `
-<meta property="og:image:width" content="${imageSize}">
-<meta property="og:image:height" content="${imageSize}">` : ""}
+<meta property="og:image" content="${imageURL}">${imageWidth ? `
+<meta property="og:image:width" content="${imageWidth}">
+<meta property="og:image:height" content="${imageHeight}">` : ""}
<meta property="og:url" content="${origin}/s/${token}">
<meta name="twitter:card" content="summary">
<meta http-equiv="refresh" content="0; url=${target}">
@@ -204,23 +205,43 @@ const WEEKDAYS = [
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
];
-// A grid-silhouette segment: an `s`/`f` tag, a base-36 side digit (2…35), then
-// the base64url bit payload. Validated structurally — including that the
-// payload carries enough bytes for the grid's bit count — so a legacy
-// base64url title is very unlikely to be mistaken for a shape. The silhouette
-// is recognised and skipped here; the dynamic preview image that renders it is
-// a later change.
-const SHAPE_SEGMENT_PATTERN = /^([sf])([0-9a-z])([A-Za-z0-9_-]+)$/;
+// A grid-silhouette segment: a tag, one or two base-36 side digits (2…35), then
+// the base64url bit payload. Lowercase `s`/`f` carry a single side digit (square
+// grids); uppercase `S`/`F` carry a width digit then a height digit (rectangular
+// grids). `s`/`S` are 180°-symmetric (half the cells stored); `f`/`F` are a full
+// dump. Returns the parsed geometry, or `null` when the structure or the payload
+// byte count doesn't line up — so a legacy base64url title is very unlikely to
+// be mistaken for a shape, and an unknown tag falls through to the generic card.
+function parseShape(seg) {
+ if (seg.length < 2) return null;
+ const tag = seg[0];
+ let width;
+ let height;
+ let payload;
+ if (tag === "s" || tag === "f") {
+ width = height = parseInt(seg[1], 36);
+ payload = seg.slice(2);
+ } else if (tag === "S" || tag === "F") {
+ if (seg.length < 3) return null;
+ width = parseInt(seg[1], 36);
+ height = parseInt(seg[2], 36);
+ payload = seg.slice(3);
+ } else {
+ return null;
+ }
+ if (!(width >= 2 && width <= 35) || !(height >= 2 && height <= 35)) return null;
+ if (!/^[A-Za-z0-9_-]+$/.test(payload)) return null;
+
+ const symmetric = tag === "s" || tag === "S";
+ const cells = width * height;
+ const bits = symmetric ? Math.ceil(cells / 2) : cells;
+ const payloadBytes = Math.floor((payload.length * 6) / 8);
+ if (payloadBytes < Math.ceil(bits / 8)) return null;
+ return { symmetric, width, height, payload };
+}
function isShapeSegment(seg) {
- const m = SHAPE_SEGMENT_PATTERN.exec(seg);
- if (!m) return false;
- const side = parseInt(m[2], 36);
- if (!(side >= 2 && side <= 35)) return false;
- const cells = side * side;
- const bits = m[1] === "s" ? Math.ceil(cells / 2) : cells;
- const payloadBytes = Math.floor((m[3].length * 6) / 8);
- return payloadBytes >= Math.ceil(bits / 8);
+ return parseShape(seg) !== null;
}
// Sorts the up-to-two decoration segments into a display title and a
@@ -282,16 +303,17 @@ function escapeHTML(text) {
// MARK: - Grid silhouette
// The JS counterpart of the app's `GridSilhouette` codec — decode only, since
-// the worker never mints links. Mirrors the wire format `<tag><size><payload>`:
-// `s` stores the first ⌈N/2⌉ cells (the rest mirror by 180° rotation), `f`
-// stores all N; the payload is the cell bits MSB-first as base64url.
+// the worker never mints links. Mirrors the wire format `<tag><size…><payload>`:
+// a symmetric tag stores the first ⌈N/2⌉ cells (the rest mirror by 180°
+// rotation), a full tag stores all N; the payload is the cell bits MSB-first as
+// base64url. The 180° partner of cell `k` is `n-1-k` for any `w×h` grid.
function decodeSilhouette(seg) {
- if (!isShapeSegment(seg)) return null;
- const tag = seg[0];
- const side = parseInt(seg[1], 36);
- const n = side * side;
- const storedCount = tag === "s" ? Math.ceil(n / 2) : n;
- const bytes = base64urlToBytes(seg.slice(2));
+ const parsed = parseShape(seg);
+ if (!parsed) return null;
+ const { symmetric, width, height, payload } = parsed;
+ const n = width * height;
+ const storedCount = symmetric ? Math.ceil(n / 2) : n;
+ const bytes = base64urlToBytes(payload);
if (!bytes || bytes.length * 8 < storedCount) return null;
const stored = new Array(storedCount);
@@ -302,7 +324,7 @@ function decodeSilhouette(seg) {
for (let k = 0; k < n; k++) {
blocks[k] = k < storedCount ? stored[k] : stored[n - 1 - k];
}
- return { side, blocks };
+ return { width, height, blocks };
}
function base64urlToBytes(s) {
@@ -315,8 +337,9 @@ 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 → 842px is the largest).
+// Render constants: each cell is CELL px with a MARGIN-px quiet border, so each
+// image dimension is a pure function of that grid dimension (35 → 842px is the
+// largest).
const CELL = 22;
const MARGIN = 36;
const BG = [255, 255, 255]; // empty square / background
@@ -341,29 +364,40 @@ const SHADOW_OFFSET_Y = 0.055;
const SHADOW_BLUR = 0.150;
const SHADOW_ALPHA = 0.26;
-function silhouettePixelSize(side) {
- return MARGIN * 2 + side * CELL;
+function silhouettePixelSize(cells) {
+ return MARGIN * 2 + cells * CELL;
}
-// 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 * 3).fill(255);
- const set = (x, y, color) => setRGB(pixels, dim, x, y, color);
+// A grid earns its own rendered silhouette only when both sides clear the
+// minimum, so a thin strip never renders as a rich preview.
+function meetsDynamicMinimum(grid) {
+ return Math.min(grid.width, grid.height) >= DYNAMIC_IMAGE_MIN_SIDE;
+}
- // Grid lines bounding every cell.
- const extent = side * CELL;
- for (let i = 0; i <= side; i++) {
+// Paints the grid and a scaled Crossmate icon into a truecolor PNG. `cols` and
+// `rows` are the grid's width and height; the image may be non-square.
+async function renderSilhouettePNG(cols, rows, blocks) {
+ const dimW = silhouettePixelSize(cols);
+ const dimH = silhouettePixelSize(rows);
+ const pixels = new Uint8Array(dimW * dimH * 3).fill(255);
+ const set = (x, y, color) => setRGB(pixels, dimW, x, y, color);
+
+ // Grid lines bounding every cell: cols+1 verticals over the full height, and
+ // rows+1 horizontals over the full width.
+ const extentX = cols * CELL;
+ const extentY = rows * CELL;
+ for (let i = 0; i <= cols; i++) {
const at = MARGIN + i * CELL;
- for (let t = 0; t <= extent; t++) {
- set(MARGIN + t, at, LINE);
- set(at, MARGIN + t, LINE);
- }
+ for (let t = 0; t <= extentY; t++) set(at, MARGIN + t, LINE);
+ }
+ for (let i = 0; i <= rows; i++) {
+ const at = MARGIN + i * CELL;
+ for (let t = 0; t <= extentX; t++) set(MARGIN + t, at, LINE);
}
// Block squares fill their interior solid, leaving the gray lines between.
- for (let r = 0; r < side; r++) {
- for (let c = 0; c < side; c++) {
- if (!blocks[r * side + c]) continue;
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ if (!blocks[r * cols + c]) continue;
const x0 = MARGIN + c * CELL;
const y0 = MARGIN + r * CELL;
for (let y = 1; y < CELL; y++) {
@@ -372,8 +406,8 @@ async function renderSilhouettePNG(side, blocks) {
}
}
- drawCrossmateIcon(pixels, dim, side);
- return await encodeTruecolorPNG(dim, dim, pixels);
+ drawCrossmateIcon(pixels, dimW, dimH);
+ return await encodeTruecolorPNG(dimW, dimH, pixels);
}
function setRGB(pixels, width, x, y, color) {
@@ -383,12 +417,16 @@ function setRGB(pixels, width, x, y, color) {
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 origin = dim - padding - size;
+function drawCrossmateIcon(pixels, dimW, dimH) {
+ // Size off the smaller image dimension so a tall or wide grid doesn't get an
+ // oversized badge; anchor it in the bottom-right corner of the canvas.
+ const basis = Math.min(dimW, dimH);
+ const size = clamp(Math.round(basis * LOGO_SCALE), LOGO_MIN_SIZE, LOGO_MAX_SIZE);
+ const padding = Math.max(LOGO_MIN_PADDING, Math.round(basis * LOGO_PADDING_SCALE));
+ const originX = dimW - padding - size;
+ const originY = dimH - padding - size;
const radius = Math.round(size * 0.135);
- drawRoundedShadow(pixels, dim, origin, size, radius);
+ drawRoundedShadow(pixels, dimW, dimH, originX, originY, size, radius);
for (let y = 0; y < size; y++) {
const sourceY = Math.floor((y * LOGO_SOURCE_SIZE) / size);
@@ -396,30 +434,30 @@ function drawCrossmateIcon(pixels, dim, side) {
const coverage = roundedRectCoverage(x, y, size, radius);
if (coverage <= 0) continue;
const sourceX = Math.floor((x * LOGO_SOURCE_SIZE) / size);
- blendPixel(pixels, dim, origin + x, origin + y, crossmateIconColor(sourceX, sourceY), coverage);
+ blendPixel(pixels, dimW, originX + x, originY + y, crossmateIconColor(sourceX, sourceY), coverage);
}
}
}
-function drawRoundedShadow(pixels, dim, iconOrigin, size, radius) {
+function drawRoundedShadow(pixels, dimW, dimH, iconOriginX, iconOriginY, 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 shadowX = iconOriginX + Math.round(size * SHADOW_OFFSET_X);
+ const shadowY = iconOriginY + 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;
+ if (py < 0 || py >= dimH) continue;
for (let px = minX; px < maxX; px++) {
- if (px < 0 || px >= dim) continue;
+ if (px < 0 || px >= dimW) 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);
- blendPixel(pixels, dim, px, py, BLOCK, strength * SHADOW_ALPHA);
+ blendPixel(pixels, dimW, px, py, BLOCK, strength * SHADOW_ALPHA);
}
}
}