@johnmorrisdotca/dougu-sports 0.3.1 → 0.4.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 +27 -1
- package/NOTICE.md +1 -0
- package/README.md +27 -11
- package/dist/emblems/ball/ball.art.d.ts +24 -0
- package/dist/emblems/ball/ball.art.js +67 -0
- package/dist/emblems/ball/ball.core.d.ts +6 -0
- package/dist/emblems/ball/ball.core.js +12 -1
- package/dist/emblems/ball/ball.face.d.ts +54 -0
- package/dist/emblems/ball/ball.face.js +287 -0
- package/dist/emblems/ball/ball.image.d.ts +8 -0
- package/dist/emblems/ball/ball.image.js +122 -0
- package/dist/emblems/ball/ball.index.d.ts +3 -1
- package/dist/emblems/ball/ball.index.js +7 -5
- package/dist/emblems/ball/ball.letters.d.ts +32 -0
- package/dist/emblems/ball/ball.letters.js +142 -0
- package/dist/emblems/ball/ball.object.js +1 -1
- package/dist/emblems/ball/ball.puck.d.ts +8 -0
- package/dist/emblems/ball/ball.puck.js +19 -12
- package/dist/emblems/ball/ball.puck.print.d.ts +76 -0
- package/dist/emblems/ball/ball.puck.print.js +46 -0
- package/dist/emblems/ball/ball.signature.d.ts +4 -0
- package/dist/emblems/ball/ball.signature.js +58 -0
- package/dist/emblems/ball/ball.stack.d.ts +10 -0
- package/dist/emblems/ball/ball.stack.js +8 -1
- package/dist/emblems/emblem.api.d.ts +5 -5
- package/dist/emblems/emblem.api.js +27 -8
- package/dist/emblems/emblem.core.d.ts +6 -0
- package/dist/emblems/emblem.descriptions.js +8 -0
- package/dist/emblems/emblem.ids.d.ts +6 -1
- package/dist/emblems/emblem.ids.js +4 -1
- package/dist/emblems/emblem.registry.js +2 -2
- package/dist/emblems-entry.d.ts +3 -2
- package/dist/emblems-entry.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// The print on a puck's top face: the designs (a Canadian flag with its country below it, a red leaf, a white face with red bands, stars and stripes, a navy face with rings, a player's name and number, a made-up
|
|
2
|
+
// signature, a generic souvenir) and the options that change them. The face is drawn in its own units, a disc of radius 1 whose up is up, and ball.print.ts lays that disc on the top of the puck in 3D. All of it
|
|
3
|
+
// is our own drawing: our own leaf, our own flags, our own lettering (ball.letters.ts) and no mark, name or wordmark of a team, a league or a maker. Imports only this folder's helpers.
|
|
4
|
+
import { hexOf, num, readColour } from "./ball.core.js";
|
|
5
|
+
import { FLAG, LEAF_PATH, canadaFlag, star, starsAndStripes, usaFlag } from "./ball.art.js";
|
|
6
|
+
import { safeImage } from "./ball.image.js";
|
|
7
|
+
import { arcPath, fitSize, linePath } from "./ball.letters.js";
|
|
8
|
+
import { signaturePath } from "./ball.signature.js";
|
|
9
|
+
const LOOKS = {
|
|
10
|
+
canada: { field: "#fbf8f2", ring: "#c4202d", ink: "#ffffff", accent: FLAG.red, signature: "#1b1b1b" },
|
|
11
|
+
"canada-red": { field: FLAG.red, ring: "#1d222b", ink: "#ffffff", accent: "#ffffff", signature: "#e8b923" },
|
|
12
|
+
"canada-white": { field: "#fbf8f2", ring: "#c4202d", ink: "#c4202d", accent: FLAG.red, signature: "#1b1b1b" },
|
|
13
|
+
usa: { field: "#ffffff", ring: "#0f2a5c", ink: "#ffffff", accent: FLAG.usRed, signature: "#1b1b1b" },
|
|
14
|
+
"usa-navy": { field: "#0f2a5c", ring: "#b31942", ink: "#ffffff", accent: "#ffffff", signature: "#e8b923" },
|
|
15
|
+
player: { field: "#1f4e8c", ring: "#0d2347", ink: "#ffffff", accent: "#f2b705", signature: "#e8b923" },
|
|
16
|
+
souvenir: { field: "#f4f1e8", ring: "#13325e", ink: "#ffffff", accent: "#c4202d", signature: "#1b1b1b" },
|
|
17
|
+
};
|
|
18
|
+
/** The words and the signature a design starts with; the caller's own options replace them. */
|
|
19
|
+
const WORDS = {
|
|
20
|
+
canada: { flag: "ca", text: "CANADA", ringText: ["CANADA · ICE HOCKEY", "SOUVENIR PUCK"] },
|
|
21
|
+
"canada-red": { text: "CANADA", ringText: ["CANADA · ICE HOCKEY", "SOUVENIR PUCK"] },
|
|
22
|
+
"canada-white": { text: "CANADA", ringText: false },
|
|
23
|
+
usa: { text: "USA", ringText: ["UNITED STATES · ICE HOCKEY", "SOUVENIR PUCK"] },
|
|
24
|
+
"usa-navy": { text: "USA", ringText: ["UNITED STATES · ICE HOCKEY", "SOUVENIR PUCK"] },
|
|
25
|
+
player: { name: "MORRIS", number: 23, ringText: ["", "ICE HOCKEY"] },
|
|
26
|
+
souvenir: { text: "SOUVENIR", ringText: ["ICE HOCKEY · COLLECTOR EDITION", ""] },
|
|
27
|
+
};
|
|
28
|
+
/** A text from outside made safe: control characters out, 24 characters a line, one line, or `undefined` when nothing is left. */
|
|
29
|
+
const cleanText = (value, max = 24) => {
|
|
30
|
+
if (typeof value !== "string" && typeof value !== "number")
|
|
31
|
+
return undefined;
|
|
32
|
+
const text = [...String(value).replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim()].slice(0, max).join("");
|
|
33
|
+
return text === "" ? undefined : text;
|
|
34
|
+
};
|
|
35
|
+
const colourOf = (value) => {
|
|
36
|
+
const rgb = typeof value === "string" ? readColour(value) : undefined;
|
|
37
|
+
return rgb === undefined ? undefined : hexOf(rgb);
|
|
38
|
+
};
|
|
39
|
+
const DESIGNS = Object.keys(LOOKS);
|
|
40
|
+
/** The face, ready to draw: the design's words and colours under the caller's. `base` is the design a puck's own id starts from. */
|
|
41
|
+
function resolve(face, base, own) {
|
|
42
|
+
const f = typeof face === "object" && face !== null ? face : {};
|
|
43
|
+
const design = (typeof f.design === "string" && DESIGNS.includes(f.design) ? f.design : own?.design ?? base);
|
|
44
|
+
const words = { ...WORDS[design], ...own, ...definedOnly(f) };
|
|
45
|
+
const look = { ...LOOKS[design] };
|
|
46
|
+
for (const source of [own?.colors, f.colors]) {
|
|
47
|
+
const list = Array.isArray(source) ? { field: source[0], ring: source[1], ink: source[2], accent: source[3], signature: source[4] } : source;
|
|
48
|
+
for (const key of ["field", "ring", "ink", "accent", "signature"]) {
|
|
49
|
+
const c = colourOf(list?.[key]);
|
|
50
|
+
if (c !== undefined)
|
|
51
|
+
look[key] = c;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const ringText = words.ringText;
|
|
55
|
+
const ring = ringText === false ? undefined : typeof ringText === "string" ? [cleanText(ringText, 32) ?? "", ""] : Array.isArray(ringText) ? [cleanText(ringText[0], 32) ?? "", cleanText(ringText[1], 32) ?? ""] : undefined;
|
|
56
|
+
const number = typeof words.number === "number" || typeof words.number === "string" ? String(words.number).replace(/[^A-Za-z0-9]/g, "").slice(0, 3).toUpperCase() : "";
|
|
57
|
+
const sig = words.signature;
|
|
58
|
+
return {
|
|
59
|
+
design,
|
|
60
|
+
look,
|
|
61
|
+
image: typeof words.image === "string" ? words.image : undefined,
|
|
62
|
+
flag: typeof words.flag === "string" ? words.flag.trim().toLowerCase().slice(0, 12) : undefined,
|
|
63
|
+
flags: typeof words.flags === "function" ? words.flags : undefined,
|
|
64
|
+
text: cleanText(words.text),
|
|
65
|
+
ring,
|
|
66
|
+
number: number === "" ? undefined : number,
|
|
67
|
+
name: cleanText(words.name, 14),
|
|
68
|
+
signature: sig === false || sig === undefined || sig === null ? undefined : typeof sig === "number" ? (Number.isFinite(sig) ? sig : undefined) : cleanText(sig, 40),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** A face's own fields that are set (a field of `undefined` does not replace the design's). */
|
|
72
|
+
const definedOnly = (face) => Object.fromEntries(Object.entries(face).filter(([, v]) => v !== undefined && !(typeof v === "number" && !Number.isFinite(v))));
|
|
73
|
+
/** The ink of one line of lettering, with the outline under it when there is one. */
|
|
74
|
+
function word(text, o, x, y, fill, outline, outlineWidth = 0) {
|
|
75
|
+
const d = linePath(text, o, x, y);
|
|
76
|
+
const join = ` fill="none" stroke-linejoin="miter" stroke-miterlimit="2"`;
|
|
77
|
+
return (outline === undefined ? "" : `<path d="${d}"${join} stroke="${outline}" stroke-width="${num(o.weight * o.size + outlineWidth, 3)}"/>`) + `<path d="${d}"${join} stroke="${fill}" stroke-width="${num(o.weight * o.size, 3)}"/>`;
|
|
78
|
+
}
|
|
79
|
+
/** The letters of the ring, on the top and on the bottom, set on the circle of radius `r` with letters `size` tall, in the ink. */
|
|
80
|
+
function ringLetters(ring, r, size, ink) {
|
|
81
|
+
const spec = { weight: 0.17, wide: 1.12, track: 0.08 };
|
|
82
|
+
let out = "";
|
|
83
|
+
const set = (text, centre, inside) => {
|
|
84
|
+
if (text === "")
|
|
85
|
+
return;
|
|
86
|
+
const s = Math.min(size, fitSize(text, spec, r * 2.3, size));
|
|
87
|
+
const o = { ...spec, size: s };
|
|
88
|
+
out += `<path d="${arcPath(text, o, r, centre, inside)}" fill="none" stroke="${ink}" stroke-width="${num(o.weight * s, 3)}" stroke-linejoin="miter" stroke-miterlimit="2"/>`;
|
|
89
|
+
};
|
|
90
|
+
set(ring[0], 0, false);
|
|
91
|
+
set(ring[1], 180, true);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/** The disc's flat ground, and the ring band when there is one (inner radius `ri`). */
|
|
95
|
+
const ground = (look, ringed, ri) => `<circle r=".97" fill="${look.field}"/>` + (ringed ? `<path d="M0-.97A.97.97 0 1 1 0 .97A.97.97 0 1 1 0-.97ZM0-${ri}A${ri} ${ri} 0 1 0 0 ${ri}A${ri} ${ri} 0 1 0 0-${ri}Z" fill="${look.ring}" fill-rule="evenodd"/>` : "");
|
|
96
|
+
/** What the picture of a face is: a safe image fitted into a box, or `undefined`. The id prefix keeps two faces in a page apart. */
|
|
97
|
+
function picture(f, prefix, cx, cy, w, h) {
|
|
98
|
+
const fit = (img, tag) => {
|
|
99
|
+
const [x, y, bw, bh] = img.box;
|
|
100
|
+
const k = Math.min(w / bw, h / bh);
|
|
101
|
+
const cid = `${prefix}${tag}c`;
|
|
102
|
+
const t = `translate(${num(cx - (x + bw / 2) * k, 3)} ${num(cy - (y + bh / 2) * k, 3)}) scale(${num(k, 5)})`;
|
|
103
|
+
return { defs: `<clipPath id="${cid}"><rect x="${num(x, 3)}" y="${num(y, 3)}" width="${num(bw, 3)}" height="${num(bh, 3)}"/></clipPath>`, body: `<g transform="${t}" clip-path="url(#${cid})" color="${f.look.accent}">${img.markup}</g>`, width: bw * k, height: bh * k };
|
|
104
|
+
};
|
|
105
|
+
if (f.image !== undefined) {
|
|
106
|
+
const img = safeImage(f.image, `${prefix}i`);
|
|
107
|
+
if (img !== undefined)
|
|
108
|
+
return fit(img, "i");
|
|
109
|
+
}
|
|
110
|
+
if (f.flag !== undefined) {
|
|
111
|
+
const code = f.flag;
|
|
112
|
+
if (f.flags !== undefined) {
|
|
113
|
+
let svg;
|
|
114
|
+
try {
|
|
115
|
+
svg = f.flags(code, { shape: "4:3", size: 120, theme: "light" });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
svg = undefined;
|
|
119
|
+
}
|
|
120
|
+
const img = safeImage(svg, `${prefix}f`);
|
|
121
|
+
if (img !== undefined)
|
|
122
|
+
return fit(img, "f");
|
|
123
|
+
}
|
|
124
|
+
if (code === "ca")
|
|
125
|
+
return { defs: "", body: canadaFlag(cx, cy, Math.min(w, h * 2)) + `<rect x="${num(cx - Math.min(w, h * 2) / 2, 3)}" y="${num(cy - Math.min(w, h * 2) / 4, 3)}" width="${num(Math.min(w, h * 2), 3)}" height="${num(Math.min(w, h * 2) / 2, 3)}" fill="none" stroke="#9a9a9a" stroke-width=".008"/>`, width: Math.min(w, h * 2), height: Math.min(w, h * 2) / 2 };
|
|
126
|
+
if (code === "us")
|
|
127
|
+
return { defs: "", body: usaFlag(cx, cy, Math.min(w, h * 1.9)), width: Math.min(w, h * 1.9), height: Math.min(w, h * 1.9) / 1.9 };
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const LETTERS = { weight: 0.22, wide: 1.32, slant: 0.1, track: 0.02 };
|
|
132
|
+
/** The middle of the face in the style of any design: a picture (if there is one) with the words under it, or the words alone, or a number, or a star. `ri` is the radius of the open disc. */
|
|
133
|
+
function middle(f, prefix, ri, ink, outline) {
|
|
134
|
+
const pic = picture(f, prefix, 0, 0, ri * 1.3, ri * 0.82);
|
|
135
|
+
const text = f.text;
|
|
136
|
+
const number = f.number;
|
|
137
|
+
let defs = "";
|
|
138
|
+
let body = "";
|
|
139
|
+
if (number !== undefined) {
|
|
140
|
+
const s = fitSize(number, LETTERS, ri * 1.3, ri * 1.0);
|
|
141
|
+
body += word(number, { ...LETTERS, size: s }, 0, -0.02, f.look.accent, outline ?? ink, s * 0.1);
|
|
142
|
+
if (text !== undefined)
|
|
143
|
+
body += word(text, { ...LETTERS, size: fitSize(text, LETTERS, ri * 1.2, ri * 0.18) }, 0, ri * 0.74, ink);
|
|
144
|
+
return { defs, body };
|
|
145
|
+
}
|
|
146
|
+
if (pic !== undefined) {
|
|
147
|
+
const withText = text !== undefined;
|
|
148
|
+
const dy = withText ? -ri * 0.2 : 0;
|
|
149
|
+
const placed = picture(f, prefix, 0, dy, ri * (withText ? 1.2 : 1.5), ri * (withText ? 0.8 : 1.4));
|
|
150
|
+
defs += placed.defs;
|
|
151
|
+
body += placed.body;
|
|
152
|
+
if (withText) {
|
|
153
|
+
const y = dy + placed.height / 2 + ri * 0.26;
|
|
154
|
+
const s = fitSize(text, LETTERS, Math.sqrt(Math.max(0.01, ri * ri - y * y)) * 1.85, ri * 0.2);
|
|
155
|
+
body += word(text, { ...LETTERS, size: s }, 0, y, ink);
|
|
156
|
+
}
|
|
157
|
+
return { defs, body };
|
|
158
|
+
}
|
|
159
|
+
if (text !== undefined && f.design !== "souvenir") {
|
|
160
|
+
const s = fitSize(text, LETTERS, ri * 1.6, ri * 0.5);
|
|
161
|
+
body += word(text, { ...LETTERS, size: s }, 0, 0, ink, outline, s * 0.12);
|
|
162
|
+
return { defs, body };
|
|
163
|
+
}
|
|
164
|
+
if (text !== undefined) {
|
|
165
|
+
body += star(0, -ri * 0.2, ri * 0.4, f.look.accent);
|
|
166
|
+
body += word(text, { ...LETTERS, size: fitSize(text, LETTERS, ri * 1.3, ri * 0.2) }, 0, ri * 0.5, ink);
|
|
167
|
+
return { defs, body };
|
|
168
|
+
}
|
|
169
|
+
body += star(0, 0, ri * 0.5, f.look.accent);
|
|
170
|
+
return { defs, body };
|
|
171
|
+
}
|
|
172
|
+
/** A signature across the lower half of the face. */
|
|
173
|
+
function signed(f) {
|
|
174
|
+
if (f.signature === undefined)
|
|
175
|
+
return "";
|
|
176
|
+
const k = 1.12;
|
|
177
|
+
const sw = 0.034 / k;
|
|
178
|
+
return `<path d="${signaturePath(f.signature)}" transform="translate(.02 .2) rotate(-9) scale(${k})" fill="none" stroke="${f.look.signature}" stroke-width="${num(sw, 4)}" stroke-linecap="round" stroke-linejoin="round" opacity=".94"/>`;
|
|
179
|
+
}
|
|
180
|
+
/** The leaf of the white face, big, with the bands. */
|
|
181
|
+
function leaf(cx, cy, w, fill) {
|
|
182
|
+
return `<path d="${LEAF_PATH}" transform="translate(${num(cx, 3)} ${num(cy, 3)}) scale(${num(w / 2, 4)})" fill="${fill}"/>`;
|
|
183
|
+
}
|
|
184
|
+
/** The face of a design, in the face's units (a disc of radius 1, up is up): its definitions and its drawing. `prefix` makes every id unique to this puck. */
|
|
185
|
+
export function faceMarkup(face, design, own, prefix) {
|
|
186
|
+
const f = resolve(face, design, own);
|
|
187
|
+
const look = f.look;
|
|
188
|
+
const clip = `${prefix}clip`;
|
|
189
|
+
let defs = `<clipPath id="${clip}"><circle r=".965"/></clipPath>`;
|
|
190
|
+
let body = "";
|
|
191
|
+
const ringed = f.ring !== undefined && f.design !== "canada-white";
|
|
192
|
+
switch (f.design) {
|
|
193
|
+
case "canada-white": {
|
|
194
|
+
// A white face, the flag's red bands left and right, a big red leaf and the country under it.
|
|
195
|
+
body += ground(look, false, 0);
|
|
196
|
+
body += `<rect x="-1" y="-1" width=".42" height="2" fill="${look.accent}"/><rect x=".58" y="-1" width=".42" height="2" fill="${look.accent}"/>`;
|
|
197
|
+
const pic = picture(f, prefix, 0, -0.1, 1.0, 1.0);
|
|
198
|
+
if (pic !== undefined) {
|
|
199
|
+
defs += pic.defs;
|
|
200
|
+
body += pic.body;
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
body += leaf(0, -0.22, 1.0, look.accent);
|
|
204
|
+
if (f.text !== undefined)
|
|
205
|
+
body += word(f.text, { ...LETTERS, size: fitSize(f.text, LETTERS, 0.98, 0.17) }, 0, 0.6, look.accent);
|
|
206
|
+
if (f.ring !== undefined)
|
|
207
|
+
body += ringLetters(f.ring, 0.88, 0.09, look.accent);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case "usa": {
|
|
211
|
+
const ri = 0.73;
|
|
212
|
+
body += ground(look, true, ri);
|
|
213
|
+
if (f.image !== undefined || f.flag !== undefined) {
|
|
214
|
+
// The caller's own picture takes the place of the stars and stripes.
|
|
215
|
+
const mid = middle(f, prefix, ri, look.ring, undefined);
|
|
216
|
+
body += mid.body;
|
|
217
|
+
defs += mid.defs;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
body += `<g clip-path="url(#${prefix}u)"><g transform="scale(${num(ri, 3)})">${starsAndStripes(2, 2, 1.04, 0.92, look.accent, "#ffffff", look.ring)}</g></g>`;
|
|
221
|
+
defs += `<clipPath id="${prefix}u"><circle r="${ri}"/></clipPath>`;
|
|
222
|
+
if (f.text !== undefined) {
|
|
223
|
+
const s = fitSize(f.text, LETTERS, 1.05, 0.3);
|
|
224
|
+
body += word(f.text, { ...LETTERS, size: s }, 0, 0.34, "#ffffff", look.ring, s * 0.16);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (f.ring !== undefined)
|
|
228
|
+
body += ringLetters(f.ring, 0.855, 0.115, look.ink);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case "usa-navy": {
|
|
232
|
+
// Navy, a red ring with white letters between two white hairlines, five stars over the country, and a rule.
|
|
233
|
+
body += ground(look, false, 0);
|
|
234
|
+
const ro = 0.9, ri = 0.69;
|
|
235
|
+
body += `<path d="M0-${ro}A${ro} ${ro} 0 1 1 0 ${ro}A${ro} ${ro} 0 1 1 0-${ro}ZM0-${ri}A${ri} ${ri} 0 1 0 0 ${ri}A${ri} ${ri} 0 1 0 0-${ri}Z" fill="${look.ring}" fill-rule="evenodd"/>`;
|
|
236
|
+
body += `<circle r=".935" fill="none" stroke="#ffffff" stroke-width=".022"/><circle r=".665" fill="none" stroke="#ffffff" stroke-width=".022"/>`;
|
|
237
|
+
if (f.ring !== undefined)
|
|
238
|
+
body += ringLetters(f.ring, 0.795, 0.105, look.ink);
|
|
239
|
+
for (let i = 0; i < 5; i++) {
|
|
240
|
+
const a = ((-40 + i * 20) * Math.PI) / 180;
|
|
241
|
+
body += star(0.5 * Math.sin(a) * 1.0, -0.5 * Math.cos(a) * 1.0 + 0.0, 0.052, look.accent, (-40 + i * 20));
|
|
242
|
+
}
|
|
243
|
+
if (f.text !== undefined || f.image !== undefined || f.flag !== undefined) {
|
|
244
|
+
const mid = middle({ ...f, number: undefined }, prefix, 0.62, look.ink, look.ring);
|
|
245
|
+
body += `<g transform="translate(0 .1)">${mid.body}</g>`;
|
|
246
|
+
defs += mid.defs;
|
|
247
|
+
body += `<path d="M-.4 .42H.4" stroke="${look.ring}" stroke-width=".022" stroke-linecap="round"/>`;
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
case "player": {
|
|
252
|
+
// A jersey's face: the surname on the ring, the number big, a thin gold ring inside the band.
|
|
253
|
+
body += ground(look, true, 0.74);
|
|
254
|
+
body += `<circle r=".735" fill="none" stroke="${look.accent}" stroke-width=".02"/>`;
|
|
255
|
+
const ring = [f.name ?? f.ring?.[0] ?? "", f.ring?.[1] ?? ""];
|
|
256
|
+
body += ringLetters(ring, 0.855, 0.12, look.ink);
|
|
257
|
+
const mid = middle({ ...f, text: undefined, flag: undefined }, prefix, 0.72, look.ink, look.ink);
|
|
258
|
+
body += mid.body;
|
|
259
|
+
defs += mid.defs;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
default: {
|
|
263
|
+
// The flag with the country under it, the red face with the big leaf, and the generic souvenir share one layout: a ring when there is ring text, and the middle.
|
|
264
|
+
const ri = 0.74;
|
|
265
|
+
body += ground(look, ringed, ri);
|
|
266
|
+
if (f.design === "canada")
|
|
267
|
+
body += `<circle r="${ri}" fill="none" stroke="${look.ring}" stroke-width=".018"/>`;
|
|
268
|
+
if (ringed && f.ring !== undefined)
|
|
269
|
+
body += ringLetters(f.ring, 0.855, 0.115, look.ink);
|
|
270
|
+
const redLeaf = f.design === "canada-red" && f.image === undefined && f.flag === undefined && f.number === undefined;
|
|
271
|
+
if (redLeaf) {
|
|
272
|
+
body += leaf(0, -0.24, 0.98, look.accent);
|
|
273
|
+
const text = f.text;
|
|
274
|
+
if (text !== undefined)
|
|
275
|
+
body += word(text, { ...LETTERS, size: fitSize(text, LETTERS, 0.92, 0.14) }, 0, 0.43, look.accent);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
const dark = f.design === "souvenir" ? look.ring : f.design === "canada" ? look.accent : look.ink;
|
|
279
|
+
const mid = middle(f, prefix, ringed ? ri : 0.92, dark, undefined);
|
|
280
|
+
body += mid.body;
|
|
281
|
+
defs += mid.defs;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
body += signed(f);
|
|
286
|
+
return { defs, body: `<g clip-path="url(#${clip})">${body}</g>` };
|
|
287
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** A drawing made safe: the box of its coordinates and the markup of what it holds. */
|
|
2
|
+
export interface SafeImage {
|
|
3
|
+
/** x, y, width and height of the drawing's own coordinates (its viewBox). */
|
|
4
|
+
box: readonly [number, number, number, number];
|
|
5
|
+
markup: string;
|
|
6
|
+
}
|
|
7
|
+
/** The markup of an SVG made safe, or `undefined` when there is none to be had: not text, too long, no `<svg>` root, nothing drawable left. `prefix` is put in front of every id and every reference to one. */
|
|
8
|
+
export declare function safeImage(source: unknown, prefix: string): SafeImage | undefined;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// An SVG that comes from outside, made safe to put inside a drawing: a strict allow-list, never a deny-list. The text is taken apart into tags; only the shapes, groups, gradients and clip paths survive, with only
|
|
2
|
+
// the attributes a drawing needs and values that cannot carry a script, an address or an entity. Everything else (script, style, text, image, use, foreignObject, filters, links, event handlers, `style`) is dropped
|
|
3
|
+
// with whatever it holds. Ids are renamed under a prefix so two drawings in a page cannot reach each other's gradients. Imports only the core's escape.
|
|
4
|
+
import { escape } from "./ball.core.js";
|
|
5
|
+
const SHAPES = new Set(["g", "defs", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "linearGradient", "radialGradient", "stop", "clipPath"]);
|
|
6
|
+
const ATTRS = new Set([
|
|
7
|
+
"id", "d", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2", "fx", "fy", "width", "height", "points", "transform", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit",
|
|
8
|
+
"stroke-dasharray", "stroke-dashoffset", "fill-opacity", "stroke-opacity", "opacity", "fill-rule", "clip-rule", "clip-path", "offset", "stop-color", "stop-opacity", "gradientUnits", "gradientTransform",
|
|
9
|
+
"spreadMethod", "clipPathUnits",
|
|
10
|
+
]);
|
|
11
|
+
/** The attributes that may hold `url(#id)`. */
|
|
12
|
+
const REFS = new Set(["fill", "stroke", "clip-path"]);
|
|
13
|
+
/** A value is letters, digits and the few marks that colours, numbers, lists and transforms use: no colon (so no address), no `&` (so no entity), no quote, no angle bracket, no backslash. */
|
|
14
|
+
const VALUE = /^[A-Za-z0-9#%.,()\s+\-/_]*$/;
|
|
15
|
+
const MAX_LENGTH = 60000;
|
|
16
|
+
const MAX_NODES = 2000;
|
|
17
|
+
/** One attribute's value made safe, with its ids renamed, or `undefined` to drop it. */
|
|
18
|
+
function safeValue(name, raw, prefix) {
|
|
19
|
+
const value = raw.trim();
|
|
20
|
+
if (value.length > 20000)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (value.includes("url(")) {
|
|
23
|
+
if (!REFS.has(name))
|
|
24
|
+
return undefined;
|
|
25
|
+
const m = /^url\(\s*#([A-Za-z_][\w.-]*)\s*\)(\s+[a-z]+)?$/.exec(value);
|
|
26
|
+
return m === null ? undefined : `url(#${prefix}${m[1]})${m[2] ?? ""}`;
|
|
27
|
+
}
|
|
28
|
+
if (name === "id")
|
|
29
|
+
return /^[A-Za-z_][\w.-]*$/.test(value) ? `${prefix}${value}` : undefined;
|
|
30
|
+
return VALUE.test(value) ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** The declarations of a `style` attribute as name and value pairs (the ones an attribute of the same name may carry are kept by the caller). */
|
|
33
|
+
function styleAttrs(style) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const part of style.split(";")) {
|
|
36
|
+
const at = part.indexOf(":");
|
|
37
|
+
if (at > 0)
|
|
38
|
+
out.push([part.slice(0, at).trim().toLowerCase(), part.slice(at + 1).trim()]);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** The box of the root's attributes: its viewBox's four numbers, else its width and height, else 100 by 100. */
|
|
43
|
+
function boxOf(attrs) {
|
|
44
|
+
const vb = (attrs["viewBox"] ?? "").trim().split(/[\s,]+/).map(Number);
|
|
45
|
+
if (vb.length === 4 && vb.every(Number.isFinite) && vb[2] > 0 && vb[3] > 0)
|
|
46
|
+
return [vb[0], vb[1], vb[2], vb[3]];
|
|
47
|
+
const w = parseFloat(attrs["width"] ?? ""), h = parseFloat(attrs["height"] ?? "");
|
|
48
|
+
return Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? [0, 0, w, h] : [0, 0, 100, 100];
|
|
49
|
+
}
|
|
50
|
+
/** The markup of an SVG made safe, or `undefined` when there is none to be had: not text, too long, no `<svg>` root, nothing drawable left. `prefix` is put in front of every id and every reference to one. */
|
|
51
|
+
export function safeImage(source, prefix) {
|
|
52
|
+
if (typeof source !== "string" || source.length === 0 || source.length > MAX_LENGTH)
|
|
53
|
+
return undefined;
|
|
54
|
+
const cleaned = source.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "").replace(/<\?[\s\S]*?\?>/g, "").replace(/<![^>]*>/g, "");
|
|
55
|
+
const tag = /<(\/?)([A-Za-z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
|
|
56
|
+
let out = "";
|
|
57
|
+
let nodes = 0;
|
|
58
|
+
let root;
|
|
59
|
+
// The depth inside an element being dropped (0 when none is).
|
|
60
|
+
let dropping = 0;
|
|
61
|
+
// The elements written and still open, `#root` for the svg itself.
|
|
62
|
+
const open = [];
|
|
63
|
+
for (let m = tag.exec(cleaned); m !== null; m = tag.exec(cleaned)) {
|
|
64
|
+
const closing = m[1] === "/";
|
|
65
|
+
const name = m[2];
|
|
66
|
+
const selfClosed = m[4] === "/";
|
|
67
|
+
if (dropping > 0) {
|
|
68
|
+
if (closing)
|
|
69
|
+
dropping--;
|
|
70
|
+
else if (!selfClosed)
|
|
71
|
+
dropping++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (closing) {
|
|
75
|
+
const top = open.pop();
|
|
76
|
+
if (top !== undefined && top !== "#root")
|
|
77
|
+
out += `</${top}>`;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const attrs = {};
|
|
81
|
+
for (const a of m[3].matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
|
|
82
|
+
attrs[a[1]] = (a[2] ?? a[3]);
|
|
83
|
+
if (root === undefined) {
|
|
84
|
+
if (name !== "svg" || selfClosed)
|
|
85
|
+
return undefined;
|
|
86
|
+
root = attrs;
|
|
87
|
+
open.push("#root");
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!SHAPES.has(name)) {
|
|
91
|
+
if (!selfClosed)
|
|
92
|
+
dropping = 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (++nodes > MAX_NODES)
|
|
96
|
+
return undefined;
|
|
97
|
+
const pairs = Object.entries(attrs);
|
|
98
|
+
if (attrs["style"] !== undefined)
|
|
99
|
+
pairs.push(...styleAttrs(attrs["style"]));
|
|
100
|
+
let written = "";
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
for (const [key, raw] of pairs) {
|
|
103
|
+
if (!ATTRS.has(key) || seen.has(key))
|
|
104
|
+
continue;
|
|
105
|
+
const v = safeValue(key, raw, prefix);
|
|
106
|
+
if (v === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
seen.add(key);
|
|
109
|
+
written += ` ${key}="${escape(v)}"`;
|
|
110
|
+
}
|
|
111
|
+
out += `<${name}${written}${selfClosed ? "/>" : ">"}`;
|
|
112
|
+
if (!selfClosed)
|
|
113
|
+
open.push(name);
|
|
114
|
+
}
|
|
115
|
+
if (root === undefined || !/<(path|circle|ellipse|rect|line|polyline|polygon)\b/.test(out))
|
|
116
|
+
return undefined;
|
|
117
|
+
// Close what the text left open, so the markup is always balanced.
|
|
118
|
+
for (let top = open.pop(); top !== undefined; top = open.pop())
|
|
119
|
+
if (top !== "#root")
|
|
120
|
+
out += `</${top}>`;
|
|
121
|
+
return { box: boxOf(root), markup: out };
|
|
122
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { BallFrameOptions, Piece, PieceOptions, PieceSize } from "./ball.core.ts";
|
|
2
|
+
import { PUCKS } from "./ball.puck.print.ts";
|
|
2
3
|
/** Every ball, in the order a gallery shows them. */
|
|
3
4
|
export declare const BALLS: readonly Piece[];
|
|
4
5
|
/** The id of every ball: `"ball:soccer"`, `"ball:cricket-red"`. */
|
|
5
6
|
export declare const BALL_IDS: readonly string[];
|
|
6
|
-
/** A ball by its id, or by its name without the `ball:` (`"soccer"`). An unknown name is an error that lists the ids. */
|
|
7
|
+
/** A ball by its id, or by its name without the `ball:` (`"soccer"`); a printed puck by its id (`"puck:canada"`). An unknown name is an error that lists the ids. */
|
|
7
8
|
export declare function ballOf(name: string): Piece;
|
|
8
9
|
/** True when the text is the id of a ball (or a ball's name without `ball:`). */
|
|
9
10
|
export declare const isBallId: (name: unknown) => name is string;
|
|
@@ -45,4 +46,5 @@ export interface LineupOptions extends PieceOptions {
|
|
|
45
46
|
* ```
|
|
46
47
|
*/
|
|
47
48
|
export declare function lineup(names: readonly string[], options?: LineupOptions): string;
|
|
49
|
+
export { PUCKS };
|
|
48
50
|
export type { BallFrameOptions, Piece, PieceOptions, PieceSize, PieceStyle, PieceTheme } from "./ball.core.ts";
|
|
@@ -16,6 +16,7 @@ import { BALL_HANDBALL } from "./ball.handball.js";
|
|
|
16
16
|
import { BALL_PADEL } from "./ball.padel.js";
|
|
17
17
|
import { BALL_PICKLEBALL_OUTDOOR, BALL_PICKLEBALL_INDOOR } from "./ball.pickleball.js";
|
|
18
18
|
import { BALL_PUCK } from "./ball.puck.js";
|
|
19
|
+
import { PUCKS } from "./ball.puck.print.js";
|
|
19
20
|
import { BALL_ROUNDNET } from "./ball.roundnet.js";
|
|
20
21
|
import { BALL_RUGBY } from "./ball.rugby.js";
|
|
21
22
|
import { BALL_SEPAK_TAKRAW } from "./ball.sepaktakraw.js";
|
|
@@ -35,16 +36,16 @@ import { BALL_WATER_POLO } from "./ball.waterpolo.js";
|
|
|
35
36
|
export const BALLS = [BALL_SOCCER, BALL_SOCCER_COLOUR, BALL_SOCCER_26, BALL_SOCCER_18, BALL_SOCCER_14, BALL_SOCCER_12, BALL_SOCCER_6, BALL_SOCCER_ORANGE, BALL_SOCCER_26_ORANGE, BALL_SOCCER_18_ORANGE, BALL_SOCCER_14_ORANGE, BALL_SOCCER_12_ORANGE, BALL_SOCCER_6_ORANGE, BALL_SOCCER_YELLOW, BALL_SOCCER_26_YELLOW, BALL_SOCCER_18_YELLOW, BALL_SOCCER_14_YELLOW, BALL_SOCCER_12_YELLOW, BALL_SOCCER_6_YELLOW, BALL_FUTSAL, BALL_BEACH_SOCCER, BALL_TEQBALL, BALL_BASKETBALL, BALL_BASKETBALL_6, BALL_BASKETBALL_3X3, BALL_BASEBALL, BALL_SOFTBALL, BALL_TENNIS, BALL_PADEL, BALL_SLIOTAR, BALL_VOLLEYBALL, BALL_VOLLEYBALL_8, BALL_BEACH_VOLLEYBALL, BALL_CRICKET_RED, BALL_CRICKET_WHITE, BALL_CRICKET_PINK, BALL_FOOTBALL, BALL_FOOTBALL_CFL, BALL_FOOTBALL_IFAF, BALL_RUGBY, BALL_AFL, BALL_GAELIC_FOOTBALL, BALL_NETBALL, BALL_PICKLEBALL_OUTDOOR, BALL_PICKLEBALL_INDOOR, BALL_SQUASH_DOUBLE_YELLOW, BALL_SQUASH_YELLOW, BALL_SQUASH_GREEN, BALL_SQUASH_RED, BALL_SQUASH_BLUE, BALL_RACQUETBALL, BALL_LACROSSE, BALL_FIELD_HOCKEY, BALL_TABLE_TENNIS, BALL_HANDBALL, BALL_WATER_POLO, BALL_GOLF, BALL_PUCK, BALL_CURLING_STONE, BALL_SHOT, BALL_DISCUS, BALL_HAMMER, BALL_SEPAK_TAKRAW, BALL_ROUNDNET, BALL_SHUTTLECOCK_FEATHER, BALL_SHUTTLECOCK_NYLON];
|
|
36
37
|
/** The id of every ball: `"ball:soccer"`, `"ball:cricket-red"`. */
|
|
37
38
|
export const BALL_IDS = BALLS.map((b) => b.id);
|
|
38
|
-
const TABLE = new Map(BALLS.map((b) => [b.id, b]));
|
|
39
|
-
/** A ball by its id, or by its name without the `ball:` (`"soccer"`). An unknown name is an error that lists the ids. */
|
|
39
|
+
const TABLE = new Map([...BALLS, ...PUCKS].map((b) => [b.id, b]));
|
|
40
|
+
/** A ball by its id, or by its name without the `ball:` (`"soccer"`); a printed puck by its id (`"puck:canada"`). An unknown name is an error that lists the ids. */
|
|
40
41
|
export function ballOf(name) {
|
|
41
|
-
const found = TABLE.get(
|
|
42
|
+
const found = TABLE.get(/^(ball|puck):/.test(name) ? name : `ball:${name}`);
|
|
42
43
|
if (found === undefined)
|
|
43
|
-
throw new Error(`Unknown ball "${name}". The ids are ${BALL_IDS.join(", ")}.`);
|
|
44
|
+
throw new Error(`Unknown ball "${name}". The ids are ${BALL_IDS.join(", ")}, and the printed pucks ${PUCKS.map((p) => p.id).join(", ")}.`);
|
|
44
45
|
return found;
|
|
45
46
|
}
|
|
46
47
|
/** True when the text is the id of a ball (or a ball's name without `ball:`). */
|
|
47
|
-
export const isBallId = (name) => typeof name === "string" && TABLE.has(
|
|
48
|
+
export const isBallId = (name) => typeof name === "string" && TABLE.has(/^(ball|puck):/.test(name) ? name : `ball:${name}`);
|
|
48
49
|
/** A ball as a document of SVG: `renderBall("soccer", { size: 64 })`, or in the line style `renderBall("ball:puck", { style: "line" })`. The same arguments give the same string. */
|
|
49
50
|
export function renderBall(name, options) {
|
|
50
51
|
return ballOf(name).svg(options);
|
|
@@ -103,3 +104,4 @@ export function lineup(names, options) {
|
|
|
103
104
|
const title = `Balls at ${trueScale ? "their true relative sizes" : "one size"}: ${pieces.map((p) => p.en).join(", ")}`;
|
|
104
105
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${num(width, 2)} ${num(height, 2)}" width="${num(width, 2)}" height="${num(height, 2)}" role="img"${colour === "" ? "" : ` color="${colour}"`}><title>${escape(title)}</title>${auto}${parts.join("")}</svg>`;
|
|
105
106
|
}
|
|
107
|
+
export { PUCKS };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
interface Glyph {
|
|
2
|
+
/** The width of the box, 0 for a letter that is a single stroke. */
|
|
3
|
+
w: number;
|
|
4
|
+
/** The centre line: absolute `M L C Z` commands. */
|
|
5
|
+
d: string;
|
|
6
|
+
}
|
|
7
|
+
/** The glyph of a character (a lower-case letter is its capital, an accented one its plain one; the bullet and the middle dot are one dot), or `undefined`. */
|
|
8
|
+
export declare function glyphOf(char: string): Glyph | undefined;
|
|
9
|
+
/** How a line is set. `size` is the height of a capital in the face's units; the stroke is `weight` of it. */
|
|
10
|
+
export interface Lettering {
|
|
11
|
+
size: number;
|
|
12
|
+
/** The stroke as a fraction of the height: 0.12 is regular, 0.2 bold, 0.27 black. */
|
|
13
|
+
weight: number;
|
|
14
|
+
/** 1 is the glyphs' own width; the wide print of CANADA is 1.3 or more. */
|
|
15
|
+
wide?: number;
|
|
16
|
+
/** The lean: how far the top of a letter is pushed to the right, as a fraction of the height (0.12 is a sports slant). */
|
|
17
|
+
slant?: number;
|
|
18
|
+
/** Extra space between letters, as a fraction of the height. */
|
|
19
|
+
track?: number;
|
|
20
|
+
}
|
|
21
|
+
/** The width a text takes, in the face's units, with the stroke's own thickness included. */
|
|
22
|
+
export declare function textWidth(text: string, o: Lettering): number;
|
|
23
|
+
/** The size at which a text is as wide as `width` at most and as tall as `height` at most. */
|
|
24
|
+
export declare function fitSize(text: string, o: Omit<Lettering, "size">, width: number, height: number): number;
|
|
25
|
+
/** A line of text on a straight line, centred on (`x`, `y`), as one path: draw it with a stroke of `weight * size`. */
|
|
26
|
+
export declare function linePath(text: string, o: Lettering, x: number, y: number): string;
|
|
27
|
+
/**
|
|
28
|
+
* A line of text set on an arc of the circle of radius `r` (the middle of the letters), centred on the angle `centre` in degrees clockwise from the top. With `inside` false the letters stand outward and read
|
|
29
|
+
* clockwise (the top of the ring); with `inside` true they stand inward and read left to right (the bottom of the ring, `centre` 180). One path.
|
|
30
|
+
*/
|
|
31
|
+
export declare function arcPath(text: string, o: Lettering, r: number, centre: number, inside?: boolean): string;
|
|
32
|
+
export {};
|