pi-roundtable-drawing 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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/examples/decks/playing-cards/deck.json +274 -0
- package/examples/decks/tarot/deck.json +395 -0
- package/examples/roundtable.config.ts +23 -0
- package/fonts/NotoSansTC-Bold.otf +0 -0
- package/fonts/OFL.txt +92 -0
- package/package.json +54 -0
- package/src/cards/deck.ts +204 -0
- package/src/cards/draw.ts +66 -0
- package/src/cards/spread.ts +232 -0
- package/src/errors.ts +4 -0
- package/src/index.ts +5 -0
- package/src/magic.ts +525 -0
- package/src/plugin.ts +61 -0
- package/src/random.ts +19 -0
- package/src/relationship-map.ts +466 -0
- package/src/style.ts +31 -0
- package/src/tools/draw-cards-tool.ts +121 -0
- package/src/tools/image-tool.ts +97 -0
- package/src/tools/magic-tools.ts +140 -0
- package/src/tools/relationship-map-tool.ts +65 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
+
import { Image } from "canvas";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import Value from "typebox/value";
|
|
12
|
+
|
|
13
|
+
/** The image types node-canvas loads everywhere. */
|
|
14
|
+
export const FACE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif"] as const;
|
|
15
|
+
|
|
16
|
+
/** A deck's id is its directory name; it is what the model passes as `deck`. */
|
|
17
|
+
const DECK_ID = /^[a-z0-9][a-z0-9_-]*$/;
|
|
18
|
+
|
|
19
|
+
/** The card heights, as a multiple of the width, that a spread can draw. */
|
|
20
|
+
export const MIN_ASPECT = 0.25;
|
|
21
|
+
export const MAX_ASPECT = 4;
|
|
22
|
+
|
|
23
|
+
const CardEntry = Type.Object(
|
|
24
|
+
{
|
|
25
|
+
id: Type.String({ minLength: 1 }),
|
|
26
|
+
name: Type.String({ minLength: 1 }),
|
|
27
|
+
file: Type.Optional(Type.String({ minLength: 1 })),
|
|
28
|
+
group: Type.Optional(Type.String({ minLength: 1 })),
|
|
29
|
+
},
|
|
30
|
+
{ additionalProperties: false },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const Manifest = Type.Object(
|
|
34
|
+
{
|
|
35
|
+
name: Type.String({ minLength: 1 }),
|
|
36
|
+
reversals: Type.Optional(Type.Boolean()),
|
|
37
|
+
aspect: Type.Optional(
|
|
38
|
+
Type.Number({ minimum: MIN_ASPECT, maximum: MAX_ASPECT }),
|
|
39
|
+
),
|
|
40
|
+
cards: Type.Array(CardEntry, { minItems: 1 }),
|
|
41
|
+
},
|
|
42
|
+
{ additionalProperties: false },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
export interface Card {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
/** The face image, as an absolute path; undefined draws a plain tile with the name. */
|
|
49
|
+
face?: string;
|
|
50
|
+
group?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Deck {
|
|
54
|
+
/** The directory name, which the tool takes as `deck`. */
|
|
55
|
+
id: string;
|
|
56
|
+
/** The name the operator gave the deck in its manifest. */
|
|
57
|
+
name: string;
|
|
58
|
+
/** Whether a draw may turn cards upside down by default. */
|
|
59
|
+
reversals: boolean;
|
|
60
|
+
/** Card height divided by card width; undefined takes the ratio of the first face drawn. */
|
|
61
|
+
aspect?: number;
|
|
62
|
+
cards: readonly Card[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A deck directory that cannot be used; the message names the deck and what to fix. */
|
|
66
|
+
export class DeckError extends Error {
|
|
67
|
+
override name = "DeckError";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fail(id: string, problem: string): never {
|
|
71
|
+
throw new DeckError(`deck ${id}: ${problem}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readManifest(id: string, dir: string) {
|
|
75
|
+
const path = join(dir, "deck.json");
|
|
76
|
+
let json: unknown;
|
|
77
|
+
try {
|
|
78
|
+
json = JSON.parse(readFileSync(path, "utf8"));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return fail(
|
|
81
|
+
id,
|
|
82
|
+
`deck.json cannot be read as JSON (${error instanceof Error ? error.message : String(error)}).`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!Value.Check(Manifest, json)) {
|
|
86
|
+
const first = Value.Errors(Manifest, json)[0];
|
|
87
|
+
return fail(
|
|
88
|
+
id,
|
|
89
|
+
`deck.json is not a valid manifest at ${first?.instancePath || "the top level"}: ${first?.message ?? "unknown problem"}.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return json;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Decodes the face once, so a file that is not an image stops the start rather than the first draw. */
|
|
96
|
+
function checkImage(id: string, card: string, file: string, path: string) {
|
|
97
|
+
let problem: string | undefined;
|
|
98
|
+
const image = new Image();
|
|
99
|
+
image.onerror = (error) => {
|
|
100
|
+
problem = error.message;
|
|
101
|
+
};
|
|
102
|
+
image.src = readFileSync(path);
|
|
103
|
+
if (problem !== undefined || !image.complete || image.width === 0)
|
|
104
|
+
fail(
|
|
105
|
+
id,
|
|
106
|
+
`card ${card}: file ${file} cannot be read as an image${problem ? ` (${problem})` : ""}.`,
|
|
107
|
+
);
|
|
108
|
+
const aspect = image.height / image.width;
|
|
109
|
+
if (aspect < MIN_ASPECT || aspect > MAX_ASPECT)
|
|
110
|
+
fail(
|
|
111
|
+
id,
|
|
112
|
+
`card ${card}: file ${file} is ${image.width} by ${image.height} px, a height of ${aspect.toFixed(2)} times the width; cards must be between ${MIN_ASPECT} and ${MAX_ASPECT}.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether `path` is `dir` or below it. */
|
|
117
|
+
function within(dir: string, path: string): boolean {
|
|
118
|
+
const inside = relative(dir, path);
|
|
119
|
+
return (
|
|
120
|
+
inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside)
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function faceFile(id: string, dir: string, card: string, file: string) {
|
|
125
|
+
const outside = () =>
|
|
126
|
+
fail(id, `card ${card}: file ${file} must stay inside the deck directory.`);
|
|
127
|
+
const path = resolve(dir, file);
|
|
128
|
+
if (isAbsolute(file) || !within(dir, path)) outside();
|
|
129
|
+
if (
|
|
130
|
+
!(FACE_EXTENSIONS as readonly string[]).includes(
|
|
131
|
+
extname(path).toLowerCase(),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
fail(
|
|
135
|
+
id,
|
|
136
|
+
`card ${card}: file ${file} must be one of ${FACE_EXTENSIONS.join(", ")}.`,
|
|
137
|
+
);
|
|
138
|
+
if (!existsSync(path) || !statSync(path).isFile())
|
|
139
|
+
fail(
|
|
140
|
+
id,
|
|
141
|
+
`card ${card}: file ${file} does not exist in the deck directory.`,
|
|
142
|
+
);
|
|
143
|
+
// A link may not lead out of the deck directory either.
|
|
144
|
+
if (!within(realpathSync(dir), realpathSync(path))) outside();
|
|
145
|
+
checkImage(id, card, file, path);
|
|
146
|
+
return path;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Reads and checks one deck directory (`<id>/deck.json` and the faces it names). */
|
|
150
|
+
export function loadDeck(id: string, dir: string): Deck {
|
|
151
|
+
if (!DECK_ID.test(id))
|
|
152
|
+
fail(
|
|
153
|
+
id,
|
|
154
|
+
"the directory name must be lowercase letters, digits, dashes, or underscores, starting with a letter or digit. Rename the directory.",
|
|
155
|
+
);
|
|
156
|
+
const manifest = readManifest(id, dir);
|
|
157
|
+
const seen = new Set<string>();
|
|
158
|
+
const cards = manifest.cards.map((entry): Card => {
|
|
159
|
+
if (seen.has(entry.id))
|
|
160
|
+
fail(id, `two cards share the id ${JSON.stringify(entry.id)}.`);
|
|
161
|
+
seen.add(entry.id);
|
|
162
|
+
return {
|
|
163
|
+
id: entry.id,
|
|
164
|
+
name: entry.name,
|
|
165
|
+
...(entry.file ? { face: faceFile(id, dir, entry.id, entry.file) } : {}),
|
|
166
|
+
...(entry.group ? { group: entry.group } : {}),
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
id,
|
|
171
|
+
name: manifest.name,
|
|
172
|
+
reversals: manifest.reversals ?? true,
|
|
173
|
+
...(manifest.aspect ? { aspect: manifest.aspect } : {}),
|
|
174
|
+
cards,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Every deck under `deckDir`: each subdirectory that holds a `deck.json`. */
|
|
179
|
+
export function loadDecks(deckDir: string): Deck[] {
|
|
180
|
+
if (!existsSync(deckDir) || !statSync(deckDir).isDirectory())
|
|
181
|
+
throw new DeckError(
|
|
182
|
+
`deckDir ${deckDir} is not a directory. Point it at the directory that holds one subdirectory for each deck.`,
|
|
183
|
+
);
|
|
184
|
+
const decks = readdirSync(deckDir, { withFileTypes: true })
|
|
185
|
+
.filter(
|
|
186
|
+
(entry) =>
|
|
187
|
+
entry.isDirectory() &&
|
|
188
|
+
existsSync(join(deckDir, entry.name, "deck.json")),
|
|
189
|
+
)
|
|
190
|
+
.map((entry) => loadDeck(entry.name, join(deckDir, entry.name)))
|
|
191
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
192
|
+
if (decks.length === 0)
|
|
193
|
+
throw new DeckError(
|
|
194
|
+
`deckDir ${deckDir} holds no deck: add a subdirectory with a deck.json for each deck.`,
|
|
195
|
+
);
|
|
196
|
+
return decks;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The groups a deck names for its cards, in order of first appearance. */
|
|
200
|
+
export function groupsOf(deck: Deck): string[] {
|
|
201
|
+
return [
|
|
202
|
+
...new Set(deck.cards.flatMap((card) => (card.group ? [card.group] : []))),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DrawingError } from "../errors.ts";
|
|
2
|
+
import type { Random } from "../random.ts";
|
|
3
|
+
import { type Card, type Deck, groupsOf } from "./deck.ts";
|
|
4
|
+
|
|
5
|
+
export interface DrawnCard extends Card {
|
|
6
|
+
reversed: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DrawParams {
|
|
10
|
+
deck: Deck;
|
|
11
|
+
count: number;
|
|
12
|
+
/** Draw only from the cards of this group. */
|
|
13
|
+
group?: string | undefined;
|
|
14
|
+
exclude?: readonly string[] | undefined;
|
|
15
|
+
/** Whether cards may come up reversed; the deck's own default when absent. */
|
|
16
|
+
allowReversed?: boolean | undefined;
|
|
17
|
+
random: Random;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Draws from the whole deck minus the exclusions; there is no persistent deck between draws. */
|
|
21
|
+
export function drawCards(params: DrawParams): DrawnCard[] {
|
|
22
|
+
const { deck, count, group, exclude, allowReversed, random } = params;
|
|
23
|
+
if (!Number.isInteger(count) || count < 1)
|
|
24
|
+
throw new DrawingError(
|
|
25
|
+
`count must be a whole number of at least 1; got ${count}.`,
|
|
26
|
+
);
|
|
27
|
+
if (group !== undefined && !groupsOf(deck).includes(group))
|
|
28
|
+
throw new DrawingError(
|
|
29
|
+
`Deck ${deck.id} has no group ${JSON.stringify(group)}. ${
|
|
30
|
+
groupsOf(deck).length > 0
|
|
31
|
+
? `Its groups are ${groupsOf(deck).join(", ")}.`
|
|
32
|
+
: "It has no groups; omit group."
|
|
33
|
+
}`,
|
|
34
|
+
);
|
|
35
|
+
const known = new Set(deck.cards.map((card) => card.id));
|
|
36
|
+
const excluded = new Set(exclude ?? []);
|
|
37
|
+
for (const id of excluded)
|
|
38
|
+
if (!known.has(id))
|
|
39
|
+
throw new DrawingError(
|
|
40
|
+
`exclude names ${JSON.stringify(id)}, which is not a card of deck ${deck.id}. Use the card ids the deck lists.`,
|
|
41
|
+
);
|
|
42
|
+
const pool = deck.cards.filter(
|
|
43
|
+
(card) =>
|
|
44
|
+
!excluded.has(card.id) && (group === undefined || card.group === group),
|
|
45
|
+
);
|
|
46
|
+
if (count > pool.length)
|
|
47
|
+
throw new DrawingError(
|
|
48
|
+
`Cannot draw ${count} cards: only ${pool.length} are available in deck ${deck.id}${group ? ` group ${group}` : ""} with ${excluded.size} excluded.`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Fisher–Yates on the copy, then take the first `count`.
|
|
52
|
+
for (let i = pool.length - 1; i > 0; i--) {
|
|
53
|
+
const j = Math.floor(random() * (i + 1));
|
|
54
|
+
const a = pool[i];
|
|
55
|
+
const b = pool[j];
|
|
56
|
+
if (a && b) {
|
|
57
|
+
pool[i] = b;
|
|
58
|
+
pool[j] = a;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const reversible = allowReversed ?? deck.reversals;
|
|
62
|
+
return pool.slice(0, count).map((card) => ({
|
|
63
|
+
...card,
|
|
64
|
+
reversed: reversible ? random() < 0.5 : false,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { type CanvasRenderingContext2D, createCanvas, loadImage } from "canvas";
|
|
2
|
+
import { DrawingError } from "../errors.ts";
|
|
3
|
+
import { ensureFont, font, PALETTE } from "../style.ts";
|
|
4
|
+
import { type Deck, MAX_ASPECT, MIN_ASPECT } from "./deck.ts";
|
|
5
|
+
import type { DrawnCard } from "./draw.ts";
|
|
6
|
+
|
|
7
|
+
export interface SpreadPosition {
|
|
8
|
+
row: number;
|
|
9
|
+
col: number;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The most rows or columns a spread of your own may use; the schema holds the model to it. */
|
|
14
|
+
export const MAX_GRID = 10;
|
|
15
|
+
|
|
16
|
+
const PADDING = 32;
|
|
17
|
+
const HEADING = 64;
|
|
18
|
+
const GAP = 20;
|
|
19
|
+
const LABEL = 26;
|
|
20
|
+
const NAME = 30;
|
|
21
|
+
const DEFAULT_ASPECT = 1.5;
|
|
22
|
+
/** The picture stays within what canvas can allocate. */
|
|
23
|
+
const MAX_SIDE = 16384;
|
|
24
|
+
const MAX_PIXELS = 40_000_000;
|
|
25
|
+
|
|
26
|
+
function cardWidth(count: number): number {
|
|
27
|
+
if (count <= 5) return 200;
|
|
28
|
+
if (count <= 21) return 150;
|
|
29
|
+
return 110;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Without a custom spread, cards fill rows left to right: one row up to five, then wider rows. */
|
|
33
|
+
export function autoLayout(count: number): SpreadPosition[] {
|
|
34
|
+
const cols = count <= 5 ? count : count <= 10 ? 5 : 7;
|
|
35
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
36
|
+
row: Math.floor(i / cols),
|
|
37
|
+
col: i % cols,
|
|
38
|
+
label: "",
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A spread needs one position for each card, none shared. */
|
|
43
|
+
export function checkSpread(
|
|
44
|
+
positions: readonly SpreadPosition[],
|
|
45
|
+
count: number,
|
|
46
|
+
): void {
|
|
47
|
+
if (positions.length !== count)
|
|
48
|
+
throw new DrawingError(
|
|
49
|
+
`The spread has ${positions.length} positions for ${count} cards. Give one position for each card.`,
|
|
50
|
+
);
|
|
51
|
+
const cells = new Set<string>();
|
|
52
|
+
for (const { row, col } of positions) {
|
|
53
|
+
if (![row, col].every((n) => Number.isInteger(n) && n >= 0))
|
|
54
|
+
throw new DrawingError(
|
|
55
|
+
`Spread positions need a row and a column that are whole numbers from 0; got row ${row}, column ${col}.`,
|
|
56
|
+
);
|
|
57
|
+
const cell = `${row}:${col}`;
|
|
58
|
+
if (cells.has(cell))
|
|
59
|
+
throw new DrawingError(
|
|
60
|
+
`Two cards share row ${row}, column ${col}. Give each card its own position.`,
|
|
61
|
+
);
|
|
62
|
+
cells.add(cell);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fitText(
|
|
67
|
+
ctx: CanvasRenderingContext2D,
|
|
68
|
+
text: string,
|
|
69
|
+
maxWidth: number,
|
|
70
|
+
): string {
|
|
71
|
+
if (ctx.measureText(text).width <= maxWidth) return text;
|
|
72
|
+
let cut = text;
|
|
73
|
+
while (cut.length > 1 && ctx.measureText(`${cut}…`).width > maxWidth)
|
|
74
|
+
cut = cut.slice(0, -1);
|
|
75
|
+
return `${cut}…`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Breaks text into lines no wider than `maxWidth`, at spaces and, for a long word, anywhere. */
|
|
79
|
+
function wrapText(
|
|
80
|
+
ctx: CanvasRenderingContext2D,
|
|
81
|
+
text: string,
|
|
82
|
+
maxWidth: number,
|
|
83
|
+
): string[] {
|
|
84
|
+
const lines: string[] = [];
|
|
85
|
+
let line = "";
|
|
86
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
87
|
+
const next = line ? `${line} ${word}` : word;
|
|
88
|
+
if (ctx.measureText(next).width <= maxWidth) {
|
|
89
|
+
line = next;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (line) lines.push(line);
|
|
93
|
+
line = "";
|
|
94
|
+
for (const char of word) {
|
|
95
|
+
if (line && ctx.measureText(line + char).width > maxWidth) {
|
|
96
|
+
lines.push(line);
|
|
97
|
+
line = "";
|
|
98
|
+
}
|
|
99
|
+
line += char;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (line) lines.push(line);
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Draws the cards on a dark ground, every face visible. A card without a face image is a tile
|
|
108
|
+
* with its name. A reversed card is drawn upside down and marked "reversed" under its name.
|
|
109
|
+
*/
|
|
110
|
+
export async function renderSpread(
|
|
111
|
+
deck: Deck,
|
|
112
|
+
cards: readonly DrawnCard[],
|
|
113
|
+
positions: readonly SpreadPosition[],
|
|
114
|
+
heading: { title: string; subtitle: string },
|
|
115
|
+
): Promise<Uint8Array> {
|
|
116
|
+
checkSpread(positions, cards.length);
|
|
117
|
+
ensureFont();
|
|
118
|
+
const faces = await Promise.all(
|
|
119
|
+
cards.map(async (card) => {
|
|
120
|
+
if (!card.face) return undefined;
|
|
121
|
+
try {
|
|
122
|
+
return await loadImage(card.face);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new DrawingError(
|
|
125
|
+
`The face of card ${card.id} in deck ${deck.id} cannot be read as an image (${error instanceof Error ? error.message : String(error)}). Fix ${card.face}.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
const firstFace = faces.find((face) => face !== undefined);
|
|
131
|
+
const aspect =
|
|
132
|
+
deck.aspect ??
|
|
133
|
+
(firstFace ? firstFace.height / firstFace.width : DEFAULT_ASPECT);
|
|
134
|
+
if (aspect < MIN_ASPECT || aspect > MAX_ASPECT)
|
|
135
|
+
throw new DrawingError(
|
|
136
|
+
`Deck ${deck.id} draws cards ${aspect.toFixed(2)} times as tall as wide; set aspect in its deck.json to a value from ${MIN_ASPECT} to ${MAX_ASPECT}.`,
|
|
137
|
+
);
|
|
138
|
+
const width = cardWidth(cards.length);
|
|
139
|
+
const height = Math.round(width * aspect);
|
|
140
|
+
const hasLabels = positions.some((p) => p.label);
|
|
141
|
+
const cellW = width + GAP;
|
|
142
|
+
const cellH = (hasLabels ? LABEL : 0) + height + NAME + GAP;
|
|
143
|
+
const cols = Math.max(...positions.map((p) => p.col)) + 1;
|
|
144
|
+
const rows = Math.max(...positions.map((p) => p.row)) + 1;
|
|
145
|
+
const gridW = cols * cellW - GAP;
|
|
146
|
+
const canvasW = Math.max(PADDING * 2 + gridW, 520);
|
|
147
|
+
// A narrow spread is centred on the minimum width the heading needs.
|
|
148
|
+
const left = (canvasW - gridW) / 2;
|
|
149
|
+
const canvasH = PADDING * 2 + HEADING + rows * cellH - GAP;
|
|
150
|
+
if (
|
|
151
|
+
canvasW > MAX_SIDE ||
|
|
152
|
+
canvasH > MAX_SIDE ||
|
|
153
|
+
canvasW * canvasH > MAX_PIXELS
|
|
154
|
+
)
|
|
155
|
+
throw new DrawingError(
|
|
156
|
+
`The spread would be ${canvasW} by ${canvasH} px, which is too large to draw. Draw fewer cards or use a smaller grid.`,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const canvas = createCanvas(canvasW, canvasH);
|
|
160
|
+
const ctx = canvas.getContext("2d");
|
|
161
|
+
ctx.fillStyle = PALETTE.background;
|
|
162
|
+
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
163
|
+
|
|
164
|
+
ctx.textBaseline = "top";
|
|
165
|
+
ctx.textAlign = "left";
|
|
166
|
+
ctx.fillStyle = PALETTE.accentLight;
|
|
167
|
+
ctx.font = font(20);
|
|
168
|
+
ctx.fillText(
|
|
169
|
+
fitText(ctx, heading.title, canvasW - PADDING * 2),
|
|
170
|
+
PADDING,
|
|
171
|
+
PADDING,
|
|
172
|
+
);
|
|
173
|
+
ctx.fillStyle = PALETTE.text;
|
|
174
|
+
ctx.font = font(26);
|
|
175
|
+
ctx.fillText(
|
|
176
|
+
fitText(ctx, heading.subtitle, canvasW - PADDING * 2),
|
|
177
|
+
PADDING,
|
|
178
|
+
PADDING + 26,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const top = PADDING + HEADING;
|
|
182
|
+
for (const [i, card] of cards.entries()) {
|
|
183
|
+
const position = positions[i];
|
|
184
|
+
if (!position) continue;
|
|
185
|
+
const x = left + position.col * cellW;
|
|
186
|
+
let y = top + position.row * cellH;
|
|
187
|
+
ctx.textAlign = "center";
|
|
188
|
+
ctx.textBaseline = "top";
|
|
189
|
+
if (hasLabels) {
|
|
190
|
+
ctx.fillStyle = PALETTE.accentLight;
|
|
191
|
+
ctx.font = font(16);
|
|
192
|
+
ctx.fillText(fitText(ctx, position.label, width), x + width / 2, y);
|
|
193
|
+
y += LABEL;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const face = faces[i];
|
|
197
|
+
// A reversed card turns upside down whole, face or tile; its name stays upright below.
|
|
198
|
+
ctx.save();
|
|
199
|
+
if (card.reversed) {
|
|
200
|
+
ctx.translate(x + width, y + height);
|
|
201
|
+
ctx.rotate(Math.PI);
|
|
202
|
+
} else {
|
|
203
|
+
ctx.translate(x, y);
|
|
204
|
+
}
|
|
205
|
+
if (face) {
|
|
206
|
+
ctx.drawImage(face, 0, 0, width, height);
|
|
207
|
+
} else {
|
|
208
|
+
ctx.fillStyle = PALETTE.surface;
|
|
209
|
+
ctx.fillRect(0, 0, width, height);
|
|
210
|
+
ctx.strokeStyle = PALETTE.accent;
|
|
211
|
+
ctx.lineWidth = 2;
|
|
212
|
+
ctx.strokeRect(1, 1, width - 2, height - 2);
|
|
213
|
+
ctx.fillStyle = PALETTE.text;
|
|
214
|
+
ctx.font = font(width < 150 ? 14 : 18);
|
|
215
|
+
ctx.textBaseline = "middle";
|
|
216
|
+
const lines = wrapText(ctx, card.name, width - 16).slice(0, 6);
|
|
217
|
+
const lineHeight = width < 150 ? 18 : 24;
|
|
218
|
+
const first = height / 2 - ((lines.length - 1) * lineHeight) / 2;
|
|
219
|
+
lines.forEach((line, n) => {
|
|
220
|
+
ctx.fillText(line, width / 2, first + n * lineHeight);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
ctx.restore();
|
|
224
|
+
ctx.textBaseline = "top";
|
|
225
|
+
|
|
226
|
+
ctx.fillStyle = card.reversed ? PALETTE.accentLight : PALETTE.text;
|
|
227
|
+
ctx.font = font(width < 150 ? 12 : 15);
|
|
228
|
+
const name = card.reversed ? `${card.name} (reversed)` : card.name;
|
|
229
|
+
ctx.fillText(fitText(ctx, name, width), x + width / 2, y + height + 8);
|
|
230
|
+
}
|
|
231
|
+
return new Uint8Array(canvas.toBuffer("image/png"));
|
|
232
|
+
}
|
package/src/errors.ts
ADDED