@dloizides/poster-render 0.0.0-stage → 0.2.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/dist/index.mjs ADDED
@@ -0,0 +1,821 @@
1
+ // src/enums/BackgroundStyle.ts
2
+ var BackgroundStyle = /* @__PURE__ */ ((BackgroundStyle2) => {
3
+ BackgroundStyle2["Solid"] = "solid";
4
+ BackgroundStyle2["Tint"] = "tint";
5
+ return BackgroundStyle2;
6
+ })(BackgroundStyle || {});
7
+
8
+ // src/enums/FontSetId.ts
9
+ var FontSetId = /* @__PURE__ */ ((FontSetId2) => {
10
+ FontSetId2["Classic"] = "classic";
11
+ FontSetId2["Modern"] = "modern";
12
+ FontSetId2["Elegant"] = "elegant";
13
+ return FontSetId2;
14
+ })(FontSetId || {});
15
+
16
+ // src/enums/FormatId.ts
17
+ var FormatId = /* @__PURE__ */ ((FormatId2) => {
18
+ FormatId2["Story"] = "story";
19
+ FormatId2["Post"] = "post";
20
+ FormatId2["Square"] = "square";
21
+ FormatId2["Phone"] = "phone";
22
+ FormatId2["Desktop"] = "desktop";
23
+ return FormatId2;
24
+ })(FormatId || {});
25
+
26
+ // src/enums/LayoutId.ts
27
+ var LayoutId = /* @__PURE__ */ ((LayoutId2) => {
28
+ LayoutId2["PictureRight"] = "picture-right";
29
+ LayoutId2["PictureLeft"] = "picture-left";
30
+ LayoutId2["PictureBottom"] = "picture-bottom";
31
+ LayoutId2["List"] = "list";
32
+ LayoutId2["Arch"] = "arch";
33
+ LayoutId2["PhoneClockSafe"] = "phone-clock-safe";
34
+ LayoutId2["PhoneArch"] = "phone-arch";
35
+ LayoutId2["DesktopSafe"] = "desktop-safe";
36
+ LayoutId2["DesktopArch"] = "desktop-arch";
37
+ return LayoutId2;
38
+ })(LayoutId || {});
39
+
40
+ // src/enums/PaletteId.ts
41
+ var PaletteId = /* @__PURE__ */ ((PaletteId2) => {
42
+ PaletteId2["Sand"] = "sand";
43
+ PaletteId2["Night"] = "night";
44
+ PaletteId2["Sea"] = "sea";
45
+ PaletteId2["Rose"] = "rose";
46
+ return PaletteId2;
47
+ })(PaletteId || {});
48
+
49
+ // src/enums/PictureAnchor.ts
50
+ var PictureAnchor = /* @__PURE__ */ ((PictureAnchor2) => {
51
+ PictureAnchor2["Top"] = "top";
52
+ PictureAnchor2["Center"] = "center";
53
+ PictureAnchor2["Bottom"] = "bottom";
54
+ return PictureAnchor2;
55
+ })(PictureAnchor || {});
56
+
57
+ // src/formats.ts
58
+ var FORMAT_SIZES = {
59
+ ["story" /* Story */]: { width: 1080, height: 1920 },
60
+ ["post" /* Post */]: { width: 1080, height: 1350 },
61
+ ["square" /* Square */]: { width: 1080, height: 1080 },
62
+ ["phone" /* Phone */]: { width: 1170, height: 2532 },
63
+ ["desktop" /* Desktop */]: { width: 2560, height: 1440 }
64
+ };
65
+
66
+ // src/palettes.ts
67
+ var PALETTES = {
68
+ ["sand" /* Sand */]: { background: "#f5f4f2", tint: "#ece8e3", title: "#958a80", accent: "#e9b47f", ink: "#5f564f", soft: "#b3a89e", card: "#ffffff", arch: "#ede8e2" },
69
+ ["night" /* Night */]: { background: "#1c1a17", tint: "#25221e", title: "#e8dccb", accent: "#e9b47f", ink: "#f4efe8", soft: "#9c9284", card: "#2c2824", arch: "#25221f" },
70
+ ["sea" /* Sea */]: { background: "#eef4f3", tint: "#e1ecea", title: "#4f7d7a", accent: "#e07a5f", ink: "#2f3e46", soft: "#8aa3a1", card: "#ffffff", arch: "#e4ecea" },
71
+ ["rose" /* Rose */]: { background: "#f8efec", tint: "#f1e2dd", title: "#9b6b63", accent: "#c9846f", ink: "#4a3934", soft: "#b39a92", card: "#ffffff", arch: "#f1e5e1" }
72
+ };
73
+
74
+ // src/fontSets.ts
75
+ var FONT_SETS = {
76
+ ["classic" /* Classic */]: { display: '"Oswald", "Arial Narrow", sans-serif', script: '"Sacramento", cursive', body: '"Fredoka", sans-serif' },
77
+ ["modern" /* Modern */]: { display: '"Bebas Neue", "Arial Narrow", sans-serif', script: '"Great Vibes", cursive', body: '"Montserrat", sans-serif' },
78
+ ["elegant" /* Elegant */]: { display: '"Playfair Display", serif', script: '"Allura", cursive', body: '"Lato", sans-serif' }
79
+ };
80
+
81
+ // src/schema/flierDocSchema.ts
82
+ var SHORT = 60;
83
+ var LINE = 120;
84
+ var MAX_ROWS = 6;
85
+ var MAX_ITEMS = 8;
86
+ var MAX_PRICES = 4;
87
+ var MAX_VENUE = 3;
88
+ var MIN_SCALE = 0.3;
89
+ var text = (maxLength) => ({ type: "string", minLength: 1, maxLength });
90
+ var scheduleRow = {
91
+ type: "object",
92
+ required: ["label", "time"],
93
+ additionalProperties: false,
94
+ properties: { label: text(SHORT), time: text(SHORT) }
95
+ };
96
+ var scheduleList = { type: "array", items: scheduleRow, minItems: 1, maxItems: MAX_ROWS };
97
+ var blocks = {
98
+ type: "object",
99
+ required: ["title"],
100
+ additionalProperties: false,
101
+ properties: {
102
+ title: text(SHORT),
103
+ titleBottom: text(SHORT),
104
+ script: text(SHORT),
105
+ venue: { type: "array", items: text(SHORT), minItems: 1, maxItems: MAX_VENUE },
106
+ schedule: scheduleList,
107
+ prices: {
108
+ type: "array",
109
+ minItems: 1,
110
+ maxItems: MAX_PRICES,
111
+ items: {
112
+ type: "object",
113
+ required: ["amount", "label"],
114
+ additionalProperties: false,
115
+ properties: { amount: text(SHORT), label: text(SHORT) }
116
+ }
117
+ },
118
+ includes: {
119
+ type: "object",
120
+ required: ["heading", "items"],
121
+ additionalProperties: false,
122
+ properties: { heading: text(SHORT), items: { type: "array", items: text(LINE), minItems: 1, maxItems: MAX_ROWS } }
123
+ },
124
+ footer: text(LINE)
125
+ }
126
+ };
127
+ var flierDocSchema = {
128
+ $schema: "https://json-schema.org/draft/2020-12/schema",
129
+ $id: "https://dloizides.com/schemas/flier-doc-v1.json",
130
+ title: "FlierDoc",
131
+ description: "One poster or wallpaper: format, layout, palette, fonts, picture slot and text blocks.",
132
+ type: "object",
133
+ required: ["version", "format", "layout", "palette", "fonts", "background", "blocks"],
134
+ additionalProperties: false,
135
+ properties: {
136
+ version: { const: 1 },
137
+ format: { type: "string", enum: Object.values(FormatId) },
138
+ layout: { type: "string", enum: Object.values(LayoutId) },
139
+ palette: { type: "string", enum: Object.values(PaletteId) },
140
+ fonts: { type: "string", enum: Object.values(FontSetId) },
141
+ background: { type: "string", enum: Object.values(BackgroundStyle) },
142
+ picture: {
143
+ type: "object",
144
+ required: ["imageId", "anchor", "scale"],
145
+ additionalProperties: false,
146
+ properties: {
147
+ imageId: text(SHORT),
148
+ anchor: { type: "string", enum: Object.values(PictureAnchor) },
149
+ scale: { type: "number", minimum: MIN_SCALE, maximum: 1 }
150
+ }
151
+ },
152
+ logo: { type: "object", required: ["imageId"], additionalProperties: false, properties: { imageId: text(SHORT) } },
153
+ blocks,
154
+ items: {
155
+ type: "array",
156
+ minItems: 1,
157
+ maxItems: MAX_ITEMS,
158
+ items: {
159
+ type: "object",
160
+ required: ["date", "schedule"],
161
+ additionalProperties: false,
162
+ properties: { date: text(SHORT), schedule: scheduleList, price: text(SHORT) }
163
+ }
164
+ }
165
+ }
166
+ };
167
+
168
+ // src/schema/validate.ts
169
+ var typeName = (v) => {
170
+ if (v === null) {
171
+ return "null";
172
+ }
173
+ return Array.isArray(v) ? "array" : typeof v;
174
+ };
175
+ var join = (path, key) => path === "" ? key : `${path}.${key}`;
176
+ var show = (v) => JSON.stringify(v) ?? typeName(v);
177
+ var ARTICLE = { object: "an object", array: "an array", string: "a string", number: "a number", integer: "an integer" };
178
+ function matchesType(schema, value) {
179
+ switch (schema.type) {
180
+ case void 0:
181
+ return true;
182
+ case "integer":
183
+ return Number.isInteger(value);
184
+ case "number":
185
+ return typeof value === "number" && Number.isFinite(value);
186
+ default:
187
+ return typeName(value) === schema.type;
188
+ }
189
+ }
190
+ function checkString(schema, value, path, errors) {
191
+ if (schema.minLength !== void 0 && value.trim().length < schema.minLength) {
192
+ errors.push(`${path}: must not be empty`);
193
+ }
194
+ if (schema.maxLength !== void 0 && value.length > schema.maxLength) {
195
+ errors.push(`${path}: must be at most ${schema.maxLength} characters (got ${value.length})`);
196
+ }
197
+ }
198
+ function checkNumber(schema, value, path, errors) {
199
+ if (schema.minimum !== void 0 && value < schema.minimum) {
200
+ errors.push(`${path}: must be at least ${schema.minimum} (got ${value})`);
201
+ }
202
+ if (schema.maximum !== void 0 && value > schema.maximum) {
203
+ errors.push(`${path}: must be at most ${schema.maximum} (got ${value})`);
204
+ }
205
+ }
206
+ function checkArray(schema, value, path, errors) {
207
+ if (schema.minItems !== void 0 && value.length < schema.minItems) {
208
+ errors.push(`${path}: must have at least ${schema.minItems} entries (got ${value.length})`);
209
+ }
210
+ if (schema.maxItems !== void 0 && value.length > schema.maxItems) {
211
+ errors.push(`${path}: must have at most ${schema.maxItems} entries (got ${value.length})`);
212
+ return;
213
+ }
214
+ const itemSchema = schema.items;
215
+ if (itemSchema) {
216
+ value.forEach((item, i) => validateAgainst(itemSchema, item, `${path}[${i}]`, errors));
217
+ }
218
+ }
219
+ function checkObject(schema, value, path, errors) {
220
+ const props = schema.properties ?? {};
221
+ for (const key of schema.required ?? []) {
222
+ if (value[key] === void 0) {
223
+ errors.push(`${join(path, key)}: is required`);
224
+ }
225
+ }
226
+ for (const [key, child] of Object.entries(value)) {
227
+ const childSchema = props[key];
228
+ if (childSchema === void 0) {
229
+ if (schema.additionalProperties === false) {
230
+ errors.push(`${join(path, key)}: is not allowed`);
231
+ }
232
+ } else if (child !== void 0) {
233
+ validateAgainst(childSchema, child, join(path, key), errors);
234
+ }
235
+ }
236
+ }
237
+ function validateAgainst(schema, value, path, errors) {
238
+ if (schema.const !== void 0) {
239
+ if (value !== schema.const) {
240
+ errors.push(`${path}: must be ${schema.const} (got ${show(value)})`);
241
+ }
242
+ return;
243
+ }
244
+ if (!matchesType(schema, value)) {
245
+ errors.push(`${path}: must be ${ARTICLE[schema.type ?? "object"]} (got ${typeName(value)})`);
246
+ return;
247
+ }
248
+ if (schema.enum && !schema.enum.includes(value)) {
249
+ errors.push(`${path}: must be one of ${schema.enum.join(", ")} (got ${show(value)})`);
250
+ return;
251
+ }
252
+ if (typeof value === "string") {
253
+ checkString(schema, value, path, errors);
254
+ } else if (typeof value === "number") {
255
+ checkNumber(schema, value, path, errors);
256
+ } else if (Array.isArray(value)) {
257
+ checkArray(schema, value, path, errors);
258
+ } else if (typeName(value) === "object") {
259
+ checkObject(schema, value, path, errors);
260
+ }
261
+ }
262
+
263
+ // src/schema/validateFlierDoc.ts
264
+ var FlierDocError = class extends Error {
265
+ constructor(errors) {
266
+ super(`Invalid flier: ${errors.join("; ")}`);
267
+ this.name = "FlierDocError";
268
+ this.errors = errors;
269
+ }
270
+ };
271
+ function validateFlierDoc(input) {
272
+ if (typeName(input) !== "object") {
273
+ return { ok: false, errors: ["doc: must be an object"] };
274
+ }
275
+ const errors = [];
276
+ validateAgainst(flierDocSchema, input, "", errors);
277
+ const doc = input;
278
+ if (doc.layout === "list" /* List */ && (doc.items === void 0 || doc.items.length === 0)) {
279
+ errors.push('items: layout "list" needs at least one item');
280
+ }
281
+ return errors.length === 0 ? { ok: true, doc } : { ok: false, errors };
282
+ }
283
+ function missingAssets(doc, assets) {
284
+ const errors = [];
285
+ if (doc.picture && assets[doc.picture.imageId] === void 0) {
286
+ errors.push(`picture.imageId: no asset named "${doc.picture.imageId}"`);
287
+ }
288
+ if (doc.logo && assets[doc.logo.imageId] === void 0) {
289
+ errors.push(`logo.imageId: no asset named "${doc.logo.imageId}"`);
290
+ }
291
+ return errors;
292
+ }
293
+
294
+ // src/layout/blockGroups.ts
295
+ var BOLD = 700;
296
+ var SEMI = 600;
297
+ var MEDIUM = 500;
298
+ var SCRIPT = 400;
299
+ var makers = (p, u) => ({
300
+ display: (text2, size) => ({ text: text2, role: "display", weight: BOLD, size: size * u, color: p.title }),
301
+ venue: (text2, size) => ({ text: text2, role: "display", weight: MEDIUM, size: size * u, color: p.ink }),
302
+ script: (text2, size) => ({ text: text2, role: "script", weight: SCRIPT, size: size * u, color: p.accent }),
303
+ strong: (text2, size) => ({ text: text2, role: "body", weight: BOLD, size: size * u, color: p.ink }),
304
+ accent: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.accent }),
305
+ item: (text2, size) => ({ text: text2, role: "body", weight: SEMI, size: size * u, color: p.ink, bullet: p.accent }),
306
+ soft: (text2, size) => ({ text: text2, role: "body", weight: MEDIUM, size: size * u, color: p.soft })
307
+ });
308
+ function headerGroups(doc, p, u) {
309
+ const l = makers(p, u);
310
+ const b = doc.blocks;
311
+ const lines = [l.display?.(b.title, 250), b.script ? l.script?.(b.script, 120) : void 0, b.titleBottom ? l.display?.(b.titleBottom, 250) : void 0];
312
+ return [{ lines: lines.filter((x) => x !== void 0), gapAfter: 0 }];
313
+ }
314
+ function itemGroup(item, l, u) {
315
+ const lines = [l.script?.(item.date, 96)];
316
+ item.schedule.forEach((row) => lines.push(l.strong?.(`${row.label} ${row.time}`, 42)));
317
+ if (item.price) {
318
+ lines.push(l.accent?.(item.price, 42));
319
+ }
320
+ return { lines, gapAfter: 26 * u };
321
+ }
322
+ function eventGroups(doc, l, u, withPrices) {
323
+ const b = doc.blocks;
324
+ const groups = [];
325
+ b.schedule?.forEach((row) => groups.push({ lines: [l.strong?.(row.label, 66), l.accent?.(row.time, 60)], gapAfter: 30 * u }));
326
+ (withPrices ? b.prices : void 0)?.forEach((row) => groups.push({ lines: [l.strong?.(row.amount, 66), l.accent?.(row.label, 52)], gapAfter: 30 * u }));
327
+ return groups;
328
+ }
329
+ function listGroups(doc, l, u) {
330
+ const groups = (doc.items ?? []).map((item) => itemGroup(item, l, u));
331
+ const prices = doc.blocks.prices?.map((row) => l.strong?.(`${row.amount} ${row.label}`, 58)) ?? [];
332
+ if (prices.length > 0) {
333
+ groups.push({ lines: prices, gapAfter: 30 * u });
334
+ }
335
+ return groups;
336
+ }
337
+ function flankPriceGroups(doc, p, u) {
338
+ const l = makers(p, u);
339
+ return (doc.blocks.prices ?? []).map((row) => ({ lines: [l.strong?.(row.amount, 110), l.accent?.(row.label, 48)], gapAfter: 30 * u }));
340
+ }
341
+ function bodyGroups(doc, p, u, withPrices = true) {
342
+ const l = makers(p, u);
343
+ const b = doc.blocks;
344
+ const groups = [];
345
+ const isList = doc.layout === "list" /* List */;
346
+ if (b.venue) {
347
+ groups.push({ lines: b.venue.map((v) => l.venue?.(v, isList ? 48 : 58)), gapAfter: 40 * u, rule: { color: p.accent, height: 8 * u } });
348
+ }
349
+ groups.push(...isList ? listGroups(doc, l, u) : eventGroups(doc, l, u, withPrices));
350
+ if (b.includes) {
351
+ const lines = [l.accent?.(b.includes.heading, 38), ...b.includes.items.map((i) => l.item?.(i, 38))];
352
+ groups.push({ lines, gapAfter: 24 * u, card: { color: p.card, pad: 28 * u }, uniform: true });
353
+ }
354
+ if (b.footer) {
355
+ groups.push({ lines: [l.soft?.(b.footer, 34)], gapAfter: 0 });
356
+ }
357
+ return groups;
358
+ }
359
+
360
+ // src/layout/layoutSpecs.ts
361
+ var flanksBeside = (pic, top, bottom2, m) => [
362
+ [m, top, pic[0] - 2 * m, bottom2 - top],
363
+ [pic[0] + pic[2] + m, top, 1 - pic[0] - pic[2] - 2 * m, bottom2 - top]
364
+ ];
365
+ var ARCH_PIC = [0.22, 0.42, 0.56, 0.58];
366
+ var PHONE_ARCH_PIC = [0.2, 0.6, 0.6, 0.4];
367
+ var DESK_ARCH_PIC = [0.355, 0.07, 0.32, 0.93];
368
+ var ARCH_LANDSCAPE = {
369
+ header: [0.04, 0.06, 0.3, 0.5],
370
+ body: [0.04, 0.58, 0.3, 0.36],
371
+ picture: [0.36, 0.08, 0.3, 0.92],
372
+ alignX: 0.5,
373
+ flanks: [[0.68, 0.08, 0.28, 0.42], [0.68, 0.52, 0.28, 0.42]],
374
+ arch: 0.06
375
+ };
376
+ var LAYOUT_SPECS = {
377
+ ["arch" /* Arch */]: {
378
+ portrait: { header: [0.045, 0.025, 0.91, 0.18], body: [0.045, 0.215, 0.91, 0.19], picture: ARCH_PIC, alignX: 0.5, flanks: flanksBeside(ARCH_PIC, 0.44, 0.97, 0.03), arch: 0.04 },
379
+ landscape: ARCH_LANDSCAPE
380
+ },
381
+ ["phone-clock-safe" /* PhoneClockSafe */]: {
382
+ portrait: { header: [0.045, 0.28, 0.91, 0.2], body: [0.045, 0.5, 0.42, 0.44], picture: [0.48, 0.5, 0.52, 0.5], alignX: 1 },
383
+ landscape: { header: [0.04, 0.2, 0.52, 0.3], body: [0.04, 0.52, 0.52, 0.42], picture: [0.6, 0.12, 0.4, 0.88], alignX: 1 }
384
+ },
385
+ ["phone-arch" /* PhoneArch */]: {
386
+ portrait: { header: [0.045, 0.3, 0.91, 0.13], body: [0.045, 0.44, 0.91, 0.14], picture: PHONE_ARCH_PIC, alignX: 0.5, flanks: flanksBeside(PHONE_ARCH_PIC, 0.63, 0.94, 0.03), arch: 0.08 },
387
+ landscape: { ...ARCH_LANDSCAPE, header: [0.04, 0.16, 0.3, 0.42], picture: [0.36, 0.16, 0.3, 0.84] }
388
+ },
389
+ ["desktop-safe" /* DesktopSafe */]: {
390
+ portrait: { header: [0.08, 0.06, 0.84, 0.24], body: [0.08, 0.31, 0.46, 0.58], picture: [0.56, 0.36, 0.44, 0.64], alignX: 1 },
391
+ landscape: { header: [0.0625, 0.05, 0.58, 0.44], body: [0.0625, 0.5, 0.58, 0.38], picture: [0.67, 0.0972, 0.29, 0.9028], alignX: 1 }
392
+ },
393
+ ["desktop-arch" /* DesktopArch */]: {
394
+ portrait: { header: [0.08, 0.06, 0.84, 0.2], body: [0.08, 0.27, 0.84, 0.2], picture: [0.14, 0.48, 0.72, 0.52], alignX: 0.5, arch: 0.05 },
395
+ landscape: { header: [0.07, 0.3, 0.26, 0.45], body: [0.7, 0.25, 0.27, 0.65], picture: DESK_ARCH_PIC, alignX: 0.5, logo: [0.88, 0.04, 0.09, 0.14], arch: 0.05 }
396
+ }
397
+ };
398
+ var r = Math.round;
399
+ var toRect = (f, w, h) => {
400
+ const x = r(f[0] * w);
401
+ const y = r(f[1] * h);
402
+ return { x, y, w: r((f[0] + f[2]) * w) - x, h: r((f[1] + f[3]) * h) - y };
403
+ };
404
+
405
+ // src/layout/geometry.ts
406
+ var MARGIN = 0.045;
407
+ var HEADER_PORTRAIT = 0.3;
408
+ var HEADER_LANDSCAPE = 0.36;
409
+ var BOTTOM_HEADER = 0.2;
410
+ var BOTTOM_PICTURE = 0.52;
411
+ var PICTURE_SIDE = {
412
+ ["picture-right" /* PictureRight */]: [0.6, 0.42],
413
+ ["picture-left" /* PictureLeft */]: [0.6, 0.42],
414
+ ["list" /* List */]: [0.5, 0.36]
415
+ };
416
+ var ANCHOR_Y = { ["top" /* Top */]: 0, ["center" /* Center */]: 0.5, ["bottom" /* Bottom */]: 1 };
417
+ var r2 = Math.round;
418
+ function side(w, h, fraction) {
419
+ const m = r2(Math.min(w, h) * MARGIN);
420
+ if (h >= w) {
421
+ const split2 = r2(w * (1 - fraction[0]));
422
+ const top2 = r2(h * HEADER_PORTRAIT);
423
+ return {
424
+ header: { x: m, y: m, w: w - 2 * m, h: top2 - 2 * m },
425
+ picture: { x: split2, y: top2, w: w - split2, h: h - top2 },
426
+ body: { x: m, y: top2, w: split2 - 2 * m, h: h - top2 - m },
427
+ alignX: 1
428
+ };
429
+ }
430
+ const split = r2(w * (1 - fraction[1]));
431
+ const top = r2(h * HEADER_LANDSCAPE);
432
+ return {
433
+ header: { x: m, y: m, w: split - 2 * m, h: top - m },
434
+ picture: { x: split, y: 0, w: w - split, h },
435
+ body: { x: m, y: top + r2(m / 2), w: split - 2 * m, h: h - top - r2(m / 2) - m },
436
+ alignX: 1
437
+ };
438
+ }
439
+ function bottom(w, h) {
440
+ const m = r2(Math.min(w, h) * MARGIN);
441
+ const top = r2(h * BOTTOM_HEADER);
442
+ const pic = r2(h * BOTTOM_PICTURE);
443
+ return {
444
+ header: { x: m, y: m, w: w - 2 * m, h: top - 2 * m },
445
+ body: { x: m, y: top, w: w - 2 * m, h: pic - top - m },
446
+ picture: { x: 0, y: pic, w, h: h - pic },
447
+ alignX: 0.5
448
+ };
449
+ }
450
+ function fromSpec(spec, w, h) {
451
+ return {
452
+ header: toRect(spec.header, w, h),
453
+ body: toRect(spec.body, w, h),
454
+ picture: toRect(spec.picture, w, h),
455
+ alignX: spec.alignX,
456
+ flanks: spec.flanks?.map((f) => toRect(f, w, h)),
457
+ logo: spec.logo ? toRect(spec.logo, w, h) : void 0,
458
+ arch: spec.arch
459
+ };
460
+ }
461
+ var mirror = (rect, w) => ({ ...rect, x: w - rect.x - rect.w });
462
+ function geometry(layout, w, h) {
463
+ const oriented = LAYOUT_SPECS[layout];
464
+ if (oriented) {
465
+ return fromSpec(h >= w ? oriented.portrait : oriented.landscape, w, h);
466
+ }
467
+ if (layout === "picture-bottom" /* PictureBottom */) {
468
+ return bottom(w, h);
469
+ }
470
+ const g = side(w, h, PICTURE_SIDE[layout] ?? [0.6, 0.42]);
471
+ if (layout !== "picture-left" /* PictureLeft */) {
472
+ return g;
473
+ }
474
+ return { header: mirror(g.header, w), body: mirror(g.body, w), picture: mirror(g.picture, w), alignX: 1 - g.alignX };
475
+ }
476
+ function placeContain(image, region, anchor, alignX, scale) {
477
+ const s = Math.min(region.w / image.width, region.h / image.height) * scale;
478
+ const w = Math.floor(image.width * s);
479
+ const h = Math.floor(image.height * s);
480
+ return {
481
+ x: region.x + Math.floor((region.w - w) * alignX),
482
+ y: region.y + Math.floor((region.h - h) * ANCHOR_Y[anchor]),
483
+ w,
484
+ h
485
+ };
486
+ }
487
+
488
+ // src/layout/textFlow.ts
489
+ var LINE_HEIGHT = 1.2;
490
+ var BULLET_INDENT = 1.1;
491
+ var BULLET_RADIUS = 0.2;
492
+ var RULE_GAP = 2.5;
493
+ var RULE_WIDTH = 0.9;
494
+ var WIDE = 1.6;
495
+ var MIN_K = 0.02;
496
+ var STEPS = 22;
497
+ var fontOf = (line, size, fonts) => `${line.weight} ${size}px ${fonts[line.role]}`;
498
+ var emptyFlow = () => ({ texts: [], cards: [], rules: [], bullets: [] });
499
+ function lineSize(line, width, k, avail) {
500
+ const indent = line.bullet ? line.size * k * BULLET_INDENT : 0;
501
+ return Math.max(0, Math.min(line.size * k, line.size * (avail - indent) / Math.max(width, 1)));
502
+ }
503
+ function groupRatio(m, k, avail) {
504
+ if (!m.group.uniform) {
505
+ return null;
506
+ }
507
+ return Math.min(...m.group.lines.map((line, i) => lineSize(line, m.widths[i] ?? 1, k, avail) / line.size));
508
+ }
509
+ function placeGroup(m, k, col, y0, out) {
510
+ const { group } = m;
511
+ let y = y0;
512
+ if (group.rule) {
513
+ out?.rules.push({ rect: { x: col.x, y, w: col.w * RULE_WIDTH, h: group.rule.height * k }, color: group.rule.color });
514
+ y += group.rule.height * k * RULE_GAP;
515
+ }
516
+ const pad = group.card ? group.card.pad * k : 0;
517
+ const top = y;
518
+ y += pad;
519
+ const ratio = groupRatio(m, k, col.w - 2 * pad);
520
+ group.lines.forEach((line, i) => {
521
+ const size = ratio === null ? lineSize(line, m.widths[i] ?? 1, k, col.w - 2 * pad) : line.size * ratio;
522
+ const indent = line.bullet ? line.size * k * BULLET_INDENT : 0;
523
+ const w = (m.widths[i] ?? 0) * size / line.size;
524
+ const x = col.right ? col.x + col.w - pad - w : col.x + pad + indent;
525
+ out?.texts.push({ text: line.text, font: "", color: line.color, box: { x, y, w, h: size * LINE_HEIGHT } });
526
+ if (line.bullet && out) {
527
+ out.bullets.push({ x: col.x + pad + size * BULLET_RADIUS, y: y + size * LINE_HEIGHT / 2, r: size * BULLET_RADIUS, color: line.bullet });
528
+ }
529
+ y += size * LINE_HEIGHT;
530
+ });
531
+ y += pad;
532
+ if (group.card) {
533
+ out?.cards.unshift({ rect: { x: col.x, y: top, w: col.w, h: y - top }, color: group.card.color });
534
+ }
535
+ return y + group.gapAfter * k - y0;
536
+ }
537
+ function placeColumn(groups, k, col, out) {
538
+ let h = 0;
539
+ for (const g of groups) {
540
+ h += placeGroup(g, k, col, col.y + h, out);
541
+ }
542
+ return h;
543
+ }
544
+ function splitColumns(groups, zone) {
545
+ if (zone.w / Math.max(zone.h, 1) <= WIDE || groups.length < 2) {
546
+ return [{ col: zone, groups }];
547
+ }
548
+ const heights = groups.map((g) => placeGroup(g, 1, zone, 0, null));
549
+ const half = heights.reduce((a, b) => a + b, 0) / 2;
550
+ let acc = 0;
551
+ let cut = 1;
552
+ heights.forEach((h, i) => {
553
+ if (acc < half) {
554
+ cut = i + 1;
555
+ }
556
+ acc += h;
557
+ });
558
+ cut = Math.min(cut, groups.length - 1);
559
+ const gap = Math.round(zone.w * 0.04);
560
+ const w = (zone.w - gap) / 2;
561
+ return [
562
+ { col: { ...zone, w }, groups: groups.slice(0, cut) },
563
+ { col: { ...zone, x: zone.x + w + gap, w }, groups: groups.slice(cut) }
564
+ ];
565
+ }
566
+ function flow(ctx, groups, zone, fonts) {
567
+ const measured = groups.map((group) => ({
568
+ group,
569
+ widths: group.lines.map((line) => {
570
+ ctx.font = fontOf(line, line.size, fonts);
571
+ return ctx.measureText(line.text).width;
572
+ })
573
+ }));
574
+ const columns = splitColumns(measured, zone);
575
+ const fits = (k) => columns.every((c) => placeColumn(c.groups, k, c.col, null) <= zone.h);
576
+ let lo = MIN_K;
577
+ let hi = 1;
578
+ if (fits(1)) {
579
+ lo = 1;
580
+ }
581
+ for (let i = 0; i < STEPS && lo < hi; i++) {
582
+ const mid = (lo + hi) / 2;
583
+ if (fits(mid)) {
584
+ lo = mid;
585
+ } else {
586
+ hi = mid;
587
+ }
588
+ }
589
+ const out = emptyFlow();
590
+ columns.forEach((c) => placeColumn(c.groups, lo, c.col, out));
591
+ const lines = groups.flatMap((g) => g.lines);
592
+ out.texts.forEach((t, i) => {
593
+ const line = lines[i];
594
+ t.font = line ? fontOf(line, t.box.h / LINE_HEIGHT, fonts) : t.font;
595
+ });
596
+ return out;
597
+ }
598
+
599
+ // src/render/render.ts
600
+ var BASE = 1080;
601
+ var LOGO_SHARE = 0.26;
602
+ var CARD_RADIUS = 0.035;
603
+ var CAP_TOP = 0.15;
604
+ function roundRect(ctx, r3, radius) {
605
+ const rad = Math.min(radius, r3.w / 2, r3.h / 2);
606
+ ctx.beginPath();
607
+ ctx.moveTo(r3.x + rad, r3.y);
608
+ ctx.arcTo(r3.x + r3.w, r3.y, r3.x + r3.w, r3.y + r3.h, rad);
609
+ ctx.arcTo(r3.x + r3.w, r3.y + r3.h, r3.x, r3.y + r3.h, rad);
610
+ ctx.arcTo(r3.x, r3.y + r3.h, r3.x, r3.y, rad);
611
+ ctx.arcTo(r3.x, r3.y, r3.x + r3.w, r3.y, rad);
612
+ ctx.closePath();
613
+ ctx.fill();
614
+ }
615
+ function drawFlow(ctx, f, radius) {
616
+ f.rules.forEach((r3) => {
617
+ ctx.fillStyle = r3.color;
618
+ ctx.fillRect(r3.rect.x, r3.rect.y, r3.rect.w, r3.rect.h);
619
+ });
620
+ f.cards.forEach((c) => {
621
+ ctx.fillStyle = c.color;
622
+ roundRect(ctx, c.rect, radius);
623
+ });
624
+ f.bullets.forEach((b) => {
625
+ ctx.fillStyle = b.color;
626
+ ctx.beginPath();
627
+ ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
628
+ ctx.fill();
629
+ });
630
+ ctx.textAlign = "left";
631
+ ctx.textBaseline = "top";
632
+ f.texts.forEach((t) => {
633
+ ctx.font = t.font;
634
+ ctx.fillStyle = t.color;
635
+ ctx.fillText(t.text, t.box.x, t.box.y);
636
+ });
637
+ }
638
+ function checked(input, assets) {
639
+ const result = validateFlierDoc(input);
640
+ if (!result.ok) {
641
+ throw new FlierDocError(result.errors);
642
+ }
643
+ const missing = missingAssets(result.doc, assets);
644
+ if (missing.length > 0) {
645
+ throw new FlierDocError(missing);
646
+ }
647
+ return result.doc;
648
+ }
649
+ function archRect(geo) {
650
+ if (geo.arch === void 0) {
651
+ return null;
652
+ }
653
+ const top = geo.picture.y + Math.round(geo.picture.h * geo.arch);
654
+ return { x: geo.picture.x, y: top, w: geo.picture.w, h: geo.picture.y + geo.picture.h - top };
655
+ }
656
+ function drawArch(ctx, r3, color) {
657
+ const rad = r3.w / 2;
658
+ ctx.fillStyle = color;
659
+ ctx.fillRect(r3.x, r3.y + rad, r3.w, r3.h - rad);
660
+ ctx.beginPath();
661
+ ctx.arc(r3.x + rad, r3.y + rad, rad, Math.PI, Math.PI * 2);
662
+ ctx.fill();
663
+ }
664
+ function flankFlows(ctx, groups, flanks, fonts) {
665
+ const half = Math.ceil(groups.length / 2);
666
+ return flanks.map((zone, i) => i === 0 ? flow(ctx, groups.slice(0, half), zone, fonts) : flow(ctx, groups.slice(half), { ...zone, right: true }, fonts));
667
+ }
668
+ function capAligned(zone, head) {
669
+ const first = head.texts[0]?.box;
670
+ const shift = first ? Math.min(Math.max(0, Math.round(first.y + first.h * CAP_TOP) - zone.y), Math.round(zone.h / 2)) : 0;
671
+ return { ...zone, y: zone.y + shift, h: zone.h - shift };
672
+ }
673
+ function splitHeader(header, hasLogo) {
674
+ const logoW = hasLogo ? Math.round(header.w * LOGO_SHARE) : 0;
675
+ return { text: { ...header, w: header.w - logoW * 1.1 }, logo: { ...header, x: header.x + header.w - logoW, w: logoW } };
676
+ }
677
+ function render(input, assets, canvas) {
678
+ const doc = checked(input, assets);
679
+ const { width, height } = FORMAT_SIZES[doc.format];
680
+ canvas.width = width;
681
+ canvas.height = height;
682
+ const ctx = canvas.getContext("2d");
683
+ if (!ctx) {
684
+ throw new Error("poster-render: the canvas has no 2d context");
685
+ }
686
+ const palette = PALETTES[doc.palette];
687
+ const fonts = FONT_SETS[doc.fonts];
688
+ const geo = geometry(doc.layout, width, height);
689
+ const u = Math.min(width, height) / BASE;
690
+ ctx.globalAlpha = 1;
691
+ ctx.fillStyle = palette.background;
692
+ ctx.fillRect(0, 0, width, height);
693
+ if (doc.background === "tint" /* Tint */) {
694
+ ctx.fillStyle = palette.tint;
695
+ [geo.header, geo.body].forEach((r3) => ctx.fillRect(r3.x, r3.y, r3.w, r3.h));
696
+ }
697
+ const zones = geo.logo ? { text: geo.header, logo: geo.logo } : splitHeader(geo.header, doc.logo !== void 0);
698
+ const head = flow(ctx, headerGroups(doc, palette, u), zones.text, fonts);
699
+ const flanks = geo.flanks ?? [];
700
+ const body = flow(ctx, bodyGroups(doc, palette, u, flanks.length === 0), geo.body, fonts);
701
+ const sides = flanks.length > 0 ? flankFlows(ctx, flankPriceGroups(doc, palette, u), flanks, fonts) : [emptyFlow()];
702
+ const radius = Math.min(width, height) * CARD_RADIUS;
703
+ [head, body, ...sides].forEach((f) => drawFlow(ctx, f, radius));
704
+ const logoImage = doc.logo ? assets[doc.logo.imageId] : void 0;
705
+ if (logoImage) {
706
+ const r3 = placeContain(logoImage, capAligned(zones.logo, head), "top" /* Top */, 1, 1);
707
+ ctx.drawImage(logoImage, r3.x, r3.y, r3.w, r3.h);
708
+ }
709
+ const image = doc.picture ? assets[doc.picture.imageId] : void 0;
710
+ const backdrop = image ? archRect(geo) : null;
711
+ if (backdrop) {
712
+ drawArch(ctx, backdrop, palette.arch);
713
+ }
714
+ const picture = image && doc.picture ? placeContain(image, geo.picture, doc.picture.anchor, geo.alignX, doc.picture.scale) : null;
715
+ if (image && picture) {
716
+ ctx.drawImage(image, picture.x, picture.y, picture.w, picture.h);
717
+ }
718
+ return {
719
+ width,
720
+ height,
721
+ picture,
722
+ pictureRegion: geo.picture,
723
+ textBoxes: [head, body, ...sides].flatMap((f) => f.texts.map((t) => t.box)),
724
+ cardBoxes: body.cards.map((c) => c.rect),
725
+ backdrop
726
+ };
727
+ }
728
+
729
+ // src/exportPng.ts
730
+ var PREFIX = "data:image/png;base64,";
731
+ function exportPng(canvas) {
732
+ const url = canvas.toDataURL("image/png");
733
+ if (!url.startsWith(PREFIX)) {
734
+ throw new Error("poster-render: the canvas did not return a PNG data URL");
735
+ }
736
+ const binary = atob(url.slice(PREFIX.length));
737
+ const bytes = new Uint8Array(binary.length);
738
+ for (let i = 0; i < binary.length; i++) {
739
+ bytes[i] = binary.charCodeAt(i);
740
+ }
741
+ return new Blob([bytes], { type: "image/png" });
742
+ }
743
+
744
+ // src/layoutCatalogue.ts
745
+ var POSTERS = ["story" /* Story */, "post" /* Post */, "square" /* Square */];
746
+ var LAYOUT_CATALOGUE = [
747
+ { id: "picture-right" /* PictureRight */, designId: "E-A", label: "Picture right", description: "Titles across the top, details on the left, the picture on the right standing on the bottom edge.", formats: POSTERS },
748
+ { id: "picture-left" /* PictureLeft */, designId: "E-B", label: "Picture left", description: "Titles across the top, the picture on the left standing on the bottom edge, details on the right.", formats: POSTERS },
749
+ { id: "arch" /* Arch */, designId: "E-C", label: "Arch", description: "Titles and details on top, the picture centred in a soft arch at the bottom, prices either side.", formats: POSTERS },
750
+ { id: "picture-bottom" /* PictureBottom */, designId: "picture-bottom", label: "Picture bottom", description: "Titles and details on top, the picture across the bottom half.", formats: POSTERS },
751
+ { id: "list" /* List */, designId: "list", label: "Upcoming list", description: "Several dated events as a list beside the picture.", formats: POSTERS },
752
+ { id: "phone-clock-safe" /* PhoneClockSafe */, designId: "P-A", label: "Phone, clock clear", description: "Lock-screen wallpaper: top kept clear for the clock, details on the left, picture on the right.", formats: ["phone" /* Phone */] },
753
+ { id: "phone-arch" /* PhoneArch */, designId: "P-B", label: "Phone arch", description: "Lock-screen wallpaper: top kept clear for the clock, details in the middle, the picture in an arch at the bottom.", formats: ["phone" /* Phone */] },
754
+ { id: "desktop-safe" /* DesktopSafe */, designId: "D-A", label: "Desktop, icons clear", description: "Desktop wallpaper: text on the left clear of desktop icons and the taskbar, the picture on the right.", formats: ["desktop" /* Desktop */] },
755
+ { id: "desktop-arch" /* DesktopArch */, designId: "D-B", label: "Desktop arch", description: "Desktop wallpaper: titles left, the picture in a centred arch, details right.", formats: ["desktop" /* Desktop */] }
756
+ ];
757
+ var DEFAULTS = {
758
+ ["story" /* Story */]: "picture-right" /* PictureRight */,
759
+ ["post" /* Post */]: "picture-right" /* PictureRight */,
760
+ ["square" /* Square */]: "picture-right" /* PictureRight */,
761
+ ["phone" /* Phone */]: "phone-clock-safe" /* PhoneClockSafe */,
762
+ ["desktop" /* Desktop */]: "desktop-safe" /* DesktopSafe */
763
+ };
764
+ function defaultLayoutFor(format) {
765
+ return DEFAULTS[format];
766
+ }
767
+
768
+ // src/examples/kizombaWorkshop.ts
769
+ var WORKSHOPS = [
770
+ { script: "Saturday 31 October", date: "Sat 31 Oct", first: "20:00 - 21:00", second: "21:00 - 22:00", party: "20\u20AC" },
771
+ { script: "Saturday 7 November", date: "Sat 7 Nov", first: "20:00 - 21:00", second: "21:00 - 22:00", party: "15\u20AC" },
772
+ { script: "Sunday 29 November", date: "Sun 29 Nov", first: "17:00 - 18:00", second: "18:00 - 19:00", party: "10\u20AC" }
773
+ ];
774
+ var INCLUDES = { heading: "Party pass includes", items: ["Salsa & Bachata room", "Kizomba dedicated room", "2 drinks"] };
775
+ var schedule = (w) => [
776
+ { label: "Level 1", time: w.first },
777
+ { label: "Level 2", time: w.second }
778
+ ];
779
+ var base = {
780
+ version: 1,
781
+ format: "story" /* Story */,
782
+ palette: "sand" /* Sand */,
783
+ fonts: "classic" /* Classic */,
784
+ background: "solid" /* Solid */,
785
+ picture: { imageId: "dancers", anchor: "bottom" /* Bottom */, scale: 1 },
786
+ logo: { imageId: "logo" }
787
+ };
788
+ var shared = {
789
+ venue: ["Bailemos", "Dance School"],
790
+ includes: INCLUDES,
791
+ footer: "with Jimmy & Ismini"
792
+ };
793
+ var workshopDoc = (w) => ({
794
+ ...base,
795
+ layout: "picture-right" /* PictureRight */,
796
+ blocks: {
797
+ ...shared,
798
+ title: "KIZOMBA",
799
+ titleBottom: "WORKSHOP",
800
+ script: w.script,
801
+ schedule: schedule(w),
802
+ prices: [{ amount: "5\u20AC", label: "per hour" }, { amount: w.party, label: "Party pass" }]
803
+ }
804
+ });
805
+ var kizombaWorkshop = {
806
+ ...base,
807
+ layout: "list" /* List */,
808
+ blocks: {
809
+ ...shared,
810
+ title: "UPCOMING",
811
+ titleBottom: "WORKSHOPS",
812
+ script: "Kizomba",
813
+ prices: [{ amount: "5\u20AC", label: "per hour" }]
814
+ },
815
+ items: WORKSHOPS.map((w) => ({ date: w.date, schedule: schedule(w), price: `Party pass ${w.party}` }))
816
+ };
817
+ var kizombaWorkshopDocs = [...WORKSHOPS.map(workshopDoc), kizombaWorkshop];
818
+
819
+ export { BackgroundStyle, FONT_SETS, FORMAT_SIZES, FlierDocError, FontSetId, FormatId, LAYOUT_CATALOGUE, LayoutId, PALETTES, PaletteId, PictureAnchor, defaultLayoutFor, exportPng, flierDocSchema, kizombaWorkshop, kizombaWorkshopDocs, render, validateFlierDoc };
820
+ //# sourceMappingURL=index.mjs.map
821
+ //# sourceMappingURL=index.mjs.map