@cardanvil/frame-kit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +75 -1
  2. package/dist/authoring/collectorInfo.d.ts +17 -0
  3. package/dist/authoring/collectorInfo.js +62 -0
  4. package/dist/authoring/index.d.ts +2 -0
  5. package/dist/authoring/index.js +2 -0
  6. package/dist/authoring/layoutDefaults.d.ts +20 -2
  7. package/dist/authoring/layoutDefaults.js +48 -13
  8. package/dist/authoring/opacityStops.d.ts +8 -0
  9. package/dist/authoring/opacityStops.js +17 -0
  10. package/dist/cli/index.d.ts +1 -0
  11. package/dist/cli/index.js +1 -0
  12. package/dist/cli/loadFrame.js +1 -1
  13. package/dist/cli/main.js +72 -0
  14. package/dist/cli/studio/boxSets.js +11 -8
  15. package/dist/cli/studio/serve.js +4 -1
  16. package/dist/cli/trim.d.ts +62 -0
  17. package/dist/cli/trim.js +251 -0
  18. package/dist/layout/border.d.ts +27 -0
  19. package/dist/layout/border.js +31 -0
  20. package/dist/layout/boxOverrides.d.ts +28 -0
  21. package/dist/layout/boxOverrides.js +95 -0
  22. package/dist/layout/color.d.ts +23 -0
  23. package/dist/layout/color.js +53 -0
  24. package/dist/layout/cutouts.d.ts +28 -0
  25. package/dist/layout/cutouts.js +29 -0
  26. package/dist/layout/decorations.d.ts +1 -6
  27. package/dist/layout/decorations.js +7 -11
  28. package/dist/layout/frameLayers.d.ts +29 -2
  29. package/dist/layout/frameLayers.js +145 -3
  30. package/dist/layout/index.d.ts +17 -6
  31. package/dist/layout/index.js +17 -6
  32. package/dist/layout/paint.d.ts +36 -0
  33. package/dist/layout/paint.js +148 -0
  34. package/dist/layout/powerToughnessRow.d.ts +26 -0
  35. package/dist/layout/powerToughnessRow.js +13 -0
  36. package/dist/layout/resolveFrameAsset.d.ts +6 -0
  37. package/dist/layout/resolveFrameAsset.js +8 -0
  38. package/dist/layout/rulesSlide.d.ts +13 -0
  39. package/dist/layout/rulesSlide.js +33 -0
  40. package/dist/layout/settings.d.ts +26 -0
  41. package/dist/layout/settings.js +62 -0
  42. package/dist/layout/textStyle.d.ts +29 -0
  43. package/dist/layout/textStyle.js +28 -0
  44. package/dist/layout/turn.d.ts +54 -0
  45. package/dist/layout/turn.js +107 -0
  46. package/dist/manifest/contract.d.ts +6074 -9392
  47. package/dist/manifest/contract.js +46 -1
  48. package/dist/manifest/testFrame.js +9 -0
  49. package/dist/schema/collectorInfo.d.ts +95 -0
  50. package/dist/schema/collectorInfo.js +80 -0
  51. package/dist/schema/condition.d.ts +67 -0
  52. package/dist/schema/condition.js +47 -0
  53. package/dist/schema/frame.d.ts +17291 -25435
  54. package/dist/schema/frame.js +402 -14
  55. package/dist/schema/frameAssets.d.ts +53 -33
  56. package/dist/schema/frameAssets.js +77 -5
  57. package/dist/schema/index.d.ts +3 -0
  58. package/dist/schema/index.js +3 -0
  59. package/dist/schema/layout.d.ts +29 -4
  60. package/dist/schema/layout.js +27 -2
  61. package/dist/schema/paint.d.ts +202 -0
  62. package/dist/schema/paint.js +109 -0
  63. package/dist/studio/assets/index-BnhNwm_r.js +74 -0
  64. package/dist/studio/index.html +1 -1
  65. package/package.json +5 -3
  66. package/dist/studio/assets/index-CNzyV22G.js +0 -76
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Trimming: cutting full-sheet art down to the part of it that draws.
3
+ *
4
+ * Frame art is authored on the full 3264 × 4440 sheet so that it lines up by
5
+ * itself, but much of it covers a sliver of that: a Saga banner is under 5% of
6
+ * its sheet. A renderer decodes, caches and composites every pixel of an image,
7
+ * transparent or not, so the empty rest costs memory and time on every card.
8
+ * Trimmed, the image keeps only that sliver, and the frame records where it
9
+ * sits on the sheet.
10
+ *
11
+ * Nothing about how the art looks changes: the pixels kept are byte-for-byte
12
+ * the same, and the chunks that tell a decoder how to show them (colour
13
+ * profile, gamma) are carried over. A trimmed image also records its own
14
+ * position in an `oFFs` chunk, so trimming it again still reports where it
15
+ * sits on the sheet rather than in itself.
16
+ */
17
+ import { readFile, writeFile } from "node:fs/promises";
18
+ import path from "node:path";
19
+ import { PNG } from "pngjs";
20
+ import { PackagingError } from "./errors.js";
21
+ /**
22
+ * The smallest rectangle holding every pixel that is not fully transparent,
23
+ * or `undefined` when the image has none.
24
+ */
25
+ export function opaqueBounds({ width, height, data, }) {
26
+ let left = width;
27
+ let right = -1;
28
+ let top = height;
29
+ let bottom = -1;
30
+ for (let y = 0; y < height; y++) {
31
+ const row = y * width * 4;
32
+ for (let x = 0; x < width; x++) {
33
+ if (data[row + x * 4 + 3] !== 0) {
34
+ left = Math.min(left, x);
35
+ right = Math.max(right, x);
36
+ top = Math.min(top, y);
37
+ bottom = y;
38
+ }
39
+ }
40
+ }
41
+ return right < 0
42
+ ? undefined
43
+ : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
44
+ }
45
+ /** The smallest rectangle holding all of `rects`, or `undefined` for none. */
46
+ export function unionBounds(rects) {
47
+ if (rects.length === 0) {
48
+ return undefined;
49
+ }
50
+ const left = Math.min(...rects.map((r) => r.x));
51
+ const top = Math.min(...rects.map((r) => r.y));
52
+ const right = Math.max(...rects.map((r) => r.x + r.width));
53
+ const bottom = Math.max(...rects.map((r) => r.y + r.height));
54
+ return { x: left, y: top, width: right - left, height: bottom - top };
55
+ }
56
+ /** `pixels` cut down to `rect`, which must lie inside them. */
57
+ export function cropPixels(pixels, rect) {
58
+ const data = new Uint8Array(rect.width * rect.height * 4);
59
+ for (let y = 0; y < rect.height; y++) {
60
+ const from = ((rect.y + y) * pixels.width + rect.x) * 4;
61
+ data.set(pixels.data.subarray(from, from + rect.width * 4), y * rect.width * 4);
62
+ }
63
+ return { width: rect.width, height: rect.height, data };
64
+ }
65
+ const PNG_SIGNATURE = Buffer.from([
66
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
67
+ ]);
68
+ /** Splits a PNG file into its chunks, without decoding any. */
69
+ function readChunks(file) {
70
+ const chunks = [];
71
+ let offset = PNG_SIGNATURE.length;
72
+ while (offset + 12 <= file.length) {
73
+ const length = file.readUInt32BE(offset);
74
+ const end = offset + 12 + length;
75
+ chunks.push({
76
+ type: file.toString("latin1", offset + 4, offset + 8),
77
+ bytes: file.subarray(offset, end),
78
+ data: file.subarray(offset + 8, offset + 8 + length),
79
+ });
80
+ offset = end;
81
+ }
82
+ return chunks;
83
+ }
84
+ /**
85
+ * Chunks that change how a decoder shows the pixels — colour profile, gamma,
86
+ * chromaticities, significant bits, colour space — plus the pixel density.
87
+ * The encoder writes none of them, and a browser drawing the art honours
88
+ * every one, so each is copied from the original.
89
+ */
90
+ const KEPT_CHUNKS = new Set([
91
+ "cHRM",
92
+ "cICP",
93
+ "gAMA",
94
+ "iCCP",
95
+ "mDCV",
96
+ "cLLI",
97
+ "sBIT",
98
+ "sRGB",
99
+ "pHYs",
100
+ ]);
101
+ /** Where an image sits on its sheet, from an `oFFs` chunk in pixels. */
102
+ function readOffset(chunks) {
103
+ const offs = chunks.find((chunk) => chunk.type === "oFFs");
104
+ // Unit 0 is pixels; a micrometre offset is someone else's and is ignored.
105
+ if (offs?.data.length === 9 && offs.data[8] === 0) {
106
+ return { x: offs.data.readInt32BE(0), y: offs.data.readInt32BE(4) };
107
+ }
108
+ return { x: 0, y: 0 };
109
+ }
110
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
111
+ let c = n;
112
+ for (let k = 0; k < 8; k++) {
113
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
114
+ }
115
+ return c >>> 0;
116
+ });
117
+ function crc32(bytes) {
118
+ let crc = 0xffffffff;
119
+ for (const byte of bytes) {
120
+ crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
121
+ }
122
+ return (crc ^ 0xffffffff) >>> 0;
123
+ }
124
+ function chunk(type, data) {
125
+ const typeAndData = Buffer.concat([Buffer.from(type, "latin1"), data]);
126
+ const out = Buffer.alloc(12 + data.length);
127
+ out.writeUInt32BE(data.length, 0);
128
+ typeAndData.copy(out, 4);
129
+ out.writeUInt32BE(crc32(typeAndData), 8 + data.length);
130
+ return out;
131
+ }
132
+ function offsetChunk(x, y) {
133
+ const data = Buffer.alloc(9);
134
+ data.writeInt32BE(x, 0);
135
+ data.writeInt32BE(y, 4);
136
+ data.writeUInt8(0, 8);
137
+ return chunk("oFFs", data);
138
+ }
139
+ /**
140
+ * Encodes `pixels` as an RGBA PNG carrying `original`'s display chunks and an
141
+ * `oFFs` chunk at `at`. All of them go straight after the header, which is
142
+ * before any palette or image data, as the format requires.
143
+ */
144
+ function encode(pixels, original, at) {
145
+ const png = new PNG({ width: pixels.width, height: pixels.height });
146
+ png.data = Buffer.from(pixels.data.buffer, pixels.data.byteOffset, pixels.data.byteLength);
147
+ const encoded = PNG.sync.write(png, { colorType: 6 });
148
+ const [header, ...rest] = readChunks(encoded);
149
+ if (!header) {
150
+ throw new Error("the PNG encoder wrote no header");
151
+ }
152
+ return Buffer.concat([
153
+ PNG_SIGNATURE,
154
+ header.bytes,
155
+ ...original.filter((c) => KEPT_CHUNKS.has(c.type)).map((c) => c.bytes),
156
+ offsetChunk(at.x, at.y),
157
+ ...rest.map((c) => c.bytes),
158
+ ]);
159
+ }
160
+ async function readSource(file) {
161
+ const bytes = await readFile(file);
162
+ if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
163
+ throw new PackagingError(`${file} is not a PNG. Only PNGs have transparency to trim.`);
164
+ }
165
+ const chunks = readChunks(bytes);
166
+ // Decoding scales 16 bits down to 8, so writing it back would lose detail.
167
+ const header = chunks[0];
168
+ if (header?.type === "IHDR" && header.data[8] === 16) {
169
+ throw new PackagingError(`${file} has 16 bits a channel. Trimming keeps pixels exactly, which ` +
170
+ "it can only do for 8-bit images.");
171
+ }
172
+ const png = PNG.sync.read(bytes);
173
+ const pixels = { width: png.width, height: png.height, data: png.data };
174
+ return {
175
+ file,
176
+ bytes,
177
+ chunks,
178
+ pixels,
179
+ offset: readOffset(chunks),
180
+ bounds: opaqueBounds(pixels),
181
+ };
182
+ }
183
+ /**
184
+ * Trims each PNG in `files` to its visible pixels, or with `shared` all of
185
+ * them to one rectangle, and reports where each now sits on its sheet. An
186
+ * image already as small as it can be is not rewritten.
187
+ */
188
+ export async function trimImages(files, options = {}) {
189
+ const sources = await Promise.all(files.map((file) => readSource(file)));
190
+ let shared;
191
+ if (options.shared) {
192
+ const [first] = sources;
193
+ const mismatched = sources.find((s) => first &&
194
+ (s.pixels.width !== first.pixels.width ||
195
+ s.pixels.height !== first.pixels.height ||
196
+ s.offset.x !== first.offset.x ||
197
+ s.offset.y !== first.offset.y));
198
+ if (first && mismatched) {
199
+ throw new PackagingError(`--shared needs images of one size at one place on the sheet, but ` +
200
+ `${mismatched.file} is ${String(mismatched.pixels.width)} × ` +
201
+ `${String(mismatched.pixels.height)} at ` +
202
+ `(${String(mismatched.offset.x)}, ${String(mismatched.offset.y)}) ` +
203
+ `and ${first.file} is ${String(first.pixels.width)} × ` +
204
+ `${String(first.pixels.height)} at ` +
205
+ `(${String(first.offset.x)}, ${String(first.offset.y)}).`);
206
+ }
207
+ shared = unionBounds(sources.flatMap((s) => (s.bounds ? [s.bounds] : [])));
208
+ }
209
+ const images = await Promise.all(sources.map(async (source) => {
210
+ const crop = options.shared ? shared : source.bounds;
211
+ const { width, height } = source.pixels;
212
+ const onSheet = crop && {
213
+ ...crop,
214
+ x: source.offset.x + crop.x,
215
+ y: source.offset.y + crop.y,
216
+ };
217
+ const unchanged = !crop ||
218
+ (crop.x === 0 &&
219
+ crop.y === 0 &&
220
+ crop.width === width &&
221
+ crop.height === height);
222
+ if (unchanged || options.dryRun) {
223
+ return {
224
+ file: source.file,
225
+ written: undefined,
226
+ width,
227
+ height,
228
+ kept: onSheet,
229
+ };
230
+ }
231
+ const target = options.outDir
232
+ ? path.join(options.outDir, path.basename(source.file))
233
+ : source.file;
234
+ await writeFile(target, encode(cropPixels(source.pixels, crop), source.chunks, onSheet ?? crop));
235
+ return {
236
+ file: source.file,
237
+ written: target,
238
+ width,
239
+ height,
240
+ kept: onSheet,
241
+ };
242
+ }));
243
+ return {
244
+ images,
245
+ shared: shared && {
246
+ ...shared,
247
+ x: (sources[0]?.offset.x ?? 0) + shared.x,
248
+ y: (sources[0]?.offset.y ?? 0) + shared.y,
249
+ },
250
+ };
251
+ }
@@ -0,0 +1,27 @@
1
+ import type { BorderState } from "../schema/condition.js";
2
+ import type { LayoutMasks } from "../schema/frameAssets.js";
3
+ /**
4
+ * The mask a border color recolors the ring through: `borderFull` when
5
+ * "Color Entire Border" is on and the layout ships one, else the plain
6
+ * `border`. Undefined when the layout has no ring mask, so no border color
7
+ * can reach it.
8
+ */
9
+ export declare function ringMaskFor(masks: LayoutMasks | undefined, useFullBorder: boolean): string | undefined;
10
+ export interface BorderStateInput {
11
+ /** The border color setting, when one is picked. */
12
+ borderColor: string | null;
13
+ /** The mask set the face is drawn with. */
14
+ masks: LayoutMasks | undefined;
15
+ useNoBorder: boolean;
16
+ useFullBorder: boolean;
17
+ }
18
+ /**
19
+ * The border as it is about to be drawn, which text boxes' `overrides` match
20
+ * on (see `BorderStateSchema`).
21
+ *
22
+ * Only what the masks actually do counts: "No Border" without a `noBorder`
23
+ * mask cuts nothing away, and a border color without a ring mask recolors
24
+ * nothing — so a frame that cannot change its border always reads
25
+ * `"default"`.
26
+ */
27
+ export declare function borderStateFor({ borderColor, masks, useNoBorder, useFullBorder, }: BorderStateInput): BorderState;
@@ -0,0 +1,31 @@
1
+ import { getReadableTextColor, parseNormalizedCssColor } from "./color.js";
2
+ /**
3
+ * The mask a border color recolors the ring through: `borderFull` when
4
+ * "Color Entire Border" is on and the layout ships one, else the plain
5
+ * `border`. Undefined when the layout has no ring mask, so no border color
6
+ * can reach it.
7
+ */
8
+ export function ringMaskFor(masks, useFullBorder) {
9
+ return useFullBorder && masks?.borderFull ? masks.borderFull : masks?.border;
10
+ }
11
+ /**
12
+ * The border as it is about to be drawn, which text boxes' `overrides` match
13
+ * on (see `BorderStateSchema`).
14
+ *
15
+ * Only what the masks actually do counts: "No Border" without a `noBorder`
16
+ * mask cuts nothing away, and a border color without a ring mask recolors
17
+ * nothing — so a frame that cannot change its border always reads
18
+ * `"default"`.
19
+ */
20
+ export function borderStateFor({ borderColor, masks, useNoBorder, useFullBorder, }) {
21
+ if (useNoBorder && masks?.noBorder) {
22
+ return "none";
23
+ }
24
+ const rgb = borderColor && ringMaskFor(masks, useFullBorder)
25
+ ? parseNormalizedCssColor(borderColor)
26
+ : undefined;
27
+ if (!rgb) {
28
+ return "default";
29
+ }
30
+ return getReadableTextColor(rgb) === "black" ? "light" : "dark";
31
+ }
@@ -0,0 +1,28 @@
1
+ import type { BorderState, RenderCondition } from "../schema/condition.js";
2
+ import type { CardBoxes, SecondFaceBoxes } from "../schema/frame.js";
3
+ import type { FrameColor } from "../schema/frameAssets.js";
4
+ /** What text boxes' `overrides` are matched against, for one render. */
5
+ export interface OverrideState {
6
+ /** The border as it is about to be drawn; see `BorderStateSchema`. */
7
+ border: BorderState;
8
+ /** The template settings in effect for the layout, defaults included. */
9
+ settings: Readonly<Record<string, unknown>>;
10
+ /**
11
+ * The card's frame color, when there is a card. A condition on it never
12
+ * matches without one.
13
+ */
14
+ frameColor?: FrameColor;
15
+ }
16
+ /**
17
+ * Whether every condition a `RenderCondition` names holds for one render — a
18
+ * text box override's, a gradient's or a tinted image's.
19
+ */
20
+ export declare function overrideMatches(when: RenderCondition, state: OverrideState): boolean;
21
+ /**
22
+ * A box set as the renderer draws it for one render: each text box with its
23
+ * matching `overrides` merged over it in order, and the list itself dropped.
24
+ * Bare bounds (`art`, `setSymbol`, `ptImage`) pass through untouched.
25
+ */
26
+ export declare function resolveBoxOverrides(boxes: CardBoxes, state: OverrideState): CardBoxes;
27
+ /** {@link resolveBoxOverrides} for the set a second face is drawn from. */
28
+ export declare function resolveSecondFaceBoxOverrides(boxes: SecondFaceBoxes, state: OverrideState): SecondFaceBoxes;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The `CardBoxes` members that are text boxes, and so can carry `overrides`.
3
+ * `boxOverrides.test.ts` fails if the contract gains one this does not list.
4
+ */
5
+ const TEXT_BOX_KEYS = [
6
+ "mana",
7
+ "title",
8
+ "type",
9
+ "rules",
10
+ "abilities",
11
+ "pt",
12
+ "power",
13
+ "toughness",
14
+ "collectorInfo",
15
+ "startingLoyalty",
16
+ "nicknameTitle",
17
+ "keyword",
18
+ "flipsidePt",
19
+ "flipsideType",
20
+ "flipsideManaCost",
21
+ "flipsideRules",
22
+ "insetTitle",
23
+ "insetMana",
24
+ "insetType",
25
+ "insetRules",
26
+ "defense",
27
+ "fuse",
28
+ ];
29
+ /**
30
+ * The same for a second face's set. `boxOverrides.test.ts` holds this list to
31
+ * the contract too.
32
+ */
33
+ const SECOND_FACE_TEXT_BOX_KEYS = [
34
+ "title",
35
+ "mana",
36
+ "type",
37
+ "rules",
38
+ "pt",
39
+ "power",
40
+ "toughness",
41
+ ];
42
+ /**
43
+ * Whether every condition a `RenderCondition` names holds for one render — a
44
+ * text box override's, a gradient's or a tinted image's.
45
+ */
46
+ export function overrideMatches(when, state) {
47
+ if (when.border !== undefined) {
48
+ const borders = typeof when.border === "string" ? [when.border] : when.border;
49
+ if (!borders.includes(state.border)) {
50
+ return false;
51
+ }
52
+ }
53
+ if (when.frameColor !== undefined) {
54
+ const colors = typeof when.frameColor === "string" ? [when.frameColor] : when.frameColor;
55
+ if (state.frameColor === undefined || !colors.includes(state.frameColor)) {
56
+ return false;
57
+ }
58
+ }
59
+ return Object.entries(when.settings ?? {}).every(([key, value]) => state.settings[key] === value);
60
+ }
61
+ function resolveTextBox(box, state) {
62
+ const { overrides = [], ...resolved } = box;
63
+ for (const override of overrides) {
64
+ if (overrideMatches(override.when, state)) {
65
+ Object.assign(resolved, override.style);
66
+ }
67
+ }
68
+ return resolved;
69
+ }
70
+ /**
71
+ * A box set as the renderer draws it for one render: each text box with its
72
+ * matching `overrides` merged over it in order, and the list itself dropped.
73
+ * Bare bounds (`art`, `setSymbol`, `ptImage`) pass through untouched.
74
+ */
75
+ export function resolveBoxOverrides(boxes, state) {
76
+ const resolved = { ...boxes };
77
+ for (const key of TEXT_BOX_KEYS) {
78
+ const box = boxes[key];
79
+ if (box?.overrides) {
80
+ resolved[key] = resolveTextBox(box, state);
81
+ }
82
+ }
83
+ return resolved;
84
+ }
85
+ /** {@link resolveBoxOverrides} for the set a second face is drawn from. */
86
+ export function resolveSecondFaceBoxOverrides(boxes, state) {
87
+ const resolved = { ...boxes };
88
+ for (const key of SECOND_FACE_TEXT_BOX_KEYS) {
89
+ const box = boxes[key];
90
+ if (box?.overrides) {
91
+ resolved[key] = resolveTextBox(box, state);
92
+ }
93
+ }
94
+ return resolved;
95
+ }
@@ -0,0 +1,23 @@
1
+ export interface RgbColor {
2
+ r: number;
3
+ g: number;
4
+ b: number;
5
+ }
6
+ /**
7
+ * Parses a browser-normalized color string — what a canvas 2D context's
8
+ * `fillStyle` getter returns after being assigned a valid CSS color, e.g.
9
+ * `"#ffffff"` or `"rgba(255, 0, 0, 0.5)"`. Hex and `rgb()`/`rgba()` only: a
10
+ * named color has to go through a canvas first.
11
+ */
12
+ export declare function parseNormalizedCssColor(normalized: string): RgbColor | undefined;
13
+ /**
14
+ * WCAG relative luminance of an sRGB color (0 = black, 1 = white).
15
+ * https://www.w3.org/TR/WCAG20/#relativeluminancedef
16
+ */
17
+ export declare function relativeLuminance({ r, g, b }: RgbColor): number;
18
+ /**
19
+ * Picks whichever of black or white text has the higher WCAG contrast ratio
20
+ * against `background` — the standard way to choose readable text color for
21
+ * an arbitrary, user-chosen background (e.g. the border-color setting).
22
+ */
23
+ export declare function getReadableTextColor(background: RgbColor): "black" | "white";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Parses a browser-normalized color string — what a canvas 2D context's
3
+ * `fillStyle` getter returns after being assigned a valid CSS color, e.g.
4
+ * `"#ffffff"` or `"rgba(255, 0, 0, 0.5)"`. Hex and `rgb()`/`rgba()` only: a
5
+ * named color has to go through a canvas first.
6
+ */
7
+ export function parseNormalizedCssColor(normalized) {
8
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(normalized.trim());
9
+ const digits = hex?.[1];
10
+ if (digits) {
11
+ if (digits.length === 3) {
12
+ const [r = "0", g = "0", b = "0"] = digits;
13
+ return {
14
+ r: parseInt(r + r, 16),
15
+ g: parseInt(g + g, 16),
16
+ b: parseInt(b + b, 16),
17
+ };
18
+ }
19
+ return {
20
+ r: parseInt(digits.slice(0, 2), 16),
21
+ g: parseInt(digits.slice(2, 4), 16),
22
+ b: parseInt(digits.slice(4, 6), 16),
23
+ };
24
+ }
25
+ const fn = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(normalized.trim());
26
+ const [, rStr, gStr, bStr] = fn ?? [];
27
+ if (rStr && gStr && bStr) {
28
+ return { r: Number(rStr), g: Number(gStr), b: Number(bStr) };
29
+ }
30
+ return undefined;
31
+ }
32
+ /**
33
+ * WCAG relative luminance of an sRGB color (0 = black, 1 = white).
34
+ * https://www.w3.org/TR/WCAG20/#relativeluminancedef
35
+ */
36
+ export function relativeLuminance({ r, g, b }) {
37
+ const linear = (channel) => {
38
+ const c = channel / 255;
39
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
40
+ };
41
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
42
+ }
43
+ /**
44
+ * Picks whichever of black or white text has the higher WCAG contrast ratio
45
+ * against `background` — the standard way to choose readable text color for
46
+ * an arbitrary, user-chosen background (e.g. the border-color setting).
47
+ */
48
+ export function getReadableTextColor(background) {
49
+ const l = relativeLuminance(background);
50
+ const contrastWithWhite = 1.05 / (l + 0.05);
51
+ const contrastWithBlack = (l + 0.05) / 0.05;
52
+ return contrastWithWhite >= contrastWithBlack ? "white" : "black";
53
+ }
@@ -0,0 +1,28 @@
1
+ import type { BaseColor, FrameAssets, LayoutMasks } from "../schema/frameAssets.js";
2
+ export interface FrameCutoutInput {
3
+ /** The mask set the face is drawn with. */
4
+ masks: LayoutMasks | undefined;
5
+ frameAssets: FrameAssets;
6
+ useNoBorder: boolean;
7
+ isLegendary: boolean;
8
+ hasNickname: boolean;
9
+ /** The base frame's color, after any fallback. */
10
+ frameColor: BaseColor;
11
+ /** The planeswalker 4+-ability ("tall") base frame is in use. */
12
+ isTallFrame: boolean;
13
+ }
14
+ /**
15
+ * The masks cut out of a face's combined frame — base art plus section
16
+ * overlays — in the order the renderer applies them as `destination-in`, each
17
+ * seeing the result of the one before:
18
+ *
19
+ * - `noBorder`, when "No Border" is on;
20
+ * - `legendary`, on a legendary card;
21
+ * - `extended`, whenever the layout ships it (the extended-art silhouette);
22
+ * - the planeswalker colorless-nickname banner cutout, on a nicknamed
23
+ * colorless card.
24
+ *
25
+ * Crowns, nickname plates and PT boxes are drawn afterwards, over the result,
26
+ * so none of these cut them.
27
+ */
28
+ export declare function frameCutoutMasks(input: FrameCutoutInput): string[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The masks cut out of a face's combined frame — base art plus section
3
+ * overlays — in the order the renderer applies them as `destination-in`, each
4
+ * seeing the result of the one before:
5
+ *
6
+ * - `noBorder`, when "No Border" is on;
7
+ * - `legendary`, on a legendary card;
8
+ * - `extended`, whenever the layout ships it (the extended-art silhouette);
9
+ * - the planeswalker colorless-nickname banner cutout, on a nicknamed
10
+ * colorless card.
11
+ *
12
+ * Crowns, nickname plates and PT boxes are drawn afterwards, over the result,
13
+ * so none of these cut them.
14
+ */
15
+ export function frameCutoutMasks(input) {
16
+ const { masks, frameAssets, useNoBorder, isLegendary, hasNickname, frameColor, isTallFrame, } = input;
17
+ const nicknameMasks = frameAssets.planeswalker?.colorlessNicknameMasks;
18
+ const colorlessNickname = frameColor === "c" && hasNickname
19
+ ? isTallFrame
20
+ ? nicknameMasks?.tall
21
+ : nicknameMasks?.regular
22
+ : undefined;
23
+ return [
24
+ useNoBorder ? masks?.noBorder : undefined,
25
+ isLegendary ? masks?.legendary : undefined,
26
+ masks?.extended,
27
+ colorlessNickname,
28
+ ].filter((url) => Boolean(url));
29
+ }
@@ -23,12 +23,7 @@ export type BannerColors = FrameColor | [FrameColor, FrameColor];
23
23
  export declare function identityColors(details: FrameDetails, facts?: DecorationFacts): BannerColors;
24
24
  /** Legendary crown colour(s). */
25
25
  export declare function crownColors(details: FrameDetails, facts?: DecorationFacts): BannerColors;
26
- /**
27
- * Nickname banner colour(s).
28
- *
29
- * A land with no resolved identity uses the dedicated land nickname art rather
30
- * than the colourless one.
31
- */
26
+ /** Nickname banner colour(s). */
32
27
  export declare function nicknameColors(details: FrameDetails, facts?: DecorationFacts): BannerColors;
33
28
  /**
34
29
  * PT box colour.
@@ -17,24 +17,20 @@ export function identityColors(details, facts = {}) {
17
17
  if (details.identity.length > 1) {
18
18
  return "m";
19
19
  }
20
- // No identity: a colourless artifact takes the artifact token so its crown
21
- // matches its artifact pinlines; anything else stays colourless.
20
+ // No identity: a land takes the land token so its banners match its land
21
+ // pinlines, and a colourless artifact the artifact token so they match its
22
+ // artifact pinlines; anything else stays colourless.
23
+ if (facts.isLand === true) {
24
+ return "l";
25
+ }
22
26
  return facts.isArtifact === true ? "a" : "c";
23
27
  }
24
28
  /** Legendary crown colour(s). */
25
29
  export function crownColors(details, facts = {}) {
26
30
  return identityColors(details, facts);
27
31
  }
28
- /**
29
- * Nickname banner colour(s).
30
- *
31
- * A land with no resolved identity uses the dedicated land nickname art rather
32
- * than the colourless one.
33
- */
32
+ /** Nickname banner colour(s). */
34
33
  export function nicknameColors(details, facts = {}) {
35
- if (facts.isLand === true && !details.identity) {
36
- return "l";
37
- }
38
34
  return identityColors(details, facts);
39
35
  }
40
36
  /**
@@ -19,6 +19,13 @@ export declare const LAYERS: {
19
19
  };
20
20
  /** WUBRG in canonical order, which every multi-colour string is sorted into. */
21
21
  export declare const COLOR_ORDER: readonly ["W", "U", "B", "R", "G"];
22
+ /** The five colour letters, for checking a string is made of them. */
23
+ export declare const COLOR_LETTERS: ReadonlySet<string>;
24
+ /**
25
+ * Two or three colours in the order cards print them (`UW` → `WU`, `UGW` →
26
+ * `GWU`); anything else as it came.
27
+ */
28
+ export declare function printedColorOrder(colors: string): string;
22
29
  /**
23
30
  * What the frame logic concluded about a card, before any art is chosen.
24
31
  *
@@ -44,7 +51,7 @@ export interface FrameDetails {
44
51
  */
45
52
  export type FrameFamily = "normal" | "land" | "nyx";
46
53
  /** A mask that recolours one section of the frame. */
47
- export type OverlayMaskName = "pinlines" | "rules" | "titleAndType" | "title" | "rightHalf";
54
+ export type OverlayMaskName = "pinlines" | "rules" | "titleAndType" | "title" | "rightHalf" | "rulesRight" | "adventureRules" | "adventureRulesRight" | "wings";
48
55
  export interface FrameOverlay {
49
56
  frame: FrameRef;
50
57
  mask: OverlayMaskName;
@@ -92,6 +99,21 @@ export interface SelectOptions {
92
99
  * halves under a colourless wash.
93
100
  */
94
101
  export declare function selectFrameLayers(details: FrameDetails, options?: SelectOptions): FrameLayers;
102
+ /**
103
+ * The art to composite for a storybook Adventure, whose text box is an open
104
+ * book: the card's rules on the right page, its adventure on the left.
105
+ *
106
+ * Everything outside the book is selected as for any card, as though the
107
+ * layout had pinlines, with one exception: the book has no pinline art, so a
108
+ * two-colour gold card halves its base frame between its colours, which
109
+ * splits the frame's own pinlines, and lays gold over the title and type bars
110
+ * as the regular frame has them, and over the book's page edges (`wings`).
111
+ * Each page is then coloured on its own: the right by the card's pinline
112
+ * colours, the left by `adventureColors`, the adventure's colours in the
113
+ * order cards print them (see `printedColorOrder`). An adventure with no
114
+ * colours takes the card's.
115
+ */
116
+ export declare function selectStorybookFrameLayers(details: FrameDetails, adventureColors: string, options?: SelectOptions): FrameLayers;
95
117
  /**
96
118
  * What a card is, reduced to the switches that actually change its frame.
97
119
  *
@@ -117,7 +139,12 @@ export interface CardShape {
117
139
  /** Legendary cards get the crown. */
118
140
  readonly isLegendary?: boolean;
119
141
  }
120
- /** WUBRG order, which every colour combination is normalised into. */
142
+ /**
143
+ * A card's colours as one string, in the order cards print them: `GW`, not
144
+ * `WG`. Card Anvil orders a card's identity the same way, and a two-colour
145
+ * split reads its left half from the first letter, so a plain WUBRG sort
146
+ * would swap the halves of GW, GU and RW cards. Four colours stay WUBRG.
147
+ */
121
148
  export declare function orderColors(colors: readonly FrameColor[]): string;
122
149
  /**
123
150
  * The frame details a card shape implies.