@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.
- package/README.md +75 -1
- package/dist/authoring/collectorInfo.d.ts +17 -0
- package/dist/authoring/collectorInfo.js +62 -0
- package/dist/authoring/index.d.ts +2 -0
- package/dist/authoring/index.js +2 -0
- package/dist/authoring/layoutDefaults.d.ts +20 -2
- package/dist/authoring/layoutDefaults.js +48 -13
- package/dist/authoring/opacityStops.d.ts +8 -0
- package/dist/authoring/opacityStops.js +17 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/loadFrame.js +1 -1
- package/dist/cli/main.js +72 -0
- package/dist/cli/studio/boxSets.js +11 -8
- package/dist/cli/studio/serve.js +4 -1
- package/dist/cli/trim.d.ts +62 -0
- package/dist/cli/trim.js +251 -0
- package/dist/layout/border.d.ts +27 -0
- package/dist/layout/border.js +31 -0
- package/dist/layout/boxOverrides.d.ts +28 -0
- package/dist/layout/boxOverrides.js +95 -0
- package/dist/layout/color.d.ts +23 -0
- package/dist/layout/color.js +53 -0
- package/dist/layout/cutouts.d.ts +28 -0
- package/dist/layout/cutouts.js +29 -0
- package/dist/layout/decorations.d.ts +1 -6
- package/dist/layout/decorations.js +7 -11
- package/dist/layout/frameLayers.d.ts +29 -2
- package/dist/layout/frameLayers.js +145 -3
- package/dist/layout/index.d.ts +17 -6
- package/dist/layout/index.js +17 -6
- package/dist/layout/paint.d.ts +36 -0
- package/dist/layout/paint.js +148 -0
- package/dist/layout/powerToughnessRow.d.ts +26 -0
- package/dist/layout/powerToughnessRow.js +13 -0
- package/dist/layout/resolveFrameAsset.d.ts +6 -0
- package/dist/layout/resolveFrameAsset.js +8 -0
- package/dist/layout/rulesSlide.d.ts +13 -0
- package/dist/layout/rulesSlide.js +33 -0
- package/dist/layout/settings.d.ts +26 -0
- package/dist/layout/settings.js +62 -0
- package/dist/layout/textStyle.d.ts +29 -0
- package/dist/layout/textStyle.js +28 -0
- package/dist/layout/turn.d.ts +54 -0
- package/dist/layout/turn.js +107 -0
- package/dist/manifest/contract.d.ts +6074 -9392
- package/dist/manifest/contract.js +46 -1
- package/dist/manifest/testFrame.js +9 -0
- package/dist/schema/collectorInfo.d.ts +95 -0
- package/dist/schema/collectorInfo.js +80 -0
- package/dist/schema/condition.d.ts +67 -0
- package/dist/schema/condition.js +47 -0
- package/dist/schema/frame.d.ts +17291 -25435
- package/dist/schema/frame.js +402 -14
- package/dist/schema/frameAssets.d.ts +53 -33
- package/dist/schema/frameAssets.js +77 -5
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.js +3 -0
- package/dist/schema/layout.d.ts +29 -4
- package/dist/schema/layout.js +27 -2
- package/dist/schema/paint.d.ts +202 -0
- package/dist/schema/paint.js +109 -0
- package/dist/studio/assets/index-BnhNwm_r.js +74 -0
- package/dist/studio/index.html +1 -1
- package/package.json +5 -3
- package/dist/studio/assets/index-CNzyV22G.js +0 -76
package/dist/cli/trim.js
ADDED
|
@@ -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
|
|
21
|
-
//
|
|
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
|
-
/**
|
|
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.
|